diff --git a/.github/filter-native-tests-json.sh b/.github/filter-integration-tests-json.sh similarity index 79% rename from .github/filter-native-tests-json.sh rename to .github/filter-integration-tests-json.sh index 36f215b60464f..f04771185c8d5 100755 --- a/.github/filter-native-tests-json.sh +++ b/.github/filter-integration-tests-json.sh @@ -1,7 +1,9 @@ #!/bin/bash -# Purpose: Prints a filtered version of native-tests.json, with "test-modules" reduced to the ones passed in as the first argument. -# This first argument is expected to the define one module per line. +# Purpose: Prints a filtered version of a JSON file derived from the first argument, +# e.g. "native" => "native-tests.json" or "virtual-threads" => "virtual-threads-tests.json", +# with "test-modules" reduced to the ones passed in as the second argument. +# This second argument is expected to the define one module per line. # "include" elements that (after filtering) have no "test-modules" anymore are deleted entirely! # Note: This script is only for CI and does therefore not aim to be compatible with BSD/macOS. @@ -11,13 +13,15 @@ shopt -s failglob # path of this shell script PRG_PATH=$( cd "$(dirname "$0")" ; pwd -P ) -JSON=$(cat ${PRG_PATH}/native-tests.json) +FILE_BASE_NAME=$1 +shift +JSON=$(cat ${PRG_PATH}/${FILE_BASE_NAME}-tests.json) -JSON=$( echo "$JSON" | jq ' +JSON=$( echo "$JSON" | jq "$(cat < 0 if nothing matches (which is a valid case) - # - "paste" joins all matches to get a single line - FILTERED=$(echo -n "${modules}" | grep -Po "${EXPR}|" | paste -sd " " -) - JSON=$(echo -n "${JSON}" | sed "s|${modules}|${FILTERED}|") -done < <(echo -n "${JSON}" | jq -r '.include[] | ."test-modules"') - -# Step 3: delete entire elements from "include" array that now have an empty "test-modules" list -JSON=$(echo "${JSON}" | jq 'del(.include[] | select(."test-modules" == ""))') - -# Step 4: echo final result, printing only {} in case _all_ elements were removed from "include" array -if [ -z "$(echo "${JSON}" | jq '.include[]')" ] -then - echo -n '{}' -else - echo -n "${JSON}" -fi diff --git a/.github/virtual-threads-tests.json b/.github/virtual-threads-tests.json index 32cc3f535f03e..230d56ec8a9fb 100644 --- a/.github/virtual-threads-tests.json +++ b/.github/virtual-threads-tests.json @@ -3,19 +3,19 @@ { "category": "Main", "timeout": 60, - "test-modules": "virtual-threads-disabled, grpc-virtual-threads, mailer-virtual-threads, redis-virtual-threads, rest-client-reactive-virtual-threads, resteasy-reactive-virtual-threads, vertx-event-bus-virtual-threads, scheduler-virtual-threads, quartz-virtual-threads, metrics-virtual-threads", + "test-modules": "virtual-threads/virtual-threads-disabled, virtual-threads/grpc-virtual-threads, virtual-threads/mailer-virtual-threads, virtual-threads/redis-virtual-threads, virtual-threads/rest-client-reactive-virtual-threads, virtual-threads/resteasy-reactive-virtual-threads, virtual-threads/vertx-event-bus-virtual-threads, virtual-threads/scheduler-virtual-threads, virtual-threads/quartz-virtual-threads, virtual-threads/metrics-virtual-threads", "os-name": "ubuntu-latest" }, { "category": "Messaging", "timeout": 45, - "test-modules": "amqp-virtual-threads, jms-virtual-threads, kafka-virtual-threads", + "test-modules": "virtual-threads/amqp-virtual-threads, virtual-threads/jms-virtual-threads, virtual-threads/kafka-virtual-threads", "os-name": "ubuntu-latest" }, { "category": "Security", "timeout": 20, - "test-modules": "security-webauthn-virtual-threads", + "test-modules": "virtual-threads/security-webauthn-virtual-threads", "os-name": "ubuntu-latest" } ] diff --git a/.github/workflows/ci-actions-incremental.yml b/.github/workflows/ci-actions-incremental.yml index b344521561efe..643508e23fc04 100644 --- a/.github/workflows/ci-actions-incremental.yml +++ b/.github/workflows/ci-actions-incremental.yml @@ -375,7 +375,7 @@ jobs: - name: Verify native-tests.json run: ./.github/verify-tests-json.sh native-tests.json integration-tests/ - name: Verify virtual-threads-tests.json - run: ./.github/verify-tests-json.sh virtual-threads-tests.json integration-tests/virtual-threads/ + run: ./.github/verify-tests-json.sh virtual-threads-tests.json integration-tests/ - name: Setup Develocity Build Scan capture uses: gradle/develocity-actions/setup-maven@v2.1 with: @@ -498,7 +498,7 @@ jobs: id: calc-native-matrix run: | echo "GIB_IMPACTED_MODULES: ${GIB_IMPACTED_MODULES}" - json=$(.github/filter-native-tests-json.sh "${GIB_IMPACTED_MODULES}" | tr -d '\n') + json=$(.github/filter-integration-tests-json.sh native "${GIB_IMPACTED_MODULES}" | tr -d '\n') echo "${json}" echo "matrix=${json}" >> $GITHUB_OUTPUT - name: Calculate matrix from matrix-jvm-tests.json @@ -511,13 +511,13 @@ jobs: id: calc-virtual-threads-matrix run: | echo "GIB_IMPACTED_MODULES: ${GIB_IMPACTED_MODULES}" - json=$(.github/filter-virtual-threads-tests-json.sh "${GIB_IMPACTED_MODULES}" | tr -d '\n') + json=$(.github/filter-integration-tests-json.sh virtual-threads "${GIB_IMPACTED_MODULES}" | tr -d '\n') echo "${json}" echo "matrix=${json}" >> $GITHUB_OUTPUT - name: Calculate run flags id: calc-run-flags run: | - run_devtools=true; run_gradle=true; run_maven=true; run_kubernetes=true; run_quickstarts=true; run_tcks=true + run_devtools=true; run_gradle=true; run_maven=true; run_kubernetes=true; run_quickstarts=true; run_tcks=true; if [ -z "${GIB_IMPACTED_MODULES}" ] then run_devtools=false; run_gradle=false; run_maven=false; run_kubernetes=false; run_quickstarts=false; run_tcks=false @@ -1403,14 +1403,14 @@ jobs: CONTAINER_BUILD: ${{startsWith(matrix.os-name, 'windows') && 'false' || 'true'}} CAPTURE_BUILD_SCAN: true run: | - export LANG=en_US && ./mvnw $COMMON_MAVEN_ARGS $COMMON_TEST_MAVEN_ARGS $PTS_MAVEN_ARGS -f integration-tests/virtual-threads -pl "$TEST_MODULES" $NATIVE_TEST_MAVEN_ARGS -Dextra-args=--enable-preview -Dquarkus.native.container-build=true + export LANG=en_US && ./mvnw $COMMON_MAVEN_ARGS $COMMON_TEST_MAVEN_ARGS $PTS_MAVEN_ARGS -f integration-tests -pl "$TEST_MODULES" $NATIVE_TEST_MAVEN_ARGS -Dextra-args=--enable-preview -Dquarkus.native.container-build=true - name: Prepare build reports archive if: always() run: | 7z a -tzip build-reports.zip -r \ - 'integration-tests/virtual-threads/**/target/*-reports/TEST-*.xml' \ - 'integration-tests/virtual-threads/target/build-report.json' \ - 'integration-tests/virtual-threads/target/gradle-build-scan-url.txt' \ + 'integration-tests/**/target/*-reports/TEST-*.xml' \ + 'integration-tests/**/target/build-report.json' \ + 'integration-tests/**/target/gradle-build-scan-url.txt' \ LICENSE - name: Upload build reports uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 diff --git a/.github/workflows/native-it-selected-graalvm.yml b/.github/workflows/native-it-selected-graalvm.yml index 2675eca3af334..1d04a381ac9ec 100644 --- a/.github/workflows/native-it-selected-graalvm.yml +++ b/.github/workflows/native-it-selected-graalvm.yml @@ -204,7 +204,7 @@ jobs: id: calc-native-matrix run: | echo "GIB_IMPACTED_MODULES: ${GIB_IMPACTED_MODULES}" - json=$(.github/filter-native-tests-json.sh "${GIB_IMPACTED_MODULES}" | tr -d '\n') + json=$(.github/filter-integration-tests-json.sh native "${GIB_IMPACTED_MODULES}" | tr -d '\n') # Remove Windows from the matrix json=$(echo $json | jq 'del(.include[] | select(."os-name" == "windows-latest"))') json=$(echo $json | tr -d '\n') @@ -214,7 +214,7 @@ jobs: id: calc-virtual-threads-matrix run: | echo "GIB_IMPACTED_MODULES: ${GIB_IMPACTED_MODULES}" - json=$(.github/filter-virtual-threads-tests-json.sh "${GIB_IMPACTED_MODULES}" | tr -d '\n') + json=$(.github/filter-integration-tests-json.sh virtual-threads "${GIB_IMPACTED_MODULES}" | tr -d '\n') # Remove Windows from the matrix json=$(echo $json | jq 'del(.include[] | select(."os-name" == "windows-latest"))') json=$(echo $json | tr -d '\n') diff --git a/AI_POLICY.md b/AI_POLICY.md index 5a4aeee11d0d9..2fe8a3c9f97a1 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -9,6 +9,7 @@ However, recent patterns of use have led to increased moderation burden, low-val - You may use agents/LLMs to help you **write better**, but not to **post more**. - AI may be used to **assist your development** — for example drafting code, writing documentation, or proposing fixes — as long as **you understand, validate**, and **take responsibility for the results**. - You should only submit contributions (PRs, comments, discussions, issues) that reflect your **own understanding** and **intent**, not what an agent/LLM "spit out." + - This means that when the contribution is scrutinized by a maintainer, you need to be ready to argue about all aspects of it; simply responding that the contribution is what the LLM provided is completely unacceptable ## Unacceptable Use of AI diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 1e004571eac44..d105dc7001bd4 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -46,7 +46,7 @@ 2.0.2 4.1.1 2.17.0 - 3.17.1 + 3.17.2 4.3.0 4.3.0 2.18.0 @@ -882,11 +882,26 @@ quarkus-cyclonedx-deployment ${project.version} + + io.quarkus + quarkus-cyclonedx-deployment-spi + ${project.version} + io.quarkus quarkus-cyclonedx-generator ${project.version} + + io.quarkus + quarkus-cyclonedx-endpoint + ${project.version} + + + io.quarkus + quarkus-cyclonedx-endpoint-deployment + ${project.version} + io.quarkus quarkus-datasource-common diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index ee0409e212873..880342aa3c1d9 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -518,6 +518,19 @@ + + io.quarkus + quarkus-cyclonedx-endpoint + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-datasource diff --git a/devtools/gradle/gradle/libs.versions.toml b/devtools/gradle/gradle/libs.versions.toml index 0ece82d4d8383..db2296bdaad2f 100644 --- a/devtools/gradle/gradle/libs.versions.toml +++ b/devtools/gradle/gradle/libs.versions.toml @@ -2,7 +2,7 @@ plugin-publish = "2.1.1" kotlin = "2.3.10" -smallrye-config = "3.17.1" +smallrye-config = "3.17.2" junit = "6.0.3" assertj = "3.27.7" diff --git a/devtools/maven/src/main/java/io/quarkus/maven/DependencySbomMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/DependencySbomMojo.java index aa89cb0d970bb..d5a49af06af17 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/DependencySbomMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/DependencySbomMojo.java @@ -82,6 +82,12 @@ public class DependencySbomMojo extends AbstractMojo { @Parameter(property = "quarkus.dependency.sbom.schema-version") String schemaVersion; + /** + * Whether to pretty-print the generated SBOM output. The default is {@code false} + */ + @Parameter(property = "quarkus.dependency.sbom.pretty-print", defaultValue = "false") + boolean prettyPrint; + /** * Whether to limit application dependencies to only those that are included in the runtime */ @@ -107,6 +113,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { .setEffectiveModelResolver(EffectiveModelResolver.of(getResolver())) .setSchemaVersion(schemaVersion) .setIncludeLicenseText(includeLicenseText) + .setPrettyPrint(prettyPrint) .generate(); getLog().info("The SBOM has been saved in " + outputFilePath); } diff --git a/docs/pom.xml b/docs/pom.xml index 8534e96ca1e27..36256e37520b6 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -482,6 +482,19 @@ + + io.quarkus + quarkus-cyclonedx-endpoint-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-datasource-deployment diff --git a/docs/src/main/asciidoc/cyclonedx.adoc b/docs/src/main/asciidoc/cyclonedx.adoc index b75802b64e0ee..e128fc5323cbe 100644 --- a/docs/src/main/asciidoc/cyclonedx.adoc +++ b/docs/src/main/asciidoc/cyclonedx.adoc @@ -4,39 +4,49 @@ and pull requests should be submitted there: https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc //// [id="cyclonedx"] -= Generating CycloneDX BOMs += Generating and Using CycloneDX SBOMs include::_attributes.adoc[] :categories: tooling -:summary: This guide explains how to generate SBOMs for Quarkus applications in the CycloneDX format. +:summary: This guide explains how to generate and use SBOMs for Quarkus applications in the CycloneDX format. :topics: sbom :extensions: io.quarkus:quarkus-cyclonedx -An SBOM (Software Bill of Material) is a manifest that describes what a given software distribution consists of in terms of components. In addition to that, it may include a lot more information such as relationships between those components, licenses, provenance, etc. -SBOMs would typically be used by software security and software supply chain risk management tools to perform vulnerability and compliance related analysis. +An SBOM (Software Bill of Materials) is a manifest that describes what a given software distribution consists of in terms of components. In addition to that, it may include a lot more information such as relationships between those components, licenses, provenance, etc. +SBOMs are typically used by software security and software supply chain risk management tools to perform vulnerability and compliance related analysis. -This guide describes Quarkus SBOM generation capabilities following https://cyclonedx.org/[CycloneDX] specification. +This guide covers: + +* <> — generating SBOMs that manifest application dependencies before a build +* <> — embedding dependency SBOMs into applications as classpath resources +* <> — exposing embedded dependency SBOMs via a REST endpoint +* <> — generating SBOMs that manifest the final application distribution after a build +* <> — using generated SBOMs with tools such as Grype to identify known vulnerabilities + +All SBOM generation in this guide follows the https://cyclonedx.org/[CycloneDX] specification. == Why Quarkus-specific tooling? -While Quarkus integrates with build tools such as https://maven.apache.org/[Maven] and https://gradle.org/[Gradle], it could itself be categorized as a build tool with its own component and dependency model, build steps, and build outcomes. One of the essential component types of a Quarkus application is a Quarkus extension, which consists of a runtime and a build time artifacts, and their dependencies. +While Quarkus integrates with build tools such as https://maven.apache.org/[Maven] and https://gradle.org/[Gradle], it could itself be categorized as a build tool with its own component and dependency model, build steps, and build outcomes. One of the essential component types of a Quarkus application is a Quarkus extension, which consists of runtime and build time artifacts, and their dependencies. -To properly resolve Quarkus extension and other application dependencies Quarkus uses its own dependency resolver, which is implemented on top of the dependency resolver provided by the underlying build tool: Maven or Gradle. +To properly resolve Quarkus extension and other application dependencies, Quarkus uses its own dependency resolver, which is implemented on top of the dependency resolver provided by the underlying build tool: Maven or Gradle. -As a consequence, in case of Maven, for example, the results of `dependency:tree` will not include all the dependencies Quarkus will use to build an application. A similar issue will affect other dependency analysis tools that assume a project adheres to the standard Maven dependency model: they will not be able to capture the effective Quarkus application dependency graph. Unfortunately, that includes the implementation of the https://github.com/CycloneDX/cyclonedx-maven-plugin[CycloneDX Maven plugin]. +As a consequence, in the case of Maven, for example, the results of `dependency:tree` will not include all the dependencies Quarkus will use to build an application. A similar issue will affect other dependency analysis tools that assume a project adheres to the standard Maven dependency model: they will not be able to capture the effective Quarkus application dependency graph. Unfortunately, that includes the implementation of the https://github.com/CycloneDX/cyclonedx-maven-plugin[CycloneDX Maven plugin]. -Besides the dependencies, that are an input to a build process, there is also an outcome of the build that is the final distribution of an application. Users of an application may request an SBOM manifesting not only the dependencies (the input to a build) but also the final distribution (the outcome of the build) before they agree to deploy the application. Quarkus allows application developers to choose various packaging types for their applications, some of which are Quarkus-specific. Providing certain Quarkus-specific details about components included in a distribution may help better evaluate the impact of potential security-related issues. +In addition to manifesting dependencies (the input to a build), there is also an outcome of the build that is the final distribution of an application. Users of an application may request an SBOM manifesting not only the dependencies (the input to a build) but also the final distribution (the outcome of the build) before they agree to deploy the application. Quarkus allows application developers to choose various packaging types for their applications, some of which are Quarkus-specific. Providing certain Quarkus-specific details about components included in a distribution may help better evaluate the impact of potential security-related issues. +[[dependency-sboms]] == Dependency SBOMs -This chapter describes how to generate SBOMs manifesting only the dependencies of an application before it is built. In other words, these SBOMs will manifest the input into a build. These SBOMs could be used to perform vulnerability and compliance related analysis before building applications. +Dependency SBOMs manifest the dependencies of an application before it is built. In other words, they describe the input to a build. These SBOMs can be used to perform vulnerability and compliance related analysis before building applications. === Maven Dependency SBOMs -For Quarkus Maven projects dependency SBOMs can be generated with the `quarkus:dependency-sbom` goal. The outcome of the goal will be saved in a `target/--dependency-cyclonedx.json` file (which can be changed by setting the `outputFile` goal parameter or the `quarkus.dependency.sbom.output-file` property). The complete Quarkus build and runtime dependency graphs will be recorded in the https://cyclonedx.org/[CycloneDX] `JSON` format. +For Quarkus Maven projects, dependency SBOMs can be generated with the `quarkus:dependency-sbom` goal. The output of the goal will be saved in a `target/--dependency-cyclonedx.json` file (which can be changed by setting the `outputFile` goal parameter or the `quarkus.dependency.sbom.output-file` property). The complete Quarkus build and runtime dependency graphs will be recorded in the https://cyclonedx.org/[CycloneDX] `JSON` format. + +`XML` format can be requested by setting the `format` goal parameter (or `quarkus.dependency.sbom.format` property) to `xml`. -`XML` format can be requested by setting `format` goal parameter (or `quarkus.dependency.sbom.format` property) to `xml`. +Each component in the generated SBOM will include the `quarkus:component:scope` property indicating whether the component is used at runtime or only at development/build time. -Each component in the generated SBOM will include the `quarkus:component:scope` property that will indicate whether this component is used at runtime or only development/build time. [source,json] ---- { @@ -45,15 +55,19 @@ Each component in the generated SBOM will include the `quarkus:component:scope` } ---- -By default, `quarkus:dependency-sbom` captures the dependencies of a production build. Quarkus supports three application bootstrap modes: normal (production), test, and dev. In all three modes, an application may have different dependency graphs. The `mode` parameter can be used to indicate which dependency graph should be recorded. If the `mode` is set to `test` or `dev`, the target file name will become `target/---dependency-cyclonedx.json`. - The complete set of parameters and their description can be obtained by executing `mvn help:describe -Dcmd=quarkus:dependency-sbom -Ddetail`. +==== Bootstrap Modes + +By default, `quarkus:dependency-sbom` captures the dependencies of a production build. Quarkus supports three application bootstrap modes: normal (production), test, and dev. In all three modes, an application may have different dependency graphs. + +The `mode` parameter can be used to indicate which dependency graph should be recorded. If the `mode` is set to `test` or `dev`, the target file name will become `target/---dependency-cyclonedx.json`. + === Gradle Dependency SBOMs Unlike Maven, the https://github.com/CycloneDX/cyclonedx-gradle-plugin[Gradle CycloneDX plugin implementation] can be used in Quarkus projects to generate dependency SBOMs, since the implementation manifests dependency configurations registered by configured plugins. -Please, refer to the https://github.com/CycloneDX/cyclonedx-gradle-plugin[Gradle CycloneDX plugin] documentation for its configuration options. Here is a list of Quarkus dependency configurations that would be relevant for manifesting: +Please refer to the https://github.com/CycloneDX/cyclonedx-gradle-plugin[Gradle CycloneDX plugin] documentation for its configuration options. Here is a list of Quarkus dependency configurations that would be relevant for manifesting: * `quarkusProdRuntimeClasspathConfiguration` - Quarkus application production runtime dependencies; * `quarkusProdRuntimeClasspathConfigurationDeployment` - Quarkus application production runtime and build time dependencies; @@ -62,18 +76,135 @@ Please, refer to the https://github.com/CycloneDX/cyclonedx-gradle-plugin[Gradle * `quarkusDevRuntimeClasspathConfiguration` - Quarkus application dev mode runtime dependencies; * `quarkusDevRuntimeClasspathConfigurationDeployment` - Quarkus application dev mode runtime and build time dependencies. -Given that the plugin is not aware of how Quarkus uses these dependencies, it will not be able to set the `quarkus:component:scope` property for components. On the other hand, the requested configuration name can be used indicate which scope to target. +For example, to generate an SBOM for the production runtime dependencies only: + +[source,groovy] +---- +cyclonedxBom { + includeConfigs = ["quarkusProdRuntimeClasspathConfiguration"] +} +---- + +Given that the plugin is not aware of how Quarkus uses these dependencies, it will not be able to set the `quarkus:component:scope` property for components. On the other hand, the requested configuration name can be used to indicate which scope to target. + +[[embedded-dependency-sboms]] +== Embedded Dependency SBOMs + +In addition to generating dependency SBOMs as separate files, Quarkus can embed a dependency SBOM directly into the built application as a classpath resource. This allows the application to carry its own bill of materials at runtime, which can be useful for runtime auditing or to expose via a REST endpoint (see <>). + +To embed a dependency SBOM in the application: + +[source,properties] +---- +quarkus.cyclonedx.embedded.enabled=true +---- + +The SBOM will be embedded as a classpath resource at `META-INF/sbom/dependency.cdx.json` by default. The resource name can be customized: + +[source,properties] +---- +quarkus.cyclonedx.embedded.resource-name=META-INF/custom-sbom.json +---- + +The format is determined by the resource name extension: `.json` for JSON, `.xml` for XML. + +By default, the embedded SBOM is compressed with GZIP to reduce the application size. The classpath resource name will have a `.gz` extension appended (e.g., `META-INF/sbom/dependency.cdx.json.gz`). If the SBOM is exposed through the <>, it will be served with `Content-Encoding: gzip`, so HTTP clients will decompress it transparently. Compression can be disabled with: + +[source,properties] +---- +quarkus.cyclonedx.embedded.compress=false +---- + +[NOTE] +==== +Embedded dependency SBOMs are also available when running in dev mode (`quarkus dev`). +==== + +[[sbom-endpoint]] +== SBOM REST Endpoint + +The embedded dependency SBOM can be exposed through a REST endpoint, making it accessible to external tools and scanners at runtime. To enable the endpoint: + +[source,properties] +---- +quarkus.cyclonedx.endpoint.enabled=true +---- + +The embedded SBOM will be served at `/.well-known/sbom` with the `application/vnd.cyclonedx+json` content type (or `application/vnd.cyclonedx+xml` for XML SBOMs). +Since the embedded SBOM is compressed by default, the response will include `Content-Encoding: gzip`, which HTTP clients handle transparently. +The path can be customized using the `quarkus.cyclonedx.endpoint.path` property. + +Enabling the endpoint will automatically trigger SBOM embedding, even if `quarkus.cyclonedx.embedded.enabled` is not explicitly set to `true`. + +[NOTE] +==== +The `quarkus.cyclonedx.endpoint.*` configuration options are only available when `quarkus-vertx-http` is present as a direct or transitive dependency of the application, which is the case for most web applications (e.g., those using `quarkus-rest`). +==== + +[CAUTION] +==== +Exposing an SBOM endpoint allows anyone with access to it to perform composition analysis of the application, potentially revealing dependency versions with known vulnerabilities. +The endpoint should be restricted to approved consumers only by applying appropriate authentication and authorization policies. +==== + +For an example of scanning a running application through the endpoint, see <>. + +The SBOM endpoint is also available when running in dev mode (`quarkus dev`), which can be useful for scanning during development. +=== Management Interface + +When the xref:management-interface-reference.adoc[management interface] is enabled, the SBOM endpoint is automatically served on the management port (default `9000`) instead of the main application port: + +[source,properties] +---- +quarkus.management.enabled=true +---- + +The SBOM will then be accessible at `http://localhost:9000/.well-known/sbom`. +Authentication and authorization policies can be applied to the management interface to restrict access — see xref:management-interface-reference.adoc[Management Interface] for details. + +[[distribution-sboms]] == Distribution SBOMs -This chapter describes SBOMs that manifest outcomes of Quarkus builds that are final application distributions. +Distribution SBOMs manifest the outcomes of Quarkus builds — the final application distributions. Unlike dependency SBOMs, which describe the input to a build, distribution SBOMs describe what was actually produced. + +During the build and package assembly process, Quarkus captures details about the produced distribution and allows an SBOM generator to record that information in an SBOM format. -During an application build and package assembly process, Quarkus captures certain details about the produced distribution and then allows an SBOM generator to consume and record that information in an SBOM format. +To generate CycloneDX distribution SBOMs, add `io.quarkus:quarkus-cyclonedx` as a project dependency. It will generate SBOMs every time an application is built. SBOMs will be saved in the project's build output directory under `-cyclonedx.` name, where -At this point, the only SBOM generator available for Quarkus users that can manifest application distributions is `io.quarkus:quarkus-cyclonedx`. Once it's added as a project dependency it will generate SBOMs every time an application is built. SBOMs will be saved in the project's build output directory under `-cyclonedx.` name, where +* `` is the base file name (without the extension) of the executable that launches the application; +* `` is either `json` (the default) or `xml`, which can be configured using the `quarkus.cyclonedx.format` property. If both formats are desired, `quarkus.cyclonedx.format` can be set to `all`. -* `` is the base file name (without the extension) of the executable that launches an application; -* `` is either `json` (the default) or `xml`, which can be configured using `quarkus.cyclonedx.format` property. If both formats are desired `quarkus.cyclonedx.format` can be set to `all`. +By default, generated JSON SBOMs are not pretty-printed. To enable pretty-printing: + +[source,properties] +---- +quarkus.cyclonedx.pretty-print=true +---- + +=== Pedigree + +Pedigree is a way to provide information that patches or modifications have been applied to a component. + +An application developer may instruct Quarkus to remove some resources or classes from specific dependencies to exclude them from the application classpath. Manipulating the original content of an artifact will change its digest, which may get highlighted as suspicious by the tools comparing original component digest to the one found in the distribution. + +In such cases, when Quarkus generates an SBOM, it will add a pedigree note for a modified artifact documenting what content was removed from the artifact. + +For example, if an application developer decided to remove certain classpath resources from a dependency, such as + +[source,properties] +---- +quarkus.class-loading.removed-resources."jakarta.transaction\:jakarta.transaction-api"=META-INF/NOTICE.md,jakarta/transaction/package.html +---- + +The resulting SBOM will include +[source,json] +---- + "purl" : "pkg:maven/jakarta.transaction/jakarta.transaction-api@2.0.1?type=jar", + "pedigree" : { + "notes" : "Removed META-INF/NOTICE.md,jakarta/transaction/package.html" + }, +---- === Fast JAR @@ -83,7 +214,7 @@ SBOMs for Fast JAR packaging type will use the executable JAR file as their main ==== Runtime Components -Every file in the resulting Fast JAR distribution will appear in the SBOM with the `quarkus:component:scope` property set to `runtime` and `evidence.occurrences.location` field pointing to the location of the component in the application distribution directory, for example +Every file in the resulting Fast JAR distribution will appear in the SBOM with the `quarkus:component:scope` property set to `runtime` and `evidence.occurrences.location` field pointing to the location of the component in the application distribution directory, for example: [source,json] ---- @@ -103,30 +234,10 @@ Every file in the resulting Fast JAR distribution will appear in the SBOM with t } ---- -NOTE: `evidence.occurrences.location` was introduced in CycloneDX schema version 1.5, for older versions the location will be indicated using the `quarkus:component:location` property. - -==== Pedigree - -Pedigree is a way to provide information that certain patches, or changes in general, have been applied to a certain component. - -In certain cases, Quarkus may copy modified versions of dependency artifacts to an application distribution. Manipulating the original content of a component will change its hash sums which may get highlighted as suspicious by the tools comparing original component hash sums to those found in the distribution. - -When Quarkus applies modifications to artifacts resolved from Maven repositories, it can manifest these changes as pedigree notes in the generated SBOM. -For example, if an application developer decided to remove certain classpath resources from a dependency, such as - -[source,properties] ----- -quarkus.class-loading.removed-resources."jakarta.transaction\:jakarta.transaction-api"=META-INF/NOTICE.md,jakarta/transaction/package.html ----- - -The resulting SBOM will include -[source,json] ----- - "purl" : "pkg:maven/jakarta.transaction/jakarta.transaction-api@2.0.1?type=jar", - "pedigree" : { - "notes" : "Removed META-INF/NOTICE.md,jakarta/transaction/package.html" - }, ----- +[NOTE] +==== +`evidence.occurrences.location` was introduced in CycloneDX schema version 1.5, for older versions the location will be indicated using the `quarkus:component:location` property. +==== ==== Build time dependencies @@ -149,7 +260,18 @@ They will not include `evidence.occurrences.location` since they will not be fou SBOMs for Uber JARs will use the Uber JAR Maven artifact as their main component. -Since an Uber JAR is published as a Maven artifact itself, SBOMs generated for Uber JARs will also be automatically published as Maven artifacts. This, however, can be disabled by setting the `attachSboms` parameter of the `quarkus:build` goal to `false`. +Since an Uber JAR is published as a Maven artifact itself, SBOMs generated for Uber JARs will also be automatically published as Maven artifacts. This can be disabled by setting the `attachSboms` parameter of the `quarkus:build` goal to `false`: + +[source,xml] +---- + + ${quarkus.platform.group-id} + quarkus-maven-plugin + + false + + +---- Gradle users will have to explicitly configure a publishing plugin to deploy SBOMs as Maven artifacts. @@ -161,7 +283,7 @@ SBOMs for native images will use the native executable file as their main compon Since native executables are not currently attached to projects as Maven artifacts, their SBOMs will not be attached as Maven artifacts either. -As in the case of an Uber JAR, runtime components in an SBOM generated for an native executable will not include `evidence.occurrences.location` since their corresponding code and resources are included in a single native executable file. +As in the case of an Uber JAR, runtime components in an SBOM generated for a native executable will not include `evidence.occurrences.location` since their corresponding code and resources are included in a single native executable file. === Mutable JAR @@ -187,7 +309,152 @@ SBOMs generated for Mutable JAR distributions will also record locations of comp } ---- -== Quarkus Property Taxonomy +[[vulnerability-scanning]] +== Vulnerability Scanning with SBOMs + +SBOMs can be fed into vulnerability scanners such as https://github.com/anchore/grype[Grype] to identify known vulnerabilities in application dependencies. This section demonstrates how to use Quarkus-generated SBOMs with Grype. + +=== Sample Project Setup + +Create a sample project with a REST endpoint, CycloneDX SBOM generation, and `org.codehaus.plexus:plexus-utils:3.6.0` — a dependency with a known security vulnerability added here on purpose for demonstration: + +[source,bash,role="primary asciidoc-tabs-sync-cli"] +.CLI +---- +quarkus create app org.acme:my-rest-app \ + --extension='rest,cyclonedx,org.codehaus.plexus:plexus-utils:3.6.0' +cd my-rest-app +---- + +[source,bash,role="secondary asciidoc-tabs-sync-maven"] +.Maven +---- +mvn io.quarkus.platform:quarkus-maven-plugin:create \ + -DprojectGroupId=org.acme \ + -DprojectArtifactId=my-rest-app \ + -Dextensions='rest,cyclonedx,org.codehaus.plexus:plexus-utils:3.6.0' +cd my-rest-app +---- + +[source,bash,role="secondary asciidoc-tabs-sync-gradle"] +.Gradle +---- +quarkus create app org.acme:my-rest-app \ + --extension='rest,cyclonedx,org.codehaus.plexus:plexus-utils:3.6.0' \ + --gradle +cd my-rest-app +---- + +=== Scanning Distribution SBOMs + +Build the application: + +[source,bash,role="primary asciidoc-tabs-sync-cli asciidoc-tabs-sync-maven"] +.Maven +---- +./mvnw package +---- + +[source,bash,role="secondary asciidoc-tabs-sync-gradle"] +.Gradle +---- +./gradlew build +---- + +Scan the generated distribution SBOM: + +[source,bash,role="primary asciidoc-tabs-sync-cli asciidoc-tabs-sync-maven"] +.Maven +---- +grype target/quarkus-run-cyclonedx.json +---- + +[source,bash,role="secondary asciidoc-tabs-sync-gradle"] +.Gradle +---- +grype build/quarkus-run-cyclonedx.json +---- + +Grype should report the known vulnerability in `plexus-utils:3.6.0`: + +[source] +---- +NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK +plexus-utils 3.6.0 3.6.1 java-archive GHSA-6fmv-xxpf-w3cw High 0.2% (47th) 0.2 +---- + +[[scanning-via-endpoint]] +=== Scanning via the SBOM Endpoint + +With the SBOM endpoint enabled, a running application can be scanned without access to the build output. This works regardless of the packaging type, including JARs and native executables. + +Enable the endpoint by adding the following property to `src/main/resources/application.properties`: + +[source,properties] +---- +quarkus.cyclonedx.endpoint.enabled=true +---- + +This can also be done from the command line: + +[source,bash,role="primary"] +.Linux / macOS +---- +echo 'quarkus.cyclonedx.endpoint.enabled=true' >> src/main/resources/application.properties +---- + +[source,powershell,role="secondary"] +.Windows (PowerShell) +---- +Add-Content src\main\resources\application.properties 'quarkus.cyclonedx.endpoint.enabled=true' +---- + +Build the application: + +[source,bash,role="primary asciidoc-tabs-sync-cli asciidoc-tabs-sync-maven"] +.Maven +---- +./mvnw package +---- + +[source,bash,role="secondary asciidoc-tabs-sync-gradle"] +.Gradle +---- +./gradlew build +---- + +Start the application: + +[source,bash,role="primary asciidoc-tabs-sync-cli asciidoc-tabs-sync-maven"] +.Maven +---- +java -jar target/quarkus-app/quarkus-run.jar +---- + +[source,bash,role="secondary asciidoc-tabs-sync-gradle"] +.Gradle +---- +java -jar build/quarkus-app/quarkus-run.jar +---- + +In another terminal, scan the running application: + +[source,bash] +---- +curl -s http://localhost:8080/.well-known/sbom | grype +---- + +The output should be the same as when scanning the distribution SBOM: + +[source] +---- +NAME INSTALLED FIXED IN TYPE VULNERABILITY SEVERITY EPSS RISK +plexus-utils 3.6.0 3.6.1 java-archive GHSA-6fmv-xxpf-w3cw High 0.2% (47th) 0.2 +---- + +This approach is useful for scanning applications that are already deployed, for example as part of a periodic audit or a CI/CD pipeline that verifies running instances. + +== SBOM Component Properties [cols="1,1,1"] |=== @@ -195,4 +462,4 @@ SBOMs generated for Mutable JAR distributions will also record locations of comp |`quarkus:component:scope` |`runtime` or `development` |Indicates whether a component is a runtime or a build/development time dependency of an application. |`quarkus:component:location` |String representing a file system path using `/` as a path element |Used in SBOMs with schema versions 1.4 or older. Starting from schema 1.5, `evidence.occurrences.location` is used instead. This property is used only if a component is found in the distribution. The value is a relative path to a file pointing to the location of a component in a distribution using `/` as a path element separator. -|=== \ No newline at end of file +|=== diff --git a/extensions/cyclonedx/deployment-spi/pom.xml b/extensions/cyclonedx/deployment-spi/pom.xml new file mode 100644 index 0000000000000..db18f28707b0f --- /dev/null +++ b/extensions/cyclonedx/deployment-spi/pom.xml @@ -0,0 +1,22 @@ + + + + quarkus-cyclonedx-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-cyclonedx-deployment-spi + Quarkus - CycloneDX - Deployment SPI + + + + io.quarkus + quarkus-core-deployment + + + + diff --git a/extensions/cyclonedx/deployment-spi/src/main/java/io/quarkus/cyclonedx/deployment/spi/EmbeddedSbomMetadataBuildItem.java b/extensions/cyclonedx/deployment-spi/src/main/java/io/quarkus/cyclonedx/deployment/spi/EmbeddedSbomMetadataBuildItem.java new file mode 100644 index 0000000000000..45df228ab317f --- /dev/null +++ b/extensions/cyclonedx/deployment-spi/src/main/java/io/quarkus/cyclonedx/deployment/spi/EmbeddedSbomMetadataBuildItem.java @@ -0,0 +1,32 @@ +package io.quarkus.cyclonedx.deployment.spi; + +import io.quarkus.builder.item.SimpleBuildItem; + +/** + * Produced by the CycloneDX build step after embedding a dependency SBOM, + * carrying metadata about the embedded resource. + */ +public final class EmbeddedSbomMetadataBuildItem extends SimpleBuildItem { + + private final String resourceName; + private final boolean compressed; + + public EmbeddedSbomMetadataBuildItem(String resourceName, boolean compressed) { + this.resourceName = resourceName; + this.compressed = compressed; + } + + /** + * The classpath resource name of the embedded SBOM. + */ + public String getResourceName() { + return resourceName; + } + + /** + * Whether the embedded SBOM resource is GZIP-compressed. + */ + public boolean isCompressed() { + return compressed; + } +} diff --git a/extensions/cyclonedx/deployment-spi/src/main/java/io/quarkus/cyclonedx/deployment/spi/EmbeddedSbomRequestBuildItem.java b/extensions/cyclonedx/deployment-spi/src/main/java/io/quarkus/cyclonedx/deployment/spi/EmbeddedSbomRequestBuildItem.java new file mode 100644 index 0000000000000..6a1e3958e3826 --- /dev/null +++ b/extensions/cyclonedx/deployment-spi/src/main/java/io/quarkus/cyclonedx/deployment/spi/EmbeddedSbomRequestBuildItem.java @@ -0,0 +1,10 @@ +package io.quarkus.cyclonedx.deployment.spi; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Produced by extensions that require an embedded dependency SBOM to be generated + * as a classpath resource. + */ +public final class EmbeddedSbomRequestBuildItem extends MultiBuildItem { +} diff --git a/extensions/cyclonedx/deployment/pom.xml b/extensions/cyclonedx/deployment/pom.xml index 6ec2bd5e0da21..fe5a0ff3e78f6 100644 --- a/extensions/cyclonedx/deployment/pom.xml +++ b/extensions/cyclonedx/deployment/pom.xml @@ -31,6 +31,15 @@ io.quarkus quarkus-cyclonedx-generator + + io.quarkus + quarkus-cyclonedx-deployment-spi + + + io.quarkus + quarkus-cyclonedx-endpoint-deployment + true + diff --git a/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CdxSbomBuildStep.java b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CdxSbomBuildStep.java deleted file mode 100644 index 88bb44675c203..0000000000000 --- a/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CdxSbomBuildStep.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.quarkus.cyclonedx.deployment; - -import io.quarkus.cyclonedx.generator.CycloneDxSbomGenerator; -import io.quarkus.deployment.annotations.BuildProducer; -import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.builditem.AppModelProviderBuildItem; -import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; -import io.quarkus.deployment.sbom.ApplicationManifestsBuildItem; -import io.quarkus.deployment.sbom.SbomBuildItem; - -/** - * Generates SBOMs for packaged applications if the corresponding config is enabled. - * The API around this is still in development and will likely change in the near future. - */ -public class CdxSbomBuildStep { - - /** - * Generates CycloneDX SBOMs from application manifests. - * - * @param applicationManifestsBuildItem application manifests - * @param outputTargetBuildItem build output - * @param appModelProviderBuildItem application model provider - * @param cdxSbomConfig CycloneDX SBOM generation configuration - * @param sbomProducer SBOM build item producer - */ - @BuildStep - public void generate(ApplicationManifestsBuildItem applicationManifestsBuildItem, - OutputTargetBuildItem outputTargetBuildItem, - AppModelProviderBuildItem appModelProviderBuildItem, - CycloneDxConfig cdxSbomConfig, - BuildProducer sbomProducer) { - if (cdxSbomConfig.skip() || applicationManifestsBuildItem.getManifests().isEmpty()) { - // until there is a proper way to request the desired build items as build outcome - return; - } - var depInfoProvider = appModelProviderBuildItem.getDependencyInfoProvider().get(); - for (var manifest : applicationManifestsBuildItem.getManifests()) { - for (var sbom : CycloneDxSbomGenerator.newInstance() - .setManifest(manifest) - .setOutputDirectory(outputTargetBuildItem.getOutputDirectory()) - .setEffectiveModelResolver(depInfoProvider == null ? null : depInfoProvider.getMavenModelResolver()) - .setFormat(cdxSbomConfig.format()) - .setSchemaVersion(cdxSbomConfig.schemaVersion().orElse(null)) - .setIncludeLicenseText(cdxSbomConfig.includeLicenseText()) - .generate()) { - sbomProducer.produce(new SbomBuildItem(sbom)); - } - } - } -} diff --git a/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CycloneDxConfig.java b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CycloneDxConfig.java index bf0728b4bd2d6..0c18b72306dd0 100644 --- a/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CycloneDxConfig.java +++ b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CycloneDxConfig.java @@ -2,6 +2,7 @@ import java.util.Optional; +import io.quarkus.runtime.annotations.ConfigDocSection; import io.quarkus.runtime.annotations.ConfigRoot; import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; @@ -13,10 +14,11 @@ @ConfigRoot public interface CycloneDxConfig { /** - * Whether to skip SBOM generation + * Whether to CycloneDX SBOM generation is enabled. + * If this option is false, the rest of the configuration will be ignored. */ - @WithDefault("false") - boolean skip(); + @WithDefault("true") + boolean enabled(); /** * SBOM file format. Supported formats are {code json} and {code xml}. @@ -42,4 +44,54 @@ public interface CycloneDxConfig { */ @WithDefault("false") boolean includeLicenseText(); + + /** + * Whether to pretty-print the generated SBOM output. + * + * @return whether to pretty-print the generated SBOM output + */ + @WithDefault("false") + boolean prettyPrint(); + + /** + * Embedded dependency SBOM configuration + */ + @ConfigDocSection + EmbeddedSbomConfig embedded(); + + /** + * Embedded dependency SBOM configuration + */ + interface EmbeddedSbomConfig { + + /** + * Whether a dependency SBOM should be embedded in the final application. + * + * @return true, if dependency SBOM should be embedded in the final application, false - otherwise + */ + @WithDefault("false") + boolean enabled(); + + /** + * Base resource name for the embedded dependency SBOM. + * If {@link #compress()} is enabled, the actual classpath resource name will + * have a {@code .gz} extension appended (e.g., {@code META-INF/sbom/dependency.cdx.json.gz}). + * + * @return base resource name for the embedded dependency SBOM + */ + @WithDefault("META-INF/sbom/dependency.cdx.json") + String resourceName(); + + /** + * Whether to compress the embedded SBOM with GZIP. + * When enabled, the SBOM will be stored compressed in the application + * with a {@code .gz} extension appended to the {@link #resourceName()}, + * and served compressed through the endpoint with {@code Content-Encoding: gzip}. + * + * @return whether to compress the embedded SBOM with GZIP + */ + @WithDefault("true") + boolean compress(); + } + } diff --git a/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CycloneDxProcessor.java b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CycloneDxProcessor.java new file mode 100644 index 0000000000000..efca22cb66c50 --- /dev/null +++ b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/CycloneDxProcessor.java @@ -0,0 +1,133 @@ +package io.quarkus.cyclonedx.deployment; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.zip.GZIPOutputStream; + +import io.quarkus.bootstrap.app.DependencyInfoProvider; +import io.quarkus.cyclonedx.deployment.spi.EmbeddedSbomMetadataBuildItem; +import io.quarkus.cyclonedx.deployment.spi.EmbeddedSbomRequestBuildItem; +import io.quarkus.cyclonedx.generator.CycloneDxSbomGenerator; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.AppModelProviderBuildItem; +import io.quarkus.deployment.builditem.GeneratedResourceBuildItem; +import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; +import io.quarkus.deployment.pkg.builditem.OutputTargetBuildItem; +import io.quarkus.deployment.sbom.ApplicationManifestsBuildItem; +import io.quarkus.deployment.sbom.SbomBuildItem; +import io.quarkus.sbom.ApplicationManifest; +import io.quarkus.sbom.ApplicationManifestConfig; + +/** + * Generates SBOMs for packaged applications if the corresponding config is enabled. + * The API around this is still in development and will likely change in the near future. + */ +public class CycloneDxProcessor { + + /** + * Generates CycloneDX SBOMs from application manifests. + * + * @param applicationManifestsBuildItem application manifests + * @param outputTargetBuildItem build output + * @param appModelProviderBuildItem application model provider + * @param cdxSbomConfig CycloneDX SBOM generation configuration + * @param sbomProducer SBOM build item producer + */ + @BuildStep + public void generate(ApplicationManifestsBuildItem applicationManifestsBuildItem, + OutputTargetBuildItem outputTargetBuildItem, + AppModelProviderBuildItem appModelProviderBuildItem, + CycloneDxConfig cdxSbomConfig, + BuildProducer sbomProducer) { + if (!cdxSbomConfig.enabled() || applicationManifestsBuildItem.getManifests().isEmpty()) { + // until there is a proper way to request the desired build items as build outcome + return; + } + var depInfoProvider = getDependencyInfoProvider(appModelProviderBuildItem); + for (var manifest : applicationManifestsBuildItem.getManifests()) { + for (var sbom : CycloneDxSbomGenerator.newInstance() + .setManifest(manifest) + .setOutputDirectory(outputTargetBuildItem.getOutputDirectory()) + .setEffectiveModelResolver(depInfoProvider == null ? null : depInfoProvider.getMavenModelResolver()) + .setFormat(cdxSbomConfig.format()) + .setSchemaVersion(cdxSbomConfig.schemaVersion().orElse(null)) + .setIncludeLicenseText(cdxSbomConfig.includeLicenseText()) + .setPrettyPrint(cdxSbomConfig.prettyPrint()) + .generate()) { + sbomProducer.produce(new SbomBuildItem(sbom)); + } + } + } + + private static DependencyInfoProvider getDependencyInfoProvider(AppModelProviderBuildItem appModelProviderBuildItem) { + var supplier = appModelProviderBuildItem.getDependencyInfoProvider(); + return supplier == null ? null : supplier.get(); + } + + @BuildStep + public void embedDependencySbom(BuildProducer generatedResourceBuildItem, + BuildProducer embeddedSbomMetadataProducer, + CycloneDxConfig cdxConfig, + CurateOutcomeBuildItem curateOutcomeBuildItem, + AppModelProviderBuildItem appModelProviderBuildItem, + List embeddedSbomRequests) { + if (!cdxConfig.enabled() || !cdxConfig.embedded().enabled() && embeddedSbomRequests.isEmpty()) { + return; + } + + final CycloneDxConfig.EmbeddedSbomConfig dependencySbomConfig = cdxConfig.embedded(); + final String resourceName = dependencySbomConfig.resourceName(); + if (resourceName == null || resourceName.isEmpty()) { + throw new IllegalArgumentException("resourceName is not configured for the embedded dependency SBOM"); + } + + var depInfoProvider = getDependencyInfoProvider(appModelProviderBuildItem); + List result = CycloneDxSbomGenerator.newInstance() + .setManifest(ApplicationManifest.fromConfig(ApplicationManifestConfig.builder() + .setApplicationModel(curateOutcomeBuildItem.getApplicationModel()) + .build())) + .setEffectiveModelResolver(depInfoProvider == null ? null : depInfoProvider.getMavenModelResolver()) + .setFormat(getFormat(resourceName)) + .setSchemaVersion(cdxConfig.schemaVersion().orElse(null)) + .setIncludeLicenseText(cdxConfig.includeLicenseText()) + .setPrettyPrint(cdxConfig.prettyPrint()) + .generateText(); + + if (result.size() != 1) { + // this should never happen since the format is derived from the resourceName + throw new RuntimeException( + "Embedded dependency SBOM has more than 1 result for configured resource " + resourceName); + } + + byte[] sbomBytes = result.get(0).getBytes(StandardCharsets.UTF_8); + final boolean compressed = dependencySbomConfig.compress(); + String effectiveResourceName = resourceName; + if (compressed) { + sbomBytes = gzip(sbomBytes); + if (!resourceName.endsWith(".gz")) { + effectiveResourceName = resourceName + ".gz"; + } + } + + generatedResourceBuildItem.produce(new GeneratedResourceBuildItem(effectiveResourceName, sbomBytes)); + embeddedSbomMetadataProducer.produce(new EmbeddedSbomMetadataBuildItem(effectiveResourceName, compressed)); + } + + private static byte[] gzip(byte[] data) { + var baos = new ByteArrayOutputStream(data.length); + try (var gzos = new GZIPOutputStream(baos)) { + gzos.write(data); + } catch (IOException e) { + throw new RuntimeException("Failed to GZIP-compress the embedded SBOM", e); + } + return baos.toByteArray(); + } + + private static String getFormat(String resourceName) { + int lastDot = resourceName.lastIndexOf('.'); + return lastDot == -1 ? "json" : resourceName.substring(lastDot + 1); + } +} diff --git a/extensions/cyclonedx/endpoint/deployment/pom.xml b/extensions/cyclonedx/endpoint/deployment/pom.xml new file mode 100644 index 0000000000000..624f3f3cfd011 --- /dev/null +++ b/extensions/cyclonedx/endpoint/deployment/pom.xml @@ -0,0 +1,57 @@ + + + + quarkus-cyclonedx-endpoint-parent + io.quarkus + 999-SNAPSHOT + + + 4.0.0 + + quarkus-cyclonedx-endpoint-deployment + Quarkus - CycloneDX - Endpoint - Deployment + + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-vertx-http-deployment + + + io.quarkus + quarkus-cyclonedx-deployment-spi + + + io.quarkus + quarkus-cyclonedx-endpoint + + + + + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + + + \ No newline at end of file diff --git a/extensions/cyclonedx/endpoint/deployment/src/main/java/io/quarkus/cyclonedx/endpoint/deployment/CycloneDxEndpointConfig.java b/extensions/cyclonedx/endpoint/deployment/src/main/java/io/quarkus/cyclonedx/endpoint/deployment/CycloneDxEndpointConfig.java new file mode 100644 index 0000000000000..7b151a8cda2ea --- /dev/null +++ b/extensions/cyclonedx/endpoint/deployment/src/main/java/io/quarkus/cyclonedx/endpoint/deployment/CycloneDxEndpointConfig.java @@ -0,0 +1,30 @@ +package io.quarkus.cyclonedx.endpoint.deployment; + +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * CycloneDX SBOM endpoint configuration + */ +@ConfigMapping(prefix = "quarkus.cyclonedx.endpoint") +@ConfigRoot +public interface CycloneDxEndpointConfig { + + /** + * Whether the embedded SBOM should be exposed through a REST endpoint. + * + * @return true, if the embedded SBOM should be exposed through a REST endpoint, otherwise - false + */ + @WithDefault("false") + boolean enabled(); + + /** + * REST endpoint path that will provide an SBOM + * + * @return REST endpoint path that will provide an SBOM + */ + @WithDefault("/.well-known/sbom") + String path(); + +} diff --git a/extensions/cyclonedx/endpoint/deployment/src/main/java/io/quarkus/cyclonedx/endpoint/deployment/CycloneDxEndpointProcessor.java b/extensions/cyclonedx/endpoint/deployment/src/main/java/io/quarkus/cyclonedx/endpoint/deployment/CycloneDxEndpointProcessor.java new file mode 100644 index 0000000000000..2b0cda48c5b3c --- /dev/null +++ b/extensions/cyclonedx/endpoint/deployment/src/main/java/io/quarkus/cyclonedx/endpoint/deployment/CycloneDxEndpointProcessor.java @@ -0,0 +1,89 @@ +package io.quarkus.cyclonedx.endpoint.deployment; + +import java.util.Optional; + +import io.quarkus.cyclonedx.deployment.spi.EmbeddedSbomMetadataBuildItem; +import io.quarkus.cyclonedx.deployment.spi.EmbeddedSbomRequestBuildItem; +import io.quarkus.cyclonedx.endpoint.runtime.CycloneDxEndpointRecorder; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBuildItem; +import io.quarkus.vertx.http.deployment.spi.RouteBuildItem; +import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; + +/** + * Build processor that exposes an embedded CycloneDX SBOM via an HTTP endpoint. + *

+ * When enabled, this processor requests an embedded SBOM to be generated at build time + * and registers a Vert.x route that serves the SBOM content. The endpoint is registered + * on the management interface when management is enabled, otherwise as an absolute route. + */ +public class CycloneDxEndpointProcessor { + + /** + * Signals that an embedded SBOM should be generated by producing + * an {@link EmbeddedSbomRequestBuildItem} when the endpoint is enabled. + */ + @BuildStep + void requestEmbeddedSbom(CycloneDxEndpointConfig config, + BuildProducer producer) { + if (config.enabled()) { + producer.produce(new EmbeddedSbomRequestBuildItem()); + } + } + + /** + * Registers a Vert.x route that serves the embedded CycloneDX SBOM. + *

+ * The content type is derived from the SBOM resource name (XML or JSON). + * The route is placed on the management interface when management is enabled, + * otherwise it is registered as an absolute route. + * + * @return the route build item, or {@code null} if the endpoint is disabled + * or no embedded SBOM is available + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + RouteBuildItem registerSbomEndpoint(CycloneDxEndpointRecorder recorder, + CycloneDxEndpointConfig endpointConfig, + ManagementInterfaceBuildTimeConfig managementConfig, + Optional embeddedSbomMetadata) { + if (!endpointConfig.enabled() || embeddedSbomMetadata.isEmpty()) { + return null; + } + + final EmbeddedSbomMetadataBuildItem metadata = embeddedSbomMetadata.get(); + String resourceName = metadata.getResourceName(); + String path = endpointConfig.path(); + // derive content type from the base resource name (without .gz suffix) + String baseName = resourceName.endsWith(".gz") + ? resourceName.substring(0, resourceName.length() - 3) + : resourceName; + String contentType = baseName.endsWith(".xml") + ? "application/vnd.cyclonedx+xml" + : "application/vnd.cyclonedx+json"; + + var builder = managementConfig.enabled() + ? RouteBuildItem.newManagementRoute(path) + : RouteBuildItem.newAbsoluteRoute(path); + return builder + .withRoutePathConfigKey("quarkus.cyclonedx.endpoint.path") + .withRequestHandler(recorder.handler(resourceName, contentType, metadata.isCompressed())) + .displayOnNotFoundPage("CycloneDX SBOM") + .build(); + } + + /** + * Ensures the SBOM resource is included in native images. + */ + @BuildStep + public void nativeResource(BuildProducer nativeImageResourceProducer, + CycloneDxEndpointConfig endpointConfig, + Optional embeddedSbomMetadata) { + if (endpointConfig.enabled() && embeddedSbomMetadata.isPresent()) { + nativeImageResourceProducer.produce(new NativeImageResourceBuildItem(embeddedSbomMetadata.get().getResourceName())); + } + } +} diff --git a/extensions/cyclonedx/endpoint/pom.xml b/extensions/cyclonedx/endpoint/pom.xml new file mode 100644 index 0000000000000..6b946c7850690 --- /dev/null +++ b/extensions/cyclonedx/endpoint/pom.xml @@ -0,0 +1,21 @@ + + + + quarkus-cyclonedx-parent + io.quarkus + 999-SNAPSHOT + ../pom.xml + + 4.0.0 + + quarkus-cyclonedx-endpoint-parent + Quarkus - CycloneDX - Endpoint + pom + + deployment + runtime + + + \ No newline at end of file diff --git a/extensions/cyclonedx/endpoint/runtime/pom.xml b/extensions/cyclonedx/endpoint/runtime/pom.xml new file mode 100644 index 0000000000000..c440acda20bd3 --- /dev/null +++ b/extensions/cyclonedx/endpoint/runtime/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + io.quarkus + quarkus-cyclonedx-endpoint-parent + 999-SNAPSHOT + + + quarkus-cyclonedx-endpoint + Quarkus - CycloneDX - Endpoint - Runtime + CycloneDX REST Endpoint + + + io.quarkus + quarkus-core + + + io.quarkus + quarkus-vertx-http + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + io.quarkus:quarkus-vertx-http + + + + + + \ No newline at end of file diff --git a/extensions/cyclonedx/endpoint/runtime/src/main/java/io/quarkus/cyclonedx/endpoint/runtime/CycloneDxEndpointRecorder.java b/extensions/cyclonedx/endpoint/runtime/src/main/java/io/quarkus/cyclonedx/endpoint/runtime/CycloneDxEndpointRecorder.java new file mode 100644 index 0000000000000..afc75553cfbc8 --- /dev/null +++ b/extensions/cyclonedx/endpoint/runtime/src/main/java/io/quarkus/cyclonedx/endpoint/runtime/CycloneDxEndpointRecorder.java @@ -0,0 +1,51 @@ +package io.quarkus.cyclonedx.endpoint.runtime; + +import java.io.IOException; +import java.io.InputStream; + +import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.runtime.annotations.RuntimeInit; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpHeaders; +import io.vertx.ext.web.RoutingContext; + +@Recorder +public class CycloneDxEndpointRecorder { + + @RuntimeInit + public Handler handler(String resourceName, String contentType, boolean compressed) { + byte[] sbomContent; + try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourceName)) { + if (is == null) { + throw new IllegalStateException("Embedded SBOM resource not found: " + resourceName); + } + sbomContent = is.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException("Failed to read embedded SBOM resource: " + resourceName, e); + } + return new SbomHandler(sbomContent, contentType, compressed); + } + + private static class SbomHandler implements Handler { + private final Buffer content; + private final String contentType; + private final boolean compressed; + + SbomHandler(byte[] content, String contentType, boolean compressed) { + this.content = Buffer.buffer(content); + this.contentType = contentType; + this.compressed = compressed; + } + + @Override + public void handle(RoutingContext ctx) { + var response = ctx.response() + .putHeader(HttpHeaders.CONTENT_TYPE, contentType); + if (compressed) { + response.putHeader(HttpHeaders.CONTENT_ENCODING, "gzip"); + } + response.end(content); + } + } +} diff --git a/extensions/cyclonedx/endpoint/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/cyclonedx/endpoint/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..9956fb1e695e5 --- /dev/null +++ b/extensions/cyclonedx/endpoint/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,5 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "CycloneDX Endpoint" +metadata: + unlisted: true \ No newline at end of file diff --git a/extensions/cyclonedx/generator/src/main/java/io/quarkus/cyclonedx/generator/CycloneDxSbomGenerator.java b/extensions/cyclonedx/generator/src/main/java/io/quarkus/cyclonedx/generator/CycloneDxSbomGenerator.java index f3fcbd446f3b8..d68dafdf16849 100644 --- a/extensions/cyclonedx/generator/src/main/java/io/quarkus/cyclonedx/generator/CycloneDxSbomGenerator.java +++ b/extensions/cyclonedx/generator/src/main/java/io/quarkus/cyclonedx/generator/CycloneDxSbomGenerator.java @@ -13,6 +13,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.maven.model.MailingList; import org.apache.maven.model.Model; +import org.cyclonedx.Format; import org.cyclonedx.Version; import org.cyclonedx.exception.GeneratorException; import org.cyclonedx.generators.BomGeneratorFactory; @@ -74,10 +75,7 @@ public class CycloneDxSbomGenerator { private static final String CLASSIFIER_CYCLONEDX = "cyclonedx"; private static final String FORMAT_ALL = "all"; - private static final String FORMAT_JSON = "json"; - private static final String FORMAT_XML = "xml"; - private static final String DEFAULT_FORMAT = FORMAT_JSON; - private static final List SUPPORTED_FORMATS = List.of(FORMAT_JSON, FORMAT_XML); + private static final String DEFAULT_FORMAT = "json"; public static CycloneDxSbomGenerator newInstance() { return new CycloneDxSbomGenerator(); @@ -91,6 +89,7 @@ public static CycloneDxSbomGenerator newInstance() { private String format; private EffectiveModelResolver modelResolver; private boolean includeLicenseText; + private boolean prettyPrint; private Version effectiveSchemaVersion; @@ -139,30 +138,40 @@ public CycloneDxSbomGenerator setIncludeLicenseText(boolean includeLicenseText) return this; } - public List generate() { + public CycloneDxSbomGenerator setPrettyPrint(boolean prettyPrint) { ensureNotGenerated(); - Objects.requireNonNull(manifest, "Manifest is null"); + this.prettyPrint = prettyPrint; + return this; + } + + public List generateText() { + final Bom bom = createSbom(); + if (FORMAT_ALL.equalsIgnoreCase(format)) { + Format[] formats = Format.values(); + final List result = new ArrayList<>(formats.length); + for (Format format : formats) { + result.add(formatSbom(bom, format.getExtension())); + } + return result; + } + return List.of(formatSbom(bom, format == null ? DEFAULT_FORMAT : format)); + } + + public List generate() { if (outputFile == null && outputDir == null) { throw new IllegalArgumentException("Either outputDir or outputFile must be provided"); } - generated = true; + final Bom bom = createSbom(); - var bom = new Bom(); - bom.setMetadata(new Metadata()); - addToolInfo(bom); - - addApplicationComponent(bom, manifest.getMainComponent()); - for (var c : manifest.getComponents()) { - addComponent(bom, c); - } if (FORMAT_ALL.equalsIgnoreCase(format)) { if (outputFile != null) { throw new IllegalArgumentException("Can't use output file " + outputFile + " with format '" + FORMAT_ALL + "', since it implies generating multiple files"); } - final List result = new ArrayList<>(SUPPORTED_FORMATS.size()); - for (String format : SUPPORTED_FORMATS) { - result.add(persistSbom(bom, getOutputFile(format), format)); + Format[] formats = Format.values(); + final List result = new ArrayList<>(formats.length); + for (Format format : formats) { + result.add(persistSbom(bom, getOutputFile(format.getExtension()), format.getExtension())); } return result; } @@ -170,6 +179,22 @@ public List generate() { return List.of(persistSbom(bom, outputFile, getFormat(outputFile))); } + private Bom createSbom() { + ensureNotGenerated(); + Objects.requireNonNull(manifest, "Manifest is null"); + generated = true; + + var bom = new Bom(); + bom.setMetadata(new Metadata()); + addToolInfo(bom); + + addApplicationComponent(bom, manifest.getMainComponent()); + for (var c : manifest.getComponents()) { + addComponent(bom, c); + } + return bom; + } + private void addComponent(Bom bom, ApplicationComponent component) { final org.cyclonedx.model.Component c = getComponent(component); bom.addComponent(c); @@ -441,25 +466,7 @@ private static List sortAlphabetically(Collectionpom generator + deployment-spi deployment runtime + endpoint \ No newline at end of file diff --git a/extensions/cyclonedx/runtime/pom.xml b/extensions/cyclonedx/runtime/pom.xml index 3bc423d6da166..8be3d70ba6db2 100644 --- a/extensions/cyclonedx/runtime/pom.xml +++ b/extensions/cyclonedx/runtime/pom.xml @@ -18,6 +18,11 @@ io.quarkus quarkus-core + + io.quarkus + quarkus-cyclonedx-endpoint + true + diff --git a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DefaultEffectiveModelResolver.java b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DefaultEffectiveModelResolver.java index c541261cefc16..29019cd854cc1 100644 --- a/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DefaultEffectiveModelResolver.java +++ b/independent-projects/bootstrap/maven-resolver/src/main/java/io/quarkus/bootstrap/resolver/maven/DefaultEffectiveModelResolver.java @@ -104,7 +104,13 @@ public Model resolveEffectiveModel(ArtifactCoords coords, List } catch (BootstrapMavenException e) { throw new RuntimeException("Failed to resolve " + parentPom, e); } - rawModel.getParent().setRelativePath(pomFile.toPath().getParent().relativize(parentPomPath).toString()); + try { + rawModel.getParent() + .setRelativePath(pomFile.toPath().getParent().relativize(parentPomPath).toString()); + } catch (IllegalArgumentException e) { + // on Windows, relativize fails when paths have different roots; + // the model builder will fall back to the ModelResolver to locate the parent + } String repoUrl = null; for (RemoteRepository r : repos) { diff --git a/integration-tests/management-interface-auth/pom.xml b/integration-tests/management-interface-auth/pom.xml index 69e7b4ccdef9f..2226b3d5e5cdf 100644 --- a/integration-tests/management-interface-auth/pom.xml +++ b/integration-tests/management-interface-auth/pom.xml @@ -30,6 +30,10 @@ io.quarkus quarkus-elytron-security-properties-file + + io.quarkus + quarkus-cyclonedx + io.quarkus quarkus-smallrye-jwt @@ -103,6 +107,19 @@ + + io.quarkus + quarkus-cyclonedx-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-smallrye-jwt-deployment diff --git a/integration-tests/management-interface-auth/src/main/resources/application.properties b/integration-tests/management-interface-auth/src/main/resources/application.properties index f768b144fdccb..ff38fad17dcb8 100644 --- a/integration-tests/management-interface-auth/src/main/resources/application.properties +++ b/integration-tests/management-interface-auth/src/main/resources/application.properties @@ -11,6 +11,11 @@ quarkus.management.auth.permission.health.auth-mechanism=basic quarkus.management.auth.permission.metrics.paths=/q/metrics/* quarkus.management.auth.permission.metrics.policy=authenticated +quarkus.cyclonedx.endpoint.enabled=true +quarkus.management.auth.permission.sbom.paths=/.well-known/sbom +quarkus.management.auth.permission.sbom.policy=role-policy +quarkus.management.auth.permission.sbom.auth-mechanism=basic + # Main router authentication: # /service/hello/* - public resource # /service/goodbye/* - basic authentication only, `greeting` role is allowed diff --git a/integration-tests/management-interface-auth/src/test/java/io/quarkus/it/management/ManagementInterfaceTestCase.java b/integration-tests/management-interface-auth/src/test/java/io/quarkus/it/management/ManagementInterfaceTestCase.java index 878df941c99f8..4dd68f85de1ff 100644 --- a/integration-tests/management-interface-auth/src/test/java/io/quarkus/it/management/ManagementInterfaceTestCase.java +++ b/integration-tests/management-interface-auth/src/test/java/io/quarkus/it/management/ManagementInterfaceTestCase.java @@ -41,6 +41,26 @@ void verifyThatHealthChecksAreExposedOnManagementInterface() { .then().statusCode(404); } + @Test + void verifyThatSbomIsExposedOnManagementInterface() { + RestAssured.given().auth().preemptive().basic("john", "john").get(getPrefix() + "/.well-known/sbom") + .then().statusCode(401); + + RestAssured.given().auth().preemptive().basic("bob", "bob").get(getPrefix() + "/.well-known/sbom") + .then().statusCode(403); + + RestAssured.given().auth().basic("alice", "alice").get(getPrefix() + "/.well-known/sbom") + .then().statusCode(200); + + RestAssured.given().auth().oauth2(getAdminToken()).get(getPrefix() + "/.well-known/sbom") + .then().statusCode(401); + RestAssured.given().auth().oauth2(getUserToken()).get(getPrefix() + "/.well-known/sbom") + .then().statusCode(401); + + RestAssured.get("/.well-known/sbom") + .then().statusCode(404); + } + @Test void verifyThatMetricsAreExposedOnManagementInterface() { RestAssured.given().auth().basic("alice", "alice").get(getPrefix() + "/q/metrics") diff --git a/integration-tests/maven/pom.xml b/integration-tests/maven/pom.xml index 99692d618af28..aba48c25704e9 100644 --- a/integration-tests/maven/pom.xml +++ b/integration-tests/maven/pom.xml @@ -125,6 +125,23 @@ quarkus-opentelemetry-deployment test + + io.quarkus + quarkus-cyclonedx-deployment + test + ${project.version} + + + * + * + + + + + org.cyclonedx + cyclonedx-core-java + test + io.quarkus quarkus-project-core-extension-codestarts diff --git a/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxIT.java b/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxIT.java new file mode 100644 index 0000000000000..5813b5ccfcfe0 --- /dev/null +++ b/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxIT.java @@ -0,0 +1,345 @@ +package io.quarkus.maven.it; + +import static io.quarkus.maven.it.CycloneDxTestUtils.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPInputStream; + +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.cyclonedx.parsers.JsonParser; +import org.junit.jupiter.api.Test; + +import io.quarkus.maven.it.verifier.MavenProcessInvocationResult; +import io.quarkus.maven.it.verifier.RunningInvoker; +import io.quarkus.test.devmode.util.DevModeClient; +import io.smallrye.common.process.ProcessUtil; + +@DisableForNative +public class CycloneDxIT extends MojoTestBase { + + private RunningInvoker running; + private File testDir; + + @Test + public void testFastJar() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-fast-jar"); + running = new RunningInvoker(testDir, false); + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of()); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + final Bom bom = parseSbom(testDir, "quarkus-run-cyclonedx.json"); + assertRunnerMainComponent(bom); + + final List components = bom.getComponents(); + assertThat(components).isNotEmpty(); + assertComponent(components, "io.quarkus", "quarkus-rest", "runtime", "lib/main/"); + assertComponent(components, "io.quarkus", "quarkus-rest-deployment", "development", null); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx", "runtime", "lib/main/"); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx-deployment", "development", null); + } + + @Test + public void testUberJar() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-uber-jar"); + running = new RunningInvoker(testDir, false); + + Properties p = new Properties(); + p.setProperty("quarkus.package.jar.type", "uber-jar"); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of(), p); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + final Bom bom = parseSbom(testDir, "acme-app-1.0-SNAPSHOT-runner-cyclonedx.json"); + + final Component mainComponent = bom.getMetadata().getComponent(); + assertThat(mainComponent).isNotNull(); + assertThat(mainComponent.getGroup()).isEqualTo("org.acme"); + assertThat(mainComponent.getName()).isEqualTo("acme-app"); + assertThat(mainComponent.getVersion()).isEqualTo("1.0-SNAPSHOT"); + assertThat(mainComponent.getType()).isEqualTo(Component.Type.APPLICATION); + assertThat(mainComponent.getPurl()).isEqualTo( + "pkg:maven/org.acme/acme-app@1.0-SNAPSHOT?classifier=runner&type=jar"); + assertComponentScope(mainComponent, "runtime"); + + // uber-jar components have no evidence location + final List components = bom.getComponents(); + assertThat(components).isNotEmpty(); + assertComponent(components, "io.quarkus", "quarkus-rest", "runtime", null); + assertComponent(components, "io.quarkus", "quarkus-rest-deployment", "development", null); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx", "runtime", null); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx-deployment", "development", null); + } + + @Test + public void testMutableJar() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-mutable-jar"); + running = new RunningInvoker(testDir, false); + + Properties p = new Properties(); + p.setProperty("quarkus.package.jar.type", "mutable-jar"); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of(), p); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + final Bom bom = parseSbom(testDir, "quarkus-run-cyclonedx.json"); + assertRunnerMainComponent(bom); + + // mutable-jar includes deployment jars, so both runtime and development have locations + final List components = bom.getComponents(); + assertThat(components).isNotEmpty(); + assertComponent(components, "io.quarkus", "quarkus-rest", "runtime", "lib/main/"); + assertComponent(components, "io.quarkus", "quarkus-rest-deployment", "development", "lib/deployment/"); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx", "runtime", "lib/main/"); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx-deployment", "development", "lib/deployment/"); + } + + @Test + public void testEmbeddedSbomFastJar() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-embedded-fast-jar"); + running = new RunningInvoker(testDir, false); + + Properties p = new Properties(); + p.setProperty("quarkus.cyclonedx.embedded.enabled", "true"); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of(), p); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + // the embedded SBOM should be in generated-bytecode.jar, compressed by default + final Path generatedJar = testDir.toPath() + .resolve("target/quarkus-app/quarkus/generated-bytecode.jar"); + final Bom bom = parseCompressedEmbeddedSbom(generatedJar, "META-INF/sbom/dependency.cdx.json.gz"); + + assertEmbeddedSbomComponents(bom); + } + + @Test + public void testEmbeddedSbomUberJar() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-embedded-uber-jar"); + running = new RunningInvoker(testDir, false); + + Properties p = new Properties(); + p.setProperty("quarkus.package.jar.type", "uber-jar"); + p.setProperty("quarkus.cyclonedx.embedded.enabled", "true"); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of(), p); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + // for uber-jar, the resource is embedded directly in the runner jar, compressed by default + final Path uberJar = testDir.toPath() + .resolve("target/acme-app-1.0-SNAPSHOT-runner.jar"); + final Bom bom = parseCompressedEmbeddedSbom(uberJar, "META-INF/sbom/dependency.cdx.json.gz"); + + assertEmbeddedSbomComponents(bom); + } + + @Test + public void testEmbeddedSbomCustomResourceName() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-embedded-custom-name"); + running = new RunningInvoker(testDir, false); + + final String customResourceName = "META-INF/custom-sbom.json"; + Properties p = new Properties(); + p.setProperty("quarkus.cyclonedx.embedded.enabled", "true"); + p.setProperty("quarkus.cyclonedx.embedded.resource-name", customResourceName); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of(), p); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + final Path generatedJar = testDir.toPath() + .resolve("target/quarkus-app/quarkus/generated-bytecode.jar"); + + // the default resource name should not exist (neither compressed nor uncompressed) + assertNoEmbeddedResource(generatedJar, "META-INF/sbom/dependency.cdx.json"); + assertNoEmbeddedResource(generatedJar, "META-INF/sbom/dependency.cdx.json.gz"); + + // the custom resource name should be compressed by default + final Bom bom = parseCompressedEmbeddedSbom(generatedJar, customResourceName + ".gz"); + assertEmbeddedSbomComponents(bom); + } + + @Test + public void testEmbeddedSbomUncompressed() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-embedded-uncompressed"); + running = new RunningInvoker(testDir, false); + + Properties p = new Properties(); + p.setProperty("quarkus.cyclonedx.embedded.enabled", "true"); + p.setProperty("quarkus.cyclonedx.embedded.compress", "false"); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of(), p); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + final Path generatedJar = testDir.toPath() + .resolve("target/quarkus-app/quarkus/generated-bytecode.jar"); + + // with compress=false, the resource should be uncompressed JSON without .gz extension + assertNoEmbeddedResource(generatedJar, "META-INF/sbom/dependency.cdx.json.gz"); + final Bom bom = parseEmbeddedSbom(generatedJar, "META-INF/sbom/dependency.cdx.json"); + assertEmbeddedSbomComponents(bom); + } + + @Test + public void testEmbeddedSbomDisabledByDefault() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-embedded-disabled"); + running = new RunningInvoker(testDir, false); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of()); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + // without enabling embedded SBOM, the resource should not be present + final Path generatedJar = testDir.toPath() + .resolve("target/quarkus-app/quarkus/generated-bytecode.jar"); + assertNoEmbeddedResource(generatedJar, "META-INF/sbom/dependency.cdx.json"); + assertNoEmbeddedResource(generatedJar, "META-INF/sbom/dependency.cdx.json.gz"); + } + + @Test + public void testEndpointTriggersEmbedding() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-endpoint-triggers-embedding"); + running = new RunningInvoker(testDir, false); + + Properties p = new Properties(); + // only enable the endpoint, not embedded.enabled + p.setProperty("quarkus.cyclonedx.endpoint.enabled", "true"); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of(), p); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + + // enabling the endpoint should trigger SBOM embedding via the SPI, compressed by default + final Path generatedJar = testDir.toPath() + .resolve("target/quarkus-app/quarkus/generated-bytecode.jar"); + final Bom bom = parseCompressedEmbeddedSbom(generatedJar, "META-INF/sbom/dependency.cdx.json.gz"); + assertEmbeddedSbomComponents(bom); + } + + @Test + public void testEndpointServesEmbeddedSbom() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-endpoint-serves"); + buildProjectWithEndpoint(testDir); + + Process process = launchApplication(testDir); + try { + assertEmbeddedSbomComponents(fetchSbomFromEndpoint()); + } finally { + process.destroy(); + } + } + + @Test + public void testEndpointInDevMode() throws Exception { + testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-endpoint-devmode"); + running = new RunningInvoker(testDir, false); + + running.execute( + List.of("compile", "quarkus:dev", + "-Dquarkus.cyclonedx.endpoint.enabled=true", + "-Dquarkus.analytics.disabled=true"), + Map.of()); + try { + assertEmbeddedSbomComponents(fetchSbomFromEndpoint()); + } finally { + running.stop(); + } + } + + private void buildProjectWithEndpoint(File projectDir) throws Exception { + running = new RunningInvoker(projectDir, false); + + Properties p = new Properties(); + p.setProperty("quarkus.cyclonedx.endpoint.enabled", "true"); + + final MavenProcessInvocationResult result = running.execute( + List.of("package", "-DskipTests"), + Map.of(), p); + assertThat(result.getProcess().waitFor()).isEqualTo(0); + running.stop(); + } + + private static Process launchApplication(File projectDir) throws Exception { + final Path jar = projectDir.toPath().toAbsolutePath() + .resolve("target/quarkus-app/quarkus-run.jar"); + final File output = new File(projectDir, "target/output.log"); + output.createNewFile(); + + List commands = new ArrayList<>(); + commands.add(ProcessUtil.pathOfJava().toString()); + commands.add("-jar"); + commands.add(jar.toString()); + ProcessBuilder pb = new ProcessBuilder(commands); + pb.redirectOutput(ProcessBuilder.Redirect.appendTo(output)); + pb.redirectError(ProcessBuilder.Redirect.appendTo(output)); + return pb.start(); + } + + private static Bom fetchSbomFromEndpoint() throws Exception { + DevModeClient client = new DevModeClient(); + await().pollDelay(1, TimeUnit.SECONDS) + .atMost(TestUtils.getDefaultTimeout(), TimeUnit.MINUTES) + .until(() -> client.getHttpResponse("/.well-known/sbom", 200)); + + HttpURLConnection conn = (HttpURLConnection) new URL("http://localhost:8080/.well-known/sbom").openConnection(); + try { + try (InputStream is = getInputStream(conn)) { + return new JsonParser().parse(is); + } + } finally { + conn.disconnect(); + } + } + + private static InputStream getInputStream(HttpURLConnection conn) throws IOException { + final InputStream raw = conn.getInputStream(); + return "gzip".equals(conn.getContentEncoding()) + ? new GZIPInputStream(raw) + : raw; + } + + /** + * Asserts that an embedded SBOM has the expected structure and contains + * expected dependency components. + */ + private static void assertEmbeddedSbomComponents(Bom bom) { + assertThat(bom).isNotNull(); + assertThat(bom.getMetadata()).isNotNull(); + assertThat(bom.getMetadata().getComponent()).isNotNull(); + + final List components = bom.getComponents(); + assertThat(components).isNotEmpty(); + // the embedded SBOM should contain application dependencies + assertComponent(components, "io.quarkus", "quarkus-rest", "runtime", null); + assertComponent(components, "io.quarkus", "quarkus-rest-deployment", "development", null); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx", "runtime", null); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx-deployment", "development", null); + } +} diff --git a/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxNativeIT.java b/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxNativeIT.java new file mode 100644 index 0000000000000..be48f5fdb7320 --- /dev/null +++ b/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxNativeIT.java @@ -0,0 +1,59 @@ +package io.quarkus.maven.it; + +import static io.quarkus.maven.it.CycloneDxTestUtils.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.io.File; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import io.quarkus.maven.it.verifier.MavenProcessInvocationResult; +import io.quarkus.maven.it.verifier.RunningInvoker; + +@EnableForNative +public class CycloneDxNativeIT extends MojoTestBase { + + @Test + public void testNativeImage() throws Exception { + final File testDir = initProject("projects/cyclonedx-sbom", "projects/cyclonedx-sbom-native"); + final RunningInvoker running = new RunningInvoker(testDir, false); + + final List mvnArgs = TestUtils.nativeArguments("package", "-DskipTests", "-Dnative"); + final MavenProcessInvocationResult result = running.execute(mvnArgs, Collections.emptyMap()); + await().atMost(10, TimeUnit.MINUTES).until(() -> result.getProcess() != null && !result.getProcess().isAlive()); + final String processLog = running.log(); + try { + assertThat(processLog).containsIgnoringCase("BUILD SUCCESS"); + } catch (AssertionError ae) { + Assumptions.assumeFalse(processLog.contains("Cannot find the `native-image"), + "Skipping test since native-image tool isn't available"); + throw ae; + } finally { + running.stop(); + } + + final Bom bom = parseSbom(testDir, "acme-app-1.0-SNAPSHOT-runner-cyclonedx.json"); + + // native image main component is a generic file component (no Maven coords) + final Component mainComponent = bom.getMetadata().getComponent(); + assertThat(mainComponent).isNotNull(); + assertThat(mainComponent.getName()).isEqualTo("acme-app-1.0-SNAPSHOT-runner"); + assertThat(mainComponent.getVersion()).isEqualTo("1.0-SNAPSHOT"); + assertThat(mainComponent.getType()).isEqualTo(Component.Type.APPLICATION); + assertThat(mainComponent.getPurl()).isEqualTo("pkg:generic/acme-app-1.0-SNAPSHOT-runner@1.0-SNAPSHOT"); + + final List components = bom.getComponents(); + assertThat(components).isNotEmpty(); + assertComponent(components, "io.quarkus", "quarkus-rest", "runtime", null); + assertComponent(components, "io.quarkus", "quarkus-rest-deployment", "development", null); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx", "runtime", null); + assertComponent(components, "io.quarkus", "quarkus-cyclonedx-deployment", "development", null); + } +} diff --git a/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxTestUtils.java b/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxTestUtils.java new file mode 100644 index 0000000000000..c7fcf0b6025a7 --- /dev/null +++ b/integration-tests/maven/src/test/java/io/quarkus/maven/it/CycloneDxTestUtils.java @@ -0,0 +1,143 @@ +package io.quarkus.maven.it; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.zip.GZIPInputStream; + +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.Property; +import org.cyclonedx.model.component.evidence.Occurrence; +import org.cyclonedx.parsers.JsonParser; + +final class CycloneDxTestUtils { + + private CycloneDxTestUtils() { + } + + static Bom parseSbom(File testDir, String sbomFileName) throws Exception { + final File sbomFile = new File(testDir, "target/" + sbomFileName); + assertThat(sbomFile).exists(); + return new JsonParser().parse(sbomFile); + } + + /** + * Asserts that the main component is the quarkus-run.jar runner + * (used by fast-jar and mutable-jar packaging). + */ + static void assertRunnerMainComponent(Bom bom) { + final Component mainComponent = bom.getMetadata().getComponent(); + assertThat(mainComponent).isNotNull(); + assertThat(mainComponent.getName()).isEqualTo("quarkus-run.jar"); + assertThat(mainComponent.getVersion()).isEqualTo("1.0-SNAPSHOT"); + assertThat(mainComponent.getType()).isEqualTo(Component.Type.APPLICATION); + assertThat(mainComponent.getPurl()).isEqualTo("pkg:generic/quarkus-run.jar@1.0-SNAPSHOT"); + assertComponentScope(mainComponent, "runtime"); + } + + /** + * Asserts that a component with the given group and name exists in the SBOM, + * has the expected scope property, and optionally has an evidence location + * starting with the given prefix. + * + * @param components the component list + * @param group expected group + * @param name expected artifact name + * @param expectedScope expected value of the quarkus:component:scope property + * @param expectedLocationPrefix if non-null, the component's evidence location must start with this prefix; + * if null, no evidence location is expected + */ + static void assertComponent(List components, String group, String name, + String expectedScope, String expectedLocationPrefix) { + final Component component = components.stream() + .filter(c -> group.equals(c.getGroup()) && name.equals(c.getName())) + .findFirst() + .orElse(null); + assertThat(component) + .as("Expected component %s:%s in SBOM", group, name) + .isNotNull(); + assertComponentScope(component, expectedScope); + assertEvidenceLocation(component, expectedLocationPrefix); + } + + static void assertComponentScope(Component component, String expectedScope) { + final List properties = component.getProperties(); + assertThat(properties).isNotNull(); + final String scope = properties.stream() + .filter(p -> "quarkus:component:scope".equals(p.getName())) + .map(Property::getValue) + .findFirst() + .orElse(null); + assertThat(scope) + .as("quarkus:component:scope of %s:%s", component.getGroup(), component.getName()) + .isEqualTo(expectedScope); + } + + /** + * Parses a CycloneDX JSON SBOM embedded as a resource inside a JAR file. + */ + static Bom parseEmbeddedSbom(Path jarFile, String resourceName) throws Exception { + assertThat(jarFile.toFile()).exists(); + try (JarFile jar = new JarFile(jarFile.toFile())) { + JarEntry entry = jar.getJarEntry(resourceName); + assertThat(entry) + .as("Expected resource %s in %s", resourceName, jarFile.getFileName()) + .isNotNull(); + try (InputStream is = jar.getInputStream(entry)) { + return new JsonParser().parse(is); + } + } + } + + /** + * Parses a GZIP-compressed CycloneDX JSON SBOM embedded as a resource inside a JAR file. + */ + static Bom parseCompressedEmbeddedSbom(Path jarFile, String resourceName) throws Exception { + assertThat(jarFile.toFile()).exists(); + try (JarFile jar = new JarFile(jarFile.toFile())) { + JarEntry entry = jar.getJarEntry(resourceName); + assertThat(entry) + .as("Expected resource %s in %s", resourceName, jarFile.getFileName()) + .isNotNull(); + try (InputStream is = new GZIPInputStream(jar.getInputStream(entry))) { + return new JsonParser().parse(is); + } + } + } + + /** + * Asserts that a JAR file does not contain a resource with the given name. + */ + static void assertNoEmbeddedResource(Path jarFile, String resourceName) throws Exception { + assertThat(jarFile.toFile()).exists(); + try (JarFile jar = new JarFile(jarFile.toFile())) { + assertThat(jar.getJarEntry(resourceName)) + .as("Resource %s should not exist in %s", resourceName, jarFile.getFileName()) + .isNull(); + } + } + + private static void assertEvidenceLocation(Component component, String expectedLocationPrefix) { + if (expectedLocationPrefix == null) { + if (component.getEvidence() == null || component.getEvidence().getOccurrences() == null) { + return; + } + assertThat(component.getEvidence().getOccurrences()) + .as("Evidence locations of %s:%s", component.getGroup(), component.getName()) + .isEmpty(); + return; + } + assertThat(component.getEvidence()).isNotNull(); + final List occurrences = component.getEvidence().getOccurrences(); + assertThat(occurrences) + .as("Evidence locations of %s:%s", component.getGroup(), component.getName()) + .isNotEmpty(); + assertThat(occurrences.get(0).getLocation()).startsWith(expectedLocationPrefix); + } +} diff --git a/integration-tests/maven/src/test/resources-filtered/projects/cyclonedx-sbom/pom.xml b/integration-tests/maven/src/test/resources-filtered/projects/cyclonedx-sbom/pom.xml new file mode 100644 index 0000000000000..589e28a4ce1bb --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/cyclonedx-sbom/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + org.acme + acme-app + 1.0-SNAPSHOT + quarkus + + ${compiler-plugin.version} + ${maven.compiler.release} + UTF-8 + UTF-8 + quarkus-bom + io.quarkus + ${project.version} + + + + + \${quarkus.platform.group-id} + \${quarkus.platform.artifact-id} + \${quarkus.platform.version} + pom + import + + + + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-cyclonedx + + + + + + \${quarkus.platform.group-id} + quarkus-maven-plugin + \${quarkus.platform.version} + true + + + maven-compiler-plugin + \${compiler-plugin.version} + + true + + + + + + + native + + + native + + + + true + + + + diff --git a/integration-tests/maven/src/test/resources-filtered/projects/cyclonedx-sbom/src/main/java/org/acme/GreetingResource.java b/integration-tests/maven/src/test/resources-filtered/projects/cyclonedx-sbom/src/main/java/org/acme/GreetingResource.java new file mode 100644 index 0000000000000..244f294265375 --- /dev/null +++ b/integration-tests/maven/src/test/resources-filtered/projects/cyclonedx-sbom/src/main/java/org/acme/GreetingResource.java @@ -0,0 +1,16 @@ +package org.acme; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/hello") +public class GreetingResource { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String hello() { + return "Hello from Quarkus REST"; + } +}