mirror of
https://github.com/spring-projects/spring-boot.git
synced 2024-06-30 00:36:49 +08:00
Implement SBOM actuator endpoint
Closes gh-39799
This commit is contained in:
parent
75012c5173
commit
4047c00aa5
|
@ -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]
|
||||
|
|
@ -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[]
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
@ -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 = [
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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)));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue
Block a user