From 002c6f4e1b624b564f1b73fb087c47ed5e0f3282 Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 11 Aug 2024 18:25:30 +0200 Subject: [PATCH 01/12] Change badges access from checkbox to permission Replace enabling of unauthenticaed access to badges via admin config checkbox with an api authentication with a new dedicated permission "VIEW_BADGES". Signed-off-by: Kirill.Sybin --- .../org/dependencytrack/auth/Permissions.java | 4 +- .../resources/v1/BadgeResource.java | 111 ++++++++---------- 2 files changed, 51 insertions(+), 64 deletions(-) diff --git a/src/main/java/org/dependencytrack/auth/Permissions.java b/src/main/java/org/dependencytrack/auth/Permissions.java index 2c1d08cdbb..51416ef5e7 100644 --- a/src/main/java/org/dependencytrack/auth/Permissions.java +++ b/src/main/java/org/dependencytrack/auth/Permissions.java @@ -38,7 +38,8 @@ public enum Permissions { SYSTEM_CONFIGURATION("Allows the configuration of the system including notifications, repositories, and email settings"), PROJECT_CREATION_UPLOAD("Provides the ability to optionally create project (if non-existent) on BOM or scan upload"), POLICY_MANAGEMENT("Allows the creation, modification, and deletion of policy"), - TAG_MANAGEMENT("Allows the modification and deletion of tags"); + TAG_MANAGEMENT("Allows the modification and deletion of tags"), + VIEW_BADGES("Provides the ability to view badges"); private final String description; @@ -64,6 +65,7 @@ public static class Constants { public static final String PROJECT_CREATION_UPLOAD = "PROJECT_CREATION_UPLOAD"; public static final String POLICY_MANAGEMENT = "POLICY_MANAGEMENT"; public static final String TAG_MANAGEMENT = "TAG_MANAGEMENT"; + public static final String VIEW_BADGES = "VIEW_BADGES"; } } diff --git a/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java b/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java index a0a6e0cedd..c320704fcc 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java @@ -18,9 +18,7 @@ */ package org.dependencytrack.resources.v1; -import alpine.common.util.BooleanUtil; -import alpine.model.ConfigProperty; -import alpine.server.auth.AuthenticationNotRequired; +import alpine.server.auth.PermissionRequired; import alpine.server.resources.AlpineResource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -28,7 +26,10 @@ 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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; +import org.dependencytrack.auth.Permissions; import org.dependencytrack.model.Project; import org.dependencytrack.model.ProjectMetrics; import org.dependencytrack.model.validation.ValidUuid; @@ -41,8 +42,6 @@ import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Response; -import static org.dependencytrack.model.ConfigPropertyConstants.GENERAL_BADGE_ENABLED; - /** * JAX-RS resources for processing metrics. * @@ -51,47 +50,42 @@ */ @Path("/v1/badge") @Tag(name = "badge") +@SecurityRequirements({ + @SecurityRequirement(name = "ApiKeyAuth"), + @SecurityRequirement(name = "BearerAuth") +}) public class BadgeResource extends AlpineResource { private static final String SVG_MEDIA_TYPE = "image/svg+xml"; - private boolean isBadgeSupportEnabled(final QueryManager qm) { - ConfigProperty property = qm.getConfigProperty( - GENERAL_BADGE_ENABLED.getGroupName(), GENERAL_BADGE_ENABLED.getPropertyName()); - return BooleanUtil.valueOf(property.getPropertyValue()); - } - @GET @Path("/vulns/project/{uuid}") @Produces(SVG_MEDIA_TYPE) @Operation( - summary = "Returns current metrics for a specific project") + summary = "Returns current metrics for a specific project", + description = "

Requires permission VIEW_BADGES

" + ) @ApiResponses(value = { @ApiResponse( responseCode = "200", description = "A badge displaying current vulnerability metrics for a project in SVG format", content = @Content(schema = @Schema(type = "string")) ), - @ApiResponse(responseCode = "204", description = "Badge support is disabled. No content will be returned."), @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The project could not be found") }) - @AuthenticationNotRequired + @PermissionRequired(Permissions.Constants.VIEW_BADGES) public Response getProjectVulnerabilitiesBadge( @Parameter(description = "The UUID of the project to retrieve metrics for", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("uuid") @ValidUuid String uuid) { try (QueryManager qm = new QueryManager()) { - if (isBadgeSupportEnabled(qm)) { - final Project project = qm.getObjectByUuid(Project.class, uuid); - if (project != null) { - final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); - final Badger badger = new Badger(); - return Response.ok(badger.generateVulnerabilities(metrics)).build(); - } else { - return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); - } + final Project project = qm.getObjectByUuid(Project.class, uuid); + if (project != null) { + final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); + final Badger badger = new Badger(); + return Response.ok(badger.generateVulnerabilities(metrics)).build(); } else { - return Response.status(Response.Status.NO_CONTENT).build(); + return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); } } } @@ -100,35 +94,32 @@ public Response getProjectVulnerabilitiesBadge( @Path("/vulns/project/{name}/{version}") @Produces(SVG_MEDIA_TYPE) @Operation( - summary = "Returns current metrics for a specific project") + summary = "Returns current metrics for a specific project", + description = "

Requires permission VIEW_BADGES

" + ) @ApiResponses(value = { @ApiResponse( responseCode = "200", description = "A badge displaying current vulnerability metrics for a project in SVG format", content = @Content(schema = @Schema(type = "string")) ), - @ApiResponse(responseCode = "204", description = "Badge support is disabled. No content will be returned."), @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The project could not be found") }) - @AuthenticationNotRequired + @PermissionRequired(Permissions.Constants.VIEW_BADGES) public Response getProjectVulnerabilitiesBadge( @Parameter(description = "The name of the project to query on", required = true) @PathParam("name") String name, @Parameter(description = "The version of the project to query on", required = true) @PathParam("version") String version) { try (QueryManager qm = new QueryManager()) { - if (isBadgeSupportEnabled(qm)) { - final Project project = qm.getProject(name, version); - if (project != null) { - final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); - final Badger badger = new Badger(); - return Response.ok(badger.generateVulnerabilities(metrics)).build(); - } else { - return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); - } + final Project project = qm.getProject(name, version); + if (project != null) { + final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); + final Badger badger = new Badger(); + return Response.ok(badger.generateVulnerabilities(metrics)).build(); } else { - return Response.status(Response.Status.NO_CONTENT).build(); + return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); } } } @@ -137,33 +128,30 @@ public Response getProjectVulnerabilitiesBadge( @Path("/violations/project/{uuid}") @Produces(SVG_MEDIA_TYPE) @Operation( - summary = "Returns a policy violations badge for a specific project") + summary = "Returns a policy violations badge for a specific project", + description = "

Requires permission VIEW_BADGES

" + ) @ApiResponses(value = { @ApiResponse( responseCode = "200", description = "A badge displaying current policy violation metrics of a project in SVG format", content = @Content(schema = @Schema(type = "string")) ), - @ApiResponse(responseCode = "204", description = "Badge support is disabled. No content will be returned."), @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The project could not be found") }) - @AuthenticationNotRequired + @PermissionRequired(Permissions.Constants.VIEW_BADGES) public Response getProjectPolicyViolationsBadge( @Parameter(description = "The UUID of the project to retrieve a badge for", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("uuid") @ValidUuid String uuid) { try (QueryManager qm = new QueryManager()) { - if (isBadgeSupportEnabled(qm)) { - final Project project = qm.getObjectByUuid(Project.class, uuid); - if (project != null) { - final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); - final Badger badger = new Badger(); - return Response.ok(badger.generateViolations(metrics)).build(); - } else { - return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); - } + final Project project = qm.getObjectByUuid(Project.class, uuid); + if (project != null) { + final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); + final Badger badger = new Badger(); + return Response.ok(badger.generateViolations(metrics)).build(); } else { - return Response.status(Response.Status.NO_CONTENT).build(); + return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); } } } @@ -172,35 +160,32 @@ public Response getProjectPolicyViolationsBadge( @Path("/violations/project/{name}/{version}") @Produces(SVG_MEDIA_TYPE) @Operation( - summary = "Returns a policy violations badge for a specific project") + summary = "Returns a policy violations badge for a specific project", + description = "

Requires permission VIEW_BADGES

" + ) @ApiResponses(value = { @ApiResponse( responseCode = "200", description = "A badge displaying current policy violation metrics of a project in SVG format", content = @Content(schema = @Schema(type = "string")) ), - @ApiResponse(responseCode = "204", description = "Badge support is disabled. No content will be returned."), @ApiResponse(responseCode = "401", description = "Unauthorized"), @ApiResponse(responseCode = "404", description = "The project could not be found") }) - @AuthenticationNotRequired + @PermissionRequired(Permissions.Constants.VIEW_BADGES) public Response getProjectPolicyViolationsBadge( @Parameter(description = "The name of the project to query on", required = true) @PathParam("name") String name, @Parameter(description = "The version of the project to query on", required = true) @PathParam("version") String version) { try (QueryManager qm = new QueryManager()) { - if (isBadgeSupportEnabled(qm)) { - final Project project = qm.getProject(name, version); - if (project != null) { - final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); - final Badger badger = new Badger(); - return Response.ok(badger.generateViolations(metrics)).build(); - } else { - return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); - } + final Project project = qm.getProject(name, version); + if (project != null) { + final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); + final Badger badger = new Badger(); + return Response.ok(badger.generateViolations(metrics)).build(); } else { - return Response.status(Response.Status.NO_CONTENT).build(); + return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); } } } From f7cdc28008b7e87fd3e09f5b7a2f8de320f34813 Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 18 Aug 2024 12:00:33 +0200 Subject: [PATCH 02/12] Add ACL awareness to badges Signed-off-by: Kirill.Sybin --- .../dependencytrack/resources/v1/BadgeResource.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java b/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java index c320704fcc..29427084c8 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java @@ -81,6 +81,9 @@ public Response getProjectVulnerabilitiesBadge( try (QueryManager qm = new QueryManager()) { final Project project = qm.getObjectByUuid(Project.class, uuid); if (project != null) { + if (!qm.hasAccess(super.getPrincipal(), project)) { + return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); + } final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); final Badger badger = new Badger(); return Response.ok(badger.generateVulnerabilities(metrics)).build(); @@ -115,6 +118,9 @@ public Response getProjectVulnerabilitiesBadge( try (QueryManager qm = new QueryManager()) { final Project project = qm.getProject(name, version); if (project != null) { + if (!qm.hasAccess(super.getPrincipal(), project)) { + return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); + } final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); final Badger badger = new Badger(); return Response.ok(badger.generateVulnerabilities(metrics)).build(); @@ -147,6 +153,9 @@ public Response getProjectPolicyViolationsBadge( try (QueryManager qm = new QueryManager()) { final Project project = qm.getObjectByUuid(Project.class, uuid); if (project != null) { + if (!qm.hasAccess(super.getPrincipal(), project)) { + return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); + } final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); final Badger badger = new Badger(); return Response.ok(badger.generateViolations(metrics)).build(); @@ -181,6 +190,9 @@ public Response getProjectPolicyViolationsBadge( try (QueryManager qm = new QueryManager()) { final Project project = qm.getProject(name, version); if (project != null) { + if (!qm.hasAccess(super.getPrincipal(), project)) { + return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); + } final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); final Badger badger = new Badger(); return Response.ok(badger.generateViolations(metrics)).build(); From 309dc3955113b2c9e05c491459d9e7fd0433b050 Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 18 Aug 2024 14:37:12 +0200 Subject: [PATCH 03/12] Remove config property constant for badge enabling Signed-off-by: Kirill.Sybin --- .../java/org/dependencytrack/model/ConfigPropertyConstants.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index a7864276b3..e6c623decf 100644 --- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -27,7 +27,6 @@ public enum ConfigPropertyConstants { GENERAL_BASE_URL("general", "base.url", null, PropertyType.URL, "URL used to construct links back to Dependency-Track from external systems"), - GENERAL_BADGE_ENABLED("general", "badge.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable SVG badge support from metrics"), EMAIL_SMTP_ENABLED("email", "smtp.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable SMTP"), EMAIL_SMTP_FROM_ADDR("email", "smtp.from.address", null, PropertyType.STRING, "The from email address to use to send output SMTP mail"), EMAIL_PREFIX("email", "subject.prefix", "[Dependency-Track]", PropertyType.STRING, "The Prefix Subject email to use"), From d1067c264435e65fb98b65d9d6e93347bac58c4c Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 18 Aug 2024 14:37:59 +0200 Subject: [PATCH 04/12] Modify tests to accomodate badge changes Add new badge permission to tests. Remove tests for badge disabling. Add tests testing authentication, permission and ACL access. Signed-off-by: Kirill.Sybin --- .../dependencytrack/auth/PermissionsTest.java | 5 +- .../resources/v1/BadgeResourceTest.java | 339 ++++++++++++++++-- .../resources/v1/PermissionResourceTest.java | 4 + 3 files changed, 308 insertions(+), 40 deletions(-) diff --git a/src/test/java/org/dependencytrack/auth/PermissionsTest.java b/src/test/java/org/dependencytrack/auth/PermissionsTest.java index f02da899a0..3e3f5a1548 100644 --- a/src/test/java/org/dependencytrack/auth/PermissionsTest.java +++ b/src/test/java/org/dependencytrack/auth/PermissionsTest.java @@ -34,11 +34,12 @@ import static org.dependencytrack.auth.Permissions.Constants.VIEW_VULNERABILITY; import static org.dependencytrack.auth.Permissions.Constants.VULNERABILITY_ANALYSIS; import static org.dependencytrack.auth.Permissions.Constants.VULNERABILITY_MANAGEMENT; +import static org.dependencytrack.auth.Permissions.Constants.VIEW_BADGES; public class PermissionsTest { @Test public void testPermissionEnums() { - Assert.assertEquals(13, Permissions.values().length); + Assert.assertEquals(14, Permissions.values().length); Assert.assertEquals("BOM_UPLOAD", Permissions.BOM_UPLOAD.name()); Assert.assertEquals("VIEW_PORTFOLIO", Permissions.VIEW_PORTFOLIO.name()); Assert.assertEquals("PORTFOLIO_MANAGEMENT", Permissions.PORTFOLIO_MANAGEMENT.name()); @@ -52,6 +53,7 @@ public void testPermissionEnums() { Assert.assertEquals("PROJECT_CREATION_UPLOAD", Permissions.PROJECT_CREATION_UPLOAD.name()); Assert.assertEquals("POLICY_MANAGEMENT", Permissions.POLICY_MANAGEMENT.name()); Assert.assertEquals("TAG_MANAGEMENT", Permissions.TAG_MANAGEMENT.name()); + Assert.assertEquals("VIEW_BADGES", Permissions.VIEW_BADGES.name()); } @Test @@ -69,5 +71,6 @@ public void testPermissionConstants() { Assert.assertEquals("PROJECT_CREATION_UPLOAD", PROJECT_CREATION_UPLOAD); Assert.assertEquals("POLICY_MANAGEMENT", POLICY_MANAGEMENT); Assert.assertEquals("TAG_MANAGEMENT", TAG_MANAGEMENT); + Assert.assertEquals("VIEW_BADGES", VIEW_BADGES); } } diff --git a/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java index f427cc1f81..01c03a5e06 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java @@ -18,10 +18,13 @@ */ 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 org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Project; import org.glassfish.jersey.server.ResourceConfig; import org.junit.Assert; @@ -34,27 +37,25 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.UUID; -import static org.dependencytrack.model.ConfigPropertyConstants.GENERAL_BADGE_ENABLED; - public class BadgeResourceTest extends ResourceTest { @ClassRule public static JerseyTestRule jersey = new JerseyTestRule( new ResourceConfig(BadgeResource.class) - .register(ApiFilter.class)); - - @Override - public void before() throws Exception { - super.before(); - qm.createConfigProperty(GENERAL_BADGE_ENABLED.getGroupName(), GENERAL_BADGE_ENABLED.getPropertyName(), "true", IConfigProperty.PropertyType.BOOLEAN, "Badge enabled"); - } + .register(ApiFilter.class) + .register(AuthenticationFilter.class) + .register(AuthorizationFilter.class)); @Test public void projectVulnerabilitiesByUuidTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() + .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); @@ -62,24 +63,54 @@ public void projectVulnerabilitiesByUuidTest() { } @Test - public void projectVulnerabilitiesByUuidProjectDisabledTest() { - disableBadge(); + public void projectVulnerabilitiesByUuidProjectNotFoundTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + Response response = jersey.target(V1_BADGE + "/vulns/project/" + UUID.randomUUID()).request() + .header(X_API_KEY, apiKey) .get(Response.class); - Assert.assertEquals(204, response.getStatus(), 0); + Assert.assertEquals(404, response.getStatus(), 0); } @Test - public void projectVulnerabilitiesByUuidProjectNotFoundTest() { - Response response = jersey.target(V1_BADGE + "/vulns/project/" + UUID.randomUUID()).request() + public void projectVulnerabilitiesByUuidMissingAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() .get(Response.class); - Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertEquals(401, response.getStatus(), 0); } @Test - public void projectVulnerabilitiesByNameAndVersionTest() { - qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); - Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() + public void projectVulnerabilitiesByUuidMissingPermissionTest() { + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } + + @Test + public void projectVulnerabilitiesByUuidWithAclAccessTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + project.setAccessTeams(List.of(team)); + qm.persist(project); + + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() + .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); @@ -87,32 +118,137 @@ public void projectVulnerabilitiesByNameAndVersionTest() { } @Test - public void projectVulnerabilitiesByNameAndVersionDisabledTest() { - disableBadge(); - Response response = jersey.target(V1_BADGE + "/vulns/project/ProjectNameDoesNotExist/1.0.0").request() + public void projectVulnerabilitiesByUuidWithAclNoAccessTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + qm.persist(project); + + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } + + @Test + public void projectVulnerabilitiesByNameAndVersionTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() + .header(X_API_KEY, apiKey) .get(Response.class); - Assert.assertEquals(204, response.getStatus(), 0); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); } @Test public void projectVulnerabilitiesByNameAndVersionProjectNotFoundTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + Response response = jersey.target(V1_BADGE + "/vulns/project/ProjectNameDoesNotExist/1.0.0").request() + .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } @Test public void projectVulnerabilitiesByNameAndVersionVersionNotFoundTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.2.0").request() + .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } + @Test + public void projectVulnerabilitiesByNameAndVersionMissingAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() + .get(Response.class); + Assert.assertEquals(401, response.getStatus(), 0); + } + + @Test + public void projectVulnerabilitiesByNameAndVersionMissingPermissionTest() { + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } + + @Test + public void projectVulnerabilitiesByNameAndVersionWithAclAccessTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + project.setAccessTeams(List.of(team)); + qm.persist(project); + + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectVulnerabilitiesByNameAndVersionWithAclNoAccessTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + qm.persist(project); + + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } + @Test public void projectPolicyViolationsByUuidTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() + .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); @@ -120,24 +256,54 @@ public void projectPolicyViolationsByUuidTest() { } @Test - public void projectPolicyViolationsByUuidProjectDisabledTest() { - disableBadge(); + public void projectPolicyViolationsByUuidProjectNotFoundTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + Response response = jersey.target(V1_BADGE + "/violations/project/" + UUID.randomUUID()).request() + .header(X_API_KEY, apiKey) .get(Response.class); - Assert.assertEquals(204, response.getStatus(), 0); + Assert.assertEquals(404, response.getStatus(), 0); } @Test - public void projectPolicyViolationsByUuidProjectNotFoundTest() { - Response response = jersey.target(V1_BADGE + "/violations/project/" + UUID.randomUUID()).request() + public void projectPolicyViolationsByUuidMissingAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() .get(Response.class); - Assert.assertEquals(404, response.getStatus(), 0); + Assert.assertEquals(401, response.getStatus(), 0); } @Test - public void projectPolicyViolationsByNameAndVersionTest() { - qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); - Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() + public void projectPolicyViolationsByUuidMissingPermissionTest() { + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } + + @Test + public void projectPolicyViolationsByUuidWithAclAccessTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + project.setAccessTeams(List.of(team)); + qm.persist(project); + + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() + .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); @@ -145,31 +311,126 @@ public void projectPolicyViolationsByNameAndVersionTest() { } @Test - public void projectPolicyViolationsByNameAndVersionDisabledTest() { - disableBadge(); - Response response = jersey.target(V1_BADGE + "/violations/project/ProjectNameDoesNotExist/1.0.0").request() + public void projectPolicyViolationsByUuidWithAclNoAccessTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + qm.persist(project); + + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } + + @Test + public void projectPolicyViolationsByNameAndVersionTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() + .header(X_API_KEY, apiKey) .get(Response.class); - Assert.assertEquals(204, response.getStatus(), 0); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); } @Test public void projectPolicyViolationsByNameAndVersionProjectNotFoundTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + Response response = jersey.target(V1_BADGE + "/violations/project/ProjectNameDoesNotExist/1.0.0").request() + .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } @Test public void projectPolicyViolationsByNameAndVersionVersionNotFoundTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.2.0").request() + .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } - private void disableBadge() { - qm.getConfigProperty(GENERAL_BADGE_ENABLED.getGroupName(), GENERAL_BADGE_ENABLED.getPropertyName()) - .setPropertyValue("false"); + @Test + public void projectPolicyViolationsByNameAndVersionMissingAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() + .get(Response.class); + Assert.assertEquals(401, response.getStatus(), 0); + } + + @Test + public void projectPolicyViolationsByNameAndVersionMissingPermissionTest() { + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); + } + + @Test + public void projectPolicyViolationsByNameAndVersionWithAclAccessTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + project.setAccessTeams(List.of(team)); + qm.persist(project); + + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectPolicyViolationsByNameAndVersionWithAclNoAccessTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + qm.persist(project); + + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(403, response.getStatus(), 0); } private boolean isLikelySvg(String body) { diff --git a/src/test/java/org/dependencytrack/resources/v1/PermissionResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/PermissionResourceTest.java index 48a663d2bd..a2931d9b9c 100644 --- a/src/test/java/org/dependencytrack/resources/v1/PermissionResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/PermissionResourceTest.java @@ -100,6 +100,10 @@ public void getAllPermissionsTest() { "description": "Allows the modification and deletion of tags", "name": "TAG_MANAGEMENT" }, + { + "description": "Provides the ability to view badges", + "name": "VIEW_BADGES" + }, { "description": "Provides the ability to view policy violations", "name": "VIEW_POLICY_VIOLATION" From 1931654ff8f0a27b27f3bed2a6c56e33437594e5 Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 1 Sep 2024 19:01:00 +0200 Subject: [PATCH 05/12] Update documentation Signed-off-by: Kirill.Sybin --- docs/_docs/integrations/badges.md | 43 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/_docs/integrations/badges.md b/docs/_docs/integrations/badges.md index 5f648cd7b3..c6422859dc 100644 --- a/docs/_docs/integrations/badges.md +++ b/docs/_docs/integrations/badges.md @@ -5,14 +5,21 @@ chapter: 6 order: 10 --- -Dependency-Track supports badges in Scalable Vector Graphics (SVG) format. Support for badges is a globally configurable -option and is disabled by default. +Dependency-Track supports badges in Scalable Vector Graphics (SVG) format. Support for badges is configurable on a team +basis via permission. -> Enabling badge support will provide vulnerability and policy violation metric information to unauthenticated users. -> Any anonymous user with network access to Dependency-Track and knowledge of a projects information will be able -> to view the SVG badge. +To enable badges for a team, activate the permission `VIEW_BADGES`. To deactivate badges, remove the permission. To +retrieve a badge, use a team's API key either in the badge API header `X-API-Key` or in the URI parameter `apiKey`. -In all following examples, replace `{name}`, `{version}`, and `{uuid}` with their respective values. +> As badges are typically embedded in places that more people have access to than to Dependency-Track, the API key used +> for the badge request should have minimal scope to prevent unintended access beyond that badge. Ideally, the API +> key belongs to a single-purpose team, having just the `VIEW_BADGES` permission, with only one API key and access to +> only the projects/project versions whose badges are displayed at one site--the latter requiring _Portfolio Access +> Control_. + +In all following examples, replace `{name}`, `{version}`, `{uuid}`, and `{apiKey}` with their respective values. For +brevity, the examples use the URI query parameter as the method of authentication, however, they also work with +authentication by header. ### Vulnerable components Create a badge for vulnerable components of the project. It either shows: @@ -33,8 +40,8 @@ name and version. #### Examples ``` -https://dtrack.example.com/api/v1/badge/vulns/project/{name}/{version} -https://dtrack.example.com/api/v1/badge/vulns/project/{uuid} +https://dtrack.example.com/api/v1/badge/vulns/project/{name}/{version}?apiKey={apiKey} +https://dtrack.example.com/api/v1/badge/vulns/project/{uuid}?apiKey={apiKey} ``` ### Policy violations @@ -57,8 +64,8 @@ projects name and version. #### Examples ``` -https://dtrack.example.com/api/v1/badge/violations/project/{name}/{version} -https://dtrack.example.com/api/v1/badge/violations/project/{uuid} +https://dtrack.example.com/api/v1/badge/violations/project/{name}/{version}?apiKey={apiKey} +https://dtrack.example.com/api/v1/badge/violations/project/{uuid}?apiKey={apiKey} ``` @@ -67,17 +74,17 @@ You can embed the badges in other documents. It allows you to display a badge in #### HTML Examples ```html - - - - + + + + ``` #### Markdown Examples ```markdown -![alt text](https://dtrack.example.com/api/v1/badge/vulns/project/{name}/{version}) -![alt text](https://dtrack.example.com/api/v1/badge/vulns/project/{uuid}) -![alt text](https://dtrack.example.com/api/v1/badge/violations/project/{name}/{version}) -![alt text](https://dtrack.example.com/api/v1/badge/violations/project/{uuid}) +![alt text](https://dtrack.example.com/api/v1/badge/vulns/project/{name}/{version}?apiKey={apiKey}) +![alt text](https://dtrack.example.com/api/v1/badge/vulns/project/{uuid}?apiKey={apiKey}) +![alt text](https://dtrack.example.com/api/v1/badge/violations/project/{name}/{version}?apiKey={apiKey}) +![alt text](https://dtrack.example.com/api/v1/badge/violations/project/{uuid}?apiKey={apiKey}) ``` From 60ffaaf90af22eeebe5efd1a7394164f70a0508c Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 1 Sep 2024 21:31:40 +0200 Subject: [PATCH 06/12] Enable auth via URI query param for badge API Allows API authentication via URI query param for badge requests as an alternative to header authentication because typical use cases for badges do not easily allow header injection. Requires https://github.com/stevespringett/Alpine/issues/641 Signed-off-by: Kirill.Sybin --- .../org/dependencytrack/resources/v1/BadgeResource.java | 8 +++++++- src/main/resources/openapi-configuration.yaml | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java b/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java index 29427084c8..0abad88e19 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java @@ -18,6 +18,7 @@ */ package org.dependencytrack.resources.v1; +import alpine.server.auth.AllowApiKeyInQueryParameter; import alpine.server.auth.PermissionRequired; import alpine.server.resources.AlpineResource; import io.swagger.v3.oas.annotations.Operation; @@ -52,7 +53,8 @@ @Tag(name = "badge") @SecurityRequirements({ @SecurityRequirement(name = "ApiKeyAuth"), - @SecurityRequirement(name = "BearerAuth") + @SecurityRequirement(name = "BearerAuth"), + @SecurityRequirement(name = "ApiKeyQueryAuth") }) public class BadgeResource extends AlpineResource { @@ -75,6 +77,7 @@ public class BadgeResource extends AlpineResource { @ApiResponse(responseCode = "404", description = "The project could not be found") }) @PermissionRequired(Permissions.Constants.VIEW_BADGES) + @AllowApiKeyInQueryParameter public Response getProjectVulnerabilitiesBadge( @Parameter(description = "The UUID of the project to retrieve metrics for", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("uuid") @ValidUuid String uuid) { @@ -110,6 +113,7 @@ public Response getProjectVulnerabilitiesBadge( @ApiResponse(responseCode = "404", description = "The project could not be found") }) @PermissionRequired(Permissions.Constants.VIEW_BADGES) + @AllowApiKeyInQueryParameter public Response getProjectVulnerabilitiesBadge( @Parameter(description = "The name of the project to query on", required = true) @PathParam("name") String name, @@ -147,6 +151,7 @@ public Response getProjectVulnerabilitiesBadge( @ApiResponse(responseCode = "404", description = "The project could not be found") }) @PermissionRequired(Permissions.Constants.VIEW_BADGES) + @AllowApiKeyInQueryParameter public Response getProjectPolicyViolationsBadge( @Parameter(description = "The UUID of the project to retrieve a badge for", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("uuid") @ValidUuid String uuid) { @@ -182,6 +187,7 @@ public Response getProjectPolicyViolationsBadge( @ApiResponse(responseCode = "404", description = "The project could not be found") }) @PermissionRequired(Permissions.Constants.VIEW_BADGES) + @AllowApiKeyInQueryParameter public Response getProjectPolicyViolationsBadge( @Parameter(description = "The name of the project to query on", required = true) @PathParam("name") String name, diff --git a/src/main/resources/openapi-configuration.yaml b/src/main/resources/openapi-configuration.yaml index 1d17c261a0..1737efc9c4 100644 --- a/src/main/resources/openapi-configuration.yaml +++ b/src/main/resources/openapi-configuration.yaml @@ -23,6 +23,10 @@ openAPI: BearerAuth: type: http scheme: Bearer + ApiKeyQueryAuth: + name: apiKey + type: apiKey + in: query prettyPrint: true resourcePackages: - alpine.server.resources From 8c40c9a413a7622c3100b117eebb732dc4610a6e Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 1 Sep 2024 21:33:58 +0200 Subject: [PATCH 07/12] Update badge resource tests to auth via URI query Update tests to focus on API authentication via URI query parameter, but keep some tests that test header authentication as that remains an option. Requires https://github.com/stevespringett/Alpine/issues/641 Signed-off-by: Kirill.Sybin --- .../org/dependencytrack/ResourceTest.java | 1 + .../resources/v1/BadgeResourceTest.java | 232 +++++++++++++++--- 2 files changed, 205 insertions(+), 28 deletions(-) diff --git a/src/test/java/org/dependencytrack/ResourceTest.java b/src/test/java/org/dependencytrack/ResourceTest.java index ef37c77967..61d6bed35f 100644 --- a/src/test/java/org/dependencytrack/ResourceTest.java +++ b/src/test/java/org/dependencytrack/ResourceTest.java @@ -78,6 +78,7 @@ public abstract class ResourceTest { protected final String SIZE = "size"; protected final String TOTAL_COUNT_HEADER = "X-Total-Count"; protected final String X_API_KEY = "X-Api-Key"; + protected final String API_KEY = "apiKey"; protected final String V1_TAG = "/v1/tag"; // Hashing is expensive. Do it once and re-use across tests as much as possible. diff --git a/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java index 01c03a5e06..3599d3111d 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java @@ -53,6 +53,20 @@ public class BadgeResourceTest extends ResourceTest { public void projectVulnerabilitiesByUuidTest() { initializeWithPermissions(Permissions.VIEW_BADGES); + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()) + .queryParam(API_KEY, apiKey) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectVulnerabilitiesByUuidWithHeaderAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) @@ -66,8 +80,9 @@ public void projectVulnerabilitiesByUuidTest() { public void projectVulnerabilitiesByUuidProjectNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); - Response response = jersey.target(V1_BADGE + "/vulns/project/" + UUID.randomUUID()).request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/vulns/project/" + UUID.randomUUID()) + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } @@ -85,8 +100,9 @@ public void projectVulnerabilitiesByUuidMissingAuthenticationTest() { @Test public void projectVulnerabilitiesByUuidMissingPermissionTest() { Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); - Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()) + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(403, response.getStatus(), 0); } @@ -109,6 +125,33 @@ public void projectVulnerabilitiesByUuidWithAclAccessTest() { project.setAccessTeams(List.of(team)); qm.persist(project); + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()) + .queryParam(API_KEY, apiKey) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectVulnerabilitiesByUuidWithAclAccessWithHeaderAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + project.setAccessTeams(List.of(team)); + qm.persist(project); + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .get(Response.class); @@ -134,8 +177,9 @@ public void projectVulnerabilitiesByUuidWithAclNoAccessTest() { project.setVersion("1.0.0"); qm.persist(project); - Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()) + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(403, response.getStatus(), 0); } @@ -144,6 +188,20 @@ public void projectVulnerabilitiesByUuidWithAclNoAccessTest() { public void projectVulnerabilitiesByNameAndVersionTest() { initializeWithPermissions(Permissions.VIEW_BADGES); + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectVulnerabilitiesByNameAndVersionWithHeaderAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() .header(X_API_KEY, apiKey) @@ -157,8 +215,9 @@ public void projectVulnerabilitiesByNameAndVersionTest() { public void projectVulnerabilitiesByNameAndVersionProjectNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); - Response response = jersey.target(V1_BADGE + "/vulns/project/ProjectNameDoesNotExist/1.0.0").request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/vulns/project/ProjectNameDoesNotExist/1.0.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } @@ -168,8 +227,9 @@ public void projectVulnerabilitiesByNameAndVersionVersionNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); - Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.2.0").request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.2.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } @@ -187,8 +247,9 @@ public void projectVulnerabilitiesByNameAndVersionMissingAuthenticationTest() { @Test public void projectVulnerabilitiesByNameAndVersionMissingPermissionTest() { qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); - Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(403, response.getStatus(), 0); } @@ -211,6 +272,33 @@ public void projectVulnerabilitiesByNameAndVersionWithAclAccessTest() { project.setAccessTeams(List.of(team)); qm.persist(project); + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectVulnerabilitiesByNameAndVersionWithAclAccessWithHeaderAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + project.setAccessTeams(List.of(team)); + qm.persist(project); + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() .header(X_API_KEY, apiKey) .get(Response.class); @@ -236,8 +324,9 @@ public void projectVulnerabilitiesByNameAndVersionWithAclNoAccessTest() { project.setVersion("1.0.0"); qm.persist(project); - Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(403, response.getStatus(), 0); } @@ -246,6 +335,20 @@ public void projectVulnerabilitiesByNameAndVersionWithAclNoAccessTest() { public void projectPolicyViolationsByUuidTest() { initializeWithPermissions(Permissions.VIEW_BADGES); + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()) + .queryParam(API_KEY, apiKey) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectPolicyViolationsByUuidWithHeaderAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) @@ -259,8 +362,9 @@ public void projectPolicyViolationsByUuidTest() { public void projectPolicyViolationsByUuidProjectNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); - Response response = jersey.target(V1_BADGE + "/violations/project/" + UUID.randomUUID()).request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/violations/project/" + UUID.randomUUID()) + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } @@ -278,8 +382,9 @@ public void projectPolicyViolationsByUuidMissingAuthenticationTest() { @Test public void projectPolicyViolationsByUuidMissingPermissionTest() { Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); - Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()) + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(403, response.getStatus(), 0); } @@ -302,6 +407,33 @@ public void projectPolicyViolationsByUuidWithAclAccessTest() { project.setAccessTeams(List.of(team)); qm.persist(project); + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()) + .queryParam(API_KEY, apiKey) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectPolicyViolationsByUuidWithAclAccessWithHeaderAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = new Project(); + project.setName("Acme Example"); + project.setVersion("1.0.0"); + project.setAccessTeams(List.of(team)); + qm.persist(project); + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() .header(X_API_KEY, apiKey) .get(Response.class); @@ -327,8 +459,9 @@ public void projectPolicyViolationsByUuidWithAclNoAccessTest() { project.setVersion("1.0.0"); qm.persist(project); - Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()).request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()) + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(403, response.getStatus(), 0); } @@ -337,6 +470,20 @@ public void projectPolicyViolationsByUuidWithAclNoAccessTest() { public void projectPolicyViolationsByNameAndVersionTest() { initializeWithPermissions(Permissions.VIEW_BADGES); + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectPolicyViolationsByNameAndVersionWithHeaderAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() .header(X_API_KEY, apiKey) @@ -350,8 +497,9 @@ public void projectPolicyViolationsByNameAndVersionTest() { public void projectPolicyViolationsByNameAndVersionProjectNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); - Response response = jersey.target(V1_BADGE + "/violations/project/ProjectNameDoesNotExist/1.0.0").request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/violations/project/ProjectNameDoesNotExist/1.0.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } @@ -361,8 +509,9 @@ public void projectPolicyViolationsByNameAndVersionVersionNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); - Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.2.0").request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.2.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); } @@ -380,8 +529,9 @@ public void projectPolicyViolationsByNameAndVersionMissingAuthenticationTest() { @Test public void projectPolicyViolationsByNameAndVersionMissingPermissionTest() { qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); - Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(403, response.getStatus(), 0); } @@ -402,6 +552,31 @@ public void projectPolicyViolationsByNameAndVersionWithAclAccessTest() { project.setAccessTeams(List.of(team)); qm.persist(project); + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectPolicyViolationsByNameAndVersionWithAclAccessWithHeaderAuthenticationTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createConfigProperty( + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getGroupName(), + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyName(), + "true", + ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED.getPropertyType(), + null + ); + + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + project.setAccessTeams(List.of(team)); + qm.persist(project); + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() .header(X_API_KEY, apiKey) .get(Response.class); @@ -427,8 +602,9 @@ public void projectPolicyViolationsByNameAndVersionWithAclNoAccessTest() { project.setVersion("1.0.0"); qm.persist(project); - Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0").request() - .header(X_API_KEY, apiKey) + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(403, response.getStatus(), 0); } From 0e3e57623f68db6799d446964ac056f2df378c35 Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 22 Sep 2024 22:33:23 +0200 Subject: [PATCH 08/12] Add default team for badges Add a default team for viewing badges for new DBs. Signed-off-by: Kirill.Sybin --- docs/_docs/integrations/badges.md | 3 +++ .../persistence/DefaultObjectGenerator.java | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/docs/_docs/integrations/badges.md b/docs/_docs/integrations/badges.md index c6422859dc..a7055368cd 100644 --- a/docs/_docs/integrations/badges.md +++ b/docs/_docs/integrations/badges.md @@ -11,6 +11,9 @@ basis via permission. To enable badges for a team, activate the permission `VIEW_BADGES`. To deactivate badges, remove the permission. To retrieve a badge, use a team's API key either in the badge API header `X-API-Key` or in the URI parameter `apiKey`. +Dependency-Track ships with a default team "_Badge Viewers_" dedicated to badges that already has the necessary +permission and an API key. + > As badges are typically embedded in places that more people have access to than to Dependency-Track, the API key used > for the badge request should have minimal scope to prevent unintended access beyond that badge. Ideally, the API > key belongs to a single-purpose team, having just the `VIEW_BADGES` permission, with only one API key and access to diff --git a/src/main/java/org/dependencytrack/persistence/DefaultObjectGenerator.java b/src/main/java/org/dependencytrack/persistence/DefaultObjectGenerator.java index b02d08d0ef..1cc6729b57 100644 --- a/src/main/java/org/dependencytrack/persistence/DefaultObjectGenerator.java +++ b/src/main/java/org/dependencytrack/persistence/DefaultObjectGenerator.java @@ -151,6 +151,8 @@ private void loadDefaultPersonas() { final Team managers = qm.createTeam("Portfolio Managers", false); LOGGER.debug("Creating team: Automation"); final Team automation = qm.createTeam("Automation", true); + LOGGER.debug("Creating team: Badge Viewers"); + final Team badges = qm.createTeam("Badge Viewers", true); final List fullList = qm.getPermissions(); @@ -158,10 +160,12 @@ private void loadDefaultPersonas() { sysadmins.setPermissions(fullList); managers.setPermissions(getPortfolioManagersPermissions(fullList)); automation.setPermissions(getAutomationPermissions(fullList)); + badges.setPermissions(getBadgesPermissions(fullList)); qm.persist(sysadmins); qm.persist(managers); qm.persist(automation); + qm.persist(badges); LOGGER.debug("Adding admin user to System Administrators"); qm.addUserToTeam(admin, sysadmins); @@ -194,6 +198,16 @@ private List getAutomationPermissions(final List fullLis return permissions; } + private List getBadgesPermissions(final List fullList) { + final List permissions = new ArrayList<>(); + for (final Permission permission : fullList) { + if (permission.getName().equals(Permissions.Constants.VIEW_BADGES)) { + permissions.add(permission); + } + } + return permissions; + } + /** * Loads the default repositories */ From 4665b5316a8ee38d5f2c5930f80c16904a1f8460 Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 29 Sep 2024 15:34:20 +0200 Subject: [PATCH 09/12] Resurrect enable badges setting for deprecation To make the removal of unauthenticated access to badges not be a breaking change after all, the enable badges config property is kept in after all, but repurposed into a setting to enable unauthenticated access to the badges resource. If it is disabled, then the badges api remains accessible to authenticated and authorized requests. Signed-off-by: Kirill.Sybin --- .../model/ConfigPropertyConstants.java | 1 + .../resources/v1/BadgeResource.java | 161 ++++++++++++++++-- 2 files changed, 148 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java index e6c623decf..6c1032e3fa 100644 --- a/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java +++ b/src/main/java/org/dependencytrack/model/ConfigPropertyConstants.java @@ -27,6 +27,7 @@ public enum ConfigPropertyConstants { GENERAL_BASE_URL("general", "base.url", null, PropertyType.URL, "URL used to construct links back to Dependency-Track from external systems"), + GENERAL_BADGE_ENABLED("general", "badge.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable unauthenticated access to SVG badge from metrics"), EMAIL_SMTP_ENABLED("email", "smtp.enabled", "false", PropertyType.BOOLEAN, "Flag to enable/disable SMTP"), EMAIL_SMTP_FROM_ADDR("email", "smtp.from.address", null, PropertyType.STRING, "The from email address to use to send output SMTP mail"), EMAIL_PREFIX("email", "subject.prefix", "[Dependency-Track]", PropertyType.STRING, "The Prefix Subject email to use"), diff --git a/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java b/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java index 0abad88e19..6ecc04d254 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BadgeResource.java @@ -18,8 +18,18 @@ */ package org.dependencytrack.resources.v1; -import alpine.server.auth.AllowApiKeyInQueryParameter; -import alpine.server.auth.PermissionRequired; +import alpine.common.logging.Logger; +import alpine.common.util.BooleanUtil; +import alpine.model.ApiKey; +import alpine.model.ConfigProperty; +import alpine.model.UserPrincipal; +import alpine.model.LdapUser; +import alpine.model.ManagedUser; +import alpine.model.OidcUser; +import alpine.server.auth.ApiKeyAuthenticationService; +import alpine.server.auth.JwtAuthenticationService; +import alpine.server.auth.AuthenticationNotRequired; +import alpine.server.filters.AuthenticationFilter; import alpine.server.resources.AlpineResource; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -40,8 +50,16 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.HttpMethod; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.Response; +import org.glassfish.jersey.server.ContainerRequest; +import org.owasp.security.logging.SecurityMarkers; + +import javax.naming.AuthenticationException; +import java.security.Principal; + +import static org.dependencytrack.model.ConfigPropertyConstants.GENERAL_BADGE_ENABLED; /** * JAX-RS resources for processing metrics. @@ -60,6 +78,97 @@ public class BadgeResource extends AlpineResource { private static final String SVG_MEDIA_TYPE = "image/svg+xml"; + private final Logger LOGGER = Logger.getLogger(AuthenticationFilter.class); + + private boolean isUnauthenticatedBadgeAccessEnabled(final QueryManager qm) { + ConfigProperty property = qm.getConfigProperty( + GENERAL_BADGE_ENABLED.getGroupName(), GENERAL_BADGE_ENABLED.getPropertyName()); + return BooleanUtil.valueOf(property.getPropertyValue()); + } + + // Stand-in methods for alpine.server.filters.AuthenticationFilter and + // alpine.server.filters.AuthorizationFilter to allow enabling and disabling of + // unauthenticated access to the badges API during runtime, used solely to offer + // a deprecation period for unauthenticated access to badges. + private boolean passesAuthentication() { + ContainerRequest request = (ContainerRequest) super.getRequestContext().getRequest(); + + if (HttpMethod.OPTIONS.equals(request.getMethod())) { + return true; + } + + Principal principal = null; + + final ApiKeyAuthenticationService apiKeyAuthService = new ApiKeyAuthenticationService(request, true); + if (apiKeyAuthService.isSpecified()) { + try { + principal = apiKeyAuthService.authenticate(); + } catch (AuthenticationException e) { + LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Invalid API key asserted"); + return false; + } + } + + final JwtAuthenticationService jwtAuthService = new JwtAuthenticationService(request); + if (jwtAuthService.isSpecified()) { + try { + principal = jwtAuthService.authenticate(); + } catch (AuthenticationException e) { + LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Invalid JWT asserted"); + return false; + } + } + + if (principal == null) { + return false; + } else { + super.getRequestContext().setProperty("Principal", principal); + return true; + } + } + + private boolean passesAuthorization(final QueryManager qm) { + final Principal principal = (Principal) super.getRequestContext().getProperty("Principal"); + if (principal == null) { + LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "A request was made without the assertion of a valid user principal"); + return false; + } + + final String[] permissions = { Permissions.Constants.VIEW_BADGES }; + + if (principal instanceof ApiKey) { + final ApiKey apiKey = (ApiKey)principal; + for (final String permission: permissions) { + if (qm.hasPermission(apiKey, permission)) { + return true; + } + } + LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Unauthorized access attempt made by API Key " + + apiKey.getMaskedKey() + " to " + ((ContainerRequest) super.getRequestContext()).getRequestUri().toString()); + } else { + UserPrincipal user = null; + if (principal instanceof ManagedUser) { + user = qm.getManagedUser(((ManagedUser) principal).getUsername()); + } else if (principal instanceof LdapUser) { + user = qm.getLdapUser(((LdapUser) principal).getUsername()); + } else if (principal instanceof OidcUser) { + user = qm.getOidcUser(((OidcUser) principal).getUsername()); + } + if (user == null) { + LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "A request was made but the system in unable to find the user principal"); + return false; + } + for (final String permission : permissions) { + if (qm.hasPermission(user, permission, true)) { + return true; + } + } + LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Unauthorized access attempt made by " + + user.getUsername() + " to " + ((ContainerRequest) super.getRequestContext()).getRequestUri().toString()); + } + return false; + } + @GET @Path("/vulns/project/{uuid}") @Produces(SVG_MEDIA_TYPE) @@ -74,17 +183,23 @@ public class BadgeResource extends AlpineResource { content = @Content(schema = @Schema(type = "string")) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "404", description = "The project could not be found") }) - @PermissionRequired(Permissions.Constants.VIEW_BADGES) - @AllowApiKeyInQueryParameter + @AuthenticationNotRequired public Response getProjectVulnerabilitiesBadge( @Parameter(description = "The UUID of the project to retrieve metrics for", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("uuid") @ValidUuid String uuid) { try (QueryManager qm = new QueryManager()) { + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !passesAuthentication()) { + return Response.status(Response.Status.UNAUTHORIZED).build(); + } + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !passesAuthorization(qm)) { + return Response.status(Response.Status.FORBIDDEN).build(); + } final Project project = qm.getObjectByUuid(Project.class, uuid); if (project != null) { - if (!qm.hasAccess(super.getPrincipal(), project)) { + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !qm.hasAccess(super.getPrincipal(), project)) { return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); } final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); @@ -110,19 +225,25 @@ public Response getProjectVulnerabilitiesBadge( content = @Content(schema = @Schema(type = "string")) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "404", description = "The project could not be found") }) - @PermissionRequired(Permissions.Constants.VIEW_BADGES) - @AllowApiKeyInQueryParameter + @AuthenticationNotRequired public Response getProjectVulnerabilitiesBadge( @Parameter(description = "The name of the project to query on", required = true) @PathParam("name") String name, @Parameter(description = "The version of the project to query on", required = true) @PathParam("version") String version) { try (QueryManager qm = new QueryManager()) { + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !passesAuthentication()) { + return Response.status(Response.Status.UNAUTHORIZED).build(); + } + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !passesAuthorization(qm)) { + return Response.status(Response.Status.FORBIDDEN).build(); + } final Project project = qm.getProject(name, version); if (project != null) { - if (!qm.hasAccess(super.getPrincipal(), project)) { + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !qm.hasAccess(super.getPrincipal(), project)) { return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); } final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); @@ -148,17 +269,23 @@ public Response getProjectVulnerabilitiesBadge( content = @Content(schema = @Schema(type = "string")) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "404", description = "The project could not be found") }) - @PermissionRequired(Permissions.Constants.VIEW_BADGES) - @AllowApiKeyInQueryParameter + @AuthenticationNotRequired public Response getProjectPolicyViolationsBadge( @Parameter(description = "The UUID of the project to retrieve a badge for", schema = @Schema(type = "string", format = "uuid"), required = true) @PathParam("uuid") @ValidUuid String uuid) { try (QueryManager qm = new QueryManager()) { + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !passesAuthentication()) { + return Response.status(Response.Status.UNAUTHORIZED).build(); + } + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !passesAuthorization(qm)) { + return Response.status(Response.Status.FORBIDDEN).build(); + } final Project project = qm.getObjectByUuid(Project.class, uuid); if (project != null) { - if (!qm.hasAccess(super.getPrincipal(), project)) { + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !qm.hasAccess(super.getPrincipal(), project)) { return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); } final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); @@ -184,19 +311,25 @@ public Response getProjectPolicyViolationsBadge( content = @Content(schema = @Schema(type = "string")) ), @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden"), @ApiResponse(responseCode = "404", description = "The project could not be found") }) - @PermissionRequired(Permissions.Constants.VIEW_BADGES) - @AllowApiKeyInQueryParameter + @AuthenticationNotRequired public Response getProjectPolicyViolationsBadge( @Parameter(description = "The name of the project to query on", required = true) @PathParam("name") String name, @Parameter(description = "The version of the project to query on", required = true) @PathParam("version") String version) { try (QueryManager qm = new QueryManager()) { + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !passesAuthentication()) { + return Response.status(Response.Status.UNAUTHORIZED).build(); + } + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !passesAuthorization(qm)) { + return Response.status(Response.Status.FORBIDDEN).build(); + } final Project project = qm.getProject(name, version); if (project != null) { - if (!qm.hasAccess(super.getPrincipal(), project)) { + if (!isUnauthenticatedBadgeAccessEnabled(qm) && !qm.hasAccess(super.getPrincipal(), project)) { return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); } final ProjectMetrics metrics = qm.getMostRecentProjectMetrics(project); From f265b35d4194c0d8067d55317d65f0bc13dad8bd Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 29 Sep 2024 17:04:15 +0200 Subject: [PATCH 10/12] Add tests for enabled unauthenticated badge access Signed-off-by: Kirill.Sybin --- .../resources/v1/BadgeResourceTest.java | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java index 3599d3111d..fade62e6ca 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BadgeResourceTest.java @@ -18,9 +18,8 @@ */ 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 org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; import org.dependencytrack.auth.Permissions; @@ -40,14 +39,20 @@ import java.util.List; import java.util.UUID; +import static org.dependencytrack.model.ConfigPropertyConstants.GENERAL_BADGE_ENABLED; + public class BadgeResourceTest extends ResourceTest { @ClassRule public static JerseyTestRule jersey = new JerseyTestRule( new ResourceConfig(BadgeResource.class) - .register(ApiFilter.class) - .register(AuthenticationFilter.class) - .register(AuthorizationFilter.class)); + .register(ApiFilter.class)); + + @Override + public void before() throws Exception { + super.before(); + qm.createConfigProperty(GENERAL_BADGE_ENABLED.getGroupName(), GENERAL_BADGE_ENABLED.getPropertyName(), "false", IConfigProperty.PropertyType.BOOLEAN, "Unauthenticated access to badge enabled"); + } @Test public void projectVulnerabilitiesByUuidTest() { @@ -76,6 +81,19 @@ public void projectVulnerabilitiesByUuidWithHeaderAuthenticationTest() { Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); } + @Test + public void projectVulnerabilitiesByUuidMissingAuthenticationWithUnauthenticatedAccessEnabledTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + enableUnauthenticatedBadgeAccess(); + + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/" + project.getUuid()).request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + @Test public void projectVulnerabilitiesByUuidProjectNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); @@ -201,10 +219,24 @@ public void projectVulnerabilitiesByNameAndVersionTest() { @Test public void projectVulnerabilitiesByNameAndVersionWithHeaderAuthenticationTest() { initializeWithPermissions(Permissions.VIEW_BADGES); + enableUnauthenticatedBadgeAccess(); qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0").request() - .header(X_API_KEY, apiKey) + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + + @Test + public void projectVulnerabilitiesByNameAndVersionMissingAuthenticationWithUnauthenticatedAccessEnabledTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/vulns/project/Acme%20Example/1.0.0") + .queryParam(API_KEY, apiKey) + .request() .get(Response.class); Assert.assertEquals(200, response.getStatus(), 0); Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); @@ -358,6 +390,20 @@ public void projectPolicyViolationsByUuidWithHeaderAuthenticationTest() { Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); } + @Test + public void projectPolicyViolationsByUuidMissingAuthenticationWithUnauthenticatedAccessEnabledTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + enableUnauthenticatedBadgeAccess(); + + Project project = qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/" + project.getUuid()) + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + @Test public void projectPolicyViolationsByUuidProjectNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); @@ -493,6 +539,20 @@ public void projectPolicyViolationsByNameAndVersionWithHeaderAuthenticationTest( Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); } + @Test + public void projectPolicyViolationsByNameAndVersionMissingAuthenticationWithUnauthenticatedAccessEnabledTest() { + initializeWithPermissions(Permissions.VIEW_BADGES); + enableUnauthenticatedBadgeAccess(); + + qm.createProject("Acme Example", null, "1.0.0", null, null, null, true, false); + Response response = jersey.target(V1_BADGE + "/violations/project/Acme%20Example/1.0.0") + .request() + .get(Response.class); + Assert.assertEquals(200, response.getStatus(), 0); + Assert.assertEquals("image/svg+xml", response.getHeaderString("Content-Type")); + Assert.assertTrue(isLikelySvg(getPlainTextBody(response))); + } + @Test public void projectPolicyViolationsByNameAndVersionProjectNotFoundTest() { initializeWithPermissions(Permissions.VIEW_BADGES); @@ -620,4 +680,9 @@ private boolean isLikelySvg(String body) { return false; } } + + private void enableUnauthenticatedBadgeAccess() { + qm.getConfigProperty(GENERAL_BADGE_ENABLED.getGroupName(), GENERAL_BADGE_ENABLED.getPropertyName()) + .setPropertyValue("true"); + } } From 02a44accb6a1fe21bfa2b62d88347a8ddcf401d8 Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 29 Sep 2024 17:29:25 +0200 Subject: [PATCH 11/12] Update documentation Update documentation for globally configurable unauthenticated access to badges. Signed-off-by: Kirill.Sybin --- docs/_docs/integrations/badges.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/_docs/integrations/badges.md b/docs/_docs/integrations/badges.md index a7055368cd..e966fb3fe5 100644 --- a/docs/_docs/integrations/badges.md +++ b/docs/_docs/integrations/badges.md @@ -6,11 +6,25 @@ order: 10 --- Dependency-Track supports badges in Scalable Vector Graphics (SVG) format. Support for badges is configurable on a team -basis via permission. +basis via permission or globally for unauthenticated access. + +> **Deprecation Notice** +> +> Unauthenticated access to badges as a global configuration is deprecated and slated for removal in Dependency-Track +> v4.12. To enable badges for a team, activate the permission `VIEW_BADGES`. To deactivate badges, remove the permission. To retrieve a badge, use a team's API key either in the badge API header `X-API-Key` or in the URI parameter `apiKey`. +As a legacy feature, badges can also be accessed without authentication. On new Dependency-Track installations, this is +disabled by default. On Dependency-Track installations updated from ≤ v4.11, where (unauthenticated) badge support +was enabled, badges will remain accessible for unauthenticated requests. If this is disabled, badges will be accessible +for authenticated and authorized requests. + +> Enabling unauthenticated access to badges will provide vulnerability and policy violation metric information to +> unauthenticated users. Any anonymous user with network access to Dependency-Track and knowledge of a projects +> information will be able to view the SVG badge. + Dependency-Track ships with a default team "_Badge Viewers_" dedicated to badges that already has the necessary permission and an API key. From efb2504a68a4fe66f57ddbb402e9034c03544e6e Mon Sep 17 00:00:00 2001 From: "Kirill.Sybin" Date: Sun, 29 Sep 2024 20:48:42 +0200 Subject: [PATCH 12/12] Fix tests to take into account new default team Signed-off-by: Kirill.Sybin --- .../dependencytrack/persistence/DefaultObjectGeneratorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/dependencytrack/persistence/DefaultObjectGeneratorTest.java b/src/test/java/org/dependencytrack/persistence/DefaultObjectGeneratorTest.java index 410657f821..e7c5f6e12c 100644 --- a/src/test/java/org/dependencytrack/persistence/DefaultObjectGeneratorTest.java +++ b/src/test/java/org/dependencytrack/persistence/DefaultObjectGeneratorTest.java @@ -93,7 +93,7 @@ public void testLoadDefaultPersonas() throws Exception { Method method = generator.getClass().getDeclaredMethod("loadDefaultPersonas"); method.setAccessible(true); method.invoke(generator); - Assert.assertEquals(3, qm.getTeams().size()); + Assert.assertEquals(4, qm.getTeams().size()); } @Test