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,144 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.sbom;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import org.springframework.boot.actuate.endpoint.OperationResponseBody;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.StringUtils;
/**
* {@link Endpoint @Endpoint} to expose an SBOM.
*
* @author Moritz Halbritter
* @since 3.3.0
*/
@Endpoint(id = "sbom")
public class SbomEndpoint {
private static final List<String> DEFAULT_APPLICATION_SBOM_LOCATIONS = List.of("classpath:META-INF/sbom/bom.json",
"classpath:META-INF/sbom/application.cdx.json");
static final String APPLICATION_SBOM_ID = "application";
private final SbomProperties properties;
private final ResourceLoader resourceLoader;
private final Map<String, Resource> sboms;
public SbomEndpoint(SbomProperties properties, ResourceLoader resourceLoader) {
this.properties = properties;
this.resourceLoader = resourceLoader;
this.sboms = Collections.unmodifiableMap(getSboms());
}
private Map<String, Resource> getSboms() {
Map<String, Resource> result = new HashMap<>();
addKnownSboms(result);
addAdditionalSboms(result);
return result;
}
private void addAdditionalSboms(Map<String, Resource> result) {
this.properties.getAdditional().forEach((id, sbom) -> {
Resource resource = loadResource(sbom.getLocation());
if (resource != null) {
if (result.putIfAbsent(id, resource) != null) {
throw new IllegalStateException("Duplicate SBOM registration with id '%s'".formatted(id));
}
}
});
}
private void addKnownSboms(Map<String, Resource> result) {
Resource applicationSbom = getApplicationSbom();
if (applicationSbom != null) {
result.put(APPLICATION_SBOM_ID, applicationSbom);
}
}
@ReadOperation
Sboms sboms() {
return new Sboms(new TreeSet<>(this.sboms.keySet()));
}
@ReadOperation
Resource sbom(@Selector String id) {
return this.sboms.get(id);
}
private Resource getApplicationSbom() {
if (StringUtils.hasLength(this.properties.getApplication().getLocation())) {
return loadResource(this.properties.getApplication().getLocation());
}
for (String location : DEFAULT_APPLICATION_SBOM_LOCATIONS) {
Resource resource = this.resourceLoader.getResource(location);
if (resource.exists()) {
return resource;
}
}
return null;
}
private Resource loadResource(String location) {
if (location == null) {
return null;
}
Location parsedLocation = Location.of(location);
Resource resource = this.resourceLoader.getResource(parsedLocation.location());
if (resource.exists()) {
return resource;
}
if (parsedLocation.optional()) {
return null;
}
throw new IllegalStateException("Resource '%s' doesn't exist and it's not marked optional".formatted(location));
}
record Sboms(Collection<String> ids) implements OperationResponseBody {
}
private record Location(String location, boolean optional) {
private static final String OPTIONAL_PREFIX = "optional:";
static Location of(String location) {
boolean optional = isOptional(location);
return new Location(optional ? stripOptionalPrefix(location) : location, optional);
}
private static boolean isOptional(String location) {
return location.startsWith(OPTIONAL_PREFIX);
}
private static String stripOptionalPrefix(String location) {
return location.substring(OPTIONAL_PREFIX.length());
}
}
}

View File

@@ -0,0 +1,132 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.sbom;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
import org.springframework.boot.actuate.sbom.SbomProperties.Sbom;
import org.springframework.core.io.Resource;
import org.springframework.util.MimeType;
/**
* {@link EndpointWebExtension @EndpointWebExtension} for the {@link SbomEndpoint}.
*
* @author Moritz Halbritter
* @since 3.3.0
*/
@EndpointWebExtension(endpoint = SbomEndpoint.class)
public class SbomEndpointWebExtension {
private final SbomEndpoint sbomEndpoint;
private final SbomProperties properties;
private final Map<String, SbomType> detectedMediaTypeCache = new ConcurrentHashMap<>();
public SbomEndpointWebExtension(SbomEndpoint sbomEndpoint, SbomProperties properties) {
this.sbomEndpoint = sbomEndpoint;
this.properties = properties;
}
@ReadOperation
WebEndpointResponse<Resource> sbom(@Selector String id) {
Resource resource = this.sbomEndpoint.sbom(id);
if (resource == null) {
return new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND);
}
MimeType type = getMediaType(id, resource);
return (type != null) ? new WebEndpointResponse<>(resource, type) : new WebEndpointResponse<>(resource);
}
private MimeType getMediaType(String id, Resource resource) {
if (SbomEndpoint.APPLICATION_SBOM_ID.equals(id) && this.properties.getApplication().getMediaType() != null) {
return this.properties.getApplication().getMediaType();
}
Sbom sbomProperties = this.properties.getAdditional().get(id);
if (sbomProperties != null && sbomProperties.getMediaType() != null) {
return sbomProperties.getMediaType();
}
return this.detectedMediaTypeCache.computeIfAbsent(id, (ignored) -> {
try {
return detectSbomType(resource);
}
catch (IOException ex) {
throw new UncheckedIOException("Failed to detect type of resource '%s'".formatted(resource), ex);
}
}).getMediaType();
}
private SbomType detectSbomType(Resource resource) throws IOException {
String content = resource.getContentAsString(StandardCharsets.UTF_8);
for (SbomType candidate : SbomType.values()) {
if (candidate.matches(content)) {
return candidate;
}
}
return SbomType.UNKNOWN;
}
enum SbomType {
CYCLONE_DX(MimeType.valueOf("application/vnd.cyclonedx+json")) {
@Override
boolean matches(String content) {
return content.replaceAll("\\s", "").contains("\"bomFormat\":\"CycloneDX\"");
}
},
SPDX(MimeType.valueOf("application/spdx+json")) {
@Override
boolean matches(String content) {
return content.contains("\"spdxVersion\"");
}
},
SYFT(MimeType.valueOf("application/vnd.syft+json")) {
@Override
boolean matches(String content) {
return content.contains("\"FoundBy\"") || content.contains("\"foundBy\"");
}
},
UNKNOWN(null) {
@Override
boolean matches(String content) {
return false;
}
};
private final MimeType mediaType;
SbomType(MimeType mediaType) {
this.mediaType = mediaType;
}
MimeType getMediaType() {
return this.mediaType;
}
abstract boolean matches(String content);
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.sbom;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.MimeType;
/**
* Configuration properties for the SBOM endpoint.
*
* @author Moritz Halbritter
* @since 3.3.0
*/
@ConfigurationProperties(prefix = "management.endpoint.sbom")
public class SbomProperties {
/**
* Application SBOM configuration.
*/
private final Sbom application = new Sbom();
/**
* Additional SBOMs.
*/
private Map<String, Sbom> additional = new HashMap<>();
public Sbom getApplication() {
return this.application;
}
public Map<String, Sbom> getAdditional() {
return this.additional;
}
public void setAdditional(Map<String, Sbom> additional) {
this.additional = additional;
}
public static class Sbom {
/**
* Location to the SBOM. If null, the location will be auto-detected.
*/
private String location;
/**
* Media type of the SBOM. If null, the media type will be auto-detected.
*/
private MimeType mediaType;
public String getLocation() {
return this.location;
}
public void setLocation(String location) {
this.location = location;
}
public MimeType getMediaType() {
return this.mediaType;
}
public void setMediaType(MimeType mediaType) {
this.mediaType = mediaType;
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Actuator support for SBOMs.
*/
package org.springframework.boot.actuate.sbom;