diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index b5fd00a2ac..d9c5293eed 100644 --- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -129,7 +129,13 @@ public enum ConfigPropertyConstants { DEFAULT_LANGUAGE("general", "default.locale", null, PropertyType.STRING, "Determine the default Language to use", true), TELEMETRY_SUBMISSION_ENABLED("telemetry", "submission.enabled", String.valueOf(!"true".equals(System.getProperty("dev.mode.enabled")) && Config.getInstance().getPropertyAsBoolean(ConfigKey.TELEMETRY_SUBMISSION_ENABLED_DEFAULT)), PropertyType.BOOLEAN, "Whether submission of telemetry data is enabled"), TELEMETRY_LAST_SUBMISSION_DATA("telemetry", "last.submission.data", null, PropertyType.STRING, "Data of the last telemetry submission"), - TELEMETRY_LAST_SUBMISSION_EPOCH_SECONDS("telemetry", "last.submission.epoch.seconds", null, PropertyType.INTEGER, "Timestamp of the last telemetry submission in epoch seconds"); + TELEMETRY_LAST_SUBMISSION_EPOCH_SECONDS("telemetry", "last.submission.epoch.seconds", null, PropertyType.INTEGER, "Timestamp of the last telemetry submission in epoch seconds"), + VULNERABILITY_ID_USE_CUSTOM("vulnerability-id", "use.custom.id", "false", PropertyType.BOOLEAN, "Use custom sequential ID generator; when false, uses the default random INT- format"), + VULNERABILITY_ID_ORG_CODE("vulnerability-id", "org.code", SystemUtils.getEnvironmentVariable("VULNERABILITY_ID_ORG_CODE_DEFAULT", "DT"), PropertyType.STRING, "Organization code used in vulnerability IDs"), + VULNERABILITY_ID_PROJECT_CODE("vulnerability-id", "project.code", SystemUtils.getEnvironmentVariable("VULNERABILITY_ID_PROJECT_CODE_DEFAULT", "project"), PropertyType.STRING, "Default project code used in vulnerability IDs when no project name is provided"), + VULNERABILITY_ID_TEMPLATE("vulnerability-id", "template", SystemUtils.getEnvironmentVariable("VULNERABILITY_ID_TEMPLATE_DEFAULT", "{ORG_CODE}-{YYYY}-{SEQUENCE}"), PropertyType.STRING, "Template for generating vulnerability IDs with variables: {ORG_CODE}, {PROJECT_NAME}, {PROJECT_CODE}, {YYYY}, {MM}, {DD}, {SEQUENCE}"), + VULNERABILITY_ID_RESET_POLICY("vulnerability-id", "reset.policy", SystemUtils.getEnvironmentVariable("VULNERABILITY_ID_RESET_POLICY_DEFAULT", "YEARLY"), PropertyType.STRING, "Sequence reset policy: YEARLY, MONTHLY, DAILY, NEVER"), + VULNERABILITY_ID_SEQUENCE_PADDING("vulnerability-id", "sequence.padding", SystemUtils.getEnvironmentVariable("VULNERABILITY_ID_SEQUENCE_PADDING_DEFAULT", "5"), PropertyType.INTEGER, "Number of digits for sequence padding"); private final String groupName; private final String propertyName; diff --git a/src/main/java/org/dependencytrack/model/VulnerabilitySequence.java b/src/main/java/org/dependencytrack/model/VulnerabilitySequence.java new file mode 100644 index 0000000000..51722d9ae4 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/VulnerabilitySequence.java @@ -0,0 +1,123 @@ +/* + * 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. + */ +package org.dependencytrack.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import javax.jdo.annotations.Column; +import javax.jdo.annotations.IdGeneratorStrategy; +import javax.jdo.annotations.Index; +import javax.jdo.annotations.PersistenceCapable; +import javax.jdo.annotations.Persistent; +import javax.jdo.annotations.PrimaryKey; +import javax.jdo.annotations.Unique; +import java.io.Serializable; +import java.util.Date; + +/** + * Tracks sequence counters for internally generated vulnerability IDs. + * Sequence scope is per organization code + project key. + */ +@PersistenceCapable(table = "VULNERABILITY_SEQUENCE_V2") +@Unique(name = "VULNSEQV2_ORG_PROJECT_IDX", members = {"orgCode", "projectKey"}) +public class VulnerabilitySequence implements Serializable { + + @PrimaryKey + @Persistent(valueStrategy = IdGeneratorStrategy.NATIVE) + @JsonIgnore + private long id; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "ORG_CODE", allowsNull = "false") + @Index(name = "VULNSEQV2_ORG_IDX") + private String orgCode; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "PROJECT_KEY", allowsNull = "false") + @Index(name = "VULNSEQV2_PROJECT_IDX") + private String projectKey; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "CURRENT_SEQUENCE", allowsNull = "false") + private long currentSequence; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "LAST_RESET_DATE") + private Date lastResetDate; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "RESET_POLICY", allowsNull = "false") + private String resetPolicy; + + @Persistent(defaultFetchGroup = "true") + @Column(name = "CREATED_AT") + private Date createdAt; + + public long getId() { + return id; + } + + public String getOrgCode() { + return orgCode; + } + + public void setOrgCode(final String orgCode) { + this.orgCode = orgCode; + } + + public String getProjectKey() { + return projectKey; + } + + public void setProjectKey(final String projectKey) { + this.projectKey = projectKey; + } + + public long getCurrentSequence() { + return currentSequence; + } + + public void setCurrentSequence(final long currentSequence) { + this.currentSequence = currentSequence; + } + + public Date getLastResetDate() { + return lastResetDate; + } + + public void setLastResetDate(final Date lastResetDate) { + this.lastResetDate = lastResetDate; + } + + public String getResetPolicy() { + return resetPolicy; + } + + public void setResetPolicy(final String resetPolicy) { + this.resetPolicy = resetPolicy; + } + + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(final Date createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index ade82de814..b969c92e71 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -920,6 +920,22 @@ public void synchronizeVulnerableSoftware( getVulnerableSoftwareQueryManager().synchronizeVulnerableSoftware(persistentVuln, vsList, source); } + public synchronized String generateNextVulnerabilityId(final String projectName) { + return getVulnerabilityQueryManager().generateNextVulnerabilityId(projectName); + } + + public synchronized String previewNextVulnerabilityId(final String projectName) { + return getVulnerabilityQueryManager().previewNextVulnerabilityId(projectName); + } + + public synchronized String allocateNextVulnerabilityIdInTransaction(final String projectName) { + return getVulnerabilityQueryManager().allocateNextVulnerabilityIdInTransaction(projectName); + } + + public boolean vulnerabilityIdExists(final String vulnerabilityId) { + return getVulnerabilityQueryManager().vulnerabilityIdExists(vulnerabilityId); + } + public boolean contains(Vulnerability vulnerability, Component component) { return getVulnerabilityQueryManager().contains(vulnerability, component); } diff --git a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java index cc54abec4c..d7dcb3f339 100644 --- a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java @@ -20,6 +20,7 @@ import alpine.common.logging.Logger; import alpine.event.framework.Event; +import alpine.model.ConfigProperty; import alpine.persistence.PaginatedResult; import alpine.resources.AlpineRequest; import org.apache.commons.collections4.ListUtils; @@ -28,19 +29,26 @@ import org.dependencytrack.model.AffectedVersionAttribution; import org.dependencytrack.model.Analysis; import org.dependencytrack.model.Component; +import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.FindingAttribution; import org.dependencytrack.model.Project; import org.dependencytrack.model.VulnIdAndSource; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAlias; +import org.dependencytrack.model.VulnerabilitySequence; import org.dependencytrack.model.VulnerableSoftware; import org.dependencytrack.resources.v1.vo.AffectedProject; import org.dependencytrack.tasks.VulnDbSyncTask; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; import org.dependencytrack.util.PersistenceUtil; +import org.dependencytrack.util.VulnerabilityIdGenerator; +import org.dependencytrack.util.VulnerabilityUtil; +import javax.jdo.JDODataStoreException; import javax.jdo.PersistenceManager; import javax.jdo.Query; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -48,9 +56,12 @@ import java.util.Date; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static org.dependencytrack.util.PersistenceUtil.assertPersistent; @@ -1082,4 +1093,266 @@ public boolean hasVulnerabilities(final Project project) { return !executeAndCloseResultList(query, Long.class).isEmpty(); } + /** + * Generates and reserves the next vulnerability identifier for the given project name. + * This updates sequence state immediately. + */ + public synchronized String generateNextVulnerabilityId(final String projectName) { + return callInTransaction(() -> generateVulnerabilityId(projectName, true)); + } + + /** + * Returns the next vulnerability identifier candidate for the given project name without reserving it. + */ + public synchronized String previewNextVulnerabilityId(final String projectName) { + return callInTransaction(() -> generateVulnerabilityId(projectName, false)); + } + + /** + * Generates and reserves the next vulnerability identifier in the caller's active transaction. + * Intended for create flows that need ID allocation and vulnerability persistence to be atomic. + */ + public synchronized String allocateNextVulnerabilityIdInTransaction(final String projectName) { + return generateVulnerabilityId(projectName, true); + } + + private String generateVulnerabilityId(final String projectName, final boolean reserveSequence) { + // Check toggle: if custom ID generation is disabled, fall back to OWASP random INT- format. + final ConfigProperty useCustomProp = getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_USE_CUSTOM.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_USE_CUSTOM.getPropertyName()); + final boolean useCustom = useCustomProp == null + || Boolean.parseBoolean(useCustomProp.getPropertyValue()); + if (!useCustom) { + return VulnerabilityUtil.randomInternalId(); + } + + // Get configuration from ConfigProperty. + final ConfigProperty orgCodeProp = getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE.getPropertyName()); + final String orgCode = orgCodeProp != null ? orgCodeProp.getPropertyValue() : "DT"; + + final ConfigProperty defaultProjectCodeProp = getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getPropertyName()); + final String defaultProjectCode = defaultProjectCodeProp != null + ? defaultProjectCodeProp.getPropertyValue() : "project"; + + final ConfigProperty templateProp = getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getPropertyName()); + final String defaultTemplate = ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getDefaultPropertyValue(); + final String template = templateProp != null ? templateProp.getPropertyValue() : defaultTemplate; + + final ConfigProperty paddingProp = getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING.getPropertyName()); + final int sequencePadding = paddingProp != null + ? Integer.parseInt(paddingProp.getPropertyValue()) : 5; + final ConfigProperty resetPolicyProp = getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY.getPropertyName()); + final String resetPolicy = normalizeResetPolicy(resetPolicyProp != null + ? resetPolicyProp.getPropertyValue() + : "YEARLY"); + + // Sanitize project name. If no project name was provided by the caller, + // use the configured default project code. + final String effectiveProjectName = StringUtils.isBlank(projectName) + ? defaultProjectCode + : projectName; + final String sanitizedProject = VulnerabilityIdGenerator.sanitizeProjectName(effectiveProjectName) + .toLowerCase(Locale.ROOT); + final String normalizedOrgCode = StringUtils.defaultIfBlank(orgCode, "DT") + .trim() + .toUpperCase(Locale.ROOT); + + final LocalDate now = LocalDate.now(ZoneOffset.UTC); + final VulnerabilitySequence sequence = reserveSequence + ? getOrCreateVulnerabilitySequence(normalizedOrgCode, sanitizedProject, resetPolicy) + : getVulnerabilitySequence(normalizedOrgCode, sanitizedProject); + + long currentSequence; + if (sequence == null) { + currentSequence = findHighestSequenceFromExistingVulnerabilities(normalizedOrgCode, sanitizedProject); + } else { + final LocalDate lastResetDate = sequence.getLastResetDate() == null + ? null + : sequence.getLastResetDate().toInstant().atZone(ZoneOffset.UTC).toLocalDate(); + if (!resetPolicy.equalsIgnoreCase(sequence.getResetPolicy()) + || shouldResetSequence(resetPolicy, lastResetDate, now)) { + currentSequence = 0L; + if (reserveSequence) { + sequence.setCurrentSequence(0L); + sequence.setLastResetDate(Date.from(now.atStartOfDay(ZoneOffset.UTC).toInstant())); + sequence.setResetPolicy(resetPolicy); + } + } else { + currentSequence = sequence.getCurrentSequence(); + } + } + + long nextSequence = currentSequence + 1L; + String nextId = VulnerabilityIdGenerator.generateId(orgCode, template, sequencePadding, sanitizedProject, nextSequence); + int attempts = 0; + while (vulnerabilityIdExists(nextId)) { + nextSequence += 1L; + nextId = VulnerabilityIdGenerator.generateId(orgCode, template, sequencePadding, sanitizedProject, nextSequence); + attempts++; + if (attempts > 10000) { + throw new IllegalStateException("Unable to allocate a unique vulnerability ID after 10000 attempts."); + } + } + + if (reserveSequence) { + if (sequence == null) { + throw new IllegalStateException("Unable to reserve vulnerability sequence."); + } + sequence.setCurrentSequence(nextSequence); + persist(sequence); + } + + return nextId; + } + + /** + * Returns true if an INTERNAL vulnerability already exists with the provided identifier. + */ + public boolean vulnerabilityIdExists(final String vulnId) { + if (StringUtils.isBlank(vulnId)) { + return false; + } + + final Query query = pm.newQuery(Vulnerability.class, "source == :source && vulnId == :vulnId"); + query.setRange(0, 1); + try { + final Vulnerability existing = singleResult(query.execute(Vulnerability.Source.INTERNAL.name(), vulnId)); + return existing != null; + } finally { + query.closeAll(); + } + } + + private VulnerabilitySequence getOrCreateVulnerabilitySequence(final String orgCode, + final String projectKey, + final String resetPolicy) { + VulnerabilitySequence sequence = getVulnerabilitySequence(orgCode, projectKey); + if (sequence != null) { + return sequence; + } + + sequence = new VulnerabilitySequence(); + sequence.setOrgCode(orgCode); + sequence.setProjectKey(projectKey); + sequence.setCurrentSequence(findHighestSequenceFromExistingVulnerabilities(orgCode, projectKey)); + final LocalDate today = LocalDate.now(ZoneOffset.UTC); + sequence.setLastResetDate(Date.from(today.atStartOfDay(ZoneOffset.UTC).toInstant())); + sequence.setResetPolicy(resetPolicy); + sequence.setCreatedAt(new Date()); + + try { + return persist(sequence); + } catch (final JDODataStoreException ex) { + // Another request may have created the same sequence row after our initial lookup. + final VulnerabilitySequence existing = getVulnerabilitySequence(orgCode, projectKey); + if (existing != null && isDuplicateVulnerabilitySequenceInsert(ex)) { + return existing; + } + throw ex; + } + } + + private boolean isDuplicateVulnerabilitySequenceInsert(final JDODataStoreException ex) { + Throwable cause = ex; + while (cause != null) { + final String message = cause.getMessage(); + if (message != null + && message.contains("VULNERABILITY_SEQUENCE_V2") + && message.contains("VULNSEQV2_ORG_PROJECT")) { + return true; + } + cause = cause.getCause(); + } + return false; + } + + private VulnerabilitySequence getVulnerabilitySequence(final String orgCode, final String projectKey) { + final Query query = pm.newQuery(VulnerabilitySequence.class); + query.setFilter("orgCode == :orgCode && projectKey == :projectKey"); + query.setNamedParameters(Map.of("orgCode", orgCode, "projectKey", projectKey)); + query.setRange(0, 1); + try { + final List existing = query.executeList(); + return existing.isEmpty() ? null : existing.get(0); + } finally { + query.closeAll(); + } + } + + private long findHighestSequenceFromExistingVulnerabilities(final String orgCode, final String projectKey) { + long highestSequence = 0L; + final String prefix = (orgCode + "-" + projectKey + "-").toUpperCase(Locale.ROOT); + final Pattern sequencePattern = Pattern.compile("(\\d+)$"); + + final Query query = pm.newQuery(Vulnerability.class); + query.setFilter("source == :source"); + query.setNamedParameters(Map.of("source", Vulnerability.Source.INTERNAL.name())); + try { + final List vulnerabilities = query.executeList(); + for (final Vulnerability vulnerability : vulnerabilities) { + final String vulnId = vulnerability.getVulnId(); + if (vulnId == null || !vulnId.toUpperCase(Locale.ROOT).startsWith(prefix)) { + continue; + } + + final Matcher matcher = sequencePattern.matcher(vulnId); + if (!matcher.find()) { + continue; + } + + try { + final long sequence = Long.parseLong(matcher.group(1)); + if (sequence > highestSequence) { + highestSequence = sequence; + } + } catch (NumberFormatException ignored) { + // Ignore malformed numeric suffixes. + } + } + } finally { + query.closeAll(); + } + return highestSequence; + } + + private boolean shouldResetSequence(final String resetPolicy, final LocalDate lastResetDate, final LocalDate now) { + if (lastResetDate == null) { + return false; + } + + switch (resetPolicy) { + case "DAILY": + return !lastResetDate.isEqual(now); + case "MONTHLY": + return lastResetDate.getYear() != now.getYear() + || lastResetDate.getMonthValue() != now.getMonthValue(); + case "YEARLY": + return lastResetDate.getYear() != now.getYear(); + case "NEVER": + default: + return false; + } + } + + private String normalizeResetPolicy(final String resetPolicy) { + if (resetPolicy == null) { + return "YEARLY"; + } + final String normalized = resetPolicy.trim().toUpperCase(Locale.ROOT); + return switch (normalized) { + case "DAILY", "MONTHLY", "YEARLY", "NEVER" -> normalized; + default -> "YEARLY"; + }; + } } diff --git a/src/main/java/org/dependencytrack/resources/v1/CustomizationResource.java b/src/main/java/org/dependencytrack/resources/v1/CustomizationResource.java new file mode 100644 index 0000000000..b962b8654e --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/CustomizationResource.java @@ -0,0 +1,278 @@ +/* + * 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. + */ +package org.dependencytrack.resources.v1; + +import alpine.model.ConfigProperty; +import alpine.server.auth.PermissionRequired; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.ConfigPropertyConstants; +import org.dependencytrack.persistence.QueryManager; +import org.json.JSONObject; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +/** + * JAX-RS resource for managing application customization settings. + * + *

All settings managed here are persisted in the standard {@code CONFIGPROPERTY} table using + * the same group/name structure as the {@code /v1/configProperty} API. These endpoints are + * domain-specific facades over that storage layer and exist to provide: + *

    + *
  • Strongly-typed, curated JSON responses (e.g. {@code orgCode}, {@code template}) instead + * of raw {@code ConfigProperty} model objects
  • + *
  • Business-rule validation before persistence (e.g. padding bounds, reset policy enum)
  • + *
  • Atomic multi-property updates (vulnerability ID has 6 related properties saved together)
  • + *
+ * + *

The underlying data can also be read and written via the existing + * {@code GET/POST /v1/configProperty} endpoints using the group and property names defined in + * {@link org.dependencytrack.model.ConfigPropertyConstants}. The two API surfaces are fully + * compatible — both read from and write to the same database rows. + */ +@Path("/v1/customization") +@Tag(name = "Customization", description = "Endpoints for managing application customizations") +public class CustomizationResource extends AbstractConfigPropertyResource { + + /** + * Retrieves the vulnerability ID customization settings. + * + * @return A JSON response containing the current vulnerability ID configuration + */ + @GET + @Path("/vulnerability-id") + @Produces(MediaType.APPLICATION_JSON) + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + @Operation(summary = "Retrieve vulnerability ID settings", + description = "Retrieves the current vulnerability ID customization settings.

Requires permission VIEW_PORTFOLIO

") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Vulnerability ID settings retrieved successfully", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(type = "object", example = """ + { + "orgCode": "ORG", + "projectCode": "myproject", + "template": "{ORG_CODE}-{PROJECT_NAME}-{YYYY}-{SEQUENCE}", + "resetPolicy": "YEARLY", + "sequencePadding": 5 + } + """))) + }) + public Response getVulnerabilityIdSettings() { + try (QueryManager qm = new QueryManager(getAlpineRequest())) { + JSONObject response = new JSONObject(); + + // Get organization code + ConfigProperty orgCodeProp = qm.getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE.getPropertyName()); + response.put("orgCode", orgCodeProp != null + ? orgCodeProp.getPropertyValue() + : ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE.getDefaultPropertyValue()); + + // Get default project code + ConfigProperty projectCodeProp = qm.getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getPropertyName()); + response.put("projectCode", projectCodeProp != null + ? projectCodeProp.getPropertyValue() + : ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getDefaultPropertyValue()); + + // Get template + ConfigProperty templateProp = qm.getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getPropertyName()); + response.put("template", templateProp != null + ? templateProp.getPropertyValue() + : ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getDefaultPropertyValue()); + + // Get reset policy + ConfigProperty resetPolicyProp = qm.getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY.getPropertyName()); + response.put("resetPolicy", resetPolicyProp != null + ? resetPolicyProp.getPropertyValue() + : ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY.getDefaultPropertyValue()); + + // Get sequence padding + ConfigProperty sequencePaddingProp = qm.getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING.getPropertyName()); + response.put("sequencePadding", sequencePaddingProp != null + ? Integer.parseInt(sequencePaddingProp.getPropertyValue()) + : Integer.parseInt(ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING.getDefaultPropertyValue())); + + // Get use custom ID toggle + ConfigProperty useCustomProp = qm.getConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_USE_CUSTOM.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_USE_CUSTOM.getPropertyName()); + response.put("useCustomId", useCustomProp != null + ? Boolean.parseBoolean(useCustomProp.getPropertyValue()) + : Boolean.parseBoolean(ConfigPropertyConstants.VULNERABILITY_ID_USE_CUSTOM.getDefaultPropertyValue())); + + return Response.ok(response.toString()).build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new JSONObject() + .put("error", "Error retrieving vulnerability ID settings: " + e.getMessage()) + .toString()) + .build(); + } + } + + /** + * Updates the vulnerability ID customization settings. + * + * @param jsonInput The JSON payload containing the vulnerability ID settings + * @return A 204 No Content response on success + */ + @PUT + @Path("/vulnerability-id") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @PermissionRequired(Permissions.Constants.SYSTEM_CONFIGURATION) + @Operation(summary = "Update vulnerability ID settings", + description = "Updates the vulnerability ID customization settings") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "Vulnerability ID settings updated successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input provided"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden") + }) + public Response updateVulnerabilityIdSettings(String jsonInput) { + try (QueryManager qm = new QueryManager(getAlpineRequest())) { + JSONObject json = new JSONObject(jsonInput); + + // Validate input + if (!json.has("orgCode") || json.getString("orgCode").trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new JSONObject() + .put("error", "Organization code is required and cannot be empty") + .toString()) + .build(); + } + if (!json.has("template") || json.getString("template").trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new JSONObject() + .put("error", "Template is required and cannot be empty") + .toString()) + .build(); + } + if (!json.has("resetPolicy") || json.getString("resetPolicy").trim().isEmpty()) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new JSONObject() + .put("error", "Reset policy is required") + .toString()) + .build(); + } + if (!json.has("sequencePadding") || json.getInt("sequencePadding") < 1 || + json.getInt("sequencePadding") > 20) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new JSONObject() + .put("error", "Sequence padding must be between 1 and 20") + .toString()) + .build(); + } + + // Validate reset policy values + String resetPolicy = json.getString("resetPolicy"); + if (!resetPolicy.matches("YEARLY|MONTHLY|DAILY|NEVER")) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(new JSONObject() + .put("error", "Invalid reset policy. Must be YEARLY, MONTHLY, DAILY, or NEVER") + .toString()) + .build(); + } + + // Update org code + updateConfigProperty(qm, ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE, + json.getString("orgCode")); + + // Update default project code (optional — skip if empty) + if (json.has("projectCode") && !json.getString("projectCode").trim().isEmpty()) { + updateConfigProperty(qm, ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE, + json.getString("projectCode").trim()); + } + + // Update template + updateConfigProperty(qm, ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE, + json.getString("template")); + + // Update reset policy + updateConfigProperty(qm, ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY, + resetPolicy); + + // Update sequence padding + updateConfigProperty(qm, ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING, + String.valueOf(json.getInt("sequencePadding"))); + + // Update use custom ID toggle + if (json.has("useCustomId")) { + updateConfigProperty(qm, ConfigPropertyConstants.VULNERABILITY_ID_USE_CUSTOM, + String.valueOf(json.getBoolean("useCustomId"))); + } + + return Response.noContent().build(); + } catch (Exception e) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(new JSONObject().put("error", e.getMessage()).toString()) + .build(); + } + } + + /** + * Updates or creates a ConfigProperty with the given constant and value. + * + *

This mirrors the persistence logic in {@code ConfigPropertyResource} — the same + * {@code CONFIGPROPERTY} row is written regardless of which API surface is used. + * + * @param qm The QueryManager + * @param propertyConstant The ConfigPropertyConstants constant identifying the row + * @param value The new value to persist + */ + private void updateConfigProperty(QueryManager qm, ConfigPropertyConstants propertyConstant, + String value) { + ConfigProperty property = qm.getConfigProperty( + propertyConstant.getGroupName(), + propertyConstant.getPropertyName()); + + if (property == null) { + property = qm.createConfigProperty( + propertyConstant.getGroupName(), + propertyConstant.getPropertyName(), + value, + propertyConstant.getPropertyType(), + propertyConstant.getDescription()); + } else { + property.setPropertyValue(value); + qm.persist(property); + } + } +} diff --git a/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java b/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java index ee2813f76c..3574e9fa62 100644 --- a/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java @@ -33,6 +33,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Validator; +import org.apache.commons.lang3.StringUtils; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; @@ -333,10 +334,12 @@ public Response getAllVulnerabilities() { @ApiResponse(responseCode = "409", description = "A vulnerability with the specified vulnId already exists") }) @PermissionRequired(Permissions.Constants.VULNERABILITY_MANAGEMENT) - public Response createVulnerability(Vulnerability jsonVulnerability) { + public Response createVulnerability( + @Parameter(description = "Optional project name used to scope generated internal identifiers") + @QueryParam("projectName") final String projectName, + Vulnerability jsonVulnerability) { final Validator validator = super.getValidator(); failOnValidationError( - validator.validateProperty(jsonVulnerability, "vulnId"), validator.validateProperty(jsonVulnerability, "title"), validator.validateProperty(jsonVulnerability, "subTitle"), validator.validateProperty(jsonVulnerability, "description"), @@ -353,10 +356,18 @@ public Response createVulnerability(Vulnerability jsonVulnerability) { validator.validateProperty(jsonVulnerability, "vulnerableVersions"), validator.validateProperty(jsonVulnerability, "patchedVersions") ); + final String requestedVulnId = StringUtils.trimToNull(jsonVulnerability.getVulnId()); + if (requestedVulnId != null) { + failOnValidationError(validator.validateProperty(jsonVulnerability, "vulnId")); + jsonVulnerability.setVulnId(requestedVulnId); + } try (QueryManager qm = new QueryManager()) { - Vulnerability vulnerability = qm.getVulnerabilityByVulnId( - Vulnerability.Source.INTERNAL, jsonVulnerability.getVulnId().trim()); + Vulnerability vulnerability = null; + if (requestedVulnId != null) { + vulnerability = qm.getVulnerabilityByVulnId( + Vulnerability.Source.INTERNAL, requestedVulnId); + } if (vulnerability == null) { final List cweIds = new ArrayList<>(); if (jsonVulnerability.getCwes() != null) { @@ -380,6 +391,9 @@ public Response createVulnerability(Vulnerability jsonVulnerability) { recalculateScoresAndSeverityFromVectors(jsonVulnerability); jsonVulnerability.setSource(Vulnerability.Source.INTERNAL); return qm.callInTransaction(() -> { + if (StringUtils.isBlank(jsonVulnerability.getVulnId())) { + jsonVulnerability.setVulnId(qm.allocateNextVulnerabilityIdInTransaction(projectName)); + } final Vulnerability persistentVuln = qm.createVulnerability(jsonVulnerability, true); qm.synchronizeVulnerableSoftware(persistentVuln, vsList, Vulnerability.Source.INTERNAL); if (persistentVuln.getVulnerableSoftware() != null && !persistentVuln.getVulnerableSoftware().isEmpty()) { @@ -521,6 +535,34 @@ public Response deleteVulnerability( } } + @GET + @Path("/vulnId/preview") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Previews an internal vulnerability identifier without reserving it", + description = "

Requires permission PORTFOLIO_MANAGEMENT

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "An internal vulnerability identifier candidate", + content = @Content(schema = @Schema(type = "string")) + ), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "409", description = "A vulnerability identifier could not be generated") + }) + @PermissionRequired(Permissions.Constants.PORTFOLIO_MANAGEMENT) + public Response previewInternalVulnerabilityIdentifier( + @Parameter(description = "Optional project name used to scope the generated identifier") + @QueryParam("projectName") final String projectName) { + try (QueryManager qm = new QueryManager(getAlpineRequest())) { + final String vulnId = qm.previewNextVulnerabilityId(projectName); + return Response.ok(vulnId).build(); + } catch (IllegalStateException ex) { + return Response.status(Response.Status.CONFLICT).entity(ex.getMessage()).build(); + } + } + @GET @Path("/vulnId") @Produces(MediaType.APPLICATION_JSON) @@ -534,12 +576,19 @@ public Response deleteVulnerability( description = "An internal vulnerability identifier", content = @Content(schema = @Schema(type = "string")) ), - @ApiResponse(responseCode = "401", description = "Unauthorized") + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "409", description = "A generated vulnerability identifier already exists") }) @PermissionRequired(Permissions.Constants.PORTFOLIO_MANAGEMENT) - public Response generateInternalVulnerabilityIdentifier() { - final String vulnId = VulnerabilityUtil.randomInternalId(); - return Response.ok(vulnId).build(); + public Response generateInternalVulnerabilityIdentifier( + @Parameter(description = "Optional project name used to scope the generated identifier") + @QueryParam("projectName") final String projectName) { + try (QueryManager qm = new QueryManager(getAlpineRequest())) { + final String vulnId = qm.generateNextVulnerabilityId(projectName); + return Response.ok(vulnId).build(); + } catch (IllegalStateException ex) { + return Response.status(Response.Status.CONFLICT).entity(ex.getMessage()).build(); + } } public void recalculateScoresAndSeverityFromVectors(Vulnerability vuln) throws MissingFactorException { diff --git a/src/main/java/org/dependencytrack/util/VulnerabilityIdGenerator.java b/src/main/java/org/dependencytrack/util/VulnerabilityIdGenerator.java new file mode 100644 index 0000000000..36e4448376 --- /dev/null +++ b/src/main/java/org/dependencytrack/util/VulnerabilityIdGenerator.java @@ -0,0 +1,124 @@ +/* + * 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. + */ +package org.dependencytrack.util; + +/** + * Utility class for generating custom vulnerability IDs. + * Supports customizable templates and formatting via VulnerabilityIdTemplateParser. + * Default Format: DT--YYYY-00000 + * + * @since 4.13.0 + */ +public final class VulnerabilityIdGenerator { + + private static final String DEFAULT_ORG_CODE = "DT"; + private static final String DEFAULT_TEMPLATE = "{ORG_CODE}-{PROJECT_NAME}-{YYYY}-{SEQUENCE}"; + private static final int DEFAULT_SEQUENCE_PADDING = 5; + private static final String DEFAULT_PROJECT = "PROJECT"; + + private VulnerabilityIdGenerator() { + // Utility class + } + + /** + * Generate vulnerability ID using the provided template settings. + * + * @param orgCode The organization code (from ConfigProperty) + * @param template The template string (from ConfigProperty) + * @param sequencePadding The sequence padding (from ConfigProperty) + * @param projectName The project or product name + * @param sequenceNumber The sequence number for this project + * @return Generated vulnerability ID + */ + public static String generateId(String orgCode, String template, int sequencePadding, + String projectName, long sequenceNumber) { + try { + // Validate inputs + if (orgCode == null || orgCode.trim().isEmpty()) { + orgCode = DEFAULT_ORG_CODE; + } + if (template == null || template.trim().isEmpty()) { + template = DEFAULT_TEMPLATE; + } + if (sequencePadding < 1 || sequencePadding > 20) { + sequencePadding = DEFAULT_SEQUENCE_PADDING; + } + + // Create parser and generate ID + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser(template, sequencePadding); + return parser.generateId(orgCode, projectName, sequenceNumber); + } catch (Exception e) { + // Fallback to default format if template parsing fails + return generateIdWithDefaults(projectName, sequenceNumber); + } + } + + /** + * Generate vulnerability ID with default settings. + * Used as fallback when configuration is not available or invalid. + * + * @param projectName The project or product name + * @param sequenceNumber The sequence number + * @return Generated vulnerability ID using default settings + */ + public static String generateIdWithDefaults(String projectName, long sequenceNumber) { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser(DEFAULT_TEMPLATE, + DEFAULT_SEQUENCE_PADDING); + return parser.generateId(DEFAULT_ORG_CODE, projectName, sequenceNumber); + } + + /** + * Generate vulnerability ID with default project name and default settings. + * + * @param sequenceNumber The sequence number + * @return Generated vulnerability ID + */ + public static String generateId(long sequenceNumber) { + return generateIdWithDefaults(DEFAULT_PROJECT, sequenceNumber); + } + + /** + * Sanitize project name for use in vulnerability ID + * + * @param projectName The project name to sanitize + * @return Sanitized project name + */ + public static String sanitizeProjectName(String projectName) { + if (projectName == null || projectName.trim().isEmpty()) { + return DEFAULT_PROJECT; + } + + // Keep alphanumeric, hyphens, underscores, and spaces (collapse repeats to one) + String sanitized = projectName.trim() + .replaceAll("[^a-zA-Z0-9_ -]", "") + .replaceAll(" +", " ") + .replaceAll("-+", "-") + .replaceAll("_+", "_"); + + // Remove leading/trailing hyphens + sanitized = sanitized.replaceAll("^-+|-+$", ""); + + // Ensure we have a valid name + if (sanitized.isEmpty()) { + return DEFAULT_PROJECT; + } + + return sanitized.toLowerCase(); + } +} diff --git a/src/main/java/org/dependencytrack/util/VulnerabilityIdTemplateParser.java b/src/main/java/org/dependencytrack/util/VulnerabilityIdTemplateParser.java new file mode 100644 index 0000000000..87687f311b --- /dev/null +++ b/src/main/java/org/dependencytrack/util/VulnerabilityIdTemplateParser.java @@ -0,0 +1,217 @@ +/* + * 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. + */ +package org.dependencytrack.util; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser for vulnerability ID template strings with variable substitution. + * + * Supports the following template variables: + * - {ORG_CODE}: Organization code + * - {PROJECT_NAME}: Project name (sanitized) + * - {PROJECT_CODE}: Alias for PROJECT_NAME + * - {YYYY}: Year (4-digit) + * - {MM}: Month (2-digit, zero-padded) + * - {DD}: Day (2-digit, zero-padded) + * - {SEQUENCE}: Sequential number (padded according to padding setting) + * + * Example template: "{ORG_CODE}-{PROJECT_NAME}-{YYYY}-{SEQUENCE}" + * Result: "ORG-myproject-2026-00001" + */ +public class VulnerabilityIdTemplateParser { + + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{([A-Z_]+)\\}"); + private static final Pattern PROJECT_NAME_SANITIZE_PATTERN = Pattern.compile("[^A-Za-z0-9_-]"); + + private final String template; + private final int sequencePadding; + + /** + * Constructs a new VulnerabilityIdTemplateParser. + * + * @param template The template string with variable placeholders + * @param sequencePadding The number of digits to pad the sequence number to + */ + public VulnerabilityIdTemplateParser(String template, int sequencePadding) { + if (template == null || template.trim().isEmpty()) { + throw new IllegalArgumentException("Template cannot be null or empty"); + } + if (sequencePadding < 1 || sequencePadding > 20) { + throw new IllegalArgumentException("Sequence padding must be between 1 and 20"); + } + this.template = template.trim(); + this.sequencePadding = sequencePadding; + } + + /** + * Generates a vulnerability ID from the template using the provided parameters. + * + * @param orgCode The organization code + * @param projectName The project name + * @param sequence The sequence number + * @return The formatted vulnerability ID + */ + public String generateId(String orgCode, String projectName, long sequence) { + return generateId(orgCode, projectName, sequence, LocalDate.now()); + } + + /** + * Generates a vulnerability ID from the template using the provided parameters. + * + * @param orgCode The organization code + * @param projectName The project name + * @param sequence The sequence number + * @param date The date to use for year/month/day substitution + * @return The formatted vulnerability ID + */ + public String generateId(String orgCode, String projectName, long sequence, LocalDate date) { + if (orgCode == null || orgCode.trim().isEmpty()) { + throw new IllegalArgumentException("Organization code cannot be null or empty"); + } + if (projectName == null || projectName.trim().isEmpty()) { + throw new IllegalArgumentException("Project name cannot be null or empty"); + } + if (sequence < 0) { + throw new IllegalArgumentException("Sequence must be non-negative"); + } + final LocalDate effectiveDate = (date != null) ? date : LocalDate.now(); + + String result = template; + final StringBuffer sb = new StringBuffer(); + final Matcher matcher = VARIABLE_PATTERN.matcher(result); + + while (matcher.find()) { + final String variable = matcher.group(1); + final String replacement = replaceVariable(variable, orgCode, projectName, sequence, effectiveDate); + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + + return sb.toString(); + } + + /** + * Replaces a single variable with its corresponding value. + * + * @param variable The variable name (without braces) + * @param orgCode The organization code + * @param projectName The project name + * @param sequence The sequence number + * @param date The date + * @return The replacement value + */ + private String replaceVariable(String variable, String orgCode, String projectName, + long sequence, LocalDate date) { + switch (variable) { + case "ORG_NAME": + case "ORG_CODE": + return orgCode; + case "PROJECT_NAME": + case "PROJECT_CODE": + return sanitizeProjectName(projectName); + case "YYYY": + return DateTimeFormatter.ofPattern("yyyy").format(date); + case "MM": + return DateTimeFormatter.ofPattern("MM").format(date); + case "DD": + return DateTimeFormatter.ofPattern("dd").format(date); + case "SEQUENCE": + return formatSequence(sequence); + default: + // Unknown variable - leave as-is + return "{" + variable + "}"; + } + } + + private String sanitizeProjectName(String projectName) { + // Remove anything not alphanumeric, underscore, or hyphen (spaces removed — words join together) + String result = PROJECT_NAME_SANITIZE_PATTERN.matcher(projectName).replaceAll(""); + + // Collapse consecutive underscores/hyphens + result = result.replaceAll("([_-])[_-]+", "$1"); + + // Remove leading/trailing underscores/hyphens + result = result.replaceAll("^[_-]+|[_-]+$", ""); + + if (result.isEmpty()) { + result = "project"; + } + + return result; + } + + /** + * Formats the sequence number with zero-padding. + * + * @param sequence The sequence number + * @return The padded sequence as a string + */ + private String formatSequence(long sequence) { + return String.format("%0" + sequencePadding + "d", sequence); + } + + /** + * Validates that the template contains valid variables. + * Returns a count of recognized variables. + * + * @return The number of valid variables found in the template + */ + public int countVariables() { + int count = 0; + Matcher matcher = VARIABLE_PATTERN.matcher(template); + while (matcher.find()) { + String variable = matcher.group(1); + // Only count recognized variables + switch (variable) { + case "ORG_CODE": + case "PROJECT_NAME": + case "PROJECT_CODE": + case "YYYY": + case "MM": + case "DD": + case "SEQUENCE": + count++; + break; + } + } + return count; + } + + /** + * Gets the template string. + * + * @return The template + */ + public String getTemplate() { + return template; + } + + /** + * Gets the sequence padding value. + * + * @return The padding value + */ + public int getSequencePadding() { + return sequencePadding; + } +} diff --git a/src/main/resources/META-INF/persistence.xml b/src/main/resources/META-INF/persistence.xml index 54ea30d611..7f6f4d6144 100644 --- a/src/main/resources/META-INF/persistence.xml +++ b/src/main/resources/META-INF/persistence.xml @@ -52,6 +52,7 @@ org.dependencytrack.model.ViolationAnalysisState org.dependencytrack.model.Vulnerability org.dependencytrack.model.VulnerabilityAlias + org.dependencytrack.model.VulnerabilitySequence org.dependencytrack.model.VulnerabilityMetrics org.dependencytrack.model.VulnerableSoftware diff --git a/src/test/java/org/dependencytrack/resources/v1/CustomizationResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/CustomizationResourceTest.java new file mode 100644 index 0000000000..caa25e8c8b --- /dev/null +++ b/src/test/java/org/dependencytrack/resources/v1/CustomizationResourceTest.java @@ -0,0 +1,392 @@ +/* + * 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. + */ +package org.dependencytrack.resources.v1; + +import alpine.model.IConfigProperty; +import alpine.server.filters.ApiFilter; +import alpine.server.filters.AuthenticationFilter; +import alpine.server.filters.AuthorizationFilter; +import jakarta.json.JsonObject; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.dependencytrack.JerseyTestExtension; +import org.dependencytrack.ResourceTest; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.ConfigPropertyConstants; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +class CustomizationResourceTest extends ResourceTest { + + private static final String V1_CUSTOMIZATION_VULNERABILITY_ID = "/v1/customization/vulnerability-id"; + + @RegisterExtension + public static JerseyTestExtension jersey = new JerseyTestExtension( + () -> new ResourceConfig(CustomizationResource.class) + .register(ApiFilter.class) + .register(AuthenticationFilter.class) + .register(AuthorizationFilter.class)); + + // ------------------------------------------------------------------------- + // GET /v1/customization/vulnerability-id + // ------------------------------------------------------------------------- + + @Test + void getVulnerabilityIdSettingsReturnsDefaultsWhenNotConfigured() { + initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + + assertThat(response.getStatus()).isEqualTo(200); + + JsonObject json = parseJsonObject(response); + // Defaults come from ConfigPropertyConstants — they must at minimum be non-null strings + assertThat(json.getString("orgCode")).isNotBlank(); + assertThat(json.getString("template")).isNotBlank(); + assertThat(json.getString("resetPolicy")).isNotBlank(); + assertThat(json.getInt("sequencePadding")).isGreaterThanOrEqualTo(1); + } + + @Test + void getVulnerabilityIdSettingsReturnsStoredValues() { + initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + + qm.createConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE.getPropertyName(), + "MYORG", + IConfigProperty.PropertyType.STRING, + ConfigPropertyConstants.VULNERABILITY_ID_ORG_CODE.getDescription()); + qm.createConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getPropertyName(), + "myproject", + IConfigProperty.PropertyType.STRING, + ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getDescription()); + qm.createConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getPropertyName(), + "{ORG_CODE}-{SEQUENCE}", + IConfigProperty.PropertyType.STRING, + ConfigPropertyConstants.VULNERABILITY_ID_TEMPLATE.getDescription()); + qm.createConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY.getPropertyName(), + "NEVER", + IConfigProperty.PropertyType.STRING, + ConfigPropertyConstants.VULNERABILITY_ID_RESET_POLICY.getDescription()); + qm.createConfigProperty( + ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING.getGroupName(), + ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING.getPropertyName(), + "8", + IConfigProperty.PropertyType.INTEGER, + ConfigPropertyConstants.VULNERABILITY_ID_SEQUENCE_PADDING.getDescription()); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + + assertThat(response.getStatus()).isEqualTo(200); + + JsonObject json = parseJsonObject(response); + assertThat(json.getString("orgCode")).isEqualTo("MYORG"); + assertThat(json.getString("projectCode")).isEqualTo("myproject"); + assertThat(json.getString("template")).isEqualTo("{ORG_CODE}-{SEQUENCE}"); + assertThat(json.getString("resetPolicy")).isEqualTo("NEVER"); + assertThat(json.getInt("sequencePadding")).isEqualTo(8); + } + + // ------------------------------------------------------------------------- + // PUT /v1/customization/vulnerability-id – happy path + // ------------------------------------------------------------------------- + + @Test + void updateVulnerabilityIdSettingsSucceeds() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "projectCode": "myproj", + "template": "{ORG_CODE}-{PROJECT_NAME}-{YYYY}-{SEQUENCE}", + "resetPolicy": "YEARLY", + "sequencePadding": 5 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(204); + } + + @Test + void updateVulnerabilityIdSettingsRoundTrip() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION, Permissions.VIEW_PORTFOLIO); + + // First PUT to store values + jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "template": "{ORG_CODE}-{SEQUENCE}", + "resetPolicy": "MONTHLY", + "sequencePadding": 3 + } + """, MediaType.APPLICATION_JSON)); + + // Then GET to verify they were persisted + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + + assertThat(response.getStatus()).isEqualTo(200); + JsonObject json = parseJsonObject(response); + assertThat(json.getString("orgCode")).isEqualTo("ACME"); + assertThat(json.getString("template")).isEqualTo("{ORG_CODE}-{SEQUENCE}"); + assertThat(json.getString("resetPolicy")).isEqualTo("MONTHLY"); + assertThat(json.getInt("sequencePadding")).isEqualTo(3); + } + + @Test + void updateVulnerabilityIdSettingsAcceptsAllResetPolicies() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + for (String policy : new String[]{"YEARLY", "MONTHLY", "DAILY", "NEVER"}) { + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(String.format(""" + { + "orgCode": "ORG", + "template": "{ORG_CODE}", + "resetPolicy": "%s", + "sequencePadding": 5 + } + """, policy), MediaType.APPLICATION_JSON)); + assertThat(response.getStatus()) + .as("Expected 204 for resetPolicy=%s", policy) + .isEqualTo(204); + } + } + + // ------------------------------------------------------------------------- + // PUT /v1/customization/vulnerability-id – validation errors (400) + // ------------------------------------------------------------------------- + + @Test + void updateVulnerabilityIdSettingsReturns400WhenOrgCodeMissing() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "template": "{ORG_CODE}-{SEQUENCE}", + "resetPolicy": "YEARLY", + "sequencePadding": 5 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).contains("Organization code"); + } + + @Test + void updateVulnerabilityIdSettingsReturns400WhenOrgCodeEmpty() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": " ", + "template": "{ORG_CODE}-{SEQUENCE}", + "resetPolicy": "YEARLY", + "sequencePadding": 5 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + } + + @Test + void updateVulnerabilityIdSettingsReturns400WhenTemplateMissing() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "resetPolicy": "YEARLY", + "sequencePadding": 5 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).contains("Template"); + } + + @Test + void updateVulnerabilityIdSettingsReturns400WhenResetPolicyMissing() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "template": "{ORG_CODE}", + "sequencePadding": 5 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).contains("Reset policy"); + } + + @Test + void updateVulnerabilityIdSettingsReturns400WhenResetPolicyInvalid() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "template": "{ORG_CODE}", + "resetPolicy": "WEEKLY", + "sequencePadding": 5 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).contains("Invalid reset policy"); + } + + @Test + void updateVulnerabilityIdSettingsReturns400WhenSequencePaddingTooLow() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "template": "{ORG_CODE}", + "resetPolicy": "YEARLY", + "sequencePadding": 0 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).contains("Sequence padding"); + } + + @Test + void updateVulnerabilityIdSettingsReturns400WhenSequencePaddingTooHigh() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION); + + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "template": "{ORG_CODE}", + "resetPolicy": "YEARLY", + "sequencePadding": 21 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThat(getPlainTextBody(response)).contains("Sequence padding"); + } + + @Test + void updateVulnerabilityIdSettingsIgnoresBlankProjectCode() { + initializeWithPermissions(Permissions.SYSTEM_CONFIGURATION, Permissions.VIEW_PORTFOLIO); + + // The project code is optional — a blank value is ignored rather than + // rejected, leaving the previously configured (or default) value intact. + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "projectCode": " ", + "template": "{ORG_CODE}", + "resetPolicy": "YEARLY", + "sequencePadding": 5 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(204); + + Response getResponse = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + JsonObject json = parseJsonObject(getResponse); + // Blank input was not persisted — the default remains. + assertThat(json.getString("projectCode")).isEqualTo( + ConfigPropertyConstants.VULNERABILITY_ID_PROJECT_CODE.getDefaultPropertyValue()); + } + + // ------------------------------------------------------------------------- + // PUT /v1/customization/vulnerability-id – authorization (403) + // ------------------------------------------------------------------------- + + @Test + void updateVulnerabilityIdSettingsReturns403WithoutPermission() { + // No permissions added to the team + Response response = jersey.target(V1_CUSTOMIZATION_VULNERABILITY_ID) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "orgCode": "ACME", + "template": "{ORG_CODE}", + "resetPolicy": "YEARLY", + "sequencePadding": 5 + } + """, MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(403); + } +} diff --git a/src/test/java/org/dependencytrack/util/VulnerabilityIdGeneratorTest.java b/src/test/java/org/dependencytrack/util/VulnerabilityIdGeneratorTest.java new file mode 100644 index 0000000000..c60fc6d209 --- /dev/null +++ b/src/test/java/org/dependencytrack/util/VulnerabilityIdGeneratorTest.java @@ -0,0 +1,171 @@ +/* + * 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. + */ +package org.dependencytrack.util; + +import org.junit.jupiter.api.Test; + +import java.time.Year; + +import static org.assertj.core.api.Assertions.assertThat; + +class VulnerabilityIdGeneratorTest { + + private static final int CURRENT_YEAR = Year.now().getValue(); + + // ------------------------------------------------------------------------- + // generateId(long) – sequence-only convenience method + // ------------------------------------------------------------------------- + + @Test + void generateIdWithSequenceOnlyUsesDefaultOrgAndProject() { + String id = VulnerabilityIdGenerator.generateId(1L); + assertThat(id).isEqualTo("DT-PROJECT-" + CURRENT_YEAR + "-00001"); + } + + @Test + void generateIdWithSequenceOnlyPadsSequenceToFiveDigits() { + assertThat(VulnerabilityIdGenerator.generateId(1L)).endsWith("-00001"); + assertThat(VulnerabilityIdGenerator.generateId(42L)).endsWith("-00042"); + assertThat(VulnerabilityIdGenerator.generateId(99999L)).endsWith("-99999"); + } + + @Test + void generateIdWithSequenceOnlyContainsCurrentYear() { + String id = VulnerabilityIdGenerator.generateId(1L); + assertThat(id).contains(String.valueOf(CURRENT_YEAR)); + } + + // ------------------------------------------------------------------------- + // generateIdWithDefaults(String, long) – project name + sequence + // ------------------------------------------------------------------------- + + @Test + void generateIdWithDefaultsUsesDefaultOrgCode() { + String id = VulnerabilityIdGenerator.generateIdWithDefaults("myapp", 1L); + assertThat(id).startsWith("DT-"); + } + + @Test + void generateIdWithDefaultsIncludesProjectName() { + String id = VulnerabilityIdGenerator.generateIdWithDefaults("myapp", 1L); + assertThat(id).isEqualTo("DT-myapp-" + CURRENT_YEAR + "-00001"); + } + + @Test + void generateIdWithDefaultsSanitizesProjectNameSpaces() { + // Spaces (and other non [A-Za-z0-9_-] chars) are removed and case is preserved + String id = VulnerabilityIdGenerator.generateIdWithDefaults("My App", 1L); + assertThat(id).isEqualTo("DT-MyApp-" + CURRENT_YEAR + "-00001"); + } + + @Test + void generateIdWithDefaultsSanitizesSpecialChars() { + String id = VulnerabilityIdGenerator.generateIdWithDefaults("my@project!", 1L); + assertThat(id).isEqualTo("DT-myproject-" + CURRENT_YEAR + "-00001"); + } + + @Test + void generateIdWithDefaultsPreservesHyphens() { + String id = VulnerabilityIdGenerator.generateIdWithDefaults("my-project", 1L); + assertThat(id).isEqualTo("DT-my-project-" + CURRENT_YEAR + "-00001"); + } + + @Test + void generateIdWithDefaultsPreservesProjectNameCase() { + String id = VulnerabilityIdGenerator.generateIdWithDefaults("MYAPP", 1L); + assertThat(id).contains("-MYAPP-"); + } + + // ------------------------------------------------------------------------- + // generateId(orgCode, template, padding, projectName, sequence) – full control + // ------------------------------------------------------------------------- + + @Test + void generateIdWithCustomOrgCode() { + String id = VulnerabilityIdGenerator.generateId( + "ACME", "{ORG_CODE}-{PROJECT_NAME}-{YYYY}-{SEQUENCE}", 5, "myapp", 1L); + assertThat(id).isEqualTo("ACME-myapp-" + CURRENT_YEAR + "-00001"); + } + + @Test + void generateIdWithCustomTemplate() { + String id = VulnerabilityIdGenerator.generateId( + "ORG", "{ORG_CODE}/{SEQUENCE}", 3, "proj", 7L); + assertThat(id).isEqualTo("ORG/007"); + } + + @Test + void generateIdWithCustomPadding() { + String id = VulnerabilityIdGenerator.generateId( + "ORG", "{ORG_CODE}-{SEQUENCE}", 8, "proj", 1L); + assertThat(id).isEqualTo("ORG-00000001"); + } + + @Test + void generateIdFallsBackToDefaultsWhenOrgCodeNull() { + // Null orgCode → falls back to "DT" + String id = VulnerabilityIdGenerator.generateId( + null, "{ORG_CODE}-{SEQUENCE}", 5, "myapp", 1L); + assertThat(id).startsWith("DT-"); + } + + @Test + void generateIdFallsBackToDefaultsWhenOrgCodeEmpty() { + String id = VulnerabilityIdGenerator.generateId( + " ", "{ORG_CODE}-{SEQUENCE}", 5, "myapp", 1L); + assertThat(id).startsWith("DT-"); + } + + @Test + void generateIdFallsBackToDefaultsWhenTemplateNull() { + String id = VulnerabilityIdGenerator.generateId( + "ORG", null, 5, "myapp", 1L); + // Falls back to default template and default org code + assertThat(id).isNotNull(); + assertThat(id).contains(String.valueOf(CURRENT_YEAR)); + } + + @Test + void generateIdFallsBackToDefaultsWhenPaddingOutOfRange() { + // padding=0 → falls back to default padding of 5 + String id = VulnerabilityIdGenerator.generateId( + "ORG", "{ORG_CODE}-{SEQUENCE}", 0, "proj", 1L); + assertThat(id).endsWith("-00001"); + } + + @Test + void generateIdFallsBackWhenProjectNameNullCausesException() { + // null projectName causes template parser to throw → falls back to generateIdWithDefaults(null, seq) + // generateIdWithDefaults(null, 1) will also throw since parser validates projectName + // The outer catch will call generateIdWithDefaults which also throws — just verify no NPE/wrong exception + // and the result is not null (the outer catch rethrows as runtime or returns something) + // Actually the outer catch calls generateIdWithDefaults(projectName=null) which throws too. + // So we just verify it doesn't silently succeed with a wrong ID. + String id; + try { + id = VulnerabilityIdGenerator.generateId("ORG", "{ORG_CODE}-{SEQUENCE}", 5, null, 1L); + // If it somehow doesn't throw, the result must still be a non-null string + assertThat(id).isNotNull(); + } catch (IllegalArgumentException e) { + // Expected - null project name not supported + assertThat(e.getMessage()).contains("Project name"); + } + } + +} diff --git a/src/test/java/org/dependencytrack/util/VulnerabilityIdTemplateParserTest.java b/src/test/java/org/dependencytrack/util/VulnerabilityIdTemplateParserTest.java new file mode 100644 index 0000000000..3fb3869784 --- /dev/null +++ b/src/test/java/org/dependencytrack/util/VulnerabilityIdTemplateParserTest.java @@ -0,0 +1,351 @@ +/* + * 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. + */ +package org.dependencytrack.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class VulnerabilityIdTemplateParserTest { + + // ------------------------------------------------------------------------- + // Constructor validation + // ------------------------------------------------------------------------- + + @Test + void constructorRejectsNullTemplate() { + assertThatThrownBy(() -> new VulnerabilityIdTemplateParser(null, 5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Template cannot be null or empty"); + } + + @Test + void constructorRejectsEmptyTemplate() { + assertThatThrownBy(() -> new VulnerabilityIdTemplateParser(" ", 5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Template cannot be null or empty"); + } + + @Test + void constructorRejectsPaddingBelowOne() { + assertThatThrownBy(() -> new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sequence padding must be between 1 and 20"); + } + + @Test + void constructorRejectsPaddingAboveTwenty() { + assertThatThrownBy(() -> new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 21)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sequence padding must be between 1 and 20"); + } + + @Test + void constructorAcceptsBoundaryPaddingValues() { + // Should not throw + new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 1); + new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 20); + } + + @Test + void getterReturnsTrimmedTemplate() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser(" {ORG_CODE} ", 5); + assertThat(parser.getTemplate()).isEqualTo("{ORG_CODE}"); + } + + @Test + void getterReturnsPadding() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 7); + assertThat(parser.getSequencePadding()).isEqualTo(7); + } + + // ------------------------------------------------------------------------- + // generateId – argument validation + // ------------------------------------------------------------------------- + + @Test + void generateIdRejectsNullOrgCode() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 5); + assertThatThrownBy(() -> parser.generateId(null, "MyProject", 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Organization code cannot be null or empty"); + } + + @Test + void generateIdRejectsEmptyOrgCode() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 5); + assertThatThrownBy(() -> parser.generateId(" ", "MyProject", 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Organization code cannot be null or empty"); + } + + @Test + void generateIdRejectsNullProjectName() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 5); + assertThatThrownBy(() -> parser.generateId("ACME", null, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Project name cannot be null or empty"); + } + + @Test + void generateIdRejectsEmptyProjectName() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 5); + assertThatThrownBy(() -> parser.generateId("ACME", " ", 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Project name cannot be null or empty"); + } + + @Test + void generateIdRejectsNegativeSequence() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{ORG_CODE}-{SEQUENCE}", 5); + assertThatThrownBy(() -> parser.generateId("ACME", "MyProject", -1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sequence must be non-negative"); + } + + // ------------------------------------------------------------------------- + // Variable substitution – {ORG_CODE} + // ------------------------------------------------------------------------- + + @Test + void orgCodeIsSubstitutedVerbatim() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{ORG_CODE}", 5); + String result = parser.generateId("ACME", "MyProject", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("ACME"); + } + + // ------------------------------------------------------------------------- + // Variable substitution – {PROJECT_NAME} and {PROJECT_CODE} + // ------------------------------------------------------------------------- + + @Test + void projectNameIsSubstitutedAndSanitized() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{PROJECT_NAME}", 5); + String result = parser.generateId("ACME", "My Project", 1, LocalDate.of(2026, 2, 19)); + // Spaces (and other non [A-Za-z0-9_-] chars) are removed and case is preserved + assertThat(result).isEqualTo("MyProject"); + } + + @Test + void projectCodeAliasWorksLikeProjectName() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{PROJECT_CODE}", 5); + String result = parser.generateId("ACME", "My Project", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("MyProject"); + } + + @Test + void specialCharactersAreRemovedFromProjectName() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{PROJECT_NAME}", 5); + String result = parser.generateId("ACME", "My Project @#$%!", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("MyProject"); + } + + @Test + void spacesRemovedAndCasePreservedInProjectName() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{PROJECT_NAME}", 5); + // "My Project" → spaces (and other non [A-Za-z0-9_-] chars) removed, case preserved → "MyProject" + String result = parser.generateId("ACME", "My Project", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("MyProject"); + } + + @Test + void hyphensArePreservedInProjectName() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{PROJECT_NAME}", 5); + String result = parser.generateId("ACME", "my-project", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("my-project"); + } + + @Test + void allSpecialCharProjectNameFallsBackToDefault() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{PROJECT_NAME}", 5); + String result = parser.generateId("ACME", "@#$%", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("project"); + } + + @Test + void leadingAndTrailingUnderscoresAreStripped() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{PROJECT_NAME}", 5); + // Spaces around name turn into underscores, then stripped + String result = parser.generateId("ACME", " myproject ", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("myproject"); + } + + // ------------------------------------------------------------------------- + // Variable substitution – date variables + // ------------------------------------------------------------------------- + + @Test + void yearIsFormattedAsFourDigits() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{YYYY}", 5); + String result = parser.generateId("ACME", "proj", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("2026"); + } + + @Test + void monthIsZeroPaddedTwoDigits() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{MM}", 5); + // Month 2 should be "02" + String result = parser.generateId("ACME", "proj", 1, LocalDate.of(2026, 2, 5)); + assertThat(result).isEqualTo("02"); + } + + @Test + void dayIsZeroPaddedTwoDigits() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{DD}", 5); + // Day 5 should be "05" + String result = parser.generateId("ACME", "proj", 1, LocalDate.of(2026, 2, 5)); + assertThat(result).isEqualTo("05"); + } + + @Test + void nullDateFallsBackToToday() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{YYYY}", 5); + // Should not throw and should return a 4-digit year + String result = parser.generateId("ACME", "proj", 1, null); + assertThat(result).matches("\\d{4}"); + } + + // ------------------------------------------------------------------------- + // Variable substitution – {SEQUENCE} + // ------------------------------------------------------------------------- + + @Test + void sequenceIsPaddedToConfiguredLength() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{SEQUENCE}", 5); + assertThat(parser.generateId("ACME", "proj", 1, LocalDate.of(2026, 1, 1))).isEqualTo("00001"); + assertThat(parser.generateId("ACME", "proj", 999, LocalDate.of(2026, 1, 1))).isEqualTo("00999"); + } + + @Test + void sequenceWithPaddingOfOne() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{SEQUENCE}", 1); + assertThat(parser.generateId("ACME", "proj", 5, LocalDate.of(2026, 1, 1))).isEqualTo("5"); + } + + @Test + void sequenceZeroIsPadded() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{SEQUENCE}", 5); + assertThat(parser.generateId("ACME", "proj", 0, LocalDate.of(2026, 1, 1))).isEqualTo("00000"); + } + + @Test + void largeSequenceExceedingPaddingIsNotTruncated() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{SEQUENCE}", 3); + // 10000 exceeds 3 digits – should not be truncated + assertThat(parser.generateId("ACME", "proj", 10000, LocalDate.of(2026, 1, 1))).isEqualTo("10000"); + } + + // ------------------------------------------------------------------------- + // Full template – composite tests + // ------------------------------------------------------------------------- + + @Test + void fullTemplateProducesExpectedId() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser( + "{ORG_CODE}-{PROJECT_NAME}-{YYYY}-{SEQUENCE}", 5); + String result = parser.generateId("ACME", "myproject", 1, LocalDate.of(2026, 2, 19)); + assertThat(result).isEqualTo("ACME-myproject-2026-00001"); + } + + @Test + void templateWithAllVariables() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser( + "{ORG_CODE}-{PROJECT_NAME}-{YYYY}-{MM}-{DD}-{SEQUENCE}", 3); + String result = parser.generateId("ACME", "proj", 42, LocalDate.of(2026, 2, 5)); + assertThat(result).isEqualTo("ACME-proj-2026-02-05-042"); + } + + @Test + void templateWithStaticTextPreserved() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser( + "VULN-{ORG_CODE}/{SEQUENCE}", 4); + String result = parser.generateId("ACME", "proj", 7, LocalDate.of(2026, 1, 1)); + assertThat(result).isEqualTo("VULN-ACME/0007"); + } + + @Test + void unknownVariableIsLeftAsIs() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser( + "{ORG_CODE}-{UNKNOWN_VAR}-{SEQUENCE}", 3); + String result = parser.generateId("ACME", "proj", 1, LocalDate.of(2026, 1, 1)); + assertThat(result).isEqualTo("ACME-{UNKNOWN_VAR}-001"); + } + + @Test + void templateWithNoVariablesReturnsLiteralText() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("STATIC-TEXT", 5); + String result = parser.generateId("ACME", "proj", 1, LocalDate.of(2026, 1, 1)); + assertThat(result).isEqualTo("STATIC-TEXT"); + } + + // ------------------------------------------------------------------------- + // countVariables + // ------------------------------------------------------------------------- + + @Test + void countVariablesReturnsCorrectCount() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser( + "{ORG_CODE}-{PROJECT_NAME}-{YYYY}-{SEQUENCE}", 5); + assertThat(parser.countVariables()).isEqualTo(4); + } + + @Test + void countVariablesIgnoresUnknownVariables() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser( + "{ORG_CODE}-{UNKNOWN}-{SEQUENCE}", 5); + assertThat(parser.countVariables()).isEqualTo(2); + } + + @Test + void countVariablesWithAllKnownVariables() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser( + "{ORG_CODE}-{PROJECT_NAME}-{PROJECT_CODE}-{YYYY}-{MM}-{DD}-{SEQUENCE}", 5); + assertThat(parser.countVariables()).isEqualTo(7); + } + + @Test + void countVariablesReturnsZeroForNoVariables() { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("STATIC-TEXT", 5); + assertThat(parser.countVariables()).isEqualTo(0); + } + + // ------------------------------------------------------------------------- + // Parametrized – project name sanitization edge cases + // ------------------------------------------------------------------------- + + @ParameterizedTest(name = "input=''{0}'' => expected=''{1}''") + @CsvSource({ + "simple, simple", + "My Project, MyProject", + "my-project, my-project", + "A__B, A_B", + "A--B, A-B", + " trim , trim" + }) + void sanitizationEdgeCases(String input, String expected) { + VulnerabilityIdTemplateParser parser = new VulnerabilityIdTemplateParser("{PROJECT_NAME}", 5); + String result = parser.generateId("ORG", input.stripLeading().stripTrailing(), 1, LocalDate.of(2026, 1, 1)); + assertThat(result).isEqualTo(expected.stripLeading().stripTrailing()); + } +}