diff --git a/core/runtime/src/main/java/io/quarkus/runtime/graal/GraalVM.java b/core/runtime/src/main/java/io/quarkus/runtime/graal/GraalVM.java index c9073eb9a4a23..6268eecf45824 100644 --- a/core/runtime/src/main/java/io/quarkus/runtime/graal/GraalVM.java +++ b/core/runtime/src/main/java/io/quarkus/runtime/graal/GraalVM.java @@ -98,6 +98,7 @@ public static class Version implements Comparable { public static final Version VERSION_24_1_999 = new Version("GraalVM 24.1.999", "24.1.999", "23", Distribution.GRAALVM); public static final Version VERSION_24_2_0 = new Version("GraalVM 24.2.0", "24.2.0", "24", Distribution.GRAALVM); public static final Version VERSION_25_0_0 = new Version("GraalVM 25.0.0", "25.0.0", "25", Distribution.GRAALVM); + public static final Version VERSION_25_1_0 = new Version("GraalVM 25.1.0", "25.1.0", "25", Distribution.GRAALVM); // Temporarily work around https://github.com/quarkusio/quarkus/issues/36246, // till we have a consensus on how to move forward in diff --git a/docs/src/main/asciidoc/cyclonedx.adoc b/docs/src/main/asciidoc/cyclonedx.adoc index e128fc5323cbe..bf6f2011282fe 100644 --- a/docs/src/main/asciidoc/cyclonedx.adoc +++ b/docs/src/main/asciidoc/cyclonedx.adoc @@ -285,6 +285,17 @@ Since native executables are not currently attached to projects as Maven artifac 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. +==== Embedded SBOM in native executables + +When <> is enabled, the SBOM is also embedded directly into the native executable as global symbols (`sbom` and `sbom_length`), following the https://www.graalvm.org/jdk25/security-guide/native-image/sbom/[GraalVM SBOM specification]. This allows tools such as `syft` to extract the SBOM from the binary: + +[source,bash] +---- +podman run --rm -v $(pwd):/tmp:z anchore/syft scan myapp -o cyclonedx-json +---- + +NOTE: The SBOM is always GZIP-compressed before being embedded as a global symbol, regardless of the `quarkus.cyclonedx.embedded.compress` setting. If the classpath resource is not already compressed, Quarkus will compress it at native image build time. + === Mutable JAR Mutable JAR distribution is similar to the Fast JAR one except it also includes build time dependencies to support re-augmentation (re-building) of an application. diff --git a/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/SbomNativeImageFeatureStep.java b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/SbomNativeImageFeatureStep.java new file mode 100644 index 0000000000000..21d1aa4b453df --- /dev/null +++ b/extensions/cyclonedx/deployment/src/main/java/io/quarkus/cyclonedx/deployment/SbomNativeImageFeatureStep.java @@ -0,0 +1,226 @@ +package io.quarkus.cyclonedx.deployment; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.constant.ClassDesc; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.zip.GZIPOutputStream; + +import org.graalvm.nativeimage.hosted.Feature; + +import io.quarkus.cyclonedx.deployment.spi.EmbeddedSbomMetadataBuildItem; +import io.quarkus.deployment.GeneratedClassGizmo2Adaptor; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.GeneratedNativeImageClassBuildItem; +import io.quarkus.deployment.builditem.NativeImageFeatureBuildItem; +import io.quarkus.deployment.builditem.nativeimage.JPMSExportBuildItem; +import io.quarkus.gizmo2.Const; +import io.quarkus.gizmo2.Expr; +import io.quarkus.gizmo2.Gizmo; +import io.quarkus.gizmo2.LocalVar; +import io.quarkus.gizmo2.desc.ClassMethodDesc; +import io.quarkus.gizmo2.desc.ConstructorDesc; +import io.quarkus.gizmo2.desc.MethodDesc; +import io.quarkus.runtime.graal.GraalVM; + +/** + * Generates a GraalVM {@link Feature} that embeds the application SBOM + * into the native image as {@code sbom} and {@code sbom_length} global symbols, + * following the GraalVM SBOM spec. + *

+ * Internal GraalVM APIs ({@code CGlobalDataFactory}, {@code CGlobalDataFeature}, {@code WordFactory}) + * are referenced via {@link ClassMethodDesc} to avoid compile-time dependencies. + */ +public class SbomNativeImageFeatureStep { + + static final String SBOM_EMBED_FEATURE = "io.quarkus.runner.SbomEmbedFeature"; + + private static final MethodDesc GET_RESOURCE_AS_STREAM = MethodDesc.of(Class.class, + "getResourceAsStream", InputStream.class, String.class); + private static final MethodDesc READ_ALL_BYTES = MethodDesc.of(InputStream.class, + "readAllBytes", byte[].class); + private static final MethodDesc CLOSE_INPUT_STREAM = MethodDesc.of(InputStream.class, + "close", void.class); + private static final MethodDesc GZIP_WRITE = MethodDesc.of(GZIPOutputStream.class, + "write", void.class, byte[].class); + private static final MethodDesc GZIP_CLOSE = MethodDesc.of(GZIPOutputStream.class, + "close", void.class); + private static final MethodDesc BAOS_TO_BYTE_ARRAY = MethodDesc.of(ByteArrayOutputStream.class, + "toByteArray", byte[].class); + private static final MethodDesc BAOS_CLOSE = MethodDesc.of(ByteArrayOutputStream.class, + "close", void.class); + private static final ConstructorDesc GZIP_OUTPUT_STREAM_CTOR = ConstructorDesc.of( + GZIPOutputStream.class, OutputStream.class); + + private static final MethodDesc GRAALVM_VERSION_GET_CURRENT = MethodDesc.of(GraalVM.Version.class, "getCurrent", + GraalVM.Version.class); + private static final MethodDesc GRAALVM_VERSION_COMPARE_TO = MethodDesc.of(GraalVM.Version.class, "compareTo", int.class, + int[].class); + + // GraalVM <= 25.0: com.oracle.svm.core.c + private static final ClassDesc CD_CGLOBAL_DATA = ClassDesc.of("com.oracle.svm.core.c.CGlobalData"); + private static final ClassDesc CD_CGLOBAL_DATA_FACTORY = ClassDesc.of("com.oracle.svm.core.c.CGlobalDataFactory"); + + // GraalVM >= 25.1: com.oracle.svm.guest.staging.c + private static final ClassDesc CD_CGLOBAL_DATA_STAGING = ClassDesc.of("com.oracle.svm.guest.staging.c.CGlobalData"); + private static final ClassDesc CD_CGLOBAL_DATA_FACTORY_STAGING = ClassDesc.of( + "com.oracle.svm.guest.staging.c.CGlobalDataFactory"); + + private static final ClassDesc CD_CGLOBAL_DATA_FEATURE = ClassDesc.of("com.oracle.svm.hosted.c.CGlobalDataFeature"); + private static final ClassDesc CD_UNSIGNED_WORD = ClassDesc.of("org.graalvm.word.UnsignedWord"); + private static final ClassDesc CD_WORD_BASE = ClassDesc.of("org.graalvm.word.WordBase"); + private static final ClassDesc CD_WORD_FACTORY = ClassDesc.of("org.graalvm.word.WordFactory"); + + // GraalVM <= 25.0 method descriptors + private static final ClassMethodDesc CREATE_BYTES = ClassMethodDesc.of( + CD_CGLOBAL_DATA_FACTORY, "createBytes", CD_CGLOBAL_DATA, + ClassDesc.of("java.util.function.Supplier"), ClassDesc.of("java.lang.String")); + private static final ClassMethodDesc CREATE_WORD = ClassMethodDesc.of( + CD_CGLOBAL_DATA_FACTORY, "createWord", CD_CGLOBAL_DATA, + CD_WORD_BASE, ClassDesc.of("java.lang.String")); + private static final ClassMethodDesc REGISTER_WITH_GLOBAL_SYMBOL = ClassMethodDesc.of( + CD_CGLOBAL_DATA_FEATURE, "registerWithGlobalSymbol", ClassDesc.ofDescriptor("V"), CD_CGLOBAL_DATA); + + // GraalVM >= 25.1 method descriptors + private static final ClassMethodDesc CREATE_BYTES_STAGING = ClassMethodDesc.of( + CD_CGLOBAL_DATA_FACTORY_STAGING, "createBytes", CD_CGLOBAL_DATA_STAGING, + ClassDesc.of("java.util.function.Supplier"), ClassDesc.of("java.lang.String")); + private static final ClassMethodDesc CREATE_WORD_STAGING = ClassMethodDesc.of( + CD_CGLOBAL_DATA_FACTORY_STAGING, "createWord", CD_CGLOBAL_DATA_STAGING, + CD_WORD_BASE, ClassDesc.of("java.lang.String")); + private static final ClassMethodDesc REGISTER_WITH_GLOBAL_SYMBOL_STAGING = ClassMethodDesc.of( + CD_CGLOBAL_DATA_FEATURE, "registerWithGlobalSymbol", ClassDesc.ofDescriptor("V"), CD_CGLOBAL_DATA_STAGING); + + private static final ClassMethodDesc CGLOBAL_DATA_FEATURE_SINGLETON = ClassMethodDesc.of( + CD_CGLOBAL_DATA_FEATURE, "singleton", CD_CGLOBAL_DATA_FEATURE); + private static final ClassMethodDesc WORD_FACTORY_UNSIGNED = ClassMethodDesc.of( + CD_WORD_FACTORY, "unsigned", CD_UNSIGNED_WORD, ClassDesc.ofDescriptor("J")); + private static final MethodDesc PRINT_STACK_TRACE = MethodDesc.of(Throwable.class, "printStackTrace", void.class); + + @BuildStep + void generateSbomEmbedFeature( + Optional embeddedSbomMetadata, + BuildProducer nativeImageClass, + BuildProducer features, + BuildProducer jpmsExports) { + + if (embeddedSbomMetadata.isEmpty()) { + return; + } + + EmbeddedSbomMetadataBuildItem metadata = embeddedSbomMetadata.get(); + String resourceName = metadata.getResourceName(); + boolean isCompressed = metadata.isCompressed(); + + jpmsExports.produce(new JPMSExportBuildItem("org.graalvm.nativeimage.builder", "com.oracle.svm.hosted.c")); + // GraalVM <= 25.0 + jpmsExports.produce(new JPMSExportBuildItem("org.graalvm.nativeimage.builder", "com.oracle.svm.core.c", + null, GraalVM.Version.VERSION_25_1_0)); + // GraalVM >= 25.1 + jpmsExports.produce(new JPMSExportBuildItem("org.graalvm.nativeimage.guest.staging", + "com.oracle.svm.guest.staging.c", GraalVM.Version.VERSION_25_1_0)); + + Gizmo g = Gizmo.create(new GeneratedClassGizmo2Adaptor( + item -> nativeImageClass + .produce(new GeneratedNativeImageClassBuildItem(item.binaryName(), item.getClassData())), + item -> { + }, + false)); + + g.class_(SBOM_EMBED_FEATURE, cc -> { + cc.implements_(Feature.class); + cc.defaultConstructor(); + + cc.method("getDescription", mc -> { + mc.returning(String.class); + mc.body(b -> b.return_(Const.of("Embeds the application SBOM in the native image"))); + }); + + cc.method("afterAnalysis", mc -> { + mc.parameter("access", Feature.AfterAnalysisAccess.class); + mc.body(b0 -> { + b0.try_(t -> { + t.body(tb -> { + Expr clazz = Const.of(cc.type()); + LocalVar is = tb.localVar("is", + tb.invokeVirtual(GET_RESOURCE_AS_STREAM, clazz, + Const.of("/" + resourceName))); + tb.ifNull(is, nb -> { + nb.throw_(RuntimeException.class, + "SBOM resource not found on classpath: " + resourceName); + }); + LocalVar resourceBytes = tb.localVar("resourceBytes", + tb.invokeVirtual(READ_ALL_BYTES, is)); + tb.invokeVirtual(CLOSE_INPUT_STREAM, is); + + LocalVar sbomBytes; + if (isCompressed) { + sbomBytes = tb.localVar("sbomBytes", resourceBytes); + } else { + LocalVar bout = tb.localVar("bout", tb.new_(ByteArrayOutputStream.class)); + LocalVar gout = tb.localVar("gout", tb.new_(GZIP_OUTPUT_STREAM_CTOR, bout)); + tb.invokeVirtual(GZIP_WRITE, gout, resourceBytes); + tb.invokeVirtual(GZIP_CLOSE, gout); + sbomBytes = tb.localVar("sbomBytes", tb.invokeVirtual(BAOS_TO_BYTE_ARRAY, bout)); + tb.invokeVirtual(BAOS_CLOSE, bout); + } + + LocalVar supplier = tb.localVar("supplier", tb.lambda(Supplier.class, lc -> { + var capturedBytes = lc.capture(sbomBytes); + lc.body(lb -> lb.return_(capturedBytes)); + })); + + LocalVar unsignedLen = tb.localVar("unsignedLen", + tb.invokeStatic(WORD_FACTORY_UNSIGNED, + tb.cast(sbomBytes.length(), long.class))); + + LocalVar graalVMVersion = tb.localVar("graalVMVersion", + tb.invokeStatic(GRAALVM_VERSION_GET_CURRENT)); + + LocalVar cgFeature = tb.localVar("cgFeature", + tb.invokeStatic(CGLOBAL_DATA_FEATURE_SINGLETON)); + // GraalVM >= 25.1: use com.oracle.svm.guest.staging.c + tb.ifElse(tb.ge( + tb.invokeVirtual(GRAALVM_VERSION_COMPARE_TO, + graalVMVersion, + tb.newArray(int.class, Const.of(25), Const.of(1))), + 0), newPath -> { + LocalVar sbomData = newPath.localVar("sbomData", + newPath.invokeStatic(CREATE_BYTES_STAGING, supplier, + Const.of("sbom"))); + newPath.invokeVirtual(REGISTER_WITH_GLOBAL_SYMBOL_STAGING, cgFeature, + sbomData); + + LocalVar sbomLenData = newPath.localVar("sbomLenData", + newPath.invokeStatic(CREATE_WORD_STAGING, unsignedLen, + Const.of("sbom_length"))); + newPath.invokeVirtual(REGISTER_WITH_GLOBAL_SYMBOL_STAGING, cgFeature, + sbomLenData); + }, oldPath -> { + // GraalVM <= 25.0: use com.oracle.svm.core.c + LocalVar sbomData = oldPath.localVar("sbomData", + oldPath.invokeStatic(CREATE_BYTES, supplier, Const.of("sbom"))); + oldPath.invokeVirtual(REGISTER_WITH_GLOBAL_SYMBOL, cgFeature, sbomData); + + LocalVar sbomLenData = oldPath.localVar("sbomLenData", + oldPath.invokeStatic(CREATE_WORD, unsignedLen, + Const.of("sbom_length"))); + oldPath.invokeVirtual(REGISTER_WITH_GLOBAL_SYMBOL, cgFeature, sbomLenData); + }); + }); + t.catch_(Exception.class, "e", (cb, e) -> { + cb.invokeVirtual(PRINT_STACK_TRACE, e); + cb.throw_(RuntimeException.class, e); + }); + }); + b0.return_(); + }); + }); + }); + + features.produce(new NativeImageFeatureBuildItem(SBOM_EMBED_FEATURE)); + } +} 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 index be48f5fdb7320..144dce89ec550 100644 --- 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 @@ -4,16 +4,23 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; 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.cyclonedx.parsers.JsonParser; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; +import io.quarkus.deployment.util.ContainerRuntimeUtil; +import io.quarkus.deployment.util.ContainerRuntimeUtil.ContainerRuntime; import io.quarkus.maven.it.verifier.MavenProcessInvocationResult; import io.quarkus.maven.it.verifier.RunningInvoker; @@ -25,7 +32,8 @@ 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 List mvnArgs = TestUtils.nativeArguments("package", "-DskipTests", "-Dnative", + "-Dquarkus.cyclonedx.embedded.enabled=true"); 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(); @@ -55,5 +63,56 @@ public void testNativeImage() throws Exception { 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); + + verifySbomWithSyft(testDir.toPath(), "acme-app-1.0-SNAPSHOT-runner"); + } + + /** + * Uses the anchore/syft container image to extract the SBOM embedded in the + * native executable and verifies it contains the expected components. + */ + private void verifySbomWithSyft(Path projectDir, String nativeExecutableName) throws Exception { + final ContainerRuntime containerRuntime = ContainerRuntimeUtil.detectContainerRuntime(false); + Assumptions.assumeTrue(containerRuntime != ContainerRuntime.UNAVAILABLE, + "Skipping syft verification since no container runtime is available"); + + final Path nativeImage = projectDir.resolve("target").resolve(nativeExecutableName); + assertThat(nativeImage.toFile()).exists(); + + final String runtime = containerRuntime.getExecutableName(); + final ProcessBuilder pb = new ProcessBuilder( + runtime, "run", "--rm", + "-v", nativeImage.toAbsolutePath() + ":/binary:ro,z", + "anchore/syft", + "/binary", "-o", "cyclonedx-json"); + pb.redirectError(ProcessBuilder.Redirect.DISCARD); + + final Process process = pb.start(); + final String output; + try (InputStream is = process.getInputStream()) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + is.transferTo(baos); + output = baos.toString(StandardCharsets.UTF_8); + } + final int exitCode = process.waitFor(); + assertThat(exitCode) + .as("syft exited with code %d", exitCode) + .isZero(); + + final Bom syftBom = new JsonParser().parse(output.getBytes(StandardCharsets.UTF_8)); + assertThat(syftBom).isNotNull(); + final List syftComponents = syftBom.getComponents(); + assertThat(syftComponents).isNotEmpty(); + + assertThat(syftComponents.stream() + .filter(c -> "io.quarkus".equals(c.getGroup()) && "quarkus-rest".equals(c.getName())) + .findFirst()) + .as("syft-extracted SBOM should contain quarkus-rest") + .isPresent(); + assertThat(syftComponents.stream() + .filter(c -> "io.quarkus".equals(c.getGroup()) && "quarkus-cyclonedx".equals(c.getName())) + .findFirst()) + .as("syft-extracted SBOM should contain quarkus-cyclonedx") + .isPresent(); } }