Commit d1f724cf authored by Dave Pisek's avatar Dave Pisek

Use updated sec-training query

This commit uses the updated version of the query to fetch
security training material for a given pipeline finding or
vulnerability.

It also integrates it with: the vulnerability details page, security
MR widget and security pipeline section.
parent aa3c1bef
query getSecurityTrainingVulnerability($id: ID!) { query getSecurityTrainingUrls($projectFullPath: ID!, $identifierExternalIds: [String!]!) {
vulnerability(id: $id) @client { project(fullPath: $projectFullPath) {
id id
identifiers { securityTrainingUrls(identifierExternalIds: $identifierExternalIds) {
externalType
}
securityTrainingUrls {
name name
url
status status
url
} }
} }
} }
...@@ -42,6 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -42,6 +42,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:refactor_mr_widgets_extensions, project, default_enabled: :yaml) push_frontend_feature_flag(:refactor_mr_widgets_extensions, project, default_enabled: :yaml)
push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml) push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml)
push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml) push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml)
push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml)
# Usage data feature flags # Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml) push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml)
push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml)
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import tempResolver from './temp_resolver';
Vue.use(VueApollo); Vue.use(VueApollo);
const defaultClient = createDefaultClient({ const defaultClient = createDefaultClient();
...tempResolver,
});
export default new VueApollo({ export default new VueApollo({
defaultClient, defaultClient,
......
// Note: this is behind a feature flag and only a placeholder
// until the actual GraphQL fields have been added
// https://gitlab.com/gitlab-org/gitlab/-/issues/349910
export default {
Query: {
vulnerability() {
/* eslint-disable @gitlab/require-i18n-strings */
return {
__typename: 'Vulnerability',
id: 'id: "gid://gitlab/Vulnerability/295"',
identifiers: [{ externalType: 'cwe', __typename: 'VulnerabilityIdentifier' }],
securityTrainingUrls: [
{
__typename: 'SecurityTrainingUrls',
id: 101,
name: 'Kontra',
url: null,
status: 'COMPLETED',
},
{
__typename: 'SecurityTrainingUrls',
id: 102,
name: 'Secure Code Warrior',
url: 'https://www.securecodewarrior.com/',
status: 'COMPLETED',
},
],
};
},
},
};
...@@ -3,10 +3,17 @@ import { GlFriendlyWrap, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui ...@@ -3,10 +3,17 @@ import { GlFriendlyWrap, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui
import { REPORT_TYPES } from 'ee/security_dashboard/store/constants'; import { REPORT_TYPES } from 'ee/security_dashboard/store/constants';
import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue'; import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue';
import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue'; import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants'; import {
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; SUPPORTING_MESSAGE_TYPES,
VULNERABILITY_TRAINING_HEADING,
} from 'ee/vulnerabilities/constants';
import {
convertObjectPropsToCamelCase,
convertArrayOfObjectsToCamelCase,
} from '~/lib/utils/common_utils';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import CodeBlock from '~/vue_shared/components/code_block.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue';
import VulnerabilityTraining from 'ee/vulnerabilities/components/vulnerability_training.vue';
import getFileLocation from '../store/utils/get_file_location'; import getFileLocation from '../store/utils/get_file_location';
import { bodyWithFallBack } from './helpers'; import { bodyWithFallBack } from './helpers';
import SeverityBadge from './severity_badge.vue'; import SeverityBadge from './severity_badge.vue';
...@@ -23,11 +30,17 @@ export default { ...@@ -23,11 +30,17 @@ export default {
GlLink, GlLink,
GlBadge, GlBadge,
FalsePositiveAlert, FalsePositiveAlert,
VulnerabilityTraining,
}, },
directives: { directives: {
SafeHtml: GlSafeHtmlDirective, SafeHtml: GlSafeHtmlDirective,
}, },
props: { vulnerability: { type: Object, required: true } }, props: { vulnerability: { type: Object, required: true } },
data() {
return {
showTraining: false,
};
},
computed: { computed: {
url() { url() {
return this.vulnerability.request?.url || getFileLocation(this.vulnLocation); return this.vulnerability.request?.url || getFileLocation(this.vulnLocation);
...@@ -141,6 +154,14 @@ export default { ...@@ -141,6 +154,14 @@ export default {
hasRecordedResponse() { hasRecordedResponse() {
return Boolean(this.constructedRecordedResponse); return Boolean(this.constructedRecordedResponse);
}, },
normalizedProjectFullPath() {
const projectFullPath = this.vulnerability.project?.full_path || '';
// in some cases the project's full path contains a leading slash and we need to remove it
return projectFullPath.startsWith('/') ? projectFullPath.substr(1) : projectFullPath;
},
camelCaseFormattedIdentifiers() {
return convertArrayOfObjectsToCamelCase(this.identifiers);
},
}, },
methods: { methods: {
getHeadersAsCodeBlockLines(headers) { getHeadersAsCodeBlockLines(headers) {
...@@ -175,6 +196,12 @@ export default { ...@@ -175,6 +196,12 @@ export default {
? [`${method} ${url}\n`, headerLines, '\n\n', bodyWithFallBack(body)].join('') ? [`${method} ${url}\n`, headerLines, '\n\n', bodyWithFallBack(body)].join('')
: ''; : '';
}, },
handleShowTraining(showVulnerabilityTraining) {
this.showTraining = showVulnerabilityTraining;
},
},
i18n: {
VULNERABILITY_TRAINING_HEADING,
}, },
}; };
</script> </script>
...@@ -309,5 +336,14 @@ export default { ...@@ -309,5 +336,14 @@ export default {
class="gl-mt-4" class="gl-mt-4"
:details="vulnerability.details" :details="vulnerability.details"
/> />
<div v-if="identifiers && normalizedProjectFullPath" v-show="showTraining">
<vulnerability-detail :label="$options.i18n.VULNERABILITY_TRAINING_HEADING.title">
<vulnerability-training
:project-full-path="normalizedProjectFullPath"
:identifiers="camelCaseFormattedIdentifiers"
@show-vulnerability-training="handleShowTraining"
/>
</vulnerability-detail>
</div>
</div> </div>
</template> </template>
...@@ -27,6 +27,11 @@ export default { ...@@ -27,6 +27,11 @@ export default {
directives: { directives: {
SafeHtml: GlSafeHtmlDirective, SafeHtml: GlSafeHtmlDirective,
}, },
inject: {
projectFullPath: {
default: '',
},
},
props: { props: {
vulnerability: { vulnerability: {
type: Object, type: Object,
...@@ -153,6 +158,9 @@ export default { ...@@ -153,6 +158,9 @@ export default {
hasResponses() { hasResponses() {
return Boolean(this.hasResponse || this.hasRecordedResponse); return Boolean(this.hasResponse || this.hasRecordedResponse);
}, },
shouldShowTraining() {
return this.vulnerability.identifiers?.length > 0 && Boolean(this.projectFullPath);
},
}, },
methods: { methods: {
getHeadersAsCodeBlockLines(headers) { getHeadersAsCodeBlockLines(headers) {
...@@ -380,7 +388,11 @@ export default { ...@@ -380,7 +388,11 @@ export default {
</ul> </ul>
</template> </template>
<vulnerability-training :id="vulnerability.id"> <vulnerability-training
v-if="shouldShowTraining"
:project-full-path="projectFullPath"
:identifiers="vulnerability.identifiers"
>
<template #header> <template #header>
<h3>{{ $options.VULNERABILITY_TRAINING_HEADING.title }}</h3> <h3>{{ $options.VULNERABILITY_TRAINING_HEADING.title }}</h3>
</template> </template>
......
...@@ -4,8 +4,6 @@ import * as Sentry from '@sentry/browser'; ...@@ -4,8 +4,6 @@ import * as Sentry from '@sentry/browser';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql';
import securityTrainingVulnerabilityQuery from '~/security_configuration/graphql/security_training_vulnerability.query.graphql'; import securityTrainingVulnerabilityQuery from '~/security_configuration/graphql/security_training_vulnerability.query.graphql';
import { TYPE_VULNERABILITY } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { import {
...@@ -33,10 +31,13 @@ export default { ...@@ -33,10 +31,13 @@ export default {
GlSkeletonLoader, GlSkeletonLoader,
}, },
mixins: [glFeatureFlagsMixin(), Tracking.mixin()], mixins: [glFeatureFlagsMixin(), Tracking.mixin()],
inject: ['projectFullPath'],
props: { props: {
id: { projectFullPath: {
type: Number, type: String,
required: true,
},
identifiers: {
type: Array,
required: true, required: true,
}, },
}, },
...@@ -55,23 +56,31 @@ export default { ...@@ -55,23 +56,31 @@ export default {
}; };
}, },
}, },
vulnerability: { securityTrainingUrls: {
query: securityTrainingVulnerabilityQuery, query: securityTrainingVulnerabilityQuery,
pollInterval: 5000, pollInterval: 5000,
update({ vulnerability }) { update({ project }) {
const allUrlsAreReady = vulnerability?.securityTrainingUrls?.every( if (!project) {
return [];
}
const { securityTrainingUrls = [] } = project;
const allUrlsAreReady = securityTrainingUrls.every(
({ status }) => status === SECURITY_TRAINING_URL_STATUS_COMPLETED, ({ status }) => status === SECURITY_TRAINING_URL_STATUS_COMPLETED,
); );
if (allUrlsAreReady) { if (allUrlsAreReady) {
this.$apollo.queries.vulnerability.stopPolling(); this.$apollo.queries.securityTrainingUrls.stopPolling();
this.isUrlsLoading = false; this.isUrlsLoading = false;
} }
return vulnerability; return securityTrainingUrls;
}, },
variables() { variables() {
return { id: convertToGraphQLId(TYPE_VULNERABILITY, this.id) }; return {
projectFullPath: this.projectFullPath,
identifierExternalIds: this.supportedIdentifiersExternalIds,
};
}, },
error(e) { error(e) {
Sentry.captureException(e); Sentry.captureException(e);
...@@ -98,23 +107,25 @@ export default { ...@@ -98,23 +107,25 @@ export default {
hasSecurityTrainingProviders() { hasSecurityTrainingProviders() {
return this.securityTrainingProviders?.some(({ isEnabled }) => isEnabled); return this.securityTrainingProviders?.some(({ isEnabled }) => isEnabled);
}, },
hasSupportedIdentifier() { supportedIdentifiersExternalIds() {
return this.vulnerability?.identifiers?.some( return this.identifiers.flatMap(({ externalType, externalId }) =>
({ externalType }) => externalType?.toLowerCase() === SUPPORTED_IDENTIFIER_TYPES.cwe, externalType?.toLowerCase() === SUPPORTED_IDENTIFIER_TYPES.cwe ? externalId : [],
); );
}, },
hasSupportedIdentifier() {
return this.supportedIdentifiersExternalIds.length > 0;
},
hasSecurityTrainingUrls() { hasSecurityTrainingUrls() {
const hasSecurityTrainingUrls = this.vulnerability?.securityTrainingUrls?.length > 0; const hasSecurityTrainingUrls = this.securityTrainingUrls?.length > 0;
if (hasSecurityTrainingUrls) { if (hasSecurityTrainingUrls) {
this.track(TRACK_TRAINING_LOADED_ACTION, { this.track(TRACK_TRAINING_LOADED_ACTION, {
property: this.projectFullPath, property: this.projectFullPath,
}); });
} }
return hasSecurityTrainingUrls; return hasSecurityTrainingUrls;
}, },
securityTrainingUrls() {
return this.vulnerability?.securityTrainingUrls;
},
}, },
watch: { watch: {
showVulnerabilityTraining: { showVulnerabilityTraining: {
......
...@@ -17,10 +17,6 @@ const pipelineId = 123; ...@@ -17,10 +17,6 @@ const pipelineId = 123;
const pipelineIid = 12; const pipelineIid = 12;
const vulnerabilitiesEndpoint = `${TEST_HOST}/vulnerabilities`; const vulnerabilitiesEndpoint = `${TEST_HOST}/vulnerabilities`;
jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockReturnValue([]),
}));
jest.mock('~/flash'); jest.mock('~/flash');
describe('Security Dashboard component', () => { describe('Security Dashboard component', () => {
......
...@@ -200,5 +200,18 @@ key2: value2 ...@@ -200,5 +200,18 @@ key2: value2
<!----> <!---->
<!----> <!---->
<div
style="display: none;"
>
<vulnerability-detail-stub
label="Training"
>
<vulnerability-training-stub
identifiers="[object Object],[object Object]"
projectfullpath="gitlab-org/gitlab-ui"
/>
</vulnerability-detail-stub>
</div>
</div> </div>
`; `;
...@@ -6,6 +6,7 @@ import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_ba ...@@ -6,6 +6,7 @@ import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_ba
import VulnerabilityDetails from 'ee/vue_shared/security_reports/components/vulnerability_details.vue'; import VulnerabilityDetails from 'ee/vue_shared/security_reports/components/vulnerability_details.vue';
import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue'; import FalsePositiveAlert from 'ee/vulnerabilities/components/false_positive_alert.vue';
import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue'; import GenericReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import VulnerabilityTraining from 'ee/vulnerabilities/components/vulnerability_training.vue';
import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants'; import { SUPPORTING_MESSAGE_TYPES } from 'ee/vulnerabilities/constants';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
...@@ -401,6 +402,46 @@ describe('VulnerabilityDetails component', () => { ...@@ -401,6 +402,46 @@ describe('VulnerabilityDetails component', () => {
}); });
}); });
describe('vulnerability training', () => {
describe('with vulnerability identifiers', () => {
const identifiers = [{ externalType: 'cwe', externalId: 'cwe-123' }];
const project = {
id: 7071551,
name: 'project',
full_path: '/namespace/project',
full_name: 'GitLab.org / gitlab-ui',
};
beforeEach(() => {
const vulnerability = makeVulnerability({ identifiers, project });
componentFactory(vulnerability);
});
it(`passes the vulnerability's identifiers to the training section`, () => {
expect(wrapper.findComponent(VulnerabilityTraining).props('identifiers')).toEqual(
identifiers,
);
});
it(`passes the project's full path without a leading slash`, () => {
expect(wrapper.findComponent(VulnerabilityTraining).props('projectFullPath')).toBe(
'namespace/project',
);
});
});
describe('without vulnerability identifiers', () => {
beforeEach(() => {
const vulnerability = makeVulnerability({ identifiers: [] });
componentFactory(vulnerability);
});
it('does not render the vulnerability training section', () => {
expect(wrapper.findComponent(VulnerabilityTraining).exists()).toBe(false);
});
});
});
describe('pin test', () => { describe('pin test', () => {
const factory = (vulnFinding) => { const factory = (vulnFinding) => {
wrapper = shallowMount(VulnerabilityDetails, { wrapper = shallowMount(VulnerabilityDetails, {
......
...@@ -5,8 +5,8 @@ import { ...@@ -5,8 +5,8 @@ import {
} from 'ee/vulnerabilities/constants'; } from 'ee/vulnerabilities/constants';
export const testIdentifiers = [ export const testIdentifiers = [
{ externalType: SUPPORTED_IDENTIFIER_TYPES.cwe }, { externalType: SUPPORTED_IDENTIFIER_TYPES.cwe, externalId: 'cwe-1' },
{ externalType: 'cve' }, { externalType: 'cve', externalId: 'cve-1' },
]; ];
export const generateNote = ({ id = 1295 } = {}) => ({ export const generateNote = ({ id = 1295 } = {}) => ({
...@@ -43,14 +43,8 @@ export const addTypenamesToDiscussion = (discussion) => { ...@@ -43,14 +43,8 @@ export const addTypenamesToDiscussion = (discussion) => {
}; };
}; };
export const defaultProps = { const createSecurityTrainingUrls = ({ urlOverrides = {}, urls } = {}) =>
id: 200, urls || [
};
const createSecurityTrainingVulnerability = ({ urlOverrides = {}, urls, identifiers } = {}) => ({
...defaultProps,
identifiers: identifiers || testIdentifiers,
securityTrainingUrls: urls || [
{ {
name: testProviderName[0], name: testProviderName[0],
url: testTrainingUrls[0], url: testTrainingUrls[0],
...@@ -63,20 +57,16 @@ const createSecurityTrainingVulnerability = ({ urlOverrides = {}, urls, identifi ...@@ -63,20 +57,16 @@ const createSecurityTrainingVulnerability = ({ urlOverrides = {}, urls, identifi
status: SECURITY_TRAINING_URL_STATUS_COMPLETED, status: SECURITY_TRAINING_URL_STATUS_COMPLETED,
...urlOverrides.second, ...urlOverrides.second,
}, },
], ];
});
export const getSecurityTrainingVulnerabilityData = (vulnerabilityOverrides = {}) => { export const getSecurityTrainingProjectData = (urlOverrides = {}) => ({
const vulnerability = createSecurityTrainingVulnerability(vulnerabilityOverrides); response: {
const response = {
data: { data: {
vulnerability, project: {
id: 'gid://gitlab/Project/1',
__typename: 'Project',
securityTrainingUrls: createSecurityTrainingUrls(urlOverrides),
},
}, },
}; },
});
return {
response,
data: vulnerability,
};
};
...@@ -21,6 +21,8 @@ describe('Vulnerability Details', () => { ...@@ -21,6 +21,8 @@ describe('Vulnerability Details', () => {
descriptionHtml: 'vulnerability description <code>sample</code>', descriptionHtml: 'vulnerability description <code>sample</code>',
}; };
const TEST_PROJECT_FULL_PATH = 'namespace/project';
const createWrapper = (vulnerabilityOverrides, { mountFn = mount, options = {} } = {}) => { const createWrapper = (vulnerabilityOverrides, { mountFn = mount, options = {} } = {}) => {
const propsData = { const propsData = {
vulnerability: { ...vulnerability, ...vulnerabilityOverrides }, vulnerability: { ...vulnerability, ...vulnerabilityOverrides },
...@@ -28,7 +30,7 @@ describe('Vulnerability Details', () => { ...@@ -28,7 +30,7 @@ describe('Vulnerability Details', () => {
wrapper = mountFn(VulnerabilityDetails, { wrapper = mountFn(VulnerabilityDetails, {
propsData, propsData,
provide: { provide: {
projectFullPath: 'namespace/project', projectFullPath: TEST_PROJECT_FULL_PATH,
}, },
...options, ...options,
}); });
...@@ -204,24 +206,28 @@ describe('Vulnerability Details', () => { ...@@ -204,24 +206,28 @@ describe('Vulnerability Details', () => {
}); });
describe('VulnerabilityTraining', () => { describe('VulnerabilityTraining', () => {
const { id } = vulnerability; const identifiers = [{ externalType: 'cwe', externalId: 'cwe-123' }];
it('renders component', () => { it('renders component', () => {
createShallowWrapper(); createShallowWrapper({ identifiers });
expect(findVulnerabilityTraining().props()).toMatchObject({ expect(findVulnerabilityTraining().props()).toMatchObject({
id, identifiers,
projectFullPath: TEST_PROJECT_FULL_PATH,
}); });
}); });
it('renders title text', () => { it('renders title text', () => {
createShallowWrapper(null, { createShallowWrapper(
stubs: { { identifiers },
VulnerabilityTraining: { {
template: '<div><slot name="header"></slot></div>', stubs: {
VulnerabilityTraining: {
template: '<div><slot name="header"></slot></div>',
},
}, },
}, },
}); );
expect(wrapper.text()).toContain(VULNERABILITY_TRAINING_HEADING.title); expect(wrapper.text()).toContain(VULNERABILITY_TRAINING_HEADING.title);
}); });
......
...@@ -13,6 +13,7 @@ describe('Vulnerability Report', () => { ...@@ -13,6 +13,7 @@ describe('Vulnerability Report', () => {
const el = document.createElement('div'); const el = document.createElement('div');
const elDataSet = { const elDataSet = {
vulnerability: JSON.stringify(mockVulnerability), vulnerability: JSON.stringify(mockVulnerability),
projectFullPath: 'namespace/project',
}; };
Object.assign(el.dataset, { Object.assign(el.dataset, {
......
...@@ -41,7 +41,8 @@ export const getSecurityTrainingProvidersData = (providerOverrides = {}) => { ...@@ -41,7 +41,8 @@ export const getSecurityTrainingProvidersData = (providerOverrides = {}) => {
const response = { const response = {
data: { data: {
project: { project: {
id: 1, id: 'gid://gitlab/Project/1',
__typename: 'Project',
securityTrainingProviders, securityTrainingProviders,
}, },
}, },
......
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