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,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