diff --git a/.github/workflows/_meta-build.yaml b/.github/workflows/_meta-build.yaml index 4819ff67e5..40c11ecda2 100644 --- a/.github/workflows/_meta-build.yaml +++ b/.github/workflows/_meta-build.yaml @@ -67,13 +67,20 @@ jobs: mvn -B -Pquick -Dservices.bom.merge.skip=false package - name: Upload Artifacts - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2 with: name: assembled-wars path: |- apiserver/target/*.jar apiserver/target/bom.json + - name: Upload OpenAPI Spec + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # tag=v4.6.2 + with: + name: openapi-spec + path: |- + api/target/classes/**/openapi.yaml + build-container: runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/ci-openapi.yaml b/.github/workflows/ci-openapi.yaml new file mode 100644 index 0000000000..998a84538c --- /dev/null +++ b/.github/workflows/ci-openapi.yaml @@ -0,0 +1,69 @@ +# 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. +name: OpenAPI + +on: + pull_request: + paths: + - api/src/main/openapi/** + - api/src/main/spectral/** + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: { } + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + checks: write + timeout-minutes: 5 + steps: + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2 + - name: Lint OpenAPI Spec + uses: stoplightio/spectral-action@577bade2d6e0eeb50528c94182a5588bf961ae8f # tag=v0.8.12 + with: + spectral_ruleset: "api/src/main/spectral/ruleset.yaml" + file_glob: "api/src/main/openapi/openapi.yaml" + +# TODO: Uncomment after the OpenAPI Spec is present in main. +# breaking-changes: +# name: Breaking Changes +# runs-on: ubuntu-latest +# timeout-minutes: 5 +# steps: +# - name: Checkout Repository +# uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2 +# with: +# fetch-depth: 0 +# - name: Prepare OpenAPI Spec Comparison +# run: |- +# cd api/src/main/resources/org/dependencytrack/api/v2 +# mv openapi.yaml openapi-after.yaml +# +# git checkout ${{ github.event.pull_request.base.sha }} -- openapi.yaml +# mv openapi.yaml openapi-before.yaml +# - name: Detect Breaking Changes in OpenAPI Spec +# run: |- +# docker run -i --rm \ +# -v "$(pwd)/api/src/main/resources/org/dependencytrack/api/v2:/work:ro" \ +# pb33f/openapi-changes summary --no-color --no-logo \ +# openapi-before.yaml openapi-after.yaml \ No newline at end of file diff --git a/.mvn/maven-build-cache-config.xml b/.mvn/maven-build-cache-config.xml index 3eecd9f34c..761cac1a50 100644 --- a/.mvn/maven-build-cache-config.xml +++ b/.mvn/maven-build-cache-config.xml @@ -31,7 +31,7 @@ - {*.java,*.properties,*.proto,*.sql,*.xml} + {*.java,*.properties,*.proto,*.sql,*.xml,*.yaml} src/ diff --git a/alpine/alpine-server/src/main/java/alpine/server/filters/AuthenticationFilter.java b/alpine/alpine-server/src/main/java/alpine/server/filters/AuthenticationFilter.java index 26d332b313..bb4de20380 100644 --- a/alpine/alpine-server/src/main/java/alpine/server/filters/AuthenticationFilter.java +++ b/alpine/alpine-server/src/main/java/alpine/server/filters/AuthenticationFilter.java @@ -20,8 +20,8 @@ import alpine.common.logging.Logger; import alpine.model.ApiKey; -import alpine.server.auth.ApiKeyAuthenticationService; import alpine.server.auth.AllowApiKeyInQueryParameter; +import alpine.server.auth.ApiKeyAuthenticationService; import alpine.server.auth.JwtAuthenticationService; import org.glassfish.jersey.server.ContainerRequest; import org.owasp.security.logging.SecurityMarkers; @@ -29,14 +29,15 @@ import jakarta.annotation.Priority; import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.Priorities; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.container.ContainerResponseContext; import jakarta.ws.rs.container.ContainerResponseFilter; -import jakarta.ws.rs.core.Response; import jakarta.ws.rs.container.ResourceInfo; import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; import javax.naming.AuthenticationException; import java.io.IOException; import java.security.Principal; @@ -79,8 +80,7 @@ public void filter(ContainerRequestContext requestContext) { } } catch (AuthenticationException e) { LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Invalid API key asserted"); - requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); - return; + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); } } @@ -90,13 +90,12 @@ public void filter(ContainerRequestContext requestContext) { principal = jwtAuthService.authenticate(); } catch (AuthenticationException e) { LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Invalid JWT asserted"); - requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); - return; + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); } } if (principal == null) { - requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build()); + throw new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED).build()); } else { requestContext.setProperty("Principal", principal); MDC.put("principal", principal.getName()); diff --git a/alpine/alpine-server/src/main/java/alpine/server/filters/AuthorizationFilter.java b/alpine/alpine-server/src/main/java/alpine/server/filters/AuthorizationFilter.java index 1c7d6e2d78..d838bcff40 100644 --- a/alpine/alpine-server/src/main/java/alpine/server/filters/AuthorizationFilter.java +++ b/alpine/alpine-server/src/main/java/alpine/server/filters/AuthorizationFilter.java @@ -27,6 +27,7 @@ import org.owasp.security.logging.SecurityMarkers; import jakarta.annotation.Priority; +import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.Priorities; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; @@ -62,8 +63,7 @@ public void filter(ContainerRequestContext requestContext) { final Principal principal = (Principal) requestContext.getProperty("Principal"); if (principal == null) { LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "A request was made without the assertion of a valid user principal"); - requestContext.abortWith(Response.status(Response.Status.FORBIDDEN).build()); - return; + throw new ForbiddenException(Response.status(Response.Status.FORBIDDEN).build()); } final Set effectivePermissions; @@ -97,7 +97,7 @@ public void filter(ContainerRequestContext requestContext) { LOGGER.info(SecurityMarkers.SECURITY_FAILURE, "Unauthorized access attempt made by %s to %s" .formatted(requestPrincipal, requestUri)); - requestContext.abortWith(Response.status(Response.Status.FORBIDDEN).build()); + throw new ForbiddenException(Response.status(Response.Status.FORBIDDEN).build()); } else { requestContext.setProperty(EFFECTIVE_PERMISSIONS_PROPERTY, effectivePermissions); } diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000000..7bce0e890d --- /dev/null +++ b/api/README.md @@ -0,0 +1,15 @@ +# api + +Definition of Dependency-Track's REST API, in [OpenAPI v3.0] format. + +The API draws inspiration from [Zalando's RESTful API Guidelines]. + +Conformance to API guidelines is enforced with [spectral] in CI. +Validation may be performed locally using [`openapi-lint.sh`](../dev/scripts/openapi-lint.sh). + +Interfaces and model classes are generated as part of the build using [openapi-generator]. + +[OpenAPI v3.0]: https://spec.openapis.org/oas/v3.0.3.html +[Zalando's RESTful API Guidelines]: https://opensource.zalando.com/restful-api-guidelines/ +[openapi-generator]: https://github.com/OpenAPITools/openapi-generator +[spectral]: https://github.com/stoplightio/spectral \ No newline at end of file diff --git a/api/pom.xml b/api/pom.xml new file mode 100644 index 0000000000..15e172b66f --- /dev/null +++ b/api/pom.xml @@ -0,0 +1,113 @@ + + + + 4.0.0 + + org.dependencytrack + dependency-track-parent + 5.6.0-SNAPSHOT + + + api + jar + + + ${project.basedir}/.. + true + + + + + com.fasterxml.jackson.core + jackson-annotations + provided + + + com.fasterxml.jackson.core + jackson-databind + provided + + + + jakarta.annotation + jakarta.annotation-api + provided + + + jakarta.validation + jakarta.validation-api + provided + + + jakarta.ws.rs + jakarta.ws.rs-api + provided + + + + + + + org.openapitools + openapi-generator-maven-plugin + 7.13.0 + + + generate-api-v2 + generate-sources + + generate + + + true + ${basedir}/src/main/openapi/openapi.yaml + ${project.build.directory}/classes/org/dependencytrack/api/v2/openapi + jaxrs-spec + false + false + false + true + REF_AS_PARENT_IN_ALLOF=true + + org.dependencytrack.api.v2 + org.dependencytrack.api.v2.model + true + true + true + true + java8 + false + true + false + . + + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + + + + + + + + + + \ No newline at end of file diff --git a/api/src/main/openapi/components/parameters/page-token.yaml b/api/src/main/openapi/components/parameters/page-token.yaml new file mode 100644 index 0000000000..4082e81a02 --- /dev/null +++ b/api/src/main/openapi/components/parameters/page-token.yaml @@ -0,0 +1,21 @@ +# 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. +name: page_token +description: Opaque token pointing to a specific position in a collection +in: query +schema: + type: string \ No newline at end of file diff --git a/api/src/main/openapi/components/parameters/pagination-limit.yaml b/api/src/main/openapi/components/parameters/pagination-limit.yaml new file mode 100644 index 0000000000..f0ee13e1f2 --- /dev/null +++ b/api/src/main/openapi/components/parameters/pagination-limit.yaml @@ -0,0 +1,25 @@ +# 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. +name: limit +description: Maximum number of items to retrieve from the collection +in: query +schema: + type: integer + format: int32 + minimum: 1 + maximum: 1000 + default: 100 \ No newline at end of file diff --git a/api/src/main/openapi/components/responses/generic-conflict-error.yaml b/api/src/main/openapi/components/responses/generic-conflict-error.yaml new file mode 100644 index 0000000000..311467633e --- /dev/null +++ b/api/src/main/openapi/components/responses/generic-conflict-error.yaml @@ -0,0 +1,26 @@ +# 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. +description: Forbidden +content: + application/problem+json: + schema: + $ref: "../schemas/problem-details.yaml" + example: + type: about:blank + status: 409 + title: Conflict + detail: The resource already exists. \ No newline at end of file diff --git a/api/src/main/openapi/components/responses/generic-error.yaml b/api/src/main/openapi/components/responses/generic-error.yaml new file mode 100644 index 0000000000..6795cdb471 --- /dev/null +++ b/api/src/main/openapi/components/responses/generic-error.yaml @@ -0,0 +1,21 @@ +# 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. +description: Unexpected error +content: + application/problem+json: + schema: + $ref: "../schemas/problem-details.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/components/responses/generic-forbidden-error.yaml b/api/src/main/openapi/components/responses/generic-forbidden-error.yaml new file mode 100644 index 0000000000..575f662105 --- /dev/null +++ b/api/src/main/openapi/components/responses/generic-forbidden-error.yaml @@ -0,0 +1,26 @@ +# 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. +description: Forbidden +content: + application/problem+json: + schema: + $ref: "../schemas/problem-details.yaml" + example: + type: about:blank + status: 403 + title: Forbidden + detail: Not permitted to access the requested resource. \ No newline at end of file diff --git a/api/src/main/openapi/components/responses/generic-not-found-error.yaml b/api/src/main/openapi/components/responses/generic-not-found-error.yaml new file mode 100644 index 0000000000..867797036e --- /dev/null +++ b/api/src/main/openapi/components/responses/generic-not-found-error.yaml @@ -0,0 +1,26 @@ +# 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. +description: Not found +content: + application/problem+json: + schema: + $ref: "../schemas/problem-details.yaml" + example: + type: about:blank + status: 404 + title: Not Found + detail: The requested resource could not be found. \ No newline at end of file diff --git a/api/src/main/openapi/components/responses/generic-unauthorized-error.yaml b/api/src/main/openapi/components/responses/generic-unauthorized-error.yaml new file mode 100644 index 0000000000..d3d041abe6 --- /dev/null +++ b/api/src/main/openapi/components/responses/generic-unauthorized-error.yaml @@ -0,0 +1,26 @@ +# 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. +description: Unauthorized +content: + application/problem+json: + schema: + $ref: "../schemas/problem-details.yaml" + example: + type: about:blank + status: 401 + title: Unauthorized + detail: Not authorized to access the requested resource. \ No newline at end of file diff --git a/api/src/main/openapi/components/responses/invalid-request-error.yaml b/api/src/main/openapi/components/responses/invalid-request-error.yaml new file mode 100644 index 0000000000..252b4f40e4 --- /dev/null +++ b/api/src/main/openapi/components/responses/invalid-request-error.yaml @@ -0,0 +1,30 @@ +# 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. +description: Bad request +content: + application/problem+json: + schema: + $ref: "../schemas/invalid-request-problem-details.yaml" + example: + type: about:blank + status: 400 + title: Bad Request + detail: The request could not be processed because it failed validation. + errors: + - path: foo.bar + value: baz + message: Must be a number \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/constraint-violation-error.yaml b/api/src/main/openapi/components/schemas/constraint-violation-error.yaml new file mode 100644 index 0000000000..b6e4932240 --- /dev/null +++ b/api/src/main/openapi/components/schemas/constraint-violation-error.yaml @@ -0,0 +1,29 @@ +# 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. +type: object +properties: + path: + type: string + description: Path to the invalid field in the request + value: + type: string + description: The invalid value + message: + type: string + description: Message explaining the error +required: +- message \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/create-team-membership-request.yaml b/api/src/main/openapi/components/schemas/create-team-membership-request.yaml new file mode 100644 index 0000000000..36fa40c478 --- /dev/null +++ b/api/src/main/openapi/components/schemas/create-team-membership-request.yaml @@ -0,0 +1,29 @@ +# 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. +type: object +properties: + team_name: + type: string + description: Name of the team + maxLength: 255 + username: + type: string + description: Name of the user + maxLength: 255 +required: +- team_name +- username \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/create-team-request.yaml b/api/src/main/openapi/components/schemas/create-team-request.yaml new file mode 100644 index 0000000000..8d2708fda2 --- /dev/null +++ b/api/src/main/openapi/components/schemas/create-team-request.yaml @@ -0,0 +1,31 @@ +# 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. +type: object +properties: + name: + type: string + description: Name of the team to create + maxLength: 255 + permissions: + type: array + items: + type: string + maxLength: 255 + uniqueItems: true + description: Permissions to assign to the team +required: +- name \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/get-team-response.yaml b/api/src/main/openapi/components/schemas/get-team-response.yaml new file mode 100644 index 0000000000..ce421d6bd3 --- /dev/null +++ b/api/src/main/openapi/components/schemas/get-team-response.yaml @@ -0,0 +1,31 @@ +# 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. +type: object +properties: + name: + type: string + description: Name of the team + maxLength: 255 + permissions: + type: array + items: + type: string + maxLength: 255 + description: Permissions assigned to the team +required: +- name +- permissions \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/invalid-request-problem-details.yaml b/api/src/main/openapi/components/schemas/invalid-request-problem-details.yaml new file mode 100644 index 0000000000..0d7fa15e46 --- /dev/null +++ b/api/src/main/openapi/components/schemas/invalid-request-problem-details.yaml @@ -0,0 +1,26 @@ +# 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. +type: object +allOf: +- $ref: "./problem-details.yaml" +properties: + errors: + type: array + items: + $ref: "./constraint-violation-error.yaml" +required: +- errors \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-team-memberships-response-item.yaml b/api/src/main/openapi/components/schemas/list-team-memberships-response-item.yaml new file mode 100644 index 0000000000..36fa40c478 --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-team-memberships-response-item.yaml @@ -0,0 +1,29 @@ +# 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. +type: object +properties: + team_name: + type: string + description: Name of the team + maxLength: 255 + username: + type: string + description: Name of the user + maxLength: 255 +required: +- team_name +- username \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-team-memberships-response.yaml b/api/src/main/openapi/components/schemas/list-team-memberships-response.yaml new file mode 100644 index 0000000000..389ea33eff --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-team-memberships-response.yaml @@ -0,0 +1,27 @@ +# 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. +type: object +allOf: +- $ref: "./paginated-response.yaml" +properties: + memberships: + type: array + items: + $ref: "./list-team-memberships-response-item.yaml" +required: +- _pagination +- memberships \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-teams-response-item.yaml b/api/src/main/openapi/components/schemas/list-teams-response-item.yaml new file mode 100644 index 0000000000..793c020e8d --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-teams-response-item.yaml @@ -0,0 +1,35 @@ +# 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. +type: object +properties: + name: + type: string + description: Name of the team + maxLength: 255 + api_keys: + type: integer + format: int32 + minimum: 0 + description: Number of API keys assigned to this team + members: + type: integer + format: int32 + minimum: 0 + description: Number of users that are member of this team +required: +- name +- members \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-teams-response.yaml b/api/src/main/openapi/components/schemas/list-teams-response.yaml new file mode 100644 index 0000000000..ac96641443 --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-teams-response.yaml @@ -0,0 +1,27 @@ +# 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. +type: object +allOf: +- $ref: "./paginated-response.yaml" +properties: + teams: + type: array + items: + $ref: "./list-teams-response-item.yaml" +required: +- _pagination +- teams \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response-item.yaml b/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response-item.yaml new file mode 100644 index 0000000000..c435044d2d --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response-item.yaml @@ -0,0 +1,34 @@ +# 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. +type: object +properties: + year: + type: integer + format: int32 + month: + type: integer + format: int32 + count: + type: integer + format: int32 + observed_at: + $ref: "./timestamp.yaml" +required: +- count +- month +- observed_at +- year \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response.yaml b/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response.yaml new file mode 100644 index 0000000000..f33b0be994 --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-vulnerability-metrics-response.yaml @@ -0,0 +1,27 @@ +# 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. +type: object +allOf: + - $ref: "./paginated-response.yaml" +properties: + metrics: + type: array + items: + $ref: "./list-vulnerability-metrics-response-item.yaml" +required: + - _pagination + - metrics \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-workflow-states-response-item.yaml b/api/src/main/openapi/components/schemas/list-workflow-states-response-item.yaml new file mode 100644 index 0000000000..e61be261d2 --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-workflow-states-response-item.yaml @@ -0,0 +1,49 @@ +# 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. +type: object +properties: + parent: + $ref: './list-workflow-states-response-item.yaml' + step: + type: string + enum: + - BOM_CONSUMPTION + - BOM_PROCESSING + - VULN_ANALYSIS + - REPO_META_ANALYSIS + - POLICY_EVALUATION + - METRICS_UPDATE + - POLICY_BUNDLE_SYNC + - PROJECT_CLONE + status: + type: string + enum: + - PENDING + - TIMED_OUT + - COMPLETED + - FAILED + - CANCELLED + - NOT_APPLICABLE + failure_reason: + type: string + token: + type: string + format: uuid + started_at: + $ref: "./timestamp.yaml" + updated_at: + $ref: "./timestamp.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-workflow-states-response.yaml b/api/src/main/openapi/components/schemas/list-workflow-states-response.yaml new file mode 100644 index 0000000000..ae57ddd26b --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-workflow-states-response.yaml @@ -0,0 +1,22 @@ +# 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. +type: object +properties: + states: + type: array + items: + $ref: "./list-workflow-states-response-item.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/paginated-response.yaml b/api/src/main/openapi/components/schemas/paginated-response.yaml new file mode 100644 index 0000000000..6675b43fa1 --- /dev/null +++ b/api/src/main/openapi/components/schemas/paginated-response.yaml @@ -0,0 +1,45 @@ +# 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. +type: object +properties: + _pagination: + title: Pagination Metadata + description: Metadata of paginated responses + type: object + properties: + links: + title: Pagination Links + description: Links to navigate through the collection + type: object + properties: + self: + type: string + format: uri + description: Link to the current page of the collection + next: + type: string + format: uri + description: >- + Link to the next page of the collection. + If not present, no more items exist. + required: + - self + required: + - links +# Enable inheritance for schemas that extend this object via allOf. +# https://github.com/OpenAPITools/openapi-generator/pull/14172 +x-parent: true \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/portfolio-metrics-response.yaml b/api/src/main/openapi/components/schemas/portfolio-metrics-response.yaml new file mode 100644 index 0000000000..f938ec465f --- /dev/null +++ b/api/src/main/openapi/components/schemas/portfolio-metrics-response.yaml @@ -0,0 +1,142 @@ +# 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. +type: object +properties: + critical: + type: integer + format: int32 + high: + type: integer + format: int32 + medium: + type: integer + format: int32 + low: + type: integer + format: int32 + unassigned: + type: integer + format: int32 + vulnerabilities: + type: integer + format: int32 + projects: + type: integer + format: int32 + vulnerable_projects: + type: integer + format: int32 + components: + type: integer + format: int32 + vulnerable_components: + type: integer + format: int32 + suppressed: + type: integer + format: int32 + findings_total: + type: integer + format: int32 + findings_audited: + type: integer + format: int32 + findings_unaudited: + type: integer + format: int32 + inherited_risk_score: + type: number + format: double + policy_violations_fail: + type: integer + format: int32 + policy_violations_warn: + type: integer + format: int32 + policy_violations_info: + type: integer + format: int32 + policy_violations_total: + type: integer + format: int32 + policy_violations_audited: + type: integer + format: int32 + policy_violations_unaudited: + type: integer + format: int32 + policy_violations_security_total: + type: integer + format: int32 + policy_violations_security_audited: + type: integer + format: int32 + policy_violations_security_unaudited: + type: integer + format: int32 + policy_violations_license_total: + type: integer + format: int32 + policy_violations_license_audited: + type: integer + format: int32 + policy_violations_license_unaudited: + type: integer + format: int32 + policy_violations_operational_total: + type: integer + format: int32 + policy_violations_operational_audited: + type: integer + format: int32 + policy_violations_operational_unaudited: + type: integer + format: int32 + observed_at: + $ref: "./timestamp.yaml" +required: +- components +- critical +- findings_audited +- findings_total +- findings_unaudited +- high +- inherited_risk_score +- low +- medium +- observed_at +- policy_violations_audited +- policy_violations_fail +- policy_violations_info +- policy_violations_license_audited +- policy_violations_license_total +- policy_violations_license_unaudited +- policy_violations_operational_audited +- policy_violations_operational_total +- policy_violations_operational_unaudited +- policy_violations_security_audited +- policy_violations_security_total +- policy_violations_security_unaudited +- policy_violations_total +- policy_violations_unaudited +- policy_violations_warn +- projects +- suppressed +- unassigned +- vulnerabilities +- vulnerable_components +- vulnerable_projects \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/problem-details.yaml b/api/src/main/openapi/components/schemas/problem-details.yaml new file mode 100644 index 0000000000..15c408e315 --- /dev/null +++ b/api/src/main/openapi/components/schemas/problem-details.yaml @@ -0,0 +1,51 @@ +# 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. +type: object +description: An RFC 9457 problem object. +externalDocs: + url: https://www.rfc-editor.org/rfc/rfc9457.html +properties: + type: + type: string + format: uri + default: about:blank + description: A URI reference that identifies the problem type + status: + type: integer + format: int32 + minimum: 400 + maximum: 599 + example: 500 + description: HTTP status code generated by the origin server for this occurrence of the problem + title: + type: string + description: Short, human-readable summary of the problem type + maxLength: 255 + detail: + type: string + description: Human-readable explanation specific to this occurrence of the problem + maxLength: 1024 + instance: + type: string + format: uri + description: Reference URI that identifies the specific occurrence of the problem +required: +- title +- detail +# Enable inheritance for schemas that extend this object via allOf. +# https://github.com/OpenAPITools/openapi-generator/pull/14172 +x-parent: true \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/timestamp.yaml b/api/src/main/openapi/components/schemas/timestamp.yaml new file mode 100644 index 0000000000..6e0cec4047 --- /dev/null +++ b/api/src/main/openapi/components/schemas/timestamp.yaml @@ -0,0 +1,20 @@ +# 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. +type: integer +format: int64 +description: Epoch timestamp in milliseconds since January 1, 1970 UTC. +example: 1752209050377 \ No newline at end of file diff --git a/api/src/main/openapi/openapi.yaml b/api/src/main/openapi/openapi.yaml new file mode 100644 index 0000000000..76a05264c3 --- /dev/null +++ b/api/src/main/openapi/openapi.yaml @@ -0,0 +1,66 @@ +# 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. +openapi: 3.0.3 +info: + title: OWASP Dependency-Track + description: REST API of OWASP Dependency-Track + version: 2.0.0 + contact: + name: The Dependency-Track Authors + email: dependencytrack@owasp.org + url: https://github.com/DependencyTrack/dependency-track + license: + name: Apache-2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +security: +- apiKeyAuth: [ ] +- bearerAuth: [ ] +servers: +- url: /api/v2 +tags: +- name: Metrics + description: Endpoints related to metrics +- name: Teams + description: Endpoints related to teams +- name: Workflows + description: Endpoints related to workflows + +paths: + /metrics/portfolio/current: + $ref: "./paths/metrics_portfolio_current.yaml" + /metrics/vulnerabilities: + $ref: "./paths/metrics_vulnerabilities.yaml" + /teams: + $ref: "./paths/teams.yaml" + /teams/{name}: + $ref: "./paths/teams__name_.yaml" + /team-memberships: + $ref: "./paths/team-memberships.yaml" + /workflows/{token}: + $ref: "./paths/workflows__token__.yaml" + +components: + securitySchemes: + apiKeyAuth: + name: X-Api-Key + description: Authentication via API key + type: apiKey + in: header + bearerAuth: + description: Authentication via Bearer token + type: http + scheme: Bearer diff --git a/api/src/main/openapi/paths/metrics_portfolio_current.yaml b/api/src/main/openapi/paths/metrics_portfolio_current.yaml new file mode 100644 index 0000000000..088642a4a2 --- /dev/null +++ b/api/src/main/openapi/paths/metrics_portfolio_current.yaml @@ -0,0 +1,35 @@ +# 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. +get: + operationId: getPortfolioCurrentMetrics + summary: Returns current metrics for the entire portfolio. + description: Requires permission VIEW_PORTFOLIO + tags: + - Metrics + responses: + "200": + description: Current metrics for the entire portfolio + content: + application/json: + schema: + $ref: "../components/schemas/portfolio-metrics-response.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/paths/metrics_vulnerabilities.yaml b/api/src/main/openapi/paths/metrics_vulnerabilities.yaml new file mode 100644 index 0000000000..a270bd02c5 --- /dev/null +++ b/api/src/main/openapi/paths/metrics_vulnerabilities.yaml @@ -0,0 +1,38 @@ +# 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. +get: + operationId: getVulnerabilityMetrics + summary: Returns the sum of all vulnerabilities in the database by year and month. + description: Requires permission VIEW_PORTFOLIO + tags: + - Metrics + parameters: + - $ref: "../components/parameters/pagination-limit.yaml" + - $ref: "../components/parameters/page-token.yaml" + responses: + "200": + description: The sum of all vulnerabilities in the database by year and month + content: + application/json: + schema: + $ref: "../components/schemas/list-vulnerability-metrics-response.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/paths/team-memberships.yaml b/api/src/main/openapi/paths/team-memberships.yaml new file mode 100644 index 0000000000..25b2fbf24e --- /dev/null +++ b/api/src/main/openapi/paths/team-memberships.yaml @@ -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. +get: + operationId: listTeamMemberships + summary: List all team memberships + description: >- + Returns a paginated list of team memberships, + sorted by team name and username in ascending order + tags: + - Teams + parameters: + - name: team + in: query + description: Name of the team to filter by. Must be an exact match. + schema: + type: string + maxLength: 255 + - name: user + in: query + description: Name of the user to filter by. Must be an exact match. + schema: + type: string + maxLength: 255 + - $ref: "../components/parameters/pagination-limit.yaml" + - $ref: "../components/parameters/page-token.yaml" + responses: + "200": + description: Paginated list of team memberships + content: + application/json: + schema: + $ref: "../components/schemas/list-team-memberships-response.yaml" + "400": + $ref: "../components/responses/invalid-request-error.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" + +post: + operationId: createTeamMembership + summary: Create team membership + description: Create a team membership + tags: + - Teams + requestBody: + required: true + content: + application/json: + schema: + $ref: "../components/schemas/create-team-membership-request.yaml" + responses: + "201": + description: Team membership created + "400": + description: Bad Request + content: + application/problem+json: + schema: + oneOf: + - $ref: "../components/schemas/invalid-request-problem-details.yaml" + - $ref: "../components/schemas/problem-details.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + "404": + $ref: "../components/responses/generic-not-found-error.yaml" + "409": + $ref: "../components/responses/generic-conflict-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" + +delete: + operationId: deleteTeamMembership + summary: Delete team membership + description: Delete a team membership + tags: + - Teams + parameters: + - name: team + in: query + required: true + description: Name of the team + schema: + type: string + maxLength: 255 + - name: user + in: query + required: true + description: Name of the user + schema: + type: string + maxLength: 255 + responses: + "204": + description: Team membership deleted + "400": + $ref: "../components/responses/invalid-request-error.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + "404": + $ref: "../components/responses/generic-not-found-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/paths/teams.yaml b/api/src/main/openapi/paths/teams.yaml new file mode 100644 index 0000000000..b81ce72663 --- /dev/null +++ b/api/src/main/openapi/paths/teams.yaml @@ -0,0 +1,72 @@ +# 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. +get: + operationId: listTeams + summary: List all teams + description: Returns a paginated list of teams, sorted by name in ascending order + tags: + - Teams + parameters: + - $ref: "../components/parameters/pagination-limit.yaml" + - $ref: "../components/parameters/page-token.yaml" + responses: + "200": + description: Paginated list of teams + content: + application/json: + schema: + $ref: "../components/schemas/list-teams-response.yaml" + "400": + $ref: "../components/responses/invalid-request-error.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" + +post: + operationId: createTeam + summary: Create team + description: Create a team + tags: + - Teams + requestBody: + required: true + content: + application/json: + schema: + $ref: "../components/schemas/create-team-request.yaml" + responses: + "201": + description: Team Created + "400": + description: Bad Request + content: + application/problem+json: + schema: + oneOf: + - $ref: "../components/schemas/invalid-request-problem-details.yaml" + - $ref: "../components/schemas/problem-details.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + "409": + $ref: "../components/responses/generic-conflict-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/paths/teams__name_.yaml b/api/src/main/openapi/paths/teams__name_.yaml new file mode 100644 index 0000000000..5a3386cbcf --- /dev/null +++ b/api/src/main/openapi/paths/teams__name_.yaml @@ -0,0 +1,73 @@ +# 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. +get: + operationId: getTeam + summary: Get a team + description: Returns detailed information about a given team + tags: + - Teams + parameters: + - name: name + description: Name of the team + in: path + required: true + schema: + type: string + maxLength: 255 + responses: + "200": + description: Team details + content: + application/json: + schema: + $ref: "../components/schemas/get-team-response.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + "404": + $ref: "../components/responses/generic-not-found-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" + +delete: + operationId: deleteTeam + summary: Delete team + description: Delete a team + tags: + - Teams + parameters: + - name: name + description: Name of the team + in: path + required: true + schema: + type: string + maxLength: 255 + responses: + "204": + description: Team Deleted + "400": + $ref: "../components/responses/invalid-request-error.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + "404": + $ref: "../components/responses/generic-not-found-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/paths/workflows__token__.yaml b/api/src/main/openapi/paths/workflows__token__.yaml new file mode 100644 index 0000000000..a852f8c9a9 --- /dev/null +++ b/api/src/main/openapi/paths/workflows__token__.yaml @@ -0,0 +1,45 @@ +# 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. +get: + operationId: getWorkflowStates + summary: Retrieves workflow states associated with the token received from bom upload. + description: Requires permission BOM_UPLOAD + tags: + - Workflows + parameters: + - name: token + in: path + description: The token to query + required: true + schema: + type: string + format: uuid + responses: + "200": + description: A list of workflow states + content: + application/json: + schema: + $ref: "../components/schemas/list-workflow-states-response.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + "404": + $ref: "../components/responses/generic-not-found-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" \ No newline at end of file diff --git a/api/src/main/spectral/functions/is-object-schema.js b/api/src/main/spectral/functions/is-object-schema.js new file mode 100644 index 0000000000..5d39a67d4c --- /dev/null +++ b/api/src/main/spectral/functions/is-object-schema.js @@ -0,0 +1,49 @@ +/* + * 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. + */ +'use strict'; + +const assertObjectSchema = (schema) => { + if (schema.type !== 'object') { + throw 'Schema type is not `object`'; + } + if (schema.additionalProperties) { + throw 'Schema is a map'; + } +}; + +const check = (schema) => { + const combinedSchemas = [...(schema.anyOf || []), ...(schema.oneOf || []), ...(schema.allOf || [])]; + if (combinedSchemas.length > 0) { + combinedSchemas.forEach(check); + } else { + assertObjectSchema(schema); + } +}; + +export default (targetValue) => { + try { + check(targetValue); + } catch (ex) { + return [ + { + message: ex, + }, + ]; + } +}; \ No newline at end of file diff --git a/api/src/main/spectral/functions/is-problem-json-schema.js b/api/src/main/spectral/functions/is-problem-json-schema.js new file mode 100644 index 0000000000..9e39d4e928 --- /dev/null +++ b/api/src/main/spectral/functions/is-problem-json-schema.js @@ -0,0 +1,110 @@ +/* + * 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. + */ +'use strict'; + +/* +Minimal required problem json schema: + +type: object +properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + format: int32 + detail: + type: string + instance: + type: string +*/ + +const assertProblemSchema = (schema) => { + if (schema.type !== 'object') { + throw "Problem json must have type 'object'"; + } + const type = (schema.properties || {}).type || {}; + if (type.type !== 'string' || type.format !== 'uri') { + throw "Problem json must have property 'type' with type 'string' and format 'uri'"; + } + const title = (schema.properties || {}).title || {}; + if (title.type !== 'string') { + throw "Problem json must have property 'title' with type 'string'"; + } + const status = (schema.properties || {}).status || {}; + if (status.type !== 'integer' || status.format !== 'int32') { + throw "Problem json must have property 'status' with type 'integer' and format 'int32'"; + } + const detail = (schema.properties || {}).detail || {}; + if (detail.type !== 'string') { + throw "Problem json must have property 'detail' with type 'string'"; + } + const instance = (schema.properties || {}).instance || {}; + if (instance.type !== 'string') { + throw "Problem json must have property 'instance' with type 'string'"; + } +}; + +/* + * Merge list of schema definitions of type = 'object'. + * Return object will have a super set of attributes 'properties' and 'required'. + */ +const mergeObjectDefinitions = (allOfTypes) => { + if (allOfTypes.filter((item) => item.type !== 'object').length !== 0) { + throw "All schema definitions must be of type 'object'"; + } + + return allOfTypes.reduce((acc, item) => { + return { + type: 'object', + properties: { ...(acc.properties || {}), ...(item.properties || {}) }, + required: [...(acc.required || []), ...(item.required || [])], + }; + }, {}); +}; + +const check = (schema) => { + const combinedSchemas = [...(schema.anyOf || []), ...(schema.oneOf || [])]; + if (schema.allOf) { + const mergedAllOf = mergeObjectDefinitions(schema.allOf); + if (mergedAllOf) { + combinedSchemas.push(mergedAllOf); + } + } + + if (combinedSchemas.length > 0) { + combinedSchemas.forEach(check); + } else { + assertProblemSchema(schema); + } +}; + +export default (targetValue) => { + try { + check(targetValue); + } catch (ex) { + return [ + { + message: ex, + }, + ]; + } +}; \ No newline at end of file diff --git a/api/src/main/spectral/ruleset.yaml b/api/src/main/spectral/ruleset.yaml new file mode 100644 index 0000000000..a440830ee8 --- /dev/null +++ b/api/src/main/spectral/ruleset.yaml @@ -0,0 +1,21 @@ +# 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. +extends: +- ["spectral:oas", "all"] +- ["./zalando.yaml", "all"] +formats: +- "oas3" \ No newline at end of file diff --git a/api/src/main/spectral/zalando.yaml b/api/src/main/spectral/zalando.yaml new file mode 100644 index 0000000000..876159b3aa --- /dev/null +++ b/api/src/main/spectral/zalando.yaml @@ -0,0 +1,215 @@ +# 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. + +# Rules to assert conformance to a curated subset of Zalando's +# RESTful API guidelines: https://opensource.zalando.com/restful-api-guidelines/# +# +# Credit to the folks at baloise for providing these spectral rules: +# https://github.com/baloise-incubator/spectral-ruleset/blob/main/zalando.yml +functions: +- is-object-schema +- is-problem-json-schema + +rules: + must-always-return-json-objects-as-top-level-data-structures: + message: 'Top-level data structure must be an object' + description: MUST always return JSON objects as top-level data structures [110] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#110 + severity: error + given: "$.paths.*.*[responses,requestBody]..content[?(@property.match(/^application\\/([a-zA-Z0-9._-]+\\+)?json(;.*)?$/))]..schema" + then: + function: is-object-schema + + must-use-semantic-versioning: + message: '{{error}}' + description: MUST use semantic versioning [116] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#116 + severity: error + given: $.info.version + then: + function: schema + functionOptions: + schema: + type: string + pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$' + + must-use-snake-case-for-property-names: + message: Property name has to be ASCII snake_case + description: MUST property names must be ASCII snake_case [118] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#118 + severity: error + given: $.paths.*.*[responses,requestBody]..content..schema..properties.*~ + then: + function: pattern + functionOptions: + match: ^[a-z_][a-z_0-9]*$ + + must-use-lowercase-with-hypens-for-path-segements: + message: Path segments have to be lowercase separate words with hyphens + description: MUST use lowercase separate words with hyphens for path segments [129] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#129 + severity: error + given: $.paths.*~ + then: + function: pattern + functionOptions: + match: ^(?=((([\/a-z][a-z0-9\-\/]*)?({[^}]*})?)+))\1$ + + must-use-snake-case-for-query-parameters: + message: Query parameters must be snake_case + description: MUST use snake_case (never camelCase) for query parameters [130] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#130 + severity: error + given: $.paths.*.*.parameters[?(@ && @.in=='query')].name + then: + function: pattern + functionOptions: + match: ^[a-z][_a-z0-9]*$ + + must-use-normalized-paths-without-empty-path-segments: + message: Empty path segments are not allowed + description: MUST use normalized paths without empty path segments and trailing slashes [136] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#136 + severity: error + given: $.paths.*~ + then: + function: pattern + functionOptions: + notMatch: // + + must-use-normalized-paths-without-trailing-slash: + message: Path with trailing slash is not allowed + description: MUST use normalized paths without empty path segments and trailing slashes [136] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#136 + severity: error + given: $.paths.*~ + then: + function: pattern + functionOptions: + notMatch: /$ + + must-specify-default-response: + message: Operation does not contain a default response + description: MUST specify success and error responses [151] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#151 + severity: error + given: $.paths.*.*.responses + then: + field: default + function: truthy + + must-use-standard-formats-for-date-and-time-properties-example: + message: "You should provide an example for {{property}}" + description: MUST use standard formats for date and time properties [169] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#169 + severity: warn # Not an error as you only should provide an example to help your consumers + given: $.paths..[?(@.type === 'string' && (@.format === 'date-time' || @.format === 'date' || @.format === 'time' || @.format === 'duration' || @.format === 'period'))] + then: + field: example + function: truthy + + must-use-standard-formats-for-date-and-time-properties-utc: + message: "You should UTC for {{property}}" + description: MUST use standard formats for date and time properties [169] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#169 + severity: warn # Not an error as you only should provide an example to help your consumers + given: $.paths..[?(@.type === 'string' && @.format === 'date-time')] + then: + field: example + function: pattern + functionOptions: + match: "Z$" + + must-use-problem-json-as-default-response: + message: Operation must use problem json as default response + description: MUST specify success and error responses [151] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#151 + severity: error + given: $.paths.*.*.responses.default + then: + field: content.application/problem+json + function: truthy + + must-define-a-format-for-number-types: + message: Numeric properties must have valid format specified + description: MUST define a format for number and integer types [171] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#171 + severity: error + given: $.paths.*.*..schema..properties..[?(@ && @.type=='number')] + then: + - field: format + function: defined + - field: format + function: pattern + functionOptions: + match: ^(float|double|decimal)$ + + must-define-a-format-for-integer-types: + message: Numeric properties must have valid format specified + description: MUST define a format for number and integer types [171] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#171 + severity: error + given: $.paths.*.*..schema..properties..[?(@ && @.type=='integer')] + then: + - field: format + function: defined + - field: format + function: pattern + functionOptions: + match: ^(int32|int64|bigint)$ + + should-prefer-standard-media-type-names: + message: Custom media types should only be used for versioning + description: SHOULD prefer standard media type name application/json [172] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#172 + severity: warn + given: $.paths.*.*.responses.*.content.*~ + then: + function: pattern + functionOptions: + match: ^application\/(problem\+)?json$|^[a-zA-Z0-9_]+\/[-+.a-zA-Z0-9_]+;(v|version)=[0-9]+$ + + must-use-problem-json-for-errors: + message: Error response must be application/problem+json + description: MUST support problem JSON [176] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#176 + severity: error + given: $.paths.*.*.responses[?(@ && @property.match(/^(4|5)/))] + then: + field: content.application/problem+json + function: truthy + + must-use-valid-problem-json-schema: + message: '{{error}}' + description: MUST support problem JSON [176] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#176 + severity: error + given: $.paths.*.*.responses.*.content.application/problem+json + then: + field: schema + function: is-problem-json-schema + + should-declare-enum-values-using-upper-snake-case-format: + message: 'Enum values should be in UPPER_SNAKE_CASE format' + description: SHOULD declare enum values using UPPER_SNAKE_CASE format [240] + documentationUrl: https://opensource.zalando.com/restful-api-guidelines/#240 + severity: warn + given: $.paths..[?(@ && @.type=='string')].[enum,x-extensible-enum].* + then: + function: pattern + functionOptions: + match: ^[A-Z][A-Z_0-9]*$ \ No newline at end of file diff --git a/apiserver/pom.xml b/apiserver/pom.xml index 193e50b849..975b981768 100644 --- a/apiserver/pom.xml +++ b/apiserver/pom.xml @@ -73,6 +73,10 @@ + + org.dependencytrack + api + org.dependencytrack datanucleus-plugin diff --git a/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsApplicationEventListener.java b/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsApplicationEventListener.java index b580191d0a..cc8fa39014 100644 --- a/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsApplicationEventListener.java +++ b/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsApplicationEventListener.java @@ -23,12 +23,9 @@ import org.glassfish.jersey.micrometer.server.DefaultJerseyTagsProvider; import org.glassfish.jersey.micrometer.server.MetricsApplicationEventListener; -import jakarta.ws.rs.ext.Provider; - /** * @since 5.5.0 */ -@Provider public class JerseyMetricsApplicationEventListener extends MetricsApplicationEventListener { public JerseyMetricsApplicationEventListener() { diff --git a/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsFeature.java b/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsFeature.java new file mode 100644 index 0000000000..4843a94c03 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/filters/JerseyMetricsFeature.java @@ -0,0 +1,45 @@ +/* + * 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.filters; + +import alpine.Config; + +import jakarta.ws.rs.core.Feature; +import jakarta.ws.rs.core.FeatureContext; +import jakarta.ws.rs.ext.Provider; + +import static alpine.Config.AlpineKey.METRICS_ENABLED; + +/** + * @since 5.6.0 + */ +@Provider +public class JerseyMetricsFeature implements Feature { + + @Override + public boolean configure(final FeatureContext context) { + if (Config.getInstance().getPropertyAsBoolean(METRICS_ENABLED)) { + context.register(JerseyMetricsApplicationEventListener.class); + return true; + } + + return false; + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/MetricsDao.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/MetricsDao.java index 7ca86b39e6..413a68a92a 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/MetricsDao.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/MetricsDao.java @@ -21,6 +21,9 @@ import org.dependencytrack.model.DependencyMetrics; import org.dependencytrack.model.PortfolioMetrics; import org.dependencytrack.model.ProjectMetrics; +import org.dependencytrack.persistence.pagination.Page; +import org.jdbi.v3.core.mapper.reflect.ConstructorMapper; +import org.jdbi.v3.core.statement.Query; import org.jdbi.v3.sqlobject.SqlObject; import org.jdbi.v3.sqlobject.config.RegisterBeanMapper; import org.jdbi.v3.sqlobject.customizer.Bind; @@ -35,11 +38,59 @@ import java.util.Collection; import java.util.List; +import static org.dependencytrack.persistence.pagination.PageUtil.decodePageToken; +import static org.dependencytrack.persistence.pagination.PageUtil.encodePageToken; + /** * @since 5.6.0 */ public interface MetricsDao extends SqlObject { + record ListVulnerabilityMetricsPageToken(int year, int month) { + } + + record ListVulnerabilityMetricsRow(int year, int month, int count, Instant measuredAt) { + } + + default Page getVulnerabilityMetrics(final int limit, final String pageToken) { + final var decodedPageToken = decodePageToken(getHandle(), pageToken, ListVulnerabilityMetricsPageToken.class); + + final Query query = getHandle().createQuery(/* language=InjectedFreeMarker */ """ + <#-- @ftlvariable name="year" type="Boolean" --> + <#-- @ftlvariable name="month" type="Boolean" --> + SELECT * + FROM "VULNERABILITYMETRICS" + WHERE TRUE + <#if year && month> + AND ("YEAR", "MONTH") > (:year, :month) + + ORDER BY "YEAR" ASC, "MONTH" ASC + LIMIT :limit + """); + + final List rows = query + .bind("year", decodedPageToken != null + ? decodedPageToken.year() + : null) + .bind("month", decodedPageToken != null + ? decodedPageToken.month() + : null) + .bind("limit", limit + 1) + .defineNamedBindings() + .map(ConstructorMapper.of(ListVulnerabilityMetricsRow.class)) + .list(); + + final List resultRows = rows.size() > 1 + ? rows.subList(0, Math.min(rows.size(), limit)) + : rows; + + final ListVulnerabilityMetricsPageToken nextPageToken = rows.size() > limit + ? new ListVulnerabilityMetricsPageToken(resultRows.getLast().year, resultRows.getLast().month) + : null; + + return new Page<>(resultRows, encodePageToken(getHandle(), nextPageToken)); + } + /** * Note that generate_series is invoked with integers rather * than dates, because the query planner tends to overestimate diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/TeamDao.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/TeamDao.java index dced37e427..9a054557d7 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/TeamDao.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/TeamDao.java @@ -18,10 +18,138 @@ */ package org.dependencytrack.persistence.jdbi; +import org.dependencytrack.persistence.pagination.Page; +import org.jdbi.v3.core.mapper.reflect.ConstructorMapper; +import org.jdbi.v3.core.statement.Query; +import org.jdbi.v3.core.statement.Update; +import org.jdbi.v3.sqlobject.SqlObject; import org.jdbi.v3.sqlobject.customizer.Bind; import org.jdbi.v3.sqlobject.statement.SqlUpdate; -public interface TeamDao { +import java.util.Collection; +import java.util.List; + +import static org.dependencytrack.persistence.pagination.PageUtil.decodePageToken; +import static org.dependencytrack.persistence.pagination.PageUtil.encodePageToken; + +public interface TeamDao extends SqlObject { + + record ListTeamsPageToken(String lastName) { + } + + record ListTeamsRow(String name, int apiKeys, int members) { + } + + default Page listTeams(final int limit, final String pageToken) { + final var decodedPageToken = decodePageToken(getHandle(), pageToken, ListTeamsPageToken.class); + + final Query query = getHandle().createQuery(/* language=InjectedFreeMarker */ """ + <#-- @ftlvariable name="lastName" type="Boolean" --> + SELECT "NAME" AS name + , (SELECT COUNT(*) FROM "APIKEYS_TEAMS" WHERE "TEAM_ID" = "TEAM"."ID") AS api_keys + , (SELECT COUNT(*) FROM "USERS_TEAMS" WHERE "TEAM_ID" = "TEAM"."ID") AS members + FROM "TEAM" + WHERE TRUE + <#if lastName> + AND "NAME" > :lastName + + ORDER BY "NAME" + LIMIT :limit + """); + + final List rows = query + .bind("lastName", decodedPageToken != null + ? decodedPageToken.lastName() + : null) + .bind("limit", limit + 1) + .defineNamedBindings() + .map(ConstructorMapper.of(ListTeamsRow.class)) + .list(); + + final List resultRows = rows.size() > 1 + ? rows.subList(0, Math.min(rows.size(), limit)) + : rows; + + final ListTeamsPageToken nextPageToken = rows.size() > limit + ? new ListTeamsPageToken(resultRows.getLast().name()) + : null; + + return new Page<>(resultRows, encodePageToken(getHandle(), nextPageToken)); + } + + record ListTeamMembershipsPageToken(String lastTeamName, String lastUsername) { + } + + record ListTeamMembershipsRow(String teamName, String username) { + } + + default Page listTeamMembers( + final String teamName, + final String username, + final int limit, + final String pageToken) { + final var decodedPageToken = decodePageToken(getHandle(), pageToken, ListTeamMembershipsPageToken.class); + + final Query query = getHandle().createQuery(/* language=InjectedFreeMarker */ """ + <#-- @ftlvariable name="teamName" type="Boolean" --> + <#-- @ftlvariable name="username" type="Boolean" --> + <#-- @ftlvariable name="lastTeamName" type="Boolean" --> + <#-- @ftlvariable name="lastUsername" type="Boolean" --> + SELECT t."NAME" AS team_name + , u."USERNAME" AS username + FROM "USERS_TEAMS" AS ut + INNER JOIN "TEAM" AS t + ON t."ID" = ut."TEAM_ID" + INNER JOIN "USER" AS u + ON u."ID" = ut."USER_ID" + WHERE TRUE + <#if teamName> + AND t."NAME" = :teamName + + <#if username> + AND u."USERNAME" = :username + + <#if lastTeamName && lastUsername> + AND (t."NAME", u."USERNAME") > (:lastTeamName, :lastUsername) + + ORDER BY t."NAME", u."USERNAME" + LIMIT :limit + """); + + final List rows = query + .bind("teamName", teamName) + .bind("username", username) + .bind("lastTeamName", decodedPageToken != null + ? decodedPageToken.lastTeamName() + : null) + .bind("lastUsername", decodedPageToken != null + ? decodedPageToken.lastUsername() + : null) + .bind("limit", limit + 1) + .defineNamedBindings() + .map(ConstructorMapper.of(ListTeamMembershipsRow.class)) + .list(); + + final List resultRows = rows.size() > 1 + ? rows.subList(0, Math.min(rows.size(), limit)) + : rows; + + final ListTeamMembershipsPageToken nextPageToken = rows.size() > limit + ? new ListTeamMembershipsPageToken( + resultRows.getLast().teamName(), + resultRows.getLast().username()) + : null; + + return new Page<>(resultRows, encodePageToken(getHandle(), nextPageToken)); + } + + @SqlUpdate(""" + DELETE + FROM "USERS_TEAMS" + WHERE "TEAM_ID" = (SELECT "ID" FROM "TEAM" WHERE "NAME" = :teamName) + AND "USER_ID" = (SELECT "ID" FROM "USER" WHERE "USERNAME" = :username) + """) + boolean deleteTeamMembership(@Bind String teamName, @Bind String username); @SqlUpdate(""" DELETE @@ -29,4 +157,19 @@ public interface TeamDao { WHERE "ID" = :teamId """) int deleteTeam(@Bind final long teamId); + + default List deleteTeamsByName(final Collection names) { + final Update update = getHandle().createUpdate(""" + DELETE + FROM "TEAM" + WHERE "NAME" = ANY(:names) + RETURNING "NAME" + """); + + return update + .bindArray("names", String.class, names) + .executeAndReturnGeneratedKeys() + .mapTo(String.class) + .list(); + } } diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/pagination/InvalidPageTokenException.java b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/InvalidPageTokenException.java new file mode 100644 index 0000000000..359722fd4a --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/InvalidPageTokenException.java @@ -0,0 +1,27 @@ +/* + * 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.persistence.pagination; + +public class InvalidPageTokenException extends IllegalArgumentException { + + public InvalidPageTokenException(final Throwable cause) { + super(cause); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/pagination/Page.java b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/Page.java new file mode 100644 index 0000000000..dd62e1c52e --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/Page.java @@ -0,0 +1,24 @@ +/* + * 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.persistence.pagination; + +import java.util.List; + +public record Page(List items, String nextPageToken) { +} diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/pagination/PageUtil.java b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/PageUtil.java new file mode 100644 index 0000000000..983a0fcd49 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/persistence/pagination/PageUtil.java @@ -0,0 +1,88 @@ +/* + * 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.persistence.pagination; + +import alpine.security.crypto.DataEncryption; +import org.dependencytrack.api.v2.model.PaginationLinks; +import org.dependencytrack.api.v2.model.PaginationMetadata; +import org.jdbi.v3.core.Handle; +import org.jdbi.v3.json.JsonConfig; +import org.jdbi.v3.json.JsonMapper; + +import jakarta.ws.rs.core.UriInfo; +import java.util.Base64; + +/** + * @since 5.6.0 + */ +public final class PageUtil { + + private PageUtil() {} + + public static T decodePageToken(final Handle handle, final String encodedToken, final Class tokenClass) { + if (encodedToken == null) { + return null; + } + + final JsonMapper.TypedJsonMapper jsonMapper = handle + .getConfig(JsonConfig.class) + .getJsonMapper() + .forType(tokenClass, handle.getConfig()); + + try { + final byte[] encryptedTokenBytes = Base64.getUrlDecoder().decode(encodedToken); + final byte[] decryptedToken = DataEncryption.decryptAsBytes(encryptedTokenBytes); + return (T) jsonMapper.fromJson(new String(decryptedToken), handle.getConfig()); + } catch (Exception e) { + throw new InvalidPageTokenException(e); + } + } + + public static String encodePageToken(final Handle handle, final T pageToken) { + if (pageToken == null) { + return null; + } + + final JsonMapper.TypedJsonMapper jsonMapper = handle + .getConfig(JsonConfig.class) + .getJsonMapper() + .forType(Object.class, handle.getConfig()); + + try { + final String tokenJson = jsonMapper.toJson(pageToken, handle.getConfig()); + final byte[] encryptedTokenBytes = DataEncryption.encryptAsBytes(tokenJson); + return Base64.getUrlEncoder().encodeToString(encryptedTokenBytes); + } catch (Exception e) { + throw new InvalidPageTokenException(e); + } + } + + public static PaginationMetadata createPaginationMetadata(final UriInfo uriInfo, final Page page) { + return PaginationMetadata.builder() + .links(PaginationLinks.builder() + .self(uriInfo.getRequestUri()) + .next(page.nextPageToken() != null ? + uriInfo.getRequestUriBuilder() + .queryParam("page_token", page.nextPageToken()) + .build() + : null) + .build()) + .build(); + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/OpenApiResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/OpenApiResource.java similarity index 97% rename from apiserver/src/main/java/org/dependencytrack/resources/OpenApiResource.java rename to apiserver/src/main/java/org/dependencytrack/resources/v1/OpenApiResource.java index f67af48a7b..9c49cd3db6 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/OpenApiResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/OpenApiResource.java @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) OWASP Foundation. All Rights Reserved. */ -package org.dependencytrack.resources; +package org.dependencytrack.resources.v1; import alpine.server.auth.AuthenticationNotRequired; import io.swagger.v3.jaxrs2.integration.resources.BaseOpenApiResource; diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/MetricsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/MetricsResource.java new file mode 100644 index 0000000000..5539130dc1 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/MetricsResource.java @@ -0,0 +1,109 @@ +/* + * 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.v2; + +import alpine.server.auth.PermissionRequired; +import alpine.server.resources.AlpineResource; +import org.dependencytrack.api.v2.MetricsApi; +import org.dependencytrack.api.v2.model.ListVulnerabilityMetricsResponse; +import org.dependencytrack.api.v2.model.ListVulnerabilityMetricsResponseItem; +import org.dependencytrack.api.v2.model.PortfolioMetricsResponse; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.PortfolioMetrics; +import org.dependencytrack.persistence.jdbi.MetricsDao; +import org.dependencytrack.persistence.pagination.Page; + +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; + +import static org.dependencytrack.persistence.jdbi.JdbiFactory.inJdbiTransaction; +import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; +import static org.dependencytrack.persistence.pagination.PageUtil.createPaginationMetadata; + +public class MetricsResource extends AlpineResource implements MetricsApi { + + @Context + private UriInfo uriInfo; + + @Override + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + public Response getPortfolioCurrentMetrics() { + PortfolioMetrics metrics = withJdbiHandle( + getAlpineRequest(), + handle -> handle.attach(MetricsDao.class).getMostRecentPortfolioMetrics()); + final var response = PortfolioMetricsResponse.builder() + .components(metrics.getComponents()) + .critical(metrics.getCritical()) + .findingsAudited(metrics.getFindingsAudited()) + .findingsTotal(metrics.getFindingsTotal()) + .findingsUnaudited(metrics.getFindingsUnaudited()) + .high(metrics.getHigh()) + .inheritedRiskScore(metrics.getInheritedRiskScore()) + .observedAt(metrics.getLastOccurrence().getTime()) + .low(metrics.getLow()) + .medium(metrics.getMedium()) + .policyViolationsAudited(metrics.getPolicyViolationsAudited()) + .policyViolationsFail(metrics.getPolicyViolationsFail()) + .policyViolationsInfo(metrics.getPolicyViolationsInfo()) + .policyViolationsLicenseAudited(metrics.getPolicyViolationsLicenseAudited()) + .policyViolationsLicenseTotal(metrics.getPolicyViolationsLicenseTotal()) + .policyViolationsLicenseUnaudited(metrics.getPolicyViolationsLicenseUnaudited()) + .policyViolationsOperationalAudited(metrics.getPolicyViolationsOperationalAudited()) + .policyViolationsOperationalTotal(metrics.getPolicyViolationsOperationalTotal()) + .policyViolationsOperationalUnaudited(metrics.getPolicyViolationsOperationalUnaudited()) + .policyViolationsSecurityAudited(metrics.getPolicyViolationsSecurityAudited()) + .policyViolationsSecurityTotal(metrics.getPolicyViolationsSecurityTotal()) + .policyViolationsSecurityUnaudited(metrics.getPolicyViolationsSecurityUnaudited()) + .policyViolationsTotal(metrics.getPolicyViolationsTotal()) + .policyViolationsUnaudited(metrics.getPolicyViolationsUnaudited()) + .policyViolationsWarn(metrics.getPolicyViolationsWarn()) + .projects(metrics.getProjects()) + .suppressed(metrics.getSuppressed()) + .unassigned(metrics.getUnassigned()) + .vulnerabilities(metrics.getVulnerabilities()) + .vulnerableComponents(metrics.getVulnerableComponents()) + .vulnerableProjects(metrics.getVulnerableProjects()) + .build(); + return Response.ok(response).build(); + } + + @Override + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + public Response getVulnerabilityMetrics(Integer limit, String pageToken) { + final Page metricsPage = inJdbiTransaction( + getAlpineRequest(), + handle -> handle.attach(MetricsDao.class).getVulnerabilityMetrics(limit, pageToken)); + + final var response = ListVulnerabilityMetricsResponse.builder() + .metrics(metricsPage.items().stream() + .map( + metricRow -> ListVulnerabilityMetricsResponseItem.builder() + .year(metricRow.year()) + .month(metricRow.month()) + .count(metricRow.count()) + .observedAt(metricRow.measuredAt().getEpochSecond()) + .build()) + .toList()) + .pagination(createPaginationMetadata(uriInfo, metricsPage)) + .build(); + + return Response.ok(response).build(); + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/OpenApiResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/OpenApiResource.java new file mode 100644 index 0000000000..19d4c9f79c --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/OpenApiResource.java @@ -0,0 +1,86 @@ +/* + * 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.v2; + +import alpine.server.auth.AuthenticationNotRequired; +import io.swagger.v3.oas.annotations.Operation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.ServerErrorException; +import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import static java.util.Objects.requireNonNull; + +@Path("/openapi.yaml") +public class OpenApiResource { + + private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiResource.class); + private static final ReadWriteLock LOCK = new ReentrantReadWriteLock(); + private static String OPENAPI_YAML; + + @GET + @Produces("application/yaml") + @Operation(hidden = true) + @AuthenticationNotRequired + public String getOpenApi() { + LOCK.readLock().lock(); + try { + if (OPENAPI_YAML == null) { + LOCK.readLock().unlock(); + + LOCK.writeLock().lock(); + try { + if (OPENAPI_YAML == null) { + OPENAPI_YAML = loadOpenapiYaml(); + } + + LOCK.readLock().lock(); + } catch (URISyntaxException | IOException e) { + LOGGER.error("Failed to load OpenAPI spec YAML", e); + throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR); + } finally { + LOCK.writeLock().unlock(); + } + } + + return OPENAPI_YAML; + } finally { + LOCK.readLock().unlock(); + } + } + + private static String loadOpenapiYaml() throws URISyntaxException, IOException { + try (final InputStream inputStream = + OpenApiResource.class.getResourceAsStream( + "/org/dependencytrack/api/v2/openapi.yaml")) { + requireNonNull(inputStream, "inputStream must not be null"); + return new String(inputStream.readAllBytes()); + } + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/ResourceConfig.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/ResourceConfig.java new file mode 100644 index 0000000000..6f5741f580 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/ResourceConfig.java @@ -0,0 +1,59 @@ +/* + * 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.v2; + +import alpine.server.filters.ApiFilter; +import alpine.server.filters.AuthenticationFeature; +import alpine.server.filters.AuthorizationFeature; +import alpine.server.filters.GZipInterceptor; +import alpine.server.filters.HeaderFilter; +import alpine.server.filters.RequestIdFilter; +import alpine.server.filters.RequestMdcEnrichmentFilter; +import org.dependencytrack.filters.JerseyMetricsFeature; +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.media.multipart.MultiPartFeature; + +import static org.glassfish.jersey.server.ServerProperties.PROVIDER_PACKAGES; +import static org.glassfish.jersey.server.ServerProperties.PROVIDER_SCANNING_RECURSIVE; + +/** + * @since 5.6.0 + */ +public final class ResourceConfig extends org.glassfish.jersey.server.ResourceConfig { + + public ResourceConfig() { + // Only scan the v2 package for providers, register everything else manually. + // This gives us more flexibility to pick-and-choose, and potentially configure + // specific features that do not necessarily overlap with v1. + property(PROVIDER_PACKAGES, getClass().getPackageName()); + property(PROVIDER_SCANNING_RECURSIVE, true); + + register(ApiFilter.class); + register(AuthenticationFeature.class); + register(AuthorizationFeature.class); + register(GZipInterceptor.class); + register(HeaderFilter.class); + register(JacksonFeature.withoutExceptionMappers()); + register(JerseyMetricsFeature.class); + register(MultiPartFeature.class); + register(RequestIdFilter.class); + register(RequestMdcEnrichmentFilter.class); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/TeamsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/TeamsResource.java new file mode 100644 index 0000000000..c047d62ff0 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/TeamsResource.java @@ -0,0 +1,216 @@ +/* + * 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.v2; + +import alpine.model.Permission; +import alpine.model.Team; +import alpine.model.User; +import alpine.server.auth.PermissionRequired; +import org.dependencytrack.api.v2.TeamsApi; +import org.dependencytrack.api.v2.model.CreateTeamMembershipRequest; +import org.dependencytrack.api.v2.model.CreateTeamRequest; +import org.dependencytrack.api.v2.model.GetTeamResponse; +import org.dependencytrack.api.v2.model.ListTeamMembershipsResponse; +import org.dependencytrack.api.v2.model.ListTeamMembershipsResponseItem; +import org.dependencytrack.api.v2.model.ListTeamsResponse; +import org.dependencytrack.api.v2.model.ListTeamsResponseItem; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.persistence.jdbi.TeamDao; +import org.dependencytrack.persistence.jdbi.TeamDao.ListTeamMembershipsRow; +import org.dependencytrack.persistence.jdbi.TeamDao.ListTeamsRow; +import org.dependencytrack.persistence.pagination.Page; +import org.owasp.security.logging.SecurityMarkers; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import jakarta.ws.rs.ext.Provider; +import java.util.List; + +import static org.dependencytrack.persistence.jdbi.JdbiFactory.inJdbiTransaction; +import static org.dependencytrack.persistence.pagination.PageUtil.createPaginationMetadata; +import static org.dependencytrack.util.PersistenceUtil.isUniqueConstraintViolation; + +@Provider +public class TeamsResource implements TeamsApi { + + private static final Logger LOGGER = LoggerFactory.getLogger(TeamsResource.class); + + @Context + private UriInfo uriInfo; + + @Override + @PermissionRequired(Permissions.Constants.ACCESS_MANAGEMENT) + public Response listTeams(final Integer limit, final String pageToken) { + final Page teamsPage = inJdbiTransaction( + handle -> handle.attach(TeamDao.class).listTeams(limit, pageToken)); + + final var response = ListTeamsResponse.builder() + .teams(teamsPage.items().stream() + .map( + teamRow -> ListTeamsResponseItem.builder() + .name(teamRow.name()) + .apiKeys(teamRow.apiKeys()) + .members(teamRow.members()) + .build()) + .toList()) + .pagination(createPaginationMetadata(uriInfo, teamsPage)) + .build(); + + return Response.ok(response).build(); + } + + @Override + @PermissionRequired(Permissions.Constants.ACCESS_MANAGEMENT) + public Response getTeam(final String name) { + try (final var qm = new QueryManager()) { + final Team team = qm.getTeam(name); + if (team == null) { + throw new NotFoundException(); + } + + final var response = GetTeamResponse.builder() + .name(name) + .permissions( + team.getPermissions().stream() + .map(Permission::getName) + .sorted() + .toList()) + .build(); + + return Response.ok(response).build(); + } + } + + @Override + @PermissionRequired(Permissions.Constants.ACCESS_MANAGEMENT) + public Response createTeam(final CreateTeamRequest request) { + try (final var qm = new QueryManager()) { + qm.runInTransaction(() -> { + final List permissions = + qm.getPermissionsByName(request.getPermissions()); + + final var team = new Team(); + team.setName(request.getName()); + team.setPermissions(permissions); + qm.persist(team); + }); + } catch (RuntimeException e) { + if (isUniqueConstraintViolation(e)) { + throw new ClientErrorException(Response.Status.CONFLICT); + } + + throw e; + } + + LOGGER.info( + SecurityMarkers.SECURITY_AUDIT, + "Team created: {}", request.getName()); + return Response + .created(uriInfo.getBaseUriBuilder() + .path("/teams") + .path(request.getName()) + .build()) + .build(); + } + + @Override + @PermissionRequired(Permissions.Constants.ACCESS_MANAGEMENT) + public Response deleteTeam(final String name) { + final List deletedTeamNames = inJdbiTransaction( + handle -> handle.attach(TeamDao.class).deleteTeamsByName(List.of(name))); + if (deletedTeamNames.isEmpty()) { + throw new NotFoundException(); + } + + LOGGER.info(SecurityMarkers.SECURITY_AUDIT, "Team deleted: {}", name); + return Response.noContent().build(); + } + + @Override + public Response listTeamMemberships(final String team, final String user, final Integer limit, final String pageToken) { + final Page membershipsPage = inJdbiTransaction( + handle -> handle.attach(TeamDao.class).listTeamMembers(team, user, limit, pageToken)); + + final var response = ListTeamMembershipsResponse.builder() + .memberships(membershipsPage.items().stream() + .map( + membershipRow -> ListTeamMembershipsResponseItem.builder() + .teamName(membershipRow.teamName()) + .username(membershipRow.username()) + .build()) + .toList()) + .pagination(createPaginationMetadata(uriInfo, membershipsPage)) + .build(); + + return Response.ok(response).build(); + } + + @Override + public Response createTeamMembership(final CreateTeamMembershipRequest request) { + try (final var qm = new QueryManager()) { + qm.runInTransaction(() -> { + final Team team = qm.getTeam(request.getTeamName()); + if (team == null) { + throw new NotFoundException(); + } + + final User user = qm.getUser(request.getUsername()); + if (user == null) { + throw new NotFoundException(); + } + + team.getUsers().add(user); + user.getTeams().add(team); + }); + } catch (RuntimeException e) { + if (isUniqueConstraintViolation(e)) { + throw new ClientErrorException(Response.Status.CONFLICT); + } + + throw e; + } + + LOGGER.info( + SecurityMarkers.SECURITY_AUDIT, + "Team membership created: team={}, user={}", + request.getTeamName(), + request.getUsername()); + return Response.created(null).build(); + } + + @Override + public Response deleteTeamMembership(final String team, final String user) { + final boolean deleted = inJdbiTransaction( + handle -> handle.attach(TeamDao.class).deleteTeamMembership(team, user)); + if (!deleted) { + throw new NotFoundException(); + } + + LOGGER.info( + SecurityMarkers.SECURITY_AUDIT, + "Team membership deleted: team={}, user={}", team, user); + return Response.noContent().build(); + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java new file mode 100644 index 0000000000..e6be2c66d7 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java @@ -0,0 +1,73 @@ +/* + * 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.v2; + +import alpine.server.auth.PermissionRequired; +import org.dependencytrack.api.v2.WorkflowsApi; +import org.dependencytrack.api.v2.model.ListWorkflowStatesResponse; +import org.dependencytrack.api.v2.model.ListWorkflowStatesResponseItem; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.WorkflowState; +import org.dependencytrack.persistence.QueryManager; + +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Provider +public class WorkflowsResource implements WorkflowsApi { + + @Override + @PermissionRequired(Permissions.Constants.BOM_UPLOAD) + public Response getWorkflowStates(final UUID token) { + List workflowStates; + try (final var qm = new QueryManager()) { + workflowStates = qm.getAllWorkflowStatesForAToken(token); + if (workflowStates.isEmpty()) { + throw new NotFoundException(); + } + } + List states = workflowStates.stream() + .map(this::mapWorkflowStateResponse) + .collect(Collectors.toList()); + return Response.ok(ListWorkflowStatesResponse.builder().states(states).build()).build(); + } + + private ListWorkflowStatesResponseItem mapWorkflowStateResponse(WorkflowState workflowState) { + var mappedState = ListWorkflowStatesResponseItem.builder() + .token(workflowState.getToken()) + .status(ListWorkflowStatesResponseItem.StatusEnum.fromString(workflowState.getStatus().name())) + .step(ListWorkflowStatesResponseItem.StepEnum.fromString(workflowState.getStep().name())) + .failureReason(workflowState.getFailureReason()) + .build(); + if (workflowState.getParent() != null) { + mappedState.setParent(mapWorkflowStateResponse(workflowState.getParent())); + } + if (workflowState.getStartedAt() != null) { + mappedState.setStartedAt(workflowState.getStartedAt().getTime()); + } + if (workflowState.getUpdatedAt() != null) { + mappedState.setUpdatedAt(workflowState.getUpdatedAt().getTime()); + } + return mappedState; + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ClientErrorExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ClientErrorExceptionMapper.java new file mode 100644 index 0000000000..b01a155a64 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ClientErrorExceptionMapper.java @@ -0,0 +1,50 @@ +/* + * 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.v2.exception; + +import org.dependencytrack.api.v2.model.ProblemDetails; + +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.ext.Provider; +import java.util.Map; + +/** + * @since 5.6.0 + */ +@Provider +public final class ClientErrorExceptionMapper extends ProblemDetailsExceptionMapper { + + private static final Map DETAIL_BY_STATUS = Map.ofEntries( + Map.entry(401, "Not authorized to access the requested resource."), + Map.entry(403, "Not permitted to access the requested resource."), + Map.entry(404, "The requested resource could not be found."), + Map.entry(409, "The resource already exists.")); + + @Override + public ProblemDetails map(final ClientErrorException exception) { + return ProblemDetails.builder() + .status(exception.getResponse().getStatus()) + .title(exception.getResponse().getStatusInfo().getReasonPhrase()) + .detail(DETAIL_BY_STATUS.getOrDefault( + exception.getResponse().getStatus(), + exception.getMessage())) + .build(); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapper.java new file mode 100644 index 0000000000..a9313b19b3 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapper.java @@ -0,0 +1,62 @@ +/* + * 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.v2.exception; + +import org.dependencytrack.api.v2.model.ConstraintViolationError; +import org.dependencytrack.api.v2.model.InvalidRequestProblemDetails; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import java.util.ArrayList; +import java.util.Set; + +/** + * @since 5.6.0 + */ +@Provider +public final class ConstraintViolationExceptionMapper extends ProblemDetailsExceptionMapper { + + @Override + public InvalidRequestProblemDetails map(final ConstraintViolationException exception) { + final Set> violations = exception.getConstraintViolations(); + + final var errors = new ArrayList(violations.size()); + + for (final ConstraintViolation violation : violations) { + errors.add( + ConstraintViolationError.builder() + .path(violation.getPropertyPath().toString()) + .value(violation.getInvalidValue() != null + ? violation.getInvalidValue().toString() + : null) + .message(violation.getMessage()) + .build()); + } + + return InvalidRequestProblemDetails.builder() + .status(Response.Status.BAD_REQUEST.getStatusCode()) + .title("Bad Request") + .detail("The request could not be processed because it failed validation.") + .errors(errors) + .build(); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapper.java new file mode 100644 index 0000000000..01f372f4c1 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapper.java @@ -0,0 +1,30 @@ +/* + * 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.v2.exception; + +import org.dependencytrack.api.v2.model.ProblemDetails; + +import jakarta.ws.rs.ext.Provider; + +/** + * @since 5.6.0 + */ +@Provider +public final class DefaultExceptionMapper extends LoggingProblemDetailsExceptionMapper { +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapper.java new file mode 100644 index 0000000000..95698148f0 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapper.java @@ -0,0 +1,48 @@ +/* + * 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.v2.exception; + +import com.fasterxml.jackson.core.JsonGenerationException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.exc.InvalidDefinitionException; +import org.dependencytrack.api.v2.model.ProblemDetails; + +import jakarta.ws.rs.ext.Provider; + +/** + * @since 5.6.0 + */ +@Provider +public class JsonProcessingExceptionMapper extends LoggingProblemDetailsExceptionMapper { + + @Override + ProblemDetails map(final JsonProcessingException exception) { + if (exception instanceof InvalidDefinitionException + || exception instanceof JsonGenerationException) { + return super.map(exception); + } + + return ProblemDetails.builder() + .status(400) + .title("JSON Processing Failed") + .detail("The provided JSON could not be processed.") + .build(); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/LoggingProblemDetailsExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/LoggingProblemDetailsExceptionMapper.java new file mode 100644 index 0000000000..4b1dfc08f9 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/LoggingProblemDetailsExceptionMapper.java @@ -0,0 +1,50 @@ +/* + * 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.v2.exception; + +import org.dependencytrack.api.v2.model.ProblemDetails; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.ws.rs.ServerErrorException; + +/** + * @since 5.6.0 + */ +abstract class LoggingProblemDetailsExceptionMapper extends ProblemDetailsExceptionMapper { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Override + @SuppressWarnings("unchecked") + P map(final E exception) { + logger.error("Uncaught exception occurred during request processing", exception); + + final int status = exception instanceof final ServerErrorException see + ? see.getResponse().getStatus() + : 500; + + return (P) ProblemDetails.builder() + .status(status) + .title("Unexpected error") + .detail("An error occurred that was not anticipated.") + .build(); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ProblemDetailsExceptionMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ProblemDetailsExceptionMapper.java new file mode 100644 index 0000000000..4912108c77 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/exception/ProblemDetailsExceptionMapper.java @@ -0,0 +1,44 @@ +/* + * 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.v2.exception; + +import org.dependencytrack.api.v2.model.ProblemDetails; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; + +/** + * @since 5.6.0 + */ +abstract class ProblemDetailsExceptionMapper implements ExceptionMapper { + + abstract P map(E exception); + + @Override + public Response toResponse(final E exception) { + final P problemDetails = map(exception); + + return Response + .status(problemDetails.getStatus()) + .header("Content-Type", "application/problem+json") + .entity(problemDetails) + .build(); + } + +} diff --git a/apiserver/src/main/webapp/WEB-INF/web.xml b/apiserver/src/main/webapp/WEB-INF/web.xml index 645caec27c..052fe67a42 100644 --- a/apiserver/src/main/webapp/WEB-INF/web.xml +++ b/apiserver/src/main/webapp/WEB-INF/web.xml @@ -116,7 +116,12 @@ alpine.server.AlpineServlet jersey.config.server.provider.packages - alpine.server.filters,alpine.server.resources,org.dependencytrack.resources,org.dependencytrack.filters + + alpine.server.filters, + alpine.server.resources, + org.dependencytrack.filters, + org.dependencytrack.resources.v1 + jersey.config.server.provider.classnames @@ -132,6 +137,19 @@ DependencyTrack /api/* + + + REST-API-v2 + org.glassfish.jersey.servlet.ServletContainer + + jakarta.ws.rs.Application + org.dependencytrack.resources.v2.ResourceConfig + + + + REST-API-v2 + /api/v2/* + Health diff --git a/apiserver/src/test/java/org/dependencytrack/JerseyTestRule.java b/apiserver/src/test/java/org/dependencytrack/JerseyTestRule.java index 8bffa30f48..0dd53ea86b 100644 --- a/apiserver/src/test/java/org/dependencytrack/JerseyTestRule.java +++ b/apiserver/src/test/java/org/dependencytrack/JerseyTestRule.java @@ -18,7 +18,9 @@ */ package org.dependencytrack; -import org.dependencytrack.resources.v1.exception.ClientErrorExceptionMapper; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; +import org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter; import org.glassfish.jersey.client.ClientConfig; import org.glassfish.jersey.grizzly.connector.GrizzlyConnectorProvider; import org.glassfish.jersey.server.ResourceConfig; @@ -32,6 +34,9 @@ import org.junit.rules.ExternalResource; import jakarta.ws.rs.client.WebTarget; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; /** * @since 4.11.0 @@ -41,6 +46,7 @@ public class JerseyTestRule extends ExternalResource { private final JerseyTest jerseyTest; public JerseyTestRule(final ResourceConfig resourceConfig) { + final boolean isV2 = isV2(resourceConfig); this.jerseyTest = new JerseyTest() { @Override @@ -54,14 +60,25 @@ protected void configureClient(final ClientConfig config) { // using the default HttpUrlConnection connector provider. // See https://github.com/eclipse-ee4j/jersey/issues/4825 config.connectorProvider(new GrizzlyConnectorProvider()); + + if (isV2) { + config.register(OpenApiValidationClientResponseFilter.class); + } } @Override protected DeploymentContext configureDeployment() { forceSet(TestProperties.CONTAINER_PORT, "0"); - return ServletDeploymentContext.forServlet(new ServletContainer( - // Ensure exception mappers are registered. - resourceConfig.packages(ClientErrorExceptionMapper.class.getPackageName()))).build(); + + // Ensure exception mappers are registered. + if (isV2) { + resourceConfig.packages("org.dependencytrack.resources.v2.exception"); + } else { + resourceConfig.packages("org.dependencytrack.resources.v1.exception"); + } + + return ServletDeploymentContext.forServlet( + new ServletContainer(resourceConfig)).build(); } }; @@ -89,4 +106,28 @@ public final WebTarget target(final String path) { return jerseyTest.target(path); } + public final WebTarget target(final URI uri) { + WebTarget target = jerseyTest.target(uri.getPath()); + + if (uri.getQuery() != null) { + final List uriQueryParams = + URLEncodedUtils.parse(uri, StandardCharsets.UTF_8); + for (final NameValuePair queryParam : uriQueryParams) { + target = target.queryParam(queryParam.getName(), queryParam.getValue()); + } + } + + return target; + } + + private boolean isV2(final ResourceConfig resourceConfig) { + for (final Class clazz : resourceConfig.getClasses()) { + if (clazz.getPackageName().startsWith("org.dependencytrack.resources.v2")) { + return true; + } + } + + return false; + } + } \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/resources/OpenApiResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/OpenApiResourceTest.java similarity index 98% rename from apiserver/src/test/java/org/dependencytrack/resources/OpenApiResourceTest.java rename to apiserver/src/test/java/org/dependencytrack/resources/v1/OpenApiResourceTest.java index c7fda53cbd..46f24cecac 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/OpenApiResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/OpenApiResourceTest.java @@ -16,12 +16,11 @@ * SPDX-License-Identifier: Apache-2.0 * Copyright (c) OWASP Foundation. All Rights Reserved. */ -package org.dependencytrack.resources; +package org.dependencytrack.resources.v1; import alpine.server.filters.ApiFilter; import io.swagger.parser.OpenAPIParser; import io.swagger.v3.parser.core.models.SwaggerParseResult; -import jakarta.ws.rs.core.Response; import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; import org.glassfish.jersey.client.ClientProperties; @@ -29,6 +28,7 @@ import org.junit.ClassRule; import org.junit.Test; +import jakarta.ws.rs.core.Response; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/MetricsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/MetricsResourceTest.java new file mode 100644 index 0000000000..21ee0afb5f --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/MetricsResourceTest.java @@ -0,0 +1,273 @@ +/* + * 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.v2; + +import alpine.server.filters.ApiFilter; +import alpine.server.filters.AuthenticationFeature; +import net.javacrumbs.jsonunit.core.Option; +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.ResourceTest; +import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.Project; +import org.dependencytrack.model.ProjectMetrics; +import org.dependencytrack.model.VulnerabilityMetrics; +import org.dependencytrack.persistence.jdbi.MetricsTestDao; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.ClassRule; +import org.junit.Test; + +import jakarta.json.JsonObject; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiHandle; + +public class MetricsResourceTest extends ResourceTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule( + new ResourceConfig(MetricsResource.class) + .register(ApiFilter.class) + .register(AuthenticationFeature.class)); + + @Test + public void getCurrentPortfolioMetricsEmptyTest() { + initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + enablePortfolioAccessControl(); + + final Response response = jersey + .target("/metrics/portfolio/current") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)) + .isEqualTo(/* language=JSON */ """ + { + "components": 0, + "critical": 0, + "findings_audited": 0, + "findings_total": 0, + "findings_unaudited": 0, + "high": 0, + "inherited_risk_score": 0.0, + "low": 0, + "medium": 0, + "observed_at": "${json-unit.any-number}", + "policy_violations_audited": 0, + "policy_violations_fail": 0, + "policy_violations_info": 0, + "policy_violations_license_audited": 0, + "policy_violations_license_total": 0, + "policy_violations_license_unaudited": 0, + "policy_violations_operational_audited": 0, + "policy_violations_operational_total": 0, + "policy_violations_operational_unaudited": 0, + "policy_violations_security_audited": 0, + "policy_violations_security_total": 0, + "policy_violations_security_unaudited": 0, + "policy_violations_total": 0, + "policy_violations_unaudited": 0, + "policy_violations_warn": 0, + "projects": 0, + "suppressed": 0, + "unassigned": 0, + "vulnerabilities": 0, + "vulnerable_components": 0, + "vulnerable_projects": 0 + } + """); + } + + @Test + public void getCurrentPortfolioMetricsAclTest() { + initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + enablePortfolioAccessControl(); + + final var accessibleProjectA = new Project(); + accessibleProjectA.setName("acme-app-a"); + accessibleProjectA.addAccessTeam(super.team); + qm.persist(accessibleProjectA); + + final var accessibleProjectB = new Project(); + accessibleProjectB.setName("acme-app-b"); + accessibleProjectB.addAccessTeam(super.team); + qm.persist(accessibleProjectB); + + final var inactiveAccessibleProject = new Project(); + inactiveAccessibleProject.setName("acme-app-inactive"); + inactiveAccessibleProject.setInactiveSince(new Date()); + inactiveAccessibleProject.addAccessTeam(super.team); + qm.persist(inactiveAccessibleProject); + + final var inaccessibleProject = new Project(); + inaccessibleProject.setName("acme-app-inaccessible"); + qm.persist(inaccessibleProject); + + final var today = LocalDate.now(); + + useJdbiHandle(handle -> { + var dao = handle.attach(MetricsTestDao.class); + + dao.createMetricsPartitionsForDate("PROJECTMETRICS", today); + dao.createMetricsPartitionsForDate("PROJECTMETRICS", today.minusDays(1)); + dao.createMetricsPartitionsForDate("PROJECTMETRICS", today.minusDays(2)); + + { + // Create metrics for "yesterday". + + var accessibleProjectAMetrics = new ProjectMetrics(); + accessibleProjectAMetrics.setProjectId(accessibleProjectA.getId()); + accessibleProjectAMetrics.setComponents(2); + accessibleProjectAMetrics.setFirstOccurrence(Date.from(today.minusDays(1).atTime(1, 1).atZone(ZoneId.systemDefault()).toInstant())); + accessibleProjectAMetrics.setLastOccurrence(accessibleProjectAMetrics.getFirstOccurrence()); + dao.createProjectMetrics(accessibleProjectAMetrics); + } + + { + // Create metrics for "today". + + // Do not create metrics for accessibleProjectA. + // Its metrics from "yesterday" are supposed to carry over to "today". + + var accessibleProjectBMetrics = new ProjectMetrics(); + accessibleProjectBMetrics.setProjectId(accessibleProjectB.getId()); + accessibleProjectBMetrics.setComponents(1); + accessibleProjectBMetrics.setFirstOccurrence(Date.from(today.atTime(1, 1).atZone(ZoneId.systemDefault()).toInstant())); + accessibleProjectBMetrics.setLastOccurrence(accessibleProjectBMetrics.getFirstOccurrence()); + dao.createProjectMetrics(accessibleProjectBMetrics); + + // Metrics of inactive projects must not be considered. + var inactiveAccessibleProjectMetrics = new ProjectMetrics(); + inactiveAccessibleProjectMetrics.setProjectId(inactiveAccessibleProject.getId()); + inactiveAccessibleProjectMetrics.setComponents(111); + inactiveAccessibleProjectMetrics.setFirstOccurrence(Date.from(today.atTime(2, 2).atZone(ZoneId.systemDefault()).toInstant())); + inactiveAccessibleProjectMetrics.setLastOccurrence(inactiveAccessibleProjectMetrics.getFirstOccurrence()); + dao.createProjectMetrics(inactiveAccessibleProjectMetrics); + + // Metrics of inaccessible projects must not be considered. + var inaccessibleProjectMetrics = new ProjectMetrics(); + inaccessibleProjectMetrics.setProjectId(inaccessibleProject.getId()); + inaccessibleProjectMetrics.setComponents(666); + inaccessibleProjectMetrics.setFirstOccurrence(Date.from(today.atTime(3, 3).atZone(ZoneId.systemDefault()).toInstant())); + inaccessibleProjectMetrics.setLastOccurrence(inaccessibleProjectMetrics.getFirstOccurrence()); + dao.createProjectMetrics(inaccessibleProjectMetrics); + } + }); + + final Response response = jersey + .target("/metrics/portfolio/current") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)) + .withOptions(Option.IGNORING_EXTRA_FIELDS) + .isEqualTo(/* language=JSON */ """ + { + "projects": 2, + "components": 3 + } + """); + } + + @Test + public void getVulnerabilityMetricsPaginated() { + initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + enablePortfolioAccessControl(); + + for (int i = 1; i < 4; i++) { + var metrics = new VulnerabilityMetrics(); + metrics.setYear(2025); + metrics.setMonth(i); + metrics.setCount(i); + metrics.setMeasuredAt(Date.from(Instant.now())); + qm.persist(metrics); + } + + Response response = jersey.target("/metrics/vulnerabilities") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "metrics" : + [ + { + "observed_at" : "${json-unit.any-number}", + "year" : 2025, + "month" : 1, + "count" : 1 + }, + { + "observed_at" : "${json-unit.any-number}", + "year" : 2025, + "month" : 2, + "count" : 2 + } + ], + "_pagination" : { + "links" : { + "self" : "${json-unit.any-string}", + "next": "${json-unit.any-string}" + } + } + } + """); + + final var nextPageUri = URI.create( + responseJson + .getJsonObject("_pagination") + .getJsonObject("links") + .getString("next")); + + response = jersey.target(nextPageUri) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "metrics" : + [ + { + "observed_at" : "${json-unit.any-number}", + "year" : 2025, + "month" : 3, + "count" : 3 + } + ], + "_pagination": { + "links": { + "self": "${json-unit.any-string}" + } + } + } + """); + } +} \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiResourceTest.java new file mode 100644 index 0000000000..d6bbc772ac --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiResourceTest.java @@ -0,0 +1,54 @@ +/* + * 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.v2; + +import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.parser.core.models.SwaggerParseResult; +import org.dependencytrack.JerseyTestRule; +import org.junit.ClassRule; +import org.junit.Test; + +import jakarta.ws.rs.core.Response; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter.DISABLE_OPENAPI_VALIDATION; + +public class OpenApiResourceTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule(new ResourceConfig()); + + @Test + public void shouldReturnSpecYaml() { + final Response response = jersey.target("/openapi.yaml") + .request() + .property(DISABLE_OPENAPI_VALIDATION, "true") + .get(Response.class); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/yaml"); + + final String openApiYaml = response.readEntity(String.class); + final SwaggerParseResult parseResult = new OpenAPIParser().readContents(openApiYaml, null, null); + + final List validationMessages = parseResult.getMessages(); + assertThat(validationMessages).isEmpty(); + } + +} \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiValidationClientResponseFilter.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiValidationClientResponseFilter.java new file mode 100644 index 0000000000..5a228716fd --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/OpenApiValidationClientResponseFilter.java @@ -0,0 +1,186 @@ +/* + * 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.v2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.InputFormat; +import com.networknt.schema.JsonMetaSchema; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.NonValidationKeyword; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import com.networknt.schema.oas.OpenApi30; +import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.parser.ObjectMapperFactory; +import io.swagger.v3.parser.core.models.ParseOptions; +import org.junit.Assert; + +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientResponseContext; +import jakarta.ws.rs.client.ClientResponseFilter; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Set; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @since 5.6.0 + */ +public class OpenApiValidationClientResponseFilter implements ClientResponseFilter { + + public static final String DISABLE_OPENAPI_VALIDATION = "disable-openapi-validation"; + + private static final JsonSchemaFactory SCHEMA_FACTORY = + JsonSchemaFactory.getInstance( + SpecVersion.VersionFlag.V4, + builder -> builder + .metaSchema(JsonMetaSchema.builder(OpenApi30.getInstance()) + .keyword(new NonValidationKeyword("exampleSetFlag")) + .keyword(new NonValidationKeyword("extensions")) + .keyword(new NonValidationKeyword("types")) + .build()) + .defaultMetaSchemaIri(OpenApi30.getInstance().getIri())); + + private final OpenAPI openApiSpec; + private final ObjectMapper objectMapper; + + public OpenApiValidationClientResponseFilter() { + this.openApiSpec = loadOpenApiSpec(); + this.objectMapper = ObjectMapperFactory.createJson(); + } + + @Override + public void filter( + final ClientRequestContext requestContext, + final ClientResponseContext responseContext) throws IOException { + if (requestContext.hasProperty(DISABLE_OPENAPI_VALIDATION)) { + return; + } + + final Operation operationDef = findOpenApiOperation(requestContext); + if (operationDef == null) { + // Undocumented request? + Assert.fail("No OpenAPI operation found for %s %s".formatted( + requestContext.getMethod(), requestContext.getUri())); + } + + // Read the response content and assign it back to the response context. + // Without this, clients won't be able to read the response anymore. + final byte[] responseBytes = responseContext.getEntityStream().readAllBytes(); + responseContext.setEntityStream(new ByteArrayInputStream(responseBytes)); + + // Identity the correct response object in the spec based on the status. + final String responseStatus = String.valueOf(responseContext.getStatus()); + assertThat(operationDef.getResponses().keySet()).contains(responseStatus); + final ApiResponse responseDef = operationDef.getResponses().get(responseStatus); + + // If the spec does not define a response, ensure that the actual + // response is also empty. + if (responseDef.getContent() == null) { + assertThat(responseBytes).asString().isEmpty(); + return; + } + + // Identity the correct media type in the spec response. + final String responseContentType = responseContext.getHeaderString("Content-Type"); + assertThat(responseDef.getContent().keySet()).contains(responseContentType); + final MediaType mediaType = responseDef.getContent().get(responseContentType); + + // Serialize the response schema to JSON so it can be used for validation. + // NB: The schema already has all $refs resolved so can be handled "standalone". + final String schemaJson = objectMapper.writeValueAsString(mediaType.getSchema()); + final JsonSchema schema = SCHEMA_FACTORY.getSchema(schemaJson); + + final Set messages = schema.validate( + new String(responseBytes), InputFormat.JSON); + assertThat(messages).isEmpty(); + } + + private Operation findOpenApiOperation(final ClientRequestContext requestContext) { + final String requestPath = requestContext.getUri().getPath(); + + for (final String specPath : openApiSpec.getPaths().keySet()) { + if (!pathsMatch(requestPath, specPath)) { + continue; + } + + final PathItem pathItem = openApiSpec.getPaths().get(specPath); + return switch (requestContext.getMethod()) { + case "DELETE" -> pathItem.getDelete(); + case "GET" -> pathItem.getGet(); + case "HEAD" -> pathItem.getHead(); + case "OPTIONS" -> pathItem.getOptions(); + case "PATCH" -> pathItem.getPatch(); + case "POST" -> pathItem.getPost(); + case "PUT" -> pathItem.getPut(); + case "TRACE" -> pathItem.getTrace(); + default -> null; + }; + } + + return null; + } + + private boolean pathsMatch(final String requestPath, final String specPath) { + final String[] requestPathSegments = requestPath.split("/"); + final String[] specPathSegments = specPath.split("/"); + + if (requestPathSegments.length != specPathSegments.length) { + return false; + } + + for (int i = 0; i < requestPathSegments.length; i++) { + final String requestPathSegment = requestPathSegments[i]; + final String specPathSegment = specPathSegments[i]; + + if (!requestPathSegment.equals(specPathSegment) && !specPathSegment.startsWith("{")) { + return false; + } + } + + return true; + } + + private static OpenAPI loadOpenApiSpec() { + try (final InputStream specInputStream = + OpenApiValidationClientResponseFilter.class.getResourceAsStream( + "/org/dependencytrack/api/v2/openapi.yaml")) { + requireNonNull(specInputStream); + final String specString = new String(specInputStream.readAllBytes()); + + final var parseOptions = new ParseOptions(); + parseOptions.setResolve(true); + parseOptions.setResolveFully(true); + + return new OpenAPIParser().readContents(specString, null, parseOptions).getOpenAPI(); + } catch (IOException e) { + throw new IllegalStateException("Failed to load OpenAPI spec", e); + } + } + +} diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/TeamsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/TeamsResourceTest.java new file mode 100644 index 0000000000..ad9f50b2e3 --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/TeamsResourceTest.java @@ -0,0 +1,589 @@ +/* + * 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.v2; + +import alpine.model.Permission; +import alpine.model.Team; +import alpine.model.User; +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.ResourceTest; +import org.dependencytrack.auth.Permissions; +import org.junit.ClassRule; +import org.junit.Test; + +import jakarta.json.JsonObject; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.Response; +import java.net.URI; +import java.util.List; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +public class TeamsResourceTest extends ResourceTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule(new ResourceConfig()); + + @Test + public void listTeamsShouldReturnPaginatedTeams() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createTeam("team0"); + qm.createTeam("team1"); + + Response response = jersey.target("/teams") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "teams": [ + { + "name": "Test Users", + "api_keys": 1, + "members": 0 + }, + { + "name": "team0", + "api_keys": 0, + "members": 0 + } + ], + "_pagination": { + "links": { + "self": "${json-unit.any-string}", + "next": "${json-unit.any-string}" + } + } + } + """); + + final var nextPageUri = URI.create( + responseJson + .getJsonObject("_pagination") + .getJsonObject("links") + .getString("next")); + + response = jersey.target(nextPageUri) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "teams": [ + { + "name": "team1", + "api_keys": 0, + "members": 0 + } + ], + "_pagination": { + "links": { + "self": "${json-unit.any-string}" + } + } + } + """); + } + + @Test + public void createTeamShouldCreateTeam() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createPermission( + Permissions.VIEW_PORTFOLIO.name(), + Permissions.VIEW_PORTFOLIO.getDescription()); + + final Response response = jersey.target("/teams") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "name": "foo", + "permissions": [ + "VIEW_PORTFOLIO" + ] + } + """)); + assertThat(response.getStatus()).isEqualTo(201); + assertThat(response.getLocation()).hasPath("/teams/foo"); + assertThat(getPlainTextBody(response)).isEmpty(); + + qm.getPersistenceManager().evictAll(); + + final Team team = qm.getTeam("foo"); + assertThat(team).isNotNull(); + assertThat(team.getPermissions()).extracting(Permission::getName).containsOnly("VIEW_PORTFOLIO"); + } + + @Test + public void createTeamShouldReturnConflictWhenAlreadyExists() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createTeam("foo"); + + final Response response = jersey.target("/teams") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "name": "foo", + "permissions": [ + "VIEW_PORTFOLIO" + ] + } + """)); + assertThat(response.getStatus()).isEqualTo(409); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status":409, + "title": "Conflict", + "detail": "The resource already exists." + } + """); + + qm.getPersistenceManager().evictAll(); + + final Team team = qm.getTeam("foo"); + assertThat(team).isNotNull(); + assertThat(team.getPermissions()).isEmpty(); + } + + @Test + public void getTeamShouldReturnTeam() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final Team team = qm.createTeam("foo"); + team.setPermissions(List.of( + qm.createPermission( + Permissions.VIEW_PORTFOLIO.name(), + Permissions.VIEW_PORTFOLIO.getDescription()), + qm.createPermission( + Permissions.VIEW_VULNERABILITY.name(), + Permissions.VIEW_VULNERABILITY.getDescription()))); + + final Response response = jersey.target("/teams/foo") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "name": "foo", + "permissions": [ + "VIEW_PORTFOLIO", + "VIEW_VULNERABILITY" + ] + } + """); + } + + @Test + public void getTeamShouldReturnNotFoundWhenTeamDoesNotExist() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final Response response = jersey.target("/teams/foo") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(404); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status": 404, + "title": "Not Found", + "detail": "The requested resource could not be found." + } + """); + } + + @Test + public void deleteTeamShouldDeleteTeam() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createTeam("foo"); + + final Response response = jersey.target("/teams/foo") + .request() + .header(X_API_KEY, apiKey) + .delete(); + assertThat(response.getStatus()).isEqualTo(204); + assertThat(getPlainTextBody(response)).isEmpty(); + + qm.getPersistenceManager().evictAll(); + + assertThat(qm.getTeam("foo")).isNull(); + } + + @Test + public void deleteTeamsShouldReturnNotFoundWhenNotExists() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final Response response = jersey.target("/teams/does-not-exist") + .request() + .header(X_API_KEY, apiKey) + .delete(); + assertThat(response.getStatus()).isEqualTo(404); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type": "about:blank", + "status": 404, + "title": "Not Found", + "detail": "The requested resource could not be found." + } + """); + } + + @Test + public void listTeamMembershipsShouldReturnPaginatedTeamMemberships() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final Team teamA = qm.createTeam("team-a"); + qm.addUserToTeam(qm.createManagedUser("foo", "password"), teamA); + qm.addUserToTeam(qm.createManagedUser("bar", "password"), teamA); + + final Team teamB = qm.createTeam("team-b"); + qm.addUserToTeam(qm.createManagedUser("aaa", "password"), teamB); + + Response response = jersey.target("/team-memberships") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "memberships": [ + { + "team_name": "team-a", + "username": "bar" + }, + { + "team_name": "team-a", + "username": "foo" + } + ], + "_pagination": { + "links": { + "self": "${json-unit.any-string}", + "next": "${json-unit.any-string}" + } + } + } + """); + + final var nextPageUri = URI.create( + responseJson + .getJsonObject("_pagination") + .getJsonObject("links") + .getString("next")); + + response = jersey.target(nextPageUri) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "memberships": [ + { + "team_name": "team-b", + "username": "aaa" + } + ], + "_pagination": { + "links": { + "self": "${json-unit.any-string}" + } + } + } + """); + } + + @Test + public void listTeamMembershipsShouldFilterByTeamName() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final Team teamA = qm.createTeam("team-a"); + qm.addUserToTeam(qm.createManagedUser("foo", "password"), teamA); + + final Team teamB = qm.createTeam("team-b"); + qm.addUserToTeam(qm.createManagedUser("bar", "password"), teamB); + + Response response = jersey.target("/team-memberships") + .queryParam("team", "team-b") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "memberships": [ + { + "team_name": "team-b", + "username": "bar" + } + ], + "_pagination": { + "links": { + "self": "${json-unit.any-string}" + } + } + } + """); + } + + @Test + public void listTeamMembershipsShouldFilterByUsername() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final Team teamA = qm.createTeam("team-a"); + qm.addUserToTeam(qm.createManagedUser("foo", "password"), teamA); + + final Team teamB = qm.createTeam("team-b"); + qm.addUserToTeam(qm.createManagedUser("bar", "password"), teamB); + + final Response response = jersey.target("/team-memberships") + .queryParam("user", "bar") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "memberships": [ + { + "team_name": "team-b", + "username": "bar" + } + ], + "_pagination": { + "links": { + "self": "${json-unit.any-string}" + } + } + } + """); + } + + @Test + public void createTeamMembershipShouldCreateTeamMembership() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createTeam("team-foo"); + qm.createManagedUser("user-bar", "password"); + + final Response response = jersey.target("/team-memberships") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "team_name": "team-foo", + "username": "user-bar" + } + """)); + assertThat(response.getStatus()).isEqualTo(201); + assertThat(response.getLocation()).isNull(); + assertThat(getPlainTextBody(response)).isEmpty(); + + qm.getPersistenceManager().evictAll(); + + assertThat(qm.getTeam("team-foo").getUsers()) + .extracting(User::getUsername) + .contains("user-bar"); + } + + @Test + public void createTeamMembershipShouldReturnConflictWhenAlreadyExists() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final Team team = qm.createTeam("team-foo"); + final User user = qm.createManagedUser("user-bar", "password"); + qm.addUserToTeam(user, team); + + final Response response = jersey.target("/team-memberships") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "team_name": "team-foo", + "username": "user-bar" + } + """)); + assertThat(response.getStatus()).isEqualTo(409); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status":409, + "title": "Conflict", + "detail": "The resource already exists." + } + """); + } + + @Test + public void createTeamMembershipShouldReturnNotFoundWhenTeamNotExists() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createManagedUser("user-bar", "password"); + + final Response response = jersey.target("/team-memberships") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "team_name": "team-foo", + "username": "user-bar" + } + """)); + assertThat(response.getStatus()).isEqualTo(404); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status": 404, + "title": "Not Found", + "detail": "The requested resource could not be found." + } + """); + } + + @Test + public void createTeamMembershipShouldReturnNotFoundWhenUserNotExists() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createTeam("team-foo"); + + final Response response = jersey.target("/team-memberships") + .request() + .header(X_API_KEY, apiKey) + .post(Entity.json(/* language=JSON */ """ + { + "team_name": "team-foo", + "username": "user-bar" + } + """)); + assertThat(response.getStatus()).isEqualTo(404); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status": 404, + "title": "Not Found", + "detail": "The requested resource could not be found." + } + """); + } + + @Test + public void deleteTeamMembershipShouldDeleteTeamMembership() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + final Team team = qm.createTeam("foo"); + qm.addUserToTeam(qm.createManagedUser("bar", "password"), team); + + final Response response = jersey.target("/team-memberships") + .queryParam("team", "foo") + .queryParam("user", "bar") + .request() + .header(X_API_KEY, apiKey) + .delete(); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + + assertThat(team.getUsers()).isEmpty(); + } + + @Test + public void deleteTeamMembershipShouldReturnNotFoundWhenTeamDoesNotExist() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createManagedUser("bar", "password"); + + final Response response = jersey.target("/team-memberships") + .queryParam("team", "foo") + .queryParam("user", "bar") + .request() + .header(X_API_KEY, apiKey) + .delete(); + assertThat(response.getStatus()).isEqualTo(404); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status": 404, + "title": "Not Found", + "detail": "The requested resource could not be found." + } + """); + } + + @Test + public void deleteTeamMembershipShouldReturnNotFoundWhenUserDoesNotExist() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createTeam("foo"); + + final Response response = jersey.target("/team-memberships") + .queryParam("team", "foo") + .queryParam("user", "bar") + .request() + .header(X_API_KEY, apiKey) + .delete(); + assertThat(response.getStatus()).isEqualTo(404); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status": 404, + "title": "Not Found", + "detail": "The requested resource could not be found." + } + """); + } + + @Test + public void deleteTeamMembershipShouldReturnNotFoundWhenMembershipDoesNotExist() { + initializeWithPermissions(Permissions.ACCESS_MANAGEMENT); + + qm.createTeam("foo"); + qm.createManagedUser("bar", "password"); + + final Response response = jersey.target("/team-memberships") + .queryParam("team", "foo") + .queryParam("user", "bar") + .request() + .header(X_API_KEY, apiKey) + .delete(); + assertThat(response.getStatus()).isEqualTo(404); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status": 404, + "title": "Not Found", + "detail": "The requested resource could not be found." + } + """); + } + +} \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/WorkflowsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/WorkflowsResourceTest.java new file mode 100644 index 0000000000..e3be771c17 --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/WorkflowsResourceTest.java @@ -0,0 +1,134 @@ +/* + * 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.v2; + +import alpine.server.filters.ApiFilter; +import alpine.server.filters.AuthenticationFeature; +import net.javacrumbs.jsonunit.core.Option; +import org.apache.http.HttpStatus; +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.ResourceTest; +import org.dependencytrack.model.WorkflowState; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.ClassRule; +import org.junit.Test; + +import jakarta.ws.rs.core.Response; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.model.WorkflowStatus.COMPLETED; +import static org.dependencytrack.model.WorkflowStatus.PENDING; +import static org.dependencytrack.model.WorkflowStep.BOM_CONSUMPTION; +import static org.dependencytrack.model.WorkflowStep.BOM_PROCESSING; +import static org.hamcrest.CoreMatchers.equalTo; + +public class WorkflowsResourceTest extends ResourceTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule( + new ResourceConfig(WorkflowsResource.class) + .register(ApiFilter.class) + .register(AuthenticationFeature.class)); + + @Test + public void getWorkflowStatusOk() { + UUID uuid = UUID.randomUUID(); + WorkflowState workflowState1 = new WorkflowState(); + workflowState1.setParent(null); + workflowState1.setFailureReason(null); + workflowState1.setStep(BOM_CONSUMPTION); + workflowState1.setStatus(COMPLETED); + workflowState1.setToken(uuid); + workflowState1.setUpdatedAt(new Date()); + var workflowState1Persisted = qm.persist(workflowState1); + + WorkflowState workflowState2 = new WorkflowState(); + workflowState2.setParent(workflowState1Persisted); + workflowState2.setFailureReason(null); + workflowState2.setStep(BOM_PROCESSING); + workflowState2.setStatus(PENDING); + workflowState2.setToken(uuid); + workflowState2.setStartedAt(Date.from(Instant.now())); + workflowState2.setUpdatedAt(Date.from(Instant.now())); + qm.persist(workflowState2); + + Response response = jersey.target("/workflows/" + uuid).request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_OK); + final String jsonResponse = getPlainTextBody(response); + assertThatJson(jsonResponse) + .withOptions(Option.IGNORING_ARRAY_ORDER) + .withMatcher("token", equalTo(uuid.toString())) + .withMatcher("step1", equalTo("BOM_CONSUMPTION")) + .withMatcher("status1", equalTo("COMPLETED")) + .withMatcher("step2", equalTo("BOM_PROCESSING")) + .withMatcher("status2", equalTo("PENDING")) + .isEqualTo(/* language=JSON */ """ + { + "states": [ + { + "token": "${json-unit.matches:token}", + "step": "${json-unit.matches:step1}", + "status": "${json-unit.matches:status1}", + "updated_at": "${json-unit.any-number}" + }, + { + "token": "${json-unit.matches:token}", + "started_at": "${json-unit.any-number}", + "updated_at": "${json-unit.any-number}", + "step": "${json-unit.matches:step2}", + "status": "${json-unit.matches:status2}" + } + ] + } + """); + } + + @Test + public void getWorkflowStatusNotFound() { + WorkflowState workflowState1 = new WorkflowState(); + workflowState1.setParent(null); + workflowState1.setFailureReason(null); + workflowState1.setStep(BOM_CONSUMPTION); + workflowState1.setStatus(COMPLETED); + workflowState1.setToken(UUID.randomUUID()); + workflowState1.setUpdatedAt(new Date()); + qm.persist(workflowState1); + + UUID randomUuid = UUID.randomUUID(); + Response response = jersey.target("/workflows/" + randomUuid).request() + .header(X_API_KEY, apiKey) + .get(); + + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_NOT_FOUND); + assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ + { + "type":"about:blank", + "status": 404, + "title": "Not Found", + "detail": "The requested resource could not be found." + } + """); + } +} \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapperTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapperTest.java new file mode 100644 index 0000000000..dce59268f2 --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/ConstraintViolationExceptionMapperTest.java @@ -0,0 +1,96 @@ +/* + * 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.v2.exception; + +import alpine.server.auth.AuthenticationNotRequired; +import net.javacrumbs.jsonunit.core.Option; +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.model.validation.ValidUuid; +import org.dependencytrack.resources.v2.ResourceConfig; +import org.junit.ClassRule; +import org.junit.Test; + +import jakarta.validation.constraints.Pattern; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter.DISABLE_OPENAPI_VALIDATION; + +public class ConstraintViolationExceptionMapperTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule( + new ResourceConfig() + .register(JsonProcessingExceptionMapperTest.TestResource.class)); + + @Test + public void test() { + final Response response = jersey.target("/test/not-a-uuid") + .queryParam("foo", "666") + .request() + .property(DISABLE_OPENAPI_VALIDATION, "true") + .get(); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(response.readEntity(String.class)) + .withOptions(Option.IGNORING_ARRAY_ORDER) + .isEqualTo(/* language=JSON */ """ + { + "type": "about:blank", + "status": 400, + "title": "Bad Request", + "detail": "The request could not be processed because it failed validation.", + "errors": [ + { + "message": "must match \\"^[a-z]+$\\"", + "path": "get.foo", + "value": "666" + }, + { + "message": "Invalid UUID", + "path": "get.uuid", + "value": "not-a-uuid" + } + ] + } + """); + } + + @Path("/test") + public static class TestResource { + + @GET + @Path("/{uuid}") + @Produces(MediaType.APPLICATION_JSON) + @AuthenticationNotRequired + public Response get(@PathParam("uuid") @ValidUuid final String uuid, + @QueryParam("optionalUuid") @ValidUuid final String optionalUuid, + @QueryParam("foo") @Pattern(regexp = "^[a-z]+$") final String foo) { + return Response.noContent().build(); + } + + } + +} \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapperTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapperTest.java new file mode 100644 index 0000000000..c2172663ea --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/DefaultExceptionMapperTest.java @@ -0,0 +1,96 @@ +/* + * 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.v2.exception; + +import alpine.server.auth.AuthenticationNotRequired; +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.resources.v2.ResourceConfig; +import org.junit.ClassRule; +import org.junit.Test; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.ServerErrorException; +import jakarta.ws.rs.core.Response; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter.DISABLE_OPENAPI_VALIDATION; + +public class DefaultExceptionMapperTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule( + new ResourceConfig() + .register(JsonProcessingExceptionMapperTest.TestResource.class)); + + @Test + public void shouldReturnInternalServerError() { + final Response response = jersey.target("/test/error") + .request() + .property(DISABLE_OPENAPI_VALIDATION, "true") + .get(); + assertThat(response.getStatus()).isEqualTo(500); + assertThatJson(response.readEntity(String.class)).isEqualTo(/* language=JSON */ """ + { + "type": "about:blank", + "status": 500, + "title": "Unexpected error", + "detail": "An error occurred that was not anticipated." + } + """); + } + + @Test + public void shouldReturnGivenStatusForServerErrors() { + final Response response = jersey.target("/test/server-error") + .request() + .property(DISABLE_OPENAPI_VALIDATION, "true") + .get(); + assertThat(response.getStatus()).isEqualTo(503); + assertThatJson(response.readEntity(String.class)).isEqualTo(/* language=JSON */ """ + { + "type": "about:blank", + "status": 503, + "title": "Unexpected error", + "detail": "An error occurred that was not anticipated." + } + """); + } + + @Path("/test") + public static class TestResource { + + @GET + @Path("/error") + @AuthenticationNotRequired + public Response fail() throws Exception { + throw new ClassNotFoundException("test"); + } + + @GET + @Path("/server-error") + @AuthenticationNotRequired + public Response serverError() { + throw new ServerErrorException(Response.status(Response.Status.SERVICE_UNAVAILABLE).build()); + } + + } + +} \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapperTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapperTest.java new file mode 100644 index 0000000000..a437321487 --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/exception/JsonProcessingExceptionMapperTest.java @@ -0,0 +1,105 @@ +/* + * 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.v2.exception; + +import alpine.server.auth.AuthenticationNotRequired; +import com.fasterxml.jackson.core.JsonGenerationException; +import org.dependencytrack.JerseyTestRule; +import org.dependencytrack.resources.v2.ResourceConfig; +import org.junit.ClassRule; +import org.junit.Test; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.resources.v2.OpenApiValidationClientResponseFilter.DISABLE_OPENAPI_VALIDATION; + +public class JsonProcessingExceptionMapperTest { + + @ClassRule + public static JerseyTestRule jersey = new JerseyTestRule( + new ResourceConfig() + .register(TestResource.class)); + + @Test + public void shouldReturnInternalServerErrorForServerSideJsonException() { + final Response response = jersey.target("/test/json-generation") + .request() + .property(DISABLE_OPENAPI_VALIDATION, "true") + .get(); + assertThat(response.getStatus()).isEqualTo(500); + assertThatJson(response.readEntity(String.class)).isEqualTo(/* language=JSON */ """ + { + "type": "about:blank", + "status": 500, + "title": "Unexpected error", + "detail": "An error occurred that was not anticipated." + } + """); + } + + @Test + public void shouldReturnBadRequestForClientSideJsonException() { + final Response response = jersey.target("/test") + .request() + .property(DISABLE_OPENAPI_VALIDATION, "true") + .post(Entity.json("[]")); + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(response.readEntity(String.class)).isEqualTo(/* language=JSON */ """ + { + "type": "about:blank", + "status": 400, + "title": "JSON Processing Failed", + "detail": "The provided JSON could not be processed." + } + """); + } + + @Path("/test") + public static class TestResource { + + public record TestRequest(String name) { + } + + @POST + @Path("/") + @Consumes(MediaType.APPLICATION_JSON) + @AuthenticationNotRequired + public Response test(final TestRequest request) { + return Response.ok(request.name()).build(); + } + + @GET + @Path("/json-generation") + @AuthenticationNotRequired + @SuppressWarnings("deprecation") + public Response jsonGeneration() throws Exception { + throw new JsonGenerationException("boom"); + } + + } + +} \ No newline at end of file diff --git a/dev/scripts/openapi-lint.sh b/dev/scripts/openapi-lint.sh new file mode 100755 index 0000000000..ab1edd1955 --- /dev/null +++ b/dev/scripts/openapi-lint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +# 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. + +set -euo pipefail + +SCRIPT_DIR="$(cd -P -- "$(dirname "$0")" && pwd -P)" +API_MODULE_DIR="$(cd -P -- "${SCRIPT_DIR}/../../api" && pwd -P)" + +# NB: Currently there's no arm64 image variant. +docker run --rm -it -w /work \ + --platform linux/amd64 \ + -v "${API_MODULE_DIR}:/work" \ + stoplight/spectral lint \ + --ruleset src/main/spectral/ruleset.yaml \ + src/main/openapi/openapi.yaml \ No newline at end of file diff --git a/pom.xml b/pom.xml index 42b4c0ac67..1157cc85cb 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ support/datanucleus-plugin support/liquibase + api alpine proto persistence-migration @@ -147,6 +148,11 @@ ${project.version} + + org.dependencytrack + api + ${project.version} + org.dependencytrack apiserver