From 82ffe5eb9545393be4a69e2a295e0fb87fccae10 Mon Sep 17 00:00:00 2001 From: nscuro Date: Sun, 3 Mar 2024 18:34:49 +0100 Subject: [PATCH] Validate uploaded BOMs against CycloneDX schema Closes #3218 Signed-off-by: nscuro --- docs/_posts/2024-xx-xx-v4.11.0.md | 11 + .../org/dependencytrack/common/ConfigKey.java | 3 +- .../parser/cyclonedx/CycloneDxValidator.java | 208 ++++++++++++++++++ .../parser/cyclonedx/InvalidBomException.java | 52 +++++ .../resources/v1/BomResource.java | 70 +++++- .../resources/v1/VexResource.java | 19 +- .../v1/problems/InvalidBomProblemDetails.java | 41 ++++ .../resources/v1/problems/ProblemDetails.java | 111 ++++++++++ .../cyclonedx/CycloneDxValidatorTest.java | 165 ++++++++++++++ .../resources/v1/BomResourceTest.java | 98 +++++++++ .../resources/v1/VexResourceTest.java | 104 ++++++++- src/test/resources/unit/bom-1.xml | 2 +- 12 files changed, 877 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidator.java create mode 100644 src/main/java/org/dependencytrack/parser/cyclonedx/InvalidBomException.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/problems/InvalidBomProblemDetails.java create mode 100644 src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java create mode 100644 src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidatorTest.java diff --git a/docs/_posts/2024-xx-xx-v4.11.0.md b/docs/_posts/2024-xx-xx-v4.11.0.md index 1ca4d09bc8..218bb35aca 100644 --- a/docs/_posts/2024-xx-xx-v4.11.0.md +++ b/docs/_posts/2024-xx-xx-v4.11.0.md @@ -12,6 +12,12 @@ predictable, and log messages emitted during processing contain additional conte Because the new implementation can have a big impact on how Dependency-Track behaves regarding BOM uploads, it is disabled by default for this release. It may be enabled by setting the environment variable `BOM_PROCESSING_TASK_V2_ENABLED` to `true`. Users are highly encouraged to do so. +* **BOM Validation**. Historically, Dependency-Track did not validate uploaded BOMs and VEXs against the CycloneDX +schema. While this allowed BOMs to be processed that did not strictly adhere to the schema, it could lead to confusion +when uploaded files were accepted, but then failed to be ingested during asynchronous processing. Starting with this +release, uploaded files will be rejected if they fail schema validation. Note that this may reveal issues in BOM +generators that currently produce invalid CycloneDX documents. Validation may be turned off by setting the +environment variable `BOM_VALIDATION_ENABLED` to `false`. **Features:** @@ -39,6 +45,7 @@ to `true`. Users are highly encouraged to do so. * Align retry configuration and behavior across analyzers - [apiserver/#3494] * Add auto-generated changelog to GitHub releases - [apiserver/#3502] * Bump SPDX license list to v3.23 - [apiserver/#3508] +* Validate uploaded BOMs against CycloneDX schema prior to processing them - [apiserver/#3522] * Show component count in projects list - [frontend/#683] * Add current *fail*, *warn*, and *info* values to bottom of policy violation metrics - [frontend/#707] * Remove unused policy violation widget - [frontend/#710] @@ -80,6 +87,9 @@ to `true`. Users are highly encouraged to do so. **Upgrade Notes:** +* To enable the optimized BOM ingestion, set the environment variable `BOM_PROCESSING_TASK_V2_ENABLED` to `true` +* Validation of uploaded BOMs and VEXs is enabled per default, but can be disabled by setting the environment +variable `BOM_VALIDATION_ENABLED` to `false` * The `CWE` table is dropped automatically upon upgrade * The default logging configuration ([logback.xml]) was updated to include the [Mapped Diagnostic Context] (MDC) * Users who [customized their logging configuration] are recommended to follow this change @@ -162,6 +172,7 @@ Special thanks to everyone who contributed code to implement enhancements and fi [apiserver/#3511]: https://github.com/DependencyTrack/dependency-track/pull/3511 [apiserver/#3512]: https://github.com/DependencyTrack/dependency-track/pull/3512 [apiserver/#3513]: https://github.com/DependencyTrack/dependency-track/pull/3513 +[apiserver/#3522]: https://github.com/DependencyTrack/dependency-track/pull/3522 [frontend/#682]: https://github.com/DependencyTrack/frontend/pull/682 [frontend/#683]: https://github.com/DependencyTrack/frontend/pull/683 diff --git a/src/main/java/org/dependencytrack/common/ConfigKey.java b/src/main/java/org/dependencytrack/common/ConfigKey.java index 03fb71b5bd..d92eace9af 100644 --- a/src/main/java/org/dependencytrack/common/ConfigKey.java +++ b/src/main/java/org/dependencytrack/common/ConfigKey.java @@ -40,7 +40,8 @@ public enum ConfigKey implements Config.Key { REPO_META_ANALYZER_CACHE_STAMPEDE_BLOCKER_LOCK_BUCKETS("repo.meta.analyzer.cacheStampedeBlocker.lock.buckets", 1000), REPO_META_ANALYZER_CACHE_STAMPEDE_BLOCKER_MAX_ATTEMPTS("repo.meta.analyzer.cacheStampedeBlocker.max.attempts", 10), SYSTEM_REQUIREMENT_CHECK_ENABLED("system.requirement.check.enabled", true), - BOM_PROCESSING_TASK_V2_ENABLED("bom.processing.task.v2.enabled", false); + BOM_PROCESSING_TASK_V2_ENABLED("bom.processing.task.v2.enabled", false), + BOM_VALIDATION_ENABLED("bom.validation.enabled", true); private final String propertyName; private final Object defaultValue; diff --git a/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidator.java b/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidator.java new file mode 100644 index 0000000000..56f27d199a --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidator.java @@ -0,0 +1,208 @@ +/* + * This file is part of Dependency-Track. + * + * 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 + * + * http://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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) Steve Springett. All Rights Reserved. + */ +package org.dependencytrack.parser.cyclonedx; + +import alpine.common.logging.Logger; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.codehaus.stax2.XMLInputFactory2; +import org.cyclonedx.CycloneDxSchema; +import org.cyclonedx.exception.ParseException; +import org.cyclonedx.parsers.JsonParser; +import org.cyclonedx.parsers.Parser; +import org.cyclonedx.parsers.XmlParser; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import javax.xml.stream.events.XMLEvent; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.cyclonedx.CycloneDxSchema.NS_BOM_10; +import static org.cyclonedx.CycloneDxSchema.NS_BOM_11; +import static org.cyclonedx.CycloneDxSchema.NS_BOM_12; +import static org.cyclonedx.CycloneDxSchema.NS_BOM_13; +import static org.cyclonedx.CycloneDxSchema.NS_BOM_14; +import static org.cyclonedx.CycloneDxSchema.NS_BOM_15; +import static org.cyclonedx.CycloneDxSchema.Version.VERSION_10; +import static org.cyclonedx.CycloneDxSchema.Version.VERSION_11; +import static org.cyclonedx.CycloneDxSchema.Version.VERSION_12; +import static org.cyclonedx.CycloneDxSchema.Version.VERSION_13; +import static org.cyclonedx.CycloneDxSchema.Version.VERSION_14; +import static org.cyclonedx.CycloneDxSchema.Version.VERSION_15; + +/** + * @since 4.11.0 + */ +public class CycloneDxValidator { + + private static final Logger LOGGER = Logger.getLogger(CycloneDxValidator.class); + private static final CycloneDxValidator INSTANCE = new CycloneDxValidator(); + + private final JsonMapper jsonMapper = new JsonMapper(); + + CycloneDxValidator() { + } + + public static CycloneDxValidator getInstance() { + return INSTANCE; + } + + public void validate(final byte[] bomBytes) { + final FormatAndVersion formatAndVersion = detectFormatAndSchemaVersion(bomBytes); + + final Parser bomParser = switch (formatAndVersion.format()) { + case JSON -> new JsonParser(); + case XML -> new XmlParser(); + }; + + final List validationErrors; + try { + validationErrors = bomParser.validate(bomBytes, formatAndVersion.version()); + } catch (IOException e) { + throw new RuntimeException("Failed to validate BOM", e); + } + + if (!validationErrors.isEmpty()) { + throw new InvalidBomException("Schema validation failed", validationErrors.stream() + .map(ParseException::getMessage) + .toList()); + } + } + + private FormatAndVersion detectFormatAndSchemaVersion(final byte[] bomBytes) { + try { + final CycloneDxSchema.Version version = detectSchemaVersionFromJson(bomBytes); + return new FormatAndVersion(Format.JSON, version); + } catch (JsonParseException e) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Failed to parse BOM as JSON", e); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + + try { + final CycloneDxSchema.Version version = detectSchemaVersionFromXml(bomBytes); + return new FormatAndVersion(Format.XML, version); + } catch (XMLStreamException e) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Failed to parse BOM as XML", e); + } + } + + throw new InvalidBomException("BOM is neither valid JSON nor XML"); + } + + private CycloneDxSchema.Version detectSchemaVersionFromJson(final byte[] bomBytes) throws IOException { + try (final com.fasterxml.jackson.core.JsonParser jsonParser = jsonMapper.createParser(bomBytes)) { + JsonToken currentToken = jsonParser.nextToken(); + if (currentToken != JsonToken.START_OBJECT) { + final String currentTokenAsString = Optional.ofNullable(currentToken) + .map(JsonToken::asString).orElse(null); + throw new JsonParseException(jsonParser, "Expected token %s, but got %s" + .formatted(JsonToken.START_OBJECT.asString(), currentTokenAsString)); + } + + CycloneDxSchema.Version schemaVersion = null; + while (jsonParser.nextToken() != JsonToken.END_OBJECT) { + final String fieldName = jsonParser.getCurrentName(); + if ("specVersion".equals(fieldName)) { + if (jsonParser.nextToken() == JsonToken.VALUE_STRING) { + final String specVersion = jsonParser.getValueAsString(); + schemaVersion = switch (jsonParser.getValueAsString()) { + case "1.0", "1.1" -> + throw new InvalidBomException("JSON is not supported for specVersion %s".formatted(specVersion)); + case "1.2" -> VERSION_12; + case "1.3" -> VERSION_13; + case "1.4" -> VERSION_14; + case "1.5" -> VERSION_15; + default -> + throw new InvalidBomException("Unrecognized specVersion %s".formatted(specVersion)); + }; + } + } + + if (schemaVersion != null) { + return schemaVersion; + } + } + + throw new InvalidBomException("Unable to determine schema version from JSON"); + } + } + + private CycloneDxSchema.Version detectSchemaVersionFromXml(final byte[] bomBytes) throws XMLStreamException { + final XMLInputFactory xmlInputFactory = XMLInputFactory2.newFactory(); + final var bomBytesStream = new ByteArrayInputStream(bomBytes); + final XMLStreamReader xmlStreamReader = xmlInputFactory.createXMLStreamReader(bomBytesStream); + + CycloneDxSchema.Version schemaVersion = null; + while (xmlStreamReader.hasNext()) { + if (xmlStreamReader.next() == XMLEvent.START_ELEMENT) { + if (!"bom".equalsIgnoreCase(xmlStreamReader.getLocalName())) { + continue; + } + + final var namespaceUrisSeen = new ArrayList(); + for (int i = 0; i < xmlStreamReader.getNamespaceCount(); i++) { + final String namespaceUri = xmlStreamReader.getNamespaceURI(i); + namespaceUrisSeen.add(namespaceUri); + + schemaVersion = switch (namespaceUri) { + case NS_BOM_10 -> VERSION_10; + case NS_BOM_11 -> VERSION_11; + case NS_BOM_12 -> VERSION_12; + case NS_BOM_13 -> VERSION_13; + case NS_BOM_14 -> VERSION_14; + case NS_BOM_15 -> VERSION_15; + default -> null; + }; + } + + if (schemaVersion == null) { + throw new InvalidBomException("Unable to determine schema version from XML namespaces %s" + .formatted(namespaceUrisSeen)); + } + + break; + } + } + + if (schemaVersion == null) { + throw new InvalidBomException("Unable to determine schema version from XML"); + } + + return schemaVersion; + } + + private enum Format { + JSON, + XML + } + + private record FormatAndVersion(Format format, CycloneDxSchema.Version version) { + } + +} diff --git a/src/main/java/org/dependencytrack/parser/cyclonedx/InvalidBomException.java b/src/main/java/org/dependencytrack/parser/cyclonedx/InvalidBomException.java new file mode 100644 index 0000000000..7faa41e326 --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/InvalidBomException.java @@ -0,0 +1,52 @@ +/* + * This file is part of Dependency-Track. + * + * 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 + * + * http://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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) Steve Springett. All Rights Reserved. + */ +package org.dependencytrack.parser.cyclonedx; + +import java.util.Collections; +import java.util.List; + +/** + * @since 4.11.0 + */ +public class InvalidBomException extends RuntimeException { + + private final List validationErrors; + + InvalidBomException(final String message) { + this(message, (Throwable) null); + } + + InvalidBomException(final String message, final Throwable cause) { + this(message, cause, Collections.emptyList()); + } + + InvalidBomException(final String message, final List validationErrors) { + this(message, null, validationErrors); + } + + private InvalidBomException(final String message, final Throwable cause, final List validationErrors) { + super(message, cause); + this.validationErrors = validationErrors; + } + + public List getValidationErrors() { + return validationErrors; + } + +} diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index 1069df97a6..1f1c2eee77 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -18,6 +18,7 @@ */ package org.dependencytrack.resources.v1; +import alpine.Config; import alpine.common.logging.Logger; import alpine.event.framework.Event; import alpine.server.auth.PermissionRequired; @@ -34,11 +35,16 @@ import org.cyclonedx.CycloneDxMediaType; import org.cyclonedx.exception.GeneratorException; import org.dependencytrack.auth.Permissions; +import org.dependencytrack.common.ConfigKey; import org.dependencytrack.event.BomUploadEvent; import org.dependencytrack.model.Component; import org.dependencytrack.model.Project; import org.dependencytrack.parser.cyclonedx.CycloneDXExporter; +import org.dependencytrack.parser.cyclonedx.CycloneDxValidator; +import org.dependencytrack.parser.cyclonedx.InvalidBomException; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.resources.v1.problems.InvalidBomProblemDetails; +import org.dependencytrack.resources.v1.problems.ProblemDetails; import org.dependencytrack.resources.v1.vo.BomSubmitRequest; import org.dependencytrack.resources.v1.vo.BomUploadResponse; import org.dependencytrack.resources.v1.vo.IsTokenBeingProcessedResponse; @@ -57,6 +63,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.io.ByteArrayInputStream; @@ -197,8 +204,22 @@ public Response exportComponentAsCycloneDx ( @PUT @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) - @ApiOperation(value = "Upload a supported bill of material format document", notes = "Expects CycloneDX along and a valid project UUID. If a UUID is not specified, then the projectName and projectVersion must be specified. Optionally, if autoCreate is specified and 'true' and the project does not exist, the project will be created. In this scenario, the principal making the request will additionally need the PORTFOLIO_MANAGEMENT or PROJECT_CREATION_UPLOAD permission.", response = BomUploadResponse.class, nickname = "UploadBomBase64Encoded") + @ApiOperation( + value = "Upload a supported bill of material format document", + notes = """ + Expects CycloneDX and a valid project UUID. If a UUID is not specified, \ + then the projectName and projectVersion must be specified. \ + Optionally, if autoCreate is specified and 'true' and the project does not exist, \ + the project will be created. In this scenario, the principal making the request will \ + additionally need the PORTFOLIO_MANAGEMENT or PROJECT_CREATION_UPLOAD permission. + The BOM will be validated against the CycloneDX schema. If schema validation fails, \ + a response with problem details in RFC 9457 format will be returned. In this case, \ + the response's content type will be application/problem+json.""", + response = BomUploadResponse.class, + nickname = "UploadBomBase64Encoded" + ) @ApiResponses(value = { + @ApiResponse(code = 400, message = "Invalid BOM", response = InvalidBomProblemDetails.class), @ApiResponse(code = 401, message = "Unauthorized"), @ApiResponse(code = 403, message = "Access to the specified project is forbidden"), @ApiResponse(code = 404, message = "The project could not be found") @@ -262,8 +283,22 @@ public Response uploadBom(BomSubmitRequest request) { @POST @Consumes(MediaType.MULTIPART_FORM_DATA) @Produces(MediaType.APPLICATION_JSON) - @ApiOperation(value = "Upload a supported bill of material format document", notes = "Expects CycloneDX along and a valid project UUID. If a UUID is not specified, then the projectName and projectVersion must be specified. Optionally, if autoCreate is specified and 'true' and the project does not exist, the project will be created. In this scenario, the principal making the request will additionally need the PORTFOLIO_MANAGEMENT or PROJECT_CREATION_UPLOAD permission.", response = BomUploadResponse.class, nickname = "UploadBom") + @ApiOperation( + value = "Upload a supported bill of material format document", + notes = """ + Expects CycloneDX and a valid project UUID. If a UUID is not specified, \ + then the projectName and projectVersion must be specified. \ + Optionally, if autoCreate is specified and 'true' and the project does not exist, \ + the project will be created. In this scenario, the principal making the request will \ + additionally need the PORTFOLIO_MANAGEMENT or PROJECT_CREATION_UPLOAD permission. + The BOM will be validated against the CycloneDX schema. If schema validation fails, \ + a response with problem details in RFC 9457 format will be returned. In this case, \ + the response's content type will be application/problem+json.""", + response = BomUploadResponse.class, + nickname = "UploadBom" + ) @ApiResponses(value = { + @ApiResponse(code = 400, message = "Invalid BOM", response = InvalidBomProblemDetails.class), @ApiResponse(code = 401, message = "Unauthorized"), @ApiResponse(code = 403, message = "Access to the specified project is forbidden"), @ApiResponse(code = 404, message = "The project could not be found") @@ -353,6 +388,7 @@ private Response process(QueryManager qm, Project project, String encodedBomData final byte[] decoded = Base64.getDecoder().decode(encodedBomData); try (final ByteArrayInputStream bain = new ByteArrayInputStream(decoded)) { final byte[] content = IOUtils.toByteArray(new BOMInputStream((bain))); + validate(content); final BomUploadEvent bomUploadEvent = new BomUploadEvent(qm.getPersistenceManager().detachCopy(project), content); Event.dispatch(bomUploadEvent); return Response.ok(Collections.singletonMap("token", bomUploadEvent.getChainIdentifier())).build(); @@ -376,6 +412,7 @@ private Response process(QueryManager qm, Project project, List errors; + + public List getErrors() { + return errors; + } + + public void setErrors(final List errors) { + this.errors = errors; + } + +} diff --git a/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java b/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java new file mode 100644 index 0000000000..b8ce606da4 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java @@ -0,0 +1,111 @@ +/* + * This file is part of Dependency-Track. + * + * 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 + * + * http://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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) Steve Springett. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.problems; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; + +import java.net.URI; + +/** + * @see RFC 9457 + * @since 4.11.0 + */ +@ApiModel( + description = "An RFC 9457 problem object", + subTypes = InvalidBomProblemDetails.class +) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProblemDetails { + + public static final String MEDIA_TYPE_JSON = "application/problem+json"; + + @ApiModelProperty( + value = "A URI reference that identifies the problem type", + example = "https://api.example.org/foo/bar/example-problem" + ) + private URI type; + + @ApiModelProperty( + value = "HTTP status code generated by the origin server for this occurrence of the problem", + example = "400" + ) + private Integer status; + + @ApiModelProperty( + value = "Short, human-readable summary of the problem type", + example = "Example title", + required = true + ) + private String title; + + @ApiModelProperty( + value = "Human-readable explanation specific to this occurrence of the problem", + example = "Example detail" + ) + private String detail; + + @ApiModelProperty( + value = "Reference URI that identifies the specific occurrence of the problem", + example = "https://api.example.org/foo/bar/example-instance" + ) + private URI instance; + + public URI getType() { + return type; + } + + public void setType(final URI type) { + this.type = type; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(final Integer status) { + this.status = status; + } + + public String getTitle() { + return title; + } + + public void setTitle(final String title) { + this.title = title; + } + + public String getDetail() { + return detail; + } + + public void setDetail(final String detail) { + this.detail = detail; + } + + public URI getInstance() { + return instance; + } + + public void setInstance(final URI instance) { + this.instance = instance; + } + +} diff --git a/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidatorTest.java b/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidatorTest.java new file mode 100644 index 0000000000..61e457b68d --- /dev/null +++ b/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidatorTest.java @@ -0,0 +1,165 @@ +/* + * This file is part of Dependency-Track. + * + * 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 + * + * http://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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) Steve Springett. All Rights Reserved. + */ +package org.dependencytrack.parser.cyclonedx; + +import org.junit.Before; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +public class CycloneDxValidatorTest { + + private CycloneDxValidator validator; + + @Before + public void setUp() { + validator = new CycloneDxValidator(); + } + + @Test + public void testValidateWithEmptyBytes() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate("".getBytes())) + .withMessage("BOM is neither valid JSON nor XML"); + } + + @Test + public void testValidateWithEmptyJson() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate("{}".getBytes())) + .withMessage("Unable to determine schema version from JSON"); + } + + @Test + public void testValidateWithEmptyXml() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate("".getBytes())) + .withMessage("Unable to determine schema version from XML namespaces []"); + } + + @Test + public void testValidateJsonWithoutSpecVersion() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + { + "components": [] + } + """.getBytes())) + .withMessage("Unable to determine schema version from JSON"); + } + + @Test + public void testValidateJsonWithUnsupportedSpecVersion() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + { + "specVersion": "1.1", + "components": [] + } + """.getBytes())) + .withMessage("JSON is not supported for specVersion 1.1"); + } + + @Test + public void testValidateJsonWithUnknownSpecVersion() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + { + "specVersion": "666", + "components": [] + } + """.getBytes())) + .withMessage("Unrecognized specVersion 666"); + } + + @Test + public void testValidateXmlWithoutNamespace() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + + + + """.getBytes())) + .withMessage("Unable to determine schema version from XML namespaces []"); + } + + @Test + public void testValidateXmlWithoutNamespace2() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + + + + """.getBytes())) + .withMessage("Unable to determine schema version from XML namespaces [http://cyclonedx.org/schema/bom/666]"); + } + + @Test + public void testValidateJsonWithInvalidComponentType() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + { + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "components": [ + { + "type": "foo", + "name": "acme-library", + "version": "1.0.0" + } + ] + } + """.getBytes())) + .withMessage("Schema validation failed") + .extracting(InvalidBomException::getValidationErrors).asList() + .containsExactly(""" + $.components[0].type: does not have a value in the enumeration \ + [application, framework, library, container, operating-system, device, firmware, file]\ + """); + } + + @Test + public void testValidateXmlWithInvalidComponentType() { + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + + + + + acme-library + 1.0.0 + + + + """.getBytes())) + .withMessage("Schema validation failed") + .extracting(InvalidBomException::getValidationErrors).asList() + .containsExactly( + """ + cvc-enumeration-valid: Value 'foo' is not facet-valid with respect to enumeration \ + '[application, framework, library, container, operating-system, device, firmware, file]'. \ + It must be a value from the enumeration.""", + """ + cvc-attribute.3: The value 'foo' of attribute 'type' on element 'component' is not \ + valid with respect to its type, 'classification'."""); + } + +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index 9f297b2a40..61c2204aed 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -34,6 +34,7 @@ import org.dependencytrack.model.ProjectMetadata; import org.dependencytrack.model.Severity; import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.parser.cyclonedx.CycloneDxValidator; import org.dependencytrack.resources.v1.vo.BomSubmitRequest; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; import org.glassfish.jersey.media.multipart.MultiPartFeature; @@ -56,6 +57,7 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; import static org.apache.commons.io.IOUtils.resourceToByteArray; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.hamcrest.CoreMatchers.equalTo; public class BomResourceTest extends ResourceTest { @@ -183,6 +185,7 @@ public void exportProjectAsCycloneDxInventoryTest() { assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_OK); final String jsonResponse = getPlainTextBody(response); + assertThatNoException().isThrownBy(() -> new CycloneDxValidator().validate(jsonResponse.getBytes())); assertThatJson(jsonResponse) .withMatcher("projectUuid", equalTo(project.getUuid().toString())) .withMatcher("componentWithoutVulnUuid", equalTo(componentWithoutVuln.getUuid().toString())) @@ -348,6 +351,7 @@ public void exportProjectAsCycloneDxInventoryWithVulnerabilitiesTest() { assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_OK); final String jsonResponse = getPlainTextBody(response); + assertThatNoException().isThrownBy(() -> new CycloneDxValidator().validate(jsonResponse.getBytes())); assertThatJson(jsonResponse) .withMatcher("vulnUuid", equalTo(vulnerability.getUuid().toString())) .withMatcher("projectUuid", equalTo(project.getUuid().toString())) @@ -541,6 +545,7 @@ public void exportProjectAsCycloneDxVdrTest() { assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_OK); final String jsonResponse = getPlainTextBody(response); + assertThatNoException().isThrownBy(() -> new CycloneDxValidator().validate(jsonResponse.getBytes())); assertThatJson(jsonResponse) .withMatcher("vulnUuid", equalTo(vulnerability.getUuid().toString())) .withMatcher("projectUuid", equalTo(project.getUuid().toString())) @@ -832,4 +837,97 @@ public void uploadBomInvalidParentTest() throws Exception { Assert.assertEquals("The parent component could not be found.", body); } + @Test + public void uploadBomInvalidJsonTest() { + initializeWithPermissions(Permissions.BOM_UPLOAD); + + final var project = new Project(); + project.setName("acme-app"); + project.setVersion("1.0.0"); + qm.persist(project); + + final String encodedBom = Base64.getEncoder().encodeToString(""" + { + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "components": [ + { + "type": "foo", + "name": "acme-library", + "version": "1.0.0" + } + ] + } + """.getBytes()); + + final Response response = target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "projectName": "acme-app", + "projectVersion": "1.0.0", + "bom": "%s" + } + """.formatted(encodedBom), MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 400, + "title": "The uploaded BOM is invalid", + "detail": "Schema validation failed", + "errors": [ + "$.components[0].type: does not have a value in the enumeration [application, framework, library, container, operating-system, device, firmware, file]" + ] + } + """); + } + + @Test + public void uploadBomInvalidXmlTest() { + initializeWithPermissions(Permissions.BOM_UPLOAD); + + final var project = new Project(); + project.setName("acme-app"); + project.setVersion("1.0.0"); + qm.persist(project); + + final String encodedBom = Base64.getEncoder().encodeToString(""" + + + + + acme-library + 1.0.0 + + + + """.getBytes()); + + final Response response = target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "projectName": "acme-app", + "projectVersion": "1.0.0", + "bom": "%s" + } + """.formatted(encodedBom), MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 400, + "title": "The uploaded BOM is invalid", + "detail": "Schema validation failed", + "errors": [ + "cvc-enumeration-valid: Value 'foo' is not facet-valid with respect to enumeration '[application, framework, library, container, operating-system, device, firmware, file]'. It must be a value from the enumeration.", + "cvc-attribute.3: The value 'foo' of attribute 'type' on element 'component' is not valid with respect to its type, 'classification'." + ] + } + """); + } + } diff --git a/src/test/java/org/dependencytrack/resources/v1/VexResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/VexResourceTest.java index cca1e45351..840373ef2d 100644 --- a/src/test/java/org/dependencytrack/resources/v1/VexResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/VexResourceTest.java @@ -21,6 +21,7 @@ import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; import org.dependencytrack.ResourceTest; +import org.dependencytrack.auth.Permissions; import org.dependencytrack.model.AnalysisResponse; import org.dependencytrack.model.AnalysisState; import org.dependencytrack.model.Classifier; @@ -28,6 +29,7 @@ import org.dependencytrack.model.Project; import org.dependencytrack.model.Severity; import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.parser.cyclonedx.CycloneDxValidator; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; import org.glassfish.jersey.media.multipart.MultiPartFeature; import org.glassfish.jersey.server.ResourceConfig; @@ -36,10 +38,15 @@ import org.glassfish.jersey.test.ServletDeploymentContext; import org.junit.Test; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import java.util.Base64; + import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.hamcrest.CoreMatchers.equalTo; public class VexResourceTest extends ResourceTest { @@ -124,7 +131,9 @@ public void exportProjectAsCycloneDxTest() { .header(X_API_KEY, apiKey) .get(Response.class); assertThat(response.getStatus()).isEqualTo(200); - assertThatJson(getPlainTextBody(response)) + final String jsonResponse = getPlainTextBody(response); + assertThatNoException().isThrownBy(() -> new CycloneDxValidator().validate(jsonResponse.getBytes())); + assertThatJson(jsonResponse) .withMatcher("vulnAUuid", equalTo(vulnA.getUuid().toString())) .withMatcher("vulnBUuid", equalTo(vulnB.getUuid().toString())) .withMatcher("projectUuid", equalTo(project.getUuid().toString())) @@ -204,4 +213,97 @@ public void exportProjectAsCycloneDxTest() { """); } + @Test + public void uploadVexInvalidJsonTest() { + initializeWithPermissions(Permissions.BOM_UPLOAD); + + final var project = new Project(); + project.setName("acme-app"); + project.setVersion("1.0.0"); + qm.persist(project); + + final String encodedVex = Base64.getEncoder().encodeToString(""" + { + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "components": [ + { + "type": "foo", + "name": "acme-library", + "version": "1.0.0" + } + ] + } + """.getBytes()); + + final Response response = target(V1_VEX).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "projectName": "acme-app", + "projectVersion": "1.0.0", + "vex": "%s" + } + """.formatted(encodedVex), MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 400, + "title": "The uploaded BOM is invalid", + "detail": "Schema validation failed", + "errors": [ + "$.components[0].type: does not have a value in the enumeration [application, framework, library, container, operating-system, device, firmware, file]" + ] + } + """); + } + + @Test + public void uploadVexInvalidXmlTest() { + initializeWithPermissions(Permissions.BOM_UPLOAD); + + final var project = new Project(); + project.setName("acme-app"); + project.setVersion("1.0.0"); + qm.persist(project); + + final String encodedVex = Base64.getEncoder().encodeToString(""" + + + + + acme-library + 1.0.0 + + + + """.getBytes()); + + final Response response = target(V1_VEX).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(""" + { + "projectName": "acme-app", + "projectVersion": "1.0.0", + "vex": "%s" + } + """.formatted(encodedVex), MediaType.APPLICATION_JSON)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 400, + "title": "The uploaded BOM is invalid", + "detail": "Schema validation failed", + "errors": [ + "cvc-enumeration-valid: Value 'foo' is not facet-valid with respect to enumeration '[application, framework, library, container, operating-system, device, firmware, file]'. It must be a value from the enumeration.", + "cvc-attribute.3: The value 'foo' of attribute 'type' on element 'component' is not valid with respect to its type, 'classification'." + ] + } + """); + } + } \ No newline at end of file diff --git a/src/test/resources/unit/bom-1.xml b/src/test/resources/unit/bom-1.xml index e48509ebce..f647f67487 100644 --- a/src/test/resources/unit/bom-1.xml +++ b/src/test/resources/unit/bom-1.xml @@ -56,7 +56,6 @@ - Sometimes this field is long because it is composed of a list of authors...................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................... Foo Incorporated https://foo.bar.com @@ -66,6 +65,7 @@ 123-456-7890 + Sometimes this field is long because it is composed of a list of authors...................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................... Example Incorporated com.example xmlutil