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;
|
||||
Reference in New Issue
Block a user