Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public static class Version implements Comparable<Version> {
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
Expand Down
11 changes: 11 additions & 0 deletions docs/src/main/asciidoc/cyclonedx.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<embedded-dependency-sboms,SBOM embedding>> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <a href="https://www.graalvm.org/jdk25/security-guide/native-image/sbom/">GraalVM SBOM spec</a>.
* <p>
* 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<EmbeddedSbomMetadataBuildItem> embeddedSbomMetadata,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to make it optional? Wouldn't a build step be skipped if required input wasn't available?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I thought as well, but it actually gets triggered with embeddedSbomMetadata being null. I also noticed you did the same in github.com//pull/53552 so I hoped you knew why it's happening. Perhaps it's worth further investigating (beyond the scope of this PR though).

BuildProducer<GeneratedNativeImageClassBuildItem> nativeImageClass,
BuildProducer<NativeImageFeatureBuildItem> features,
BuildProducer<JPMSExportBuildItem> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String> mvnArgs = TestUtils.nativeArguments("package", "-DskipTests", "-Dnative");
final List<String> 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();
Expand Down Expand Up @@ -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<Component> 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();
}
}
Loading