Implement SBOM actuator endpoint

Closes gh-39799
This commit is contained in:
Moritz Halbritter 2024-01-15 09:56:58 +01:00 committed by Phillip Webb
parent 75012c5173
commit 4047c00aa5
30 changed files with 22016 additions and 1 deletions

View File

@ -0,0 +1,66 @@
[[sbom]]
= Software Bill of Materials (`sbom`)
The `sbom` endpoint provides information about the software bill of materials (SBOM).
[[sbom.retrieving-available-sboms]]
== Retrieving the available SBOMs
To retrieve the available SBOMs, make a `GET` request to `/actuator/sbom`, as shown in the following curl-based example:
include::partial$rest/actuator/sbom/curl-request.adoc[]
The resulting response is similar to the following:
include::partial$rest/actuator/sbom/http-response.adoc[]
[[sbom.retrieving-available-sboms.response-structure]]
=== Response Structure
The response contains the available SBOMs.
The following table describes the structure of the response:
[cols="2,1,3"]
include::partial$rest/actuator/sbom/response-fields.adoc[]
[[sbom.retrieving-single-sbom]]
== Retrieving a single SBOM
To retrieve the available SBOMs, make a `GET` request to `/actuator/sbom/\{id}`, as shown in the following curl-based example:
include::partial$rest/actuator/sbom/id/curl-request.adoc[]
The preceding example retrieves the SBOM named application.
The resulting response depends on the format of the SBOM.
This example uses the CycloneDX format.
[source,http,options="nowrap"]
----
HTTP/1.1 200 OK
Content-Type: application/vnd.cyclonedx+json
Accept-Ranges: bytes
Content-Length: 160316
{
"bomFormat" : "CycloneDX",
"specVersion" : "1.5",
"serialNumber" : "urn:uuid:13862013-3360-43e5-8055-3645aa43c548",
"version" : 1,
// ...
}
----
[[sbom.retrieving-single-sbom.response-structure]]
=== Response Structure
The response depends on the format of the SBOM:
* https://cyclonedx.org/specification/overview/[CycloneDX]

View File

@ -18,6 +18,7 @@
** xref:api:rest/actuator/metrics.adoc[]
** xref:api:rest/actuator/prometheus.adoc[]
** xref:api:rest/actuator/quartz.adoc[]
** xref:api:rest/actuator/sbom.adoc[]
** xref:api:rest/actuator/scheduledtasks.adoc[]
** xref:api:rest/actuator/sessions.adoc[]
** xref:api:rest/actuator/shutdown.adoc[]

View File

@ -0,0 +1,63 @@
/*
* Copyright 2012-2024 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.actuate.autoconfigure.sbom;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure;
import org.springframework.boot.actuate.sbom.SbomEndpoint;
import org.springframework.boot.actuate.sbom.SbomEndpointWebExtension;
import org.springframework.boot.actuate.sbom.SbomProperties;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.ResourceLoader;
/**
* {@link EnableAutoConfiguration Auto-configuration} for {@link SbomEndpoint}.
*
* @author Moritz Halbritter
* @since 3.3.0
*/
@AutoConfiguration
@ConditionalOnAvailableEndpoint(endpoint = SbomEndpoint.class)
@EnableConfigurationProperties(SbomProperties.class)
public class SbomEndpointAutoConfiguration {
private final SbomProperties properties;
SbomEndpointAutoConfiguration(SbomProperties properties) {
this.properties = properties;
}
@Bean
@ConditionalOnMissingBean
SbomEndpoint sbomEndpoint(ResourceLoader resourceLoader) {
return new SbomEndpoint(this.properties, resourceLoader);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(SbomEndpoint.class)
@ConditionalOnAvailableEndpoint(exposure = EndpointExposure.WEB)
SbomEndpointWebExtension sbomEndpointWebExtension(SbomEndpoint sbomEndpoint) {
return new SbomEndpointWebExtension(sbomEndpoint, this.properties);
}
}

View File

@ -0,0 +1,20 @@
/*
* Copyright 2012-2024 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.
*/
/**
* Auto-configuration for actuator SBOM concerns.
*/
package org.springframework.boot.actuate.autoconfigure.sbom;

View File

@ -95,6 +95,7 @@ org.springframework.boot.actuate.autoconfigure.r2dbc.ConnectionFactoryHealthCont
org.springframework.boot.actuate.autoconfigure.r2dbc.R2dbcObservationAutoConfiguration
org.springframework.boot.actuate.autoconfigure.data.redis.RedisHealthContributorAutoConfiguration
org.springframework.boot.actuate.autoconfigure.data.redis.RedisReactiveHealthContributorAutoConfiguration
org.springframework.boot.actuate.autoconfigure.sbom.SbomEndpointAutoConfiguration
org.springframework.boot.actuate.autoconfigure.scheduling.ScheduledTasksEndpointAutoConfiguration
org.springframework.boot.actuate.autoconfigure.scheduling.ScheduledTasksObservabilityAutoConfiguration
org.springframework.boot.actuate.autoconfigure.security.reactive.ReactiveManagementWebSecurityAutoConfiguration

View File

@ -0,0 +1,80 @@
/*
* Copyright 2012-2024 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.actuate.autoconfigure.endpoint.web.documentation;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.sbom.SbomEndpoint;
import org.springframework.boot.actuate.sbom.SbomEndpointWebExtension;
import org.springframework.boot.actuate.sbom.SbomProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.io.ResourceLoader;
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests for generating documentation describing the {@link SbomEndpoint}.
*
* @author Moritz Halbritter
*/
class SbomEndpointDocumentationTests extends MockMvcEndpointDocumentationTests {
@Test
void sbom() throws Exception {
this.mockMvc.perform(get("/actuator/sbom"))
.andExpect(status().isOk())
.andDo(MockMvcRestDocumentation.document("sbom",
responseFields(fieldWithPath("ids").description("An array of available SBOM ids."))));
}
@Test
void sboms() throws Exception {
this.mockMvc.perform(get("/actuator/sbom/application"))
.andExpect(status().isOk())
.andDo(MockMvcRestDocumentation.document("sbom/id"));
}
@Configuration(proxyBeanMethods = false)
@Import(BaseDocumentationConfiguration.class)
static class TestConfiguration {
@Bean
SbomProperties sbomProperties() {
SbomProperties properties = new SbomProperties();
properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
return properties;
}
@Bean
SbomEndpoint endpoint(SbomProperties properties, ResourceLoader resourceLoader) {
return new SbomEndpoint(properties, resourceLoader);
}
@Bean
SbomEndpointWebExtension sbomEndpointWebExtension(SbomEndpoint endpoint, SbomProperties properties) {
return new SbomEndpointWebExtension(endpoint, properties);
}
}
}

View File

@ -0,0 +1,65 @@
/*
* Copyright 2012-2024 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.actuate.autoconfigure.sbom;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.sbom.SbomEndpoint;
import org.springframework.boot.actuate.sbom.SbomEndpointWebExtension;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link SbomEndpointAutoConfiguration}.
*
* @author Moritz Halbritter
*/
class SbomEndpointAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(SbomEndpointAutoConfiguration.class));
@Test
void runShouldHaveEndpointBean() {
this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=sbom")
.run((context) -> assertThat(context).hasSingleBean(SbomEndpoint.class));
}
@Test
void runWhenNotExposedShouldNotHaveEndpointBean() {
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(SbomEndpoint.class));
}
@Test
void runWhenEnabledPropertyIsFalseShouldNotHaveEndpointBean() {
this.contextRunner.withPropertyValues("management.endpoint.sbom.enabled:false")
.withPropertyValues("management.endpoints.web.exposure.include=*")
.run((context) -> assertThat(context).doesNotHaveBean(SbomEndpoint.class));
}
@Test
void runWhenOnlyExposedOverJmxShouldHaveEndpointBeanWithoutWebExtension() {
this.contextRunner
.withPropertyValues("management.endpoints.web.exposure.include=info", "spring.jmx.enabled=true",
"management.endpoints.jmx.exposure.include=sbom")
.run((context) -> assertThat(context).hasSingleBean(SbomEndpoint.class)
.doesNotHaveBean(SbomEndpointWebExtension.class));
}
}

View File

@ -0,0 +1,144 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import org.springframework.boot.actuate.endpoint.OperationResponseBody;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.StringUtils;
/**
* {@link Endpoint @Endpoint} to expose an SBOM.
*
* @author Moritz Halbritter
* @since 3.3.0
*/
@Endpoint(id = "sbom")
public class SbomEndpoint {
private static final List<String> DEFAULT_APPLICATION_SBOM_LOCATIONS = List.of("classpath:META-INF/sbom/bom.json",
"classpath:META-INF/sbom/application.cdx.json");
static final String APPLICATION_SBOM_ID = "application";
private final SbomProperties properties;
private final ResourceLoader resourceLoader;
private final Map<String, Resource> sboms;
public SbomEndpoint(SbomProperties properties, ResourceLoader resourceLoader) {
this.properties = properties;
this.resourceLoader = resourceLoader;
this.sboms = Collections.unmodifiableMap(getSboms());
}
private Map<String, Resource> getSboms() {
Map<String, Resource> result = new HashMap<>();
addKnownSboms(result);
addAdditionalSboms(result);
return result;
}
private void addAdditionalSboms(Map<String, Resource> result) {
this.properties.getAdditional().forEach((id, sbom) -> {
Resource resource = loadResource(sbom.getLocation());
if (resource != null) {
if (result.putIfAbsent(id, resource) != null) {
throw new IllegalStateException("Duplicate SBOM registration with id '%s'".formatted(id));
}
}
});
}
private void addKnownSboms(Map<String, Resource> result) {
Resource applicationSbom = getApplicationSbom();
if (applicationSbom != null) {
result.put(APPLICATION_SBOM_ID, applicationSbom);
}
}
@ReadOperation
Sboms sboms() {
return new Sboms(new TreeSet<>(this.sboms.keySet()));
}
@ReadOperation
Resource sbom(@Selector String id) {
return this.sboms.get(id);
}
private Resource getApplicationSbom() {
if (StringUtils.hasLength(this.properties.getApplication().getLocation())) {
return loadResource(this.properties.getApplication().getLocation());
}
for (String location : DEFAULT_APPLICATION_SBOM_LOCATIONS) {
Resource resource = this.resourceLoader.getResource(location);
if (resource.exists()) {
return resource;
}
}
return null;
}
private Resource loadResource(String location) {
if (location == null) {
return null;
}
Location parsedLocation = Location.of(location);
Resource resource = this.resourceLoader.getResource(parsedLocation.location());
if (resource.exists()) {
return resource;
}
if (parsedLocation.optional()) {
return null;
}
throw new IllegalStateException("Resource '%s' doesn't exist and it's not marked optional".formatted(location));
}
record Sboms(Collection<String> ids) implements OperationResponseBody {
}
private record Location(String location, boolean optional) {
private static final String OPTIONAL_PREFIX = "optional:";
static Location of(String location) {
boolean optional = isOptional(location);
return new Location(optional ? stripOptionalPrefix(location) : location, optional);
}
private static boolean isOptional(String location) {
return location.startsWith(OPTIONAL_PREFIX);
}
private static String stripOptionalPrefix(String location) {
return location.substring(OPTIONAL_PREFIX.length());
}
}
}

View File

@ -0,0 +1,132 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.boot.actuate.sbom.SbomProperties.Sbom;
import org.springframework.core.io.Resource;
import org.springframework.util.MimeType;
/**
* {@link EndpointWebExtension @EndpointWebExtension} for the {@link SbomEndpoint}.
*
* @author Moritz Halbritter
* @since 3.3.0
*/
@EndpointWebExtension(endpoint = SbomEndpoint.class)
public class SbomEndpointWebExtension {
private final SbomEndpoint sbomEndpoint;
private final SbomProperties properties;
private final Map<String, SbomType> detectedMediaTypeCache = new ConcurrentHashMap<>();
public SbomEndpointWebExtension(SbomEndpoint sbomEndpoint, SbomProperties properties) {
this.sbomEndpoint = sbomEndpoint;
this.properties = properties;
}
@ReadOperation
WebEndpointResponse<Resource> sbom(@Selector String id) {
Resource resource = this.sbomEndpoint.sbom(id);
if (resource == null) {
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND);
}
MimeType type = getMediaType(id, resource);
return (type != null) ? new WebEndpointResponse<>(resource, type) : new WebEndpointResponse<>(resource);
}
private MimeType getMediaType(String id, Resource resource) {
if (SbomEndpoint.APPLICATION_SBOM_ID.equals(id) && this.properties.getApplication().getMediaType() != null) {
return this.properties.getApplication().getMediaType();
}
Sbom sbomProperties = this.properties.getAdditional().get(id);
if (sbomProperties != null && sbomProperties.getMediaType() != null) {
return sbomProperties.getMediaType();
}
return this.detectedMediaTypeCache.computeIfAbsent(id, (ignored) -> {
try {
return detectSbomType(resource);
}
catch (IOException ex) {
throw new UncheckedIOException("Failed to detect type of resource '%s'".formatted(resource), ex);
}
}).getMediaType();
}
private SbomType detectSbomType(Resource resource) throws IOException {
String content = resource.getContentAsString(StandardCharsets.UTF_8);
for (SbomType candidate : SbomType.values()) {
if (candidate.matches(content)) {
return candidate;
}
}
return SbomType.UNKNOWN;
}
enum SbomType {
CYCLONE_DX(MimeType.valueOf("application/vnd.cyclonedx+json")) {
@Override
boolean matches(String content) {
return content.replaceAll("\\s", "").contains("\"bomFormat\":\"CycloneDX\"");
}
},
SPDX(MimeType.valueOf("application/spdx+json")) {
@Override
boolean matches(String content) {
return content.contains("\"spdxVersion\"");
}
},
SYFT(MimeType.valueOf("application/vnd.syft+json")) {
@Override
boolean matches(String content) {
return content.contains("\"FoundBy\"") || content.contains("\"foundBy\"");
}
},
UNKNOWN(null) {
@Override
boolean matches(String content) {
return false;
}
};
private final MimeType mediaType;
SbomType(MimeType mediaType) {
this.mediaType = mediaType;
}
MimeType getMediaType() {
return this.mediaType;
}
abstract boolean matches(String content);
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.MimeType;
/**
* Configuration properties for the SBOM endpoint.
*
* @author Moritz Halbritter
* @since 3.3.0
*/
@ConfigurationProperties(prefix = "management.endpoint.sbom")
public class SbomProperties {
/**
* Application SBOM configuration.
*/
private final Sbom application = new Sbom();
/**
* Additional SBOMs.
*/
private Map<String, Sbom> additional = new HashMap<>();
public Sbom getApplication() {
return this.application;
}
public Map<String, Sbom> getAdditional() {
return this.additional;
}
public void setAdditional(Map<String, Sbom> additional) {
this.additional = additional;
}
public static class Sbom {
/**
* Location to the SBOM. If null, the location will be auto-detected.
*/
private String location;
/**
* Media type of the SBOM. If null, the media type will be auto-detected.
*/
private MimeType mediaType;
public String getLocation() {
return this.location;
}
public void setLocation(String location) {
this.location = location;
}
public MimeType getMediaType() {
return this.mediaType;
}
public void setMediaType(MimeType mediaType) {
this.mediaType = mediaType;
}
}
}

View File

@ -0,0 +1,20 @@
/*
* Copyright 2012-2024 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.
*/
/**
* Actuator support for SBOMs.
*/
package org.springframework.boot.actuate.sbom;

View File

@ -0,0 +1,70 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
/**
* Integration tests for {@link SbomEndpoint} exposed by Jersey, Spring MVC, and WebFlux
* in CycloneDX format.
*
* @author Moritz Halbritter
*/
class SbomEndpointCycloneDxWebIntegrationTests {
@WebEndpointTest
void shouldReturnSbomContent(WebTestClient client) {
client.get()
.uri("/actuator/sbom/application")
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentType(MediaType.parseMediaType("application/vnd.cyclonedx+json"))
.expectBody()
.jsonPath("$.bomFormat")
.isEqualTo("CycloneDX");
}
@Configuration(proxyBeanMethods = false)
static class TestConfiguration {
@Bean
SbomProperties sbomProperties() {
SbomProperties properties = new SbomProperties();
properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
return properties;
}
@Bean
SbomEndpoint sbomEndpoint(SbomProperties properties, ResourceLoader resourceLoader) {
return new SbomEndpoint(properties, resourceLoader);
}
@Bean
SbomEndpointWebExtension sbomEndpointWebExtension(SbomEndpoint sbomEndpoint, SbomProperties properties) {
return new SbomEndpointWebExtension(sbomEndpoint, properties);
}
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
/**
* Integration tests for {@link SbomEndpoint} exposed by Jersey, Spring MVC, and WebFlux
* in SPDX format.
*
* @author Moritz Halbritter
*/
class SbomEndpointSpdxWebIntegrationTests {
@WebEndpointTest
void shouldReturnSbomContent(WebTestClient client) {
client.get()
.uri("/actuator/sbom/application")
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentType(MediaType.parseMediaType("application/spdx+json"))
.expectBody()
.jsonPath("$.spdxVersion")
.isEqualTo("SPDX-2.3");
}
@Configuration(proxyBeanMethods = false)
static class TestConfiguration {
@Bean
SbomProperties sbomProperties() {
SbomProperties properties = new SbomProperties();
properties.getApplication().setLocation("classpath:sbom/spdx.json");
return properties;
}
@Bean
SbomEndpoint sbomEndpoint(SbomProperties properties, ResourceLoader resourceLoader) {
return new SbomEndpoint(properties, resourceLoader);
}
@Bean
SbomEndpointWebExtension sbomEndpointWebExtension(SbomEndpoint sbomEndpoint, SbomProperties properties) {
return new SbomEndpointWebExtension(sbomEndpoint, properties);
}
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
/**
* Integration tests for {@link SbomEndpoint} exposed by Jersey, Spring MVC, and WebFlux
* in Syft format.
*
* @author Moritz Halbritter
*/
class SbomEndpointSyftWebIntegrationTests {
@WebEndpointTest
void shouldReturnSbomContent(WebTestClient client) {
client.get()
.uri("/actuator/sbom/application")
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentType(MediaType.parseMediaType("application/vnd.syft+json"))
.expectBody()
.jsonPath("$.descriptor.name")
.isEqualTo("syft");
}
@Configuration(proxyBeanMethods = false)
static class TestConfiguration {
@Bean
SbomProperties sbomProperties() {
SbomProperties properties = new SbomProperties();
properties.getApplication().setLocation("classpath:sbom/syft.json");
return properties;
}
@Bean
SbomEndpoint sbomEndpoint(SbomProperties properties, ResourceLoader resourceLoader) {
return new SbomEndpoint(properties, resourceLoader);
}
@Bean
SbomEndpointWebExtension sbomEndpointWebExtension(SbomEndpoint sbomEndpoint, SbomProperties properties) {
return new SbomEndpointWebExtension(sbomEndpoint, properties);
}
}
}

View File

@ -0,0 +1,94 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.sbom.SbomEndpoint.Sboms;
import org.springframework.boot.actuate.sbom.SbomProperties.Sbom;
import org.springframework.context.support.GenericApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/**
* Tests for {@link SbomEndpoint}.
*
* @author Moritz Halbritter
*/
class SbomEndpointTests {
private SbomProperties properties;
@BeforeEach
void setUp() {
this.properties = new SbomProperties();
}
@Test
void shouldListSboms() {
this.properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
this.properties.getAdditional().put("alpha", sbom("classpath:sbom/cyclonedx.json"));
this.properties.getAdditional().put("beta", sbom("classpath:sbom/cyclonedx.json"));
Sboms sboms = createEndpoint().sboms();
assertThat(sboms.ids()).containsExactly("alpha", "application", "beta");
}
@Test
void shouldFailIfDuplicateSbomIdIsRegistered() {
// This adds an SBOM with id 'application'
this.properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
this.properties.getAdditional().put("application", sbom("classpath:sbom/cyclonedx.json"));
assertThatIllegalStateException().isThrownBy(this::createEndpoint)
.withMessage("Duplicate SBOM registration with id 'application'");
}
@Test
void shouldUseLocationFromProperties() throws IOException {
this.properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
String content = createEndpoint().sbom("application").getContentAsString(StandardCharsets.UTF_8);
assertThat(content).contains("\"bomFormat\" : \"CycloneDX\"");
}
@Test
void shouldFailIfNonExistingLocationIsGiven() {
this.properties.getApplication().setLocation("classpath:does-not-exist.json");
assertThatIllegalStateException().isThrownBy(() -> createEndpoint().sbom("application"))
.withMessageContaining("Resource 'classpath:does-not-exist.json' doesn't exist");
}
@Test
void shouldNotFailIfNonExistingOptionalLocationIsGiven() {
this.properties.getApplication().setLocation("optional:classpath:does-not-exist.json");
assertThat(createEndpoint().sbom("application")).isNull();
}
private Sbom sbom(String location) {
Sbom result = new Sbom();
result.setLocation(location);
return result;
}
private SbomEndpoint createEndpoint() {
return new SbomEndpoint(this.properties, new GenericApplicationContext());
}
}

View File

@ -0,0 +1,149 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.EnumSource.Mode;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.sbom.SbomEndpointWebExtension.SbomType;
import org.springframework.boot.actuate.sbom.SbomProperties.Sbom;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.util.MimeType;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link SbomEndpointWebExtension}.
*
* @author Moritz Halbritter
*/
class SbomEndpointWebExtensionTests {
private SbomProperties properties;
@BeforeEach
void setUp() {
this.properties = new SbomProperties();
}
@Test
void shouldReturnHttpOk() {
this.properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
WebEndpointResponse<Resource> response = createWebExtension().sbom("application");
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void shouldReturnNotFoundIfResourceDoesntExist() {
WebEndpointResponse<Resource> response = createWebExtension().sbom("application");
assertThat(response.getStatus()).isEqualTo(404);
}
@Test
void shouldAutoDetectContentTypeForCycloneDx() {
this.properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
WebEndpointResponse<Resource> response = createWebExtension().sbom("application");
assertThat(response.getContentType()).isEqualTo(MimeType.valueOf("application/vnd.cyclonedx+json"));
}
@Test
void shouldAutoDetectContentTypeForSpdx() {
this.properties.getApplication().setLocation("classpath:sbom/spdx.json");
WebEndpointResponse<Resource> response = createWebExtension().sbom("application");
assertThat(response.getContentType()).isEqualTo(MimeType.valueOf("application/spdx+json"));
}
@Test
void shouldAutoDetectContentTypeForSyft() {
this.properties.getApplication().setLocation("classpath:sbom/syft.json");
WebEndpointResponse<Resource> response = createWebExtension().sbom("application");
assertThat(response.getContentType()).isEqualTo(MimeType.valueOf("application/vnd.syft+json"));
}
@Test
void shouldSupportUnknownFiles() {
this.properties.getApplication().setLocation("classpath:git.properties");
WebEndpointResponse<Resource> response = createWebExtension().sbom("application");
assertThat(response.getContentType()).isNull();
}
@Test
void shouldUseContentTypeIfSet() {
this.properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
this.properties.getApplication().setMediaType(MimeType.valueOf("text/plain"));
WebEndpointResponse<Resource> response = createWebExtension().sbom("application");
assertThat(response.getContentType()).isEqualTo(MimeType.valueOf("text/plain"));
}
@Test
void shouldUseContentTypeForAdditionalSbomsIfSet() {
this.properties.getAdditional()
.put("alpha", sbom("classpath:sbom/cyclonedx.json", MediaType.valueOf("text/plain")));
WebEndpointResponse<Resource> response = createWebExtension().sbom("alpha");
assertThat(response.getContentType()).isEqualTo(MimeType.valueOf("text/plain"));
}
@ParameterizedTest
@EnumSource(value = SbomType.class, names = "UNKNOWN", mode = Mode.EXCLUDE)
void shouldAutodetectFormats(SbomType type) throws IOException {
String content = getSbomContent(type);
assertThat(type.matches(content)).isTrue();
Arrays.stream(SbomType.values())
.filter((candidate) -> candidate != type)
.forEach((notType) -> assertThat(notType.matches(content)).isFalse());
}
private String getSbomContent(SbomType type) throws IOException {
return switch (type) {
case CYCLONE_DX -> readResource("/sbom/cyclonedx.json");
case SPDX -> readResource("/sbom/spdx.json");
case SYFT -> readResource("/sbom/syft.json");
case UNKNOWN -> throw new IllegalArgumentException("UNKNOWN is not supported");
};
}
private String readResource(String resource) throws IOException {
try (InputStream stream = getClass().getResourceAsStream(resource)) {
assertThat(stream).as("Resource '%s'", resource).isNotNull();
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
private Sbom sbom(String location, MimeType mediaType) {
Sbom sbom = new Sbom();
sbom.setLocation(location);
sbom.setMediaType(mediaType);
return sbom;
}
private SbomEndpointWebExtension createWebExtension() {
SbomEndpoint endpoint = new SbomEndpoint(this.properties, new GenericApplicationContext());
return new SbomEndpointWebExtension(endpoint, this.properties);
}
}

View File

@ -0,0 +1,73 @@
/*
* Copyright 2012-2024 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.actuate.sbom;
import net.minidev.json.JSONArray;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link SbomEndpoint} exposed by Jersey, Spring MVC, and WebFlux.
*
* @author Moritz Halbritter
*/
class SbomEndpointWebIntegrationTests {
@WebEndpointTest
void shouldReturnSboms(WebTestClient client) {
client.get()
.uri("/actuator/sbom")
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentType(MediaType.parseMediaType("application/vnd.spring-boot.actuator.v3+json"))
.expectBody()
.jsonPath("$.ids")
.value((value) -> assertThat(value).isEqualTo(new JSONArray().appendElement("application")));
}
@Configuration(proxyBeanMethods = false)
static class TestConfiguration {
@Bean
SbomProperties sbomProperties() {
SbomProperties properties = new SbomProperties();
properties.getApplication().setLocation("classpath:sbom/cyclonedx.json");
return properties;
}
@Bean
SbomEndpoint sbomEndpoint(SbomProperties properties, ResourceLoader resourceLoader) {
return new SbomEndpoint(properties, resourceLoader);
}
@Bean
SbomEndpointWebExtension sbomEndpointWebExtension(SbomEndpoint sbomEndpoint, SbomProperties properties) {
return new SbomEndpointWebExtension(sbomEndpoint, properties);
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -304,6 +304,13 @@ bom {
]
}
}
library("CycloneDX Maven Plugin", "2.7.11") {
group("org.cyclonedx") {
plugins = [
"cyclonedx-maven-plugin"
]
}
}
library("DB2 JDBC", "11.5.9.0") {
group("com.ibm.db2") {
modules = [

View File

@ -48,6 +48,13 @@ bom {
]
}
}
library("CycloneDX Gradle Plugin", "1.8.2") {
group("org.cyclonedx") {
modules = [
"cyclonedx-gradle-plugin"
]
}
}
library("Janino", "3.1.10") {
group("org.codehaus.janino") {
imports = [

View File

@ -152,6 +152,24 @@ publishing.publications.withType(MavenPublication) {
delegate.generateGitPropertiesFilename('${project.build.outputDirectory}/git.properties')
}
}
plugin {
delegate.groupId('org.cyclonedx')
delegate.artifactId('cyclonedx-maven-plugin')
executions {
execution {
delegate.phase('generate-resources')
goals {
delegate.goal('makeAggregateBom')
}
}
}
configuration {
delegate.projectType('application')
delegate.outputDirectory('${project.build.outputDirectory}/META-INF/sbom')
delegate.outputFormat('json')
delegate.outputName('application.cdx')
}
}
plugin {
delegate.groupId('org.springframework.boot')
delegate.artifactId('spring-boot-maven-plugin')

View File

@ -41,6 +41,9 @@ dependencies {
implementation("org.springframework:spring-core")
optional("org.graalvm.buildtools:native-gradle-plugin")
optional("org.cyclonedx:cyclonedx-gradle-plugin") {
exclude(group: "org.apache.maven", module: "maven-core")
}
optional("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") {
exclude(group: "commons-logging", module: "commons-logging")
}
@ -55,6 +58,14 @@ dependencies {
testImplementation("org.testcontainers:testcontainers")
}
repositories {
gradlePluginPortal() {
content {
includeGroup("org.cyclonedx")
}
}
}
gradlePlugin {
plugins {
springBootPlugin {

View File

@ -0,0 +1,69 @@
/*
* Copyright 2012-2024 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.gradle.plugin;
import org.cyclonedx.gradle.CycloneDxPlugin;
import org.cyclonedx.gradle.CycloneDxTask;
import org.gradle.api.Action;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.tasks.TaskProvider;
import org.springframework.boot.gradle.tasks.bundling.BootJar;
/**
* {@link Action} that is executed in response to the {@link CycloneDxPlugin} being
* applied.
*
* @author Moritz Halbritter
*/
final class CycloneDxPluginAction implements PluginApplicationAction {
@Override
public Class<? extends Plugin<? extends Project>> getPluginClass() {
return CycloneDxPlugin.class;
}
@Override
public void execute(Project project) {
TaskProvider<CycloneDxTask> cyclonedxBom = project.getTasks().named("cyclonedxBom", CycloneDxTask.class);
cyclonedxBom.configure((task) -> {
task.getProjectType().convention("application");
task.getOutputFormat().convention("json");
task.getOutputName().convention("application.cdx");
task.getIncludeLicenseText().convention(false);
});
project.getTasks().named(SpringBootPlugin.BOOT_JAR_TASK_NAME, BootJar.class).configure((bootJar) -> {
CycloneDxTask cycloneDxTask = cyclonedxBom.get();
String sbomFileName = cycloneDxTask.getOutputName().get() + getSbomExtension(cycloneDxTask);
bootJar.from(cycloneDxTask, (spec) -> spec.include(sbomFileName).into("META-INF/sbom"));
bootJar.manifest((manifest) -> {
manifest.getAttributes().put("Sbom-Format", "CycloneDX");
manifest.getAttributes().put("Sbom-Location", "META-INF/sbom/" + sbomFileName);
});
});
}
private String getSbomExtension(CycloneDxTask task) {
String format = task.getOutputFormat().get();
if ("all".equals(format)) {
return ".json";
}
return "." + format;
}
}

View File

@ -145,7 +145,8 @@ public class SpringBootPlugin implements Plugin<Project> {
project.getArtifacts());
List<PluginApplicationAction> actions = Arrays.asList(new JavaPluginAction(singlePublishedArtifact),
new WarPluginAction(singlePublishedArtifact), new DependencyManagementPluginAction(),
new ApplicationPluginAction(), new KotlinPluginAction(), new NativeImagePluginAction());
new ApplicationPluginAction(), new KotlinPluginAction(), new NativeImagePluginAction(),
new CycloneDxPluginAction());
for (PluginApplicationAction action : actions) {
withPluginClassOfAction(action,
(pluginClass) -> project.getPlugins().withType(pluginClass, (plugin) -> action.execute(project)));

View File

@ -72,6 +72,10 @@ public abstract class Packager {
private static final String BOOT_LAYERS_INDEX_ATTRIBUTE = "Spring-Boot-Layers-Index";
private static final String SBOM_LOCATION_ATTRIBUTE = "Sbom-Location";
private static final String SBOM_FORMAT_ATTRIBUTE = "Sbom-Format";
private static final byte[] ZIP_FILE_HEADER = new byte[] { 'P', 'K', 3, 4 };
private static final long FIND_WARNING_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
@ -299,6 +303,7 @@ public abstract class Packager {
Manifest manifest = createInitialManifest(source);
addMainAndStartAttributes(source, manifest);
addBootAttributes(manifest.getMainAttributes());
addSbomAttributes(source, manifest.getMainAttributes());
return manifest;
}
@ -408,6 +413,21 @@ public abstract class Packager {
}
}
private void addSbomAttributes(JarFile source, Attributes attributes) {
JarEntry sbomEntry = source.stream().filter(this::isCycloneDxBom).findAny().orElse(null);
if (sbomEntry != null) {
attributes.putValue(SBOM_LOCATION_ATTRIBUTE, sbomEntry.getName());
attributes.putValue(SBOM_FORMAT_ATTRIBUTE, "CycloneDX");
}
}
private boolean isCycloneDxBom(JarEntry entry) {
if (!entry.getName().startsWith("META-INF/sbom/")) {
return false;
}
return entry.getName().endsWith(".cdx.json") || entry.getName().endsWith("/bom.json");
}
private void putIfHasLength(Attributes attributes, String name, String value) {
if (StringUtils.hasLength(value)) {
attributes.putValue(name, value);

View File

@ -656,6 +656,17 @@ abstract class AbstractPackagerTests<P extends Packager> {
.isEqualTo(String.join("\n", expected) + "\n");
}
@Test
void sbomManifestEntriesAreWritten() throws IOException {
this.testJarFile.addClass("com/example/Application.class", ClassWithMainMethod.class);
this.testJarFile.addFile("META-INF/sbom/application.cdx.json", new ByteArrayInputStream(new byte[0]));
P packager = createPackager(this.testJarFile.getFile());
execute(packager, NO_LIBRARIES);
assertThat(getPackagedManifest().getMainAttributes().getValue("Sbom-Format")).isEqualTo("CycloneDX");
assertThat(getPackagedManifest().getMainAttributes().getValue("Sbom-Location"))
.isEqualTo("META-INF/sbom/application.cdx.json");
}
private File createLibraryJar() throws IOException {
TestJarFile library = new TestJarFile(this.tempDir);
library.addClass("com/example/library/Library.class", ClassWithoutMainMethod.class);

View File

@ -10,4 +10,7 @@
<suppress files="jquery-[0-9]\.[0-9]\.[0-9].js" checks="NoHttp" />
<suppress files="[\\/]spring-boot-project.setup" checks="NoHttp" />
<suppress files="DockerHostTests\.java" checks="NoHttp" />
<suppress files="cyclonedx\.json" checks="NoHttp" />
<suppress files="spdx\.json" checks="NoHttp" />
<suppress files="syft\.json" checks="NoHttp" />
</suppressions>