Implement SBOM actuator endpoint
Closes gh-39799
This commit is contained in:
committed by
Phillip Webb
parent
75012c5173
commit
4047c00aa5
@@ -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
Reference in New Issue
Block a user