diff --git a/src/App.vue b/src/App.vue index fdb2b3f37..f9ce893c6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -23,6 +23,16 @@ export default { } }; + const preloadCustomizationIfAuthenticated = (jwt) => { + if ( + jwt && + this.$customization && + typeof this.$customization.preloadAll === 'function' + ) { + this.$customization.preloadAll(); + } + }; + EventBus.$on('authenticated', (jwt) => { if (jwt) { sessionStorage.setItem('token', jwt); @@ -30,6 +40,7 @@ export default { sessionStorage.removeItem('token'); } setJwtForAjax(jwt); + preloadCustomizationIfAuthenticated(jwt); }); // ensure $.ajaxSettings.headers exists @@ -37,7 +48,9 @@ export default { headers: {}, }); - setJwtForAjax(getToken()); + const existingToken = getToken(); + setJwtForAjax(existingToken); + preloadCustomizationIfAuthenticated(existingToken); // Send XHR cross-site cookie credentials if (this.$api.WITH_CREDENTIALS) { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 12975b978..70f73d12e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -68,6 +68,7 @@ "configuration_test": "Configuration Test", "consumer_key": "Consumer key", "consumer_secret": "Consumer secret", + "customization": "Customization", "cpan": "CPAN", "create_alert": "Create Alert", "create_ldap_user": "Create LDAP User", @@ -200,6 +201,8 @@ "oidc_groups": "OpenID Connect Groups", "oidc_users": "OpenID Connect Users", "old_key_format": "This API key is outdated and should be updated soon for continued functionality!", + "organization_code": "Organization Name", + "organization_code_required": "Organization Name is required.", "oss_index": "Sonatype OSS Index", "osv_advisories": "Google OSV Advisories (Beta)", "password": "Password (or access token)", @@ -213,7 +216,9 @@ "portfolio_access_control": "Portfolio Access Control", "preferences": "Preferences", "preview": "Preview", + "preview_id": "Preview Vulnerability ID", "project_access": "Project access", + "project_code": "Project Name", "publisher": "Publisher", "publisher_class": "Publisher class", "python": "Python", @@ -242,6 +247,11 @@ "required_password": "Password is required", "required_team_name": "Team name is required", "required_username": "Username is required", + "reset_policy_daily": "Daily", + "reset_policy_monthly": "Monthly", + "reset_policy_never": "Never", + "reset_policy_yearly": "Yearly", + "reset_to_defaults": "Reset to defaults", "restore_default_template": "Restore default templates", "scope": "Scope", "select_ecosystem": "Select Ecosystems", @@ -251,6 +261,10 @@ "select_project": "Select Project", "select_team": "Select Team", "select_team_as_recipient": "Select team as recipient", + "sequence_padding": "Sequence Padding", + "sequence_padding_help": "Minimum number of digits for the sequence number (e.g. 4 → 0001).", + "sequence_reset_policy": "Sequence Reset Policy", + "sequence_reset_policy_help": "Defines when the numeric sequence resets to 1.", "snyk": "Snyk (Beta)", "subject_identifier": "Subject Identifier", "submit": "Submit", @@ -289,11 +303,18 @@ "token": "Token", "trivy": "Trivy", "url": "URL", + "use_custom_id_generator": "Use custom sequential ID generator", + "use_custom_id_generator_off_help": "Disabled: IDs use the default Dependency-Track format (e.g. INT-xxxx-xxxx-xxxx).", + "use_custom_id_generator_on_help": "Enabled: IDs are generated using your configured template and sequence.", "user_created": "User created", "user_deleted": "User deleted", "username": "Username", "vuln_sources": "Vulnerability Sources", "vulndb": "VulnDB", + "vulnerability_id_generation": "Internal Vulnerability ID Generation", + "vulnerability_id_template": "Vulnerability ID Template", + "vulnerability_id_template_placeholder": "Select placeholders to define your vulnerability ID format", + "vulnerability_ids": "Vulnerability IDs", "vulnsource_alias_sync_enable": "Enable vulnerability alias synchronization", "vulnsource_alias_sync_enable_tooltip": "Alias data can help in identifying identical vulnerabilities across multiple databases. If the source provides this data, synchronize it with Dependency-Track's database.", "vulnsource_github_advisories_desc": "GitHub Advisories (GHSA) is a database of CVEs and GitHub-originated security advisories affecting the open source world. Dependency-Track integrates with GHSA by mirroring advisories via GitHub's public GraphQL API. The mirror is refreshed daily, or upon restart of the Dependency-Track instance. A personal access token (PAT) is required in order to authenticate with GitHub, but no scopes need to be assigned to it.", diff --git a/src/main.js b/src/main.js index 1fdfa468c..e89405a98 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ import router from './router'; import i18n from './i18n'; import './validation'; import './plugins/table.js'; +import customizationPlugin from './plugins/customization.js'; import axios from 'axios'; import VueAxios from 'vue-axios'; import vueDebounce from 'vue-debounce'; @@ -30,6 +31,7 @@ Vue.use(VueToastr, { defaultCloseOnHover: false, }); Vue.use(vueDebounce, { defaultTime: '750ms' }); +Vue.use(customizationPlugin); Vue.use(VuePageTitle, { prefix: 'Dependency-Track -', router }); Vue.prototype.$api = api; diff --git a/src/plugins/customization.js b/src/plugins/customization.js new file mode 100644 index 000000000..c25d26867 --- /dev/null +++ b/src/plugins/customization.js @@ -0,0 +1,169 @@ +/** + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ + +import axios from 'axios'; + +/** + * Plugin for the Customization API endpoints. + * Provides methods for interacting with customization settings (vulnerability IDs) + * with in-memory caching so settings are fetched once and shared across components. + */ +export default { + install(vueApp) { + // Cache for vulnerability ID settings - loaded once, used everywhere + let cachedVulnIdSettings = null; + let settingsLoaded = false; + let loadingPromise = null; + + const defaultVulnIdSettings = () => ({ + useCustomId: false, + orgCode: 'DT', + projectCode: 'project', + template: '{ORG_CODE}-{YYYY}-{SEQUENCE}', + resetPolicy: 'YEARLY', + sequencePadding: 5, + }); + + const customizationService = { + /** + * Get cached vulnerability ID settings (instant access). + * Returns cached settings or defaults if not yet loaded. + * @returns {Object} Cached settings object + */ + getCachedVulnIdSettings() { + if (cachedVulnIdSettings) { + return cachedVulnIdSettings; + } + return defaultVulnIdSettings(); + }, + + /** + * Check if settings have been loaded. + * @returns {boolean} True if settings are loaded + */ + isSettingsLoaded() { + return settingsLoaded; + }, + + /** + * Preload vulnerability ID settings (call after authentication). + * Fetches settings from the API and caches them for instant access. + * @returns {Promise} Resolves when settings are loaded + */ + async preloadVulnIdSettings() { + if (settingsLoaded) { + return cachedVulnIdSettings; + } + if (loadingPromise) { + return loadingPromise; + } + loadingPromise = this.getVulnerabilityIdSettings() + .then((response) => { + if (response && response.data) { + cachedVulnIdSettings = response.data; + settingsLoaded = true; + } + return cachedVulnIdSettings; + }) + .catch((error) => { + console.warn( + 'Failed to preload vulnerability ID settings, using defaults:', + error, + ); + cachedVulnIdSettings = defaultVulnIdSettings(); + settingsLoaded = true; + return cachedVulnIdSettings; + }) + .finally(() => { + loadingPromise = null; + }); + return loadingPromise; + }, + + /** + * Preload all customization settings after authentication succeeds. + * Uses allSettled so one failing endpoint does not block the rest. + * @returns {Promise} + */ + async preloadAll() { + await Promise.allSettled([this.preloadVulnIdSettings()]); + }, + + /** + * Invalidate cache (call when admin updates settings). + */ + invalidateCache() { + cachedVulnIdSettings = null; + settingsLoaded = false; + }, + + /** + * Get vulnerability ID configuration settings. + * @returns {Promise} Response containing orgCode, template, resetPolicy, sequencePadding + */ + getVulnerabilityIdSettings() { + return axios.get( + vueApp.prototype.$api.BASE_URL + + '/' + + vueApp.prototype.$api.URL_CUSTOMIZATION + + '/vulnerability-id', + { + withCredentials: vueApp.prototype.$api.WITH_CREDENTIALS, + headers: { + 'Content-Type': vueApp.prototype.$api.CONTENT_TYPE_JSON, + }, + }, + ); + }, + + /** + * Update vulnerability ID configuration settings. + * @param {Object} settings - Configuration object with orgCode, template, resetPolicy, sequencePadding + * @returns {Promise} Response from update operation + */ + updateVulnerabilityIdSettings(settings) { + // Invalidate cache when settings are updated + this.invalidateCache(); + return axios + .put( + vueApp.prototype.$api.BASE_URL + + '/' + + vueApp.prototype.$api.URL_CUSTOMIZATION + + '/vulnerability-id', + settings, + { + withCredentials: vueApp.prototype.$api.WITH_CREDENTIALS, + headers: { + 'Content-Type': vueApp.prototype.$api.CONTENT_TYPE_JSON, + }, + }, + ) + .then((response) => { + // Update cache with new settings + cachedVulnIdSettings = settings; + settingsLoaded = true; + return response; + }); + }, + }; + + // Register customization service as Vue plugin property + vueApp.prototype.$customization = customizationService; + }, +}; diff --git a/src/router/index.js b/src/router/index.js index cf90116c4..988c870b9 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -40,6 +40,8 @@ const TaskScheduler = () => const Telemetry = () => import('@/views/administration/configuration/Telemetry'); const Search = () => import('@/views/administration/configuration/Search'); +const Customization = () => + import('@/views/administration/configuration/Customization'); const Experimental = () => import('@/views/administration/configuration/Experimental'); @@ -387,6 +389,17 @@ function configRoutes() { permission: 'SYSTEM_CONFIGURATION', }, }, + { + path: 'configuration/customization', + component: Customization, + meta: { + title: i18n.t('message.administration'), + i18n: 'message.administration', + sectionPath: '/admin', + sectionName: 'Admin', + permission: 'SYSTEM_CONFIGURATION', + }, + }, { path: 'configuration/welcomeMessage', component: WelcomeMessage, diff --git a/src/shared/api.json b/src/shared/api.json index b3b39f426..99c095e68 100644 --- a/src/shared/api.json +++ b/src/shared/api.json @@ -19,6 +19,7 @@ "URL_CALCULATOR_OWASP": "api/v1/calculator/owasp", "URL_COMPONENT": "api/v1/component", "URL_CONFIG_PROPERTY": "api/v1/configProperty", + "URL_CUSTOMIZATION": "api/v1/customization", "URL_CWE": "api/v1/cwe", "URL_DEPENDENCY_GRAPH": "api/v1/dependencyGraph", "URL_FINDING": "api/v1/finding", diff --git a/src/views/administration/AdminMenu.vue b/src/views/administration/AdminMenu.vue index eeea710df..e114d72de 100644 --- a/src/views/administration/AdminMenu.vue +++ b/src/views/administration/AdminMenu.vue @@ -121,6 +121,11 @@ export default { name: this.$t('message.search'), route: 'configuration/search', }, + { + component: 'Customization', + name: this.$t('admin.customization'), + route: 'configuration/customization', + }, { component: 'Experimental', name: this.$t('admin.experimental'), diff --git a/src/views/administration/configuration/Customization.vue b/src/views/administration/configuration/Customization.vue new file mode 100644 index 000000000..5a56583f3 --- /dev/null +++ b/src/views/administration/configuration/Customization.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/src/views/portfolio/vulnerabilities/VulnerabilityCreateVulnerabilityModal.vue b/src/views/portfolio/vulnerabilities/VulnerabilityCreateVulnerabilityModal.vue index 025ae9273..83a4cab13 100644 --- a/src/views/portfolio/vulnerabilities/VulnerabilityCreateVulnerabilityModal.vue +++ b/src/views/portfolio/vulnerabilities/VulnerabilityCreateVulnerabilityModal.vue @@ -18,6 +18,7 @@ input-group-size="mb-3" type="text" v-model="vulnerability.vulnId" + @input="onVulnIdInput" lazy="true" required="true" feedback="true" @@ -1136,6 +1137,10 @@ export default { currentCritical: 7.1, cvssv2Test: null, selectableCwes: [], + cachedGeneratedVulnId: null, + cachedProjectCode: null, + isAutoGeneratedId: false, + isSettingVulnIdProgrammatically: false, vulnerability: { vulnId: null, // This has to be null on initial load so that the vulnId gets populated. source: 'INTERNAL', @@ -1756,52 +1761,158 @@ export default { }, }, methods: { + getCurrentProjectCode: function () { + const settings = + this.$customization && this.$customization.getCachedVulnIdSettings + ? this.$customization.getCachedVulnIdSettings() + : null; + return settings && settings.projectCode ? settings.projectCode : null; + }, + setVulnIdProgrammatically: function (vulnId, isAutoGenerated = false) { + this.isSettingVulnIdProgrammatically = true; + this.vulnerability.vulnId = vulnId; + this.isAutoGeneratedId = isAutoGenerated; + this.isSettingVulnIdProgrammatically = false; + }, + invalidateGeneratedVulnIdCache: function () { + this.cachedGeneratedVulnId = null; + this.cachedProjectCode = null; + this.isAutoGeneratedId = false; + }, + onVulnIdInput: function (value) { + if (this.isSettingVulnIdProgrammatically) { + return; + } + const currentValue = typeof value === 'string' ? value.trim() : value; + if ( + this.isAutoGeneratedId && + this.cachedGeneratedVulnId && + currentValue === this.cachedGeneratedVulnId + ) { + return; + } + this.isAutoGeneratedId = false; + }, + refreshGeneratedVulnId: async function ({ force = false } = {}) { + const projectCode = this.getCurrentProjectCode(); + if ( + !force && + this.cachedGeneratedVulnId && + this.cachedProjectCode === projectCode + ) { + this.setVulnIdProgrammatically(this.cachedGeneratedVulnId, true); + return this.cachedGeneratedVulnId; + } + + const url = `${this.$api.BASE_URL}/${this.$api.URL_VULNERABILITY}/vulnId/preview`; + const params = {}; + if (projectCode) { + params.projectName = projectCode; + } + + try { + const response = await this.axios.get(url, { params }); + const generatedVulnId = response.data; + this.cachedGeneratedVulnId = generatedVulnId; + this.cachedProjectCode = projectCode; + this.setVulnIdProgrammatically(generatedVulnId, true); + return generatedVulnId; + } catch (error) { + console.error('Failed to generate vulnerability ID preview:', error); + this.setVulnIdProgrammatically('', false); + return null; + } + }, + isDuplicateVulnIdError: function (error) { + const apiMessage = error?.response?.data; + return ( + typeof apiMessage === 'string' && + apiMessage.toLowerCase().includes('already exists') + ); + }, onShow: function () { - let url = `${this.$api.BASE_URL}/${this.$api.URL_VULNERABILITY}/vulnId`; - this.axios.get(url).then((response) => { - this.vulnerability.vulnId = response.data; - }); + this.refreshGeneratedVulnId(); }, - createVulnerability: function () { - let url = `${this.$api.BASE_URL}/${this.$api.URL_VULNERABILITY}`; - this.axios - .put(url, { - vulnId: this.vulnerability.vulnId, - source: 'INTERNAL', - title: this.vulnerability.title, - description: this.vulnerability.description, - detail: this.vulnerability.detail, - recommendation: this.vulnerability.recommendation, - references: this.vulnerability.references, - created: this.vulnerability.created, - published: this.vulnerability.published, - updated: this.vulnerability.updated, - severity: this.vulnerability.severity, - cvssV2Vector: this.generateCvssV2Vector(), - cvssV3Vector: this.generateCvssV3Vector(), - cvssV4Vector: this.generateCvssV4Vector(), - owaspRRVector: this.generateOwaspRRVector(), - cwes: this.selectableCwes, - affectedComponents: this.vulnerability.affectedComponents, - }) - .then((response) => { - this.$emit('refreshTable'); - this.$toastr.s(this.$t('message.vulnerability_created')); - this.$router.replace({ - path: - '/vulnerabilities/INTERNAL/' + - encodeURIComponent(this.vulnerability.vulnId), - }); - }) - .catch((error) => { - this.$toastr.w(this.$t('condition.unsuccessful_action')); - }) - .finally(() => { - this.$root.$emit( - 'bv::hide::modal', - 'vulnerabilityCreateVulnerabilityModal', + createVulnerability: async function () { + const url = `${this.$api.BASE_URL}/${this.$api.URL_VULNERABILITY}`; + const requestConfig = {}; + if (this.isAutoGeneratedId) { + const projectCode = this.getCurrentProjectCode(); + if (projectCode) { + requestConfig.params = { projectName: projectCode }; + } + } + + const payload = () => ({ + // When the identifier was auto-generated, omit it so the server + // allocates (and reserves) the identifier inside the create + // transaction. A user-provided identifier is sent as-is. + vulnId: this.isAutoGeneratedId ? null : this.vulnerability.vulnId, + source: 'INTERNAL', + title: this.vulnerability.title, + description: this.vulnerability.description, + detail: this.vulnerability.detail, + recommendation: this.vulnerability.recommendation, + references: this.vulnerability.references, + created: this.vulnerability.created, + published: this.vulnerability.published, + updated: this.vulnerability.updated, + severity: this.vulnerability.severity, + cvssV2Vector: this.generateCvssV2Vector(), + cvssV3Vector: this.generateCvssV3Vector(), + cvssV4Vector: this.generateCvssV4Vector(), + owaspRRVector: this.generateOwaspRRVector(), + cwes: this.selectableCwes, + affectedComponents: this.vulnerability.affectedComponents, + }); + + try { + let response; + try { + response = await this.axios.put( + url, + payload(), + Object.keys(requestConfig).length ? requestConfig : undefined, ); + } catch (error) { + if (this.isAutoGeneratedId && this.isDuplicateVulnIdError(error)) { + // The previewed identifier was taken in the meantime — refresh + // and retry once with a newly generated identifier. + this.invalidateGeneratedVulnIdCache(); + const refreshedVulnId = await this.refreshGeneratedVulnId({ + force: true, + }); + if (refreshedVulnId) { + response = await this.axios.put( + url, + payload(), + Object.keys(requestConfig).length ? requestConfig : undefined, + ); + } else { + throw error; + } + } else { + throw error; + } + } + + const createdVulnId = + response?.data?.vulnId || this.vulnerability.vulnId; + this.$emit('refreshTable'); + this.$toastr.s(this.$t('message.vulnerability_created')); + this.invalidateGeneratedVulnIdCache(); + this.$router.replace({ + path: + '/vulnerabilities/INTERNAL/' + encodeURIComponent(createdVulnId), }); + } catch (error) { + this.$toastr.w(this.$t('condition.unsuccessful_action')); + } finally { + this.$root.$emit( + 'bv::hide::modal', + 'vulnerabilityCreateVulnerabilityModal', + ); + } }, updateCweSelection: function (selections) { this.$root.$emit('bv::hide::modal', 'selectCweModal');