diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/build.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/build.adoc index 1dfb1b307b9..b0d66ce1fa8 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/build.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/build.adoc @@ -116,6 +116,8 @@ plugins { } ---- +TIP: To print the SBOM packaged in an uber jar or war, run `java -Djarmode=tools -jar my-app.jar sbom`. + [[howto.build.customize-dependency-versions]] diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/container-images/dockerfiles.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/container-images/dockerfiles.adoc index e188df664c6..8a276833da3 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/container-images/dockerfiles.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/container-images/dockerfiles.adoc @@ -22,6 +22,7 @@ Usage: Available commands: extract Extract the contents from the jar list-layers List layers from the jar that can be extracted + sbom Print the SBOM from the jar help Help about any command ---- diff --git a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/SbomCommand.java b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/SbomCommand.java new file mode 100644 index 00000000000..8f6e55beea7 --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/SbomCommand.java @@ -0,0 +1,119 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.jarmode.tools; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Map; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.loader.jarmode.JarModeErrorException; +import org.springframework.util.StreamUtils; + +/** + * The {@code 'sbom'} tools command. + * + * @author Hyeongjun Cho + */ +class SbomCommand extends Command { + + private static final Option DESTINATION_OPTION = Option.of("destination", "string", + "File to write the SBOM to. Defaults to printing the SBOM to the console"); + + private static final String SBOM_LOCATION_ATTRIBUTE = "Sbom-Location"; + + private final Context context; + + SbomCommand(Context context) { + super("sbom", "Print the SBOM from the jar", Options.of(DESTINATION_OPTION), Parameters.none()); + this.context = context; + } + + @Override + void run(PrintStream out, Map options, List parameters) { + try (JarFile jarFile = new JarFile(this.context.getArchiveFile())) { + String location = getSbomLocation(jarFile); + ZipEntry entry = jarFile.getEntry(location); + if (entry == null || entry.isDirectory()) { + throw new JarModeErrorException( + "SBOM '%s' declared in the manifest was not found in the jar".formatted(location)); + } + try (InputStream in = jarFile.getInputStream(entry)) { + writeSbom(in, out, options); + } + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private String getSbomLocation(JarFile jarFile) throws IOException { + Manifest manifest = jarFile.getManifest(); + if (manifest != null) { + String location = manifest.getMainAttributes().getValue(SBOM_LOCATION_ATTRIBUTE); + if (location != null) { + return location; + } + } + throw new JarModeErrorException( + "No SBOM found in the jar; the manifest has no '%s' attribute".formatted(SBOM_LOCATION_ATTRIBUTE)); + } + + private void writeSbom(InputStream in, PrintStream out, Map options) throws IOException { + String destination = options.get(DESTINATION_OPTION); + if (destination == null) { + StreamUtils.copy(in, out); + if (out.checkError()) { + throw new JarModeErrorException("Failed to write the SBOM to the console"); + } + return; + } + File file = getDestinationFile(destination); + if (file.isDirectory()) { + throw new JarModeErrorException(file.getAbsoluteFile() + " already exists and is a directory"); + } + mkdirs(file.getParentFile()); + try (OutputStream fileOut = new FileOutputStream(file)) { + StreamUtils.copy(in, fileOut); + } + } + + private File getDestinationFile(String destination) { + File file = new File(destination); + if (file.isAbsolute()) { + return file; + } + return new File(this.context.getWorkingDir(), file.getPath()); + } + + private static void mkdirs(@Nullable File file) throws IOException { + if (file != null && !file.exists() && !file.mkdirs()) { + throw new IOException("Unable to create directory " + file); + } + } + +} diff --git a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ToolsJarMode.java b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ToolsJarMode.java index c9cfe51fcca..85f0f35c26f 100644 --- a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ToolsJarMode.java +++ b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ToolsJarMode.java @@ -65,7 +65,7 @@ public class ToolsJarMode implements JarMode { } static List getCommands(Context context) { - return List.of(new ExtractCommand(context), new ListLayersCommand(context)); + return List.of(new ExtractCommand(context), new ListLayersCommand(context), new SbomCommand(context)); } } diff --git a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/SbomCommandTests.java b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/SbomCommandTests.java new file mode 100644 index 00000000000..acb019072e2 --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/SbomCommandTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.jarmode.tools; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.util.ArrayDeque; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.jarmode.JarModeErrorException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link SbomCommand}. + * + * @author Hyeongjun Cho + */ +class SbomCommandTests extends AbstractJarModeTests { + + private static final String SBOM_LOCATION = "META-INF/sbom/application.cdx.json"; + + private static final String SBOM_RESOURCE = "/jar-contents/application.cdx.json"; + + @Test + void shouldPrintSbom() throws IOException { + TestPrintStream out = run(createDefaultArchive()); + assertThat(out).hasSameContentAsResource(SBOM_RESOURCE); + } + + @Test + void shouldPrintSbomWhenLocationIsUnderWebInfClasses() throws IOException { + Manifest manifest = createManifest("Sbom-Location: WEB-INF/classes/META-INF/sbom/application.cdx.json"); + File archive = createArchive(manifest, "WEB-INF/classes/META-INF/sbom/application.cdx.json", SBOM_RESOURCE); + TestPrintStream out = run(archive); + assertThat(out).hasSameContentAsResource(SBOM_RESOURCE); + } + + @Test + void shouldFailWhenSbomLocationIsMissing() { + assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(createArchive())) + .withMessage("No SBOM found in the jar; the manifest has no 'Sbom-Location' attribute"); + } + + @Test + void shouldFailWhenSbomEntryIsMissing() throws IOException { + Manifest manifest = createManifest("Sbom-Location: " + SBOM_LOCATION); + File archive = createArchive(manifest); + assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(archive)) + .withMessage("SBOM 'META-INF/sbom/application.cdx.json' declared in the manifest was not found in the jar"); + } + + @Test + void shouldFailWhenSbomLocationIsADirectory() throws IOException { + Manifest manifest = createManifest("Sbom-Location: META-INF/sbom"); + File archive = createArchive(manifest, "META-INF/sbom/", "/jar-contents/empty-file"); + assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(archive)) + .withMessage("SBOM 'META-INF/sbom' declared in the manifest was not found in the jar"); + } + + @Test + void shouldFailWhenConsoleWriteFails() throws IOException { + SbomCommand command = new SbomCommand(new Context(createDefaultArchive(), this.tempDir)); + try (PrintStream out = new PrintStream(new FailingOutputStream())) { + assertThatExceptionOfType(JarModeErrorException.class) + .isThrownBy(() -> command.run(out, new ArrayDeque<>())) + .withMessage("Failed to write the SBOM to the console"); + } + } + + @Test + void shouldWriteSbomToDestination() throws IOException { + TestPrintStream out = run(createDefaultArchive(), "--destination", "application.cdx.json"); + File destination = new File(this.tempDir, "application.cdx.json"); + assertThat(destination).hasBinaryContent(getResourceContent(SBOM_RESOURCE)); + assertThat(out.toString()).isEmpty(); + } + + @Test + void shouldWriteSbomToAbsoluteDestination() throws IOException { + File destination = new File(this.tempDir, "absolute.cdx.json"); + run(createDefaultArchive(), "--destination", destination.getAbsolutePath()); + assertThat(destination).hasBinaryContent(getResourceContent(SBOM_RESOURCE)); + } + + @Test + void shouldCreateMissingDestinationParentDirectories() throws IOException { + run(createDefaultArchive(), "--destination", "reports/sbom/application.cdx.json"); + File destination = new File(this.tempDir, "reports/sbom/application.cdx.json"); + assertThat(destination).hasBinaryContent(getResourceContent(SBOM_RESOURCE)); + } + + @Test + void shouldOverwriteExistingDestination() throws IOException { + File destination = new File(this.tempDir, "application.cdx.json"); + Files.writeString(destination.toPath(), "stale content"); + run(createDefaultArchive(), "--destination", "application.cdx.json"); + assertThat(destination).hasBinaryContent(getResourceContent(SBOM_RESOURCE)); + } + + @Test + void shouldFailWhenDestinationIsADirectory() throws IOException { + File archive = createDefaultArchive(); + File destination = new File(this.tempDir, "output"); + assertThat(destination.mkdirs()).isTrue(); + assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(archive, "--destination", "output")) + .withMessage(destination.getAbsoluteFile() + " already exists and is a directory"); + } + + private File createDefaultArchive() throws IOException { + Manifest manifest = createManifest("Sbom-Location: " + SBOM_LOCATION); + return createArchive(manifest, SBOM_LOCATION, SBOM_RESOURCE); + } + + private byte[] getResourceContent(String resource) throws IOException { + try (InputStream stream = getClass().getResourceAsStream(resource)) { + assertThat(stream).as("Resource '%s'", resource).isNotNull(); + return stream.readAllBytes(); + } + } + + private TestPrintStream run(File archive, String... arguments) { + return runCommand(SbomCommand::new, archive, arguments); + } + + static final class FailingOutputStream extends OutputStream { + + @Override + public void write(int b) throws IOException { + throw new IOException("Write failed"); + } + + } + +} diff --git a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ToolsJarModeTests.java b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ToolsJarModeTests.java index fc4c401fefe..31d8e00d8f0 100644 --- a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ToolsJarModeTests.java +++ b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ToolsJarModeTests.java @@ -69,6 +69,12 @@ class ToolsJarModeTests extends AbstractJarModeTests { assertThat(this.out).hasSameContentAsResource("tools-help-list-layers-output.txt"); } + @Test + void helpForSbom() { + run("help", "sbom"); + assertThat(this.out).hasSameContentAsResource("tools-help-sbom-output.txt"); + } + @Test void helpForHelp() { run("help", "help"); diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/application.cdx.json b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/application.cdx.json new file mode 100644 index 00000000000..30b7f4cf5de --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/application.cdx.json @@ -0,0 +1,19 @@ +{ + "bomFormat" : "CycloneDX", + "specVersion" : "1.6", + "serialNumber" : "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version" : 1, + "metadata" : { + "component" : { + "type" : "application", + "name" : "test-application", + "version" : "1.0.0" + } + }, + "components" : [ { + "type" : "library", + "name" : "dependency-1", + "version" : "1.0.0", + "purl" : "pkg:maven/com.example/dependency-1@1.0.0" + } ] +} diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-command-unknown-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-command-unknown-output.txt index 069368999c6..e1e824bb2b0 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-command-unknown-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-command-unknown-output.txt @@ -6,4 +6,5 @@ Usage: Available commands: extract Extract the contents from the jar list-layers List layers from the jar that can be extracted + sbom Print the SBOM from the jar help Help about any command diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-output.txt index 8d29d30374b..3c942b60109 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-output.txt @@ -4,4 +4,5 @@ Usage: Available commands: extract Extract the contents from the jar list-layers List layers from the jar that can be extracted + sbom Print the SBOM from the jar help Help about any command diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-sbom-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-sbom-output.txt new file mode 100644 index 00000000000..2d7922bd6cc --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-sbom-output.txt @@ -0,0 +1,7 @@ +Print the SBOM from the jar + +Usage: + java -Djarmode=tools -jar test.jar sbom [options] + +Options: + --destination string File to write the SBOM to. Defaults to printing the SBOM to the console diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-unknown-command-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-unknown-command-output.txt index f31247aea6c..08bd2f86ca2 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-unknown-command-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-unknown-command-output.txt @@ -6,4 +6,5 @@ Usage: Available commands: extract Extract the contents from the jar list-layers List layers from the jar that can be extracted + sbom Print the SBOM from the jar help Help about any command