diff --git a/docs/_posts/2024-xx-xx-v4.11.0.md b/docs/_posts/2024-xx-xx-v4.11.0.md index 4f7f9adaa6..6e44eef1d5 100644 --- a/docs/_posts/2024-xx-xx-v4.11.0.md +++ b/docs/_posts/2024-xx-xx-v4.11.0.md @@ -20,6 +20,7 @@ to `true`. Users are highly encouraged to do so. * Add more context to logs emitted during BOM processing - [apiserver/#3357] * BOM format, spec version, serial number, and version * Project UUID, name, and version +* Validate uploaded BOMs against CycloneDX schema prior to processing them - [apiserver/#3522] * Align retry configuration and behavior across analyzers - [apiserver/#3494] **Fixes:** @@ -31,6 +32,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 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 * The following configuration properties were renamed: @@ -81,6 +85,7 @@ Special thanks to everyone who contributed code to implement enhancements and fi [apiserver/#3357]: https://github.com/DependencyTrack/dependency-track/pull/3357 [apiserver/#3494]: https://github.com/DependencyTrack/dependency-track/pull/3494 +[apiserver/#3522]: https://github.com/DependencyTrack/dependency-track/pull/3522 [@malice00]: https://github.com/malice00 [@mehab]: https://github.com/mehab 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..d1ae8a1cbe --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidator.java @@ -0,0 +1,200 @@ +/* + * 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 final JsonMapper jsonMapper = new JsonMapper(); + + 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("BOM is invalid", 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..d010ad0706 --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/InvalidBomException.java @@ -0,0 +1,49 @@ +/* + * 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; + +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..3b9ac5ff53 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,15 @@ 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.vo.BomSubmitRequest; import org.dependencytrack.resources.v1.vo.BomUploadResponse; import org.dependencytrack.resources.v1.vo.IsTokenBeingProcessedResponse; @@ -57,6 +62,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; @@ -199,6 +205,7 @@ public Response exportComponentAsCycloneDx ( @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") @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") @@ -264,6 +271,7 @@ public Response uploadBom(BomSubmitRequest request) { @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") @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 +361,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 +385,7 @@ private Response process(QueryManager qm, Project project, List errors; + + public static InvalidBomProblemDetails of(final List parseExceptions) { + final var details = new InvalidBomProblemDetails(); + for (final ParseException exception : parseExceptions) { + details.errors.add(exception.getMessage()); + } + + return details; + } + + 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..5b3534f1c0 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/problems/ProblemDetails.java @@ -0,0 +1,78 @@ +/* + * 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 java.net.URI; + +/** + * @see RFC 9457 + * @since 4.11.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ProblemDetails { + + private URI type; + private Integer status; + private String title; + private String details; + 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 getDetails() { + return details; + } + + public void setDetails(final String details) { + this.details = details; + } + + 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..911b774c80 --- /dev/null +++ b/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidatorTest.java @@ -0,0 +1,167 @@ +/* + * 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.Test; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +public class CycloneDxValidatorTest { + + @Test + public void testValidateWithEmpty() { + final var validator = new CycloneDxValidator(); + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate("".getBytes())) + .withMessage("BOM is neither valid JSON nor XML"); + } + + @Test + public void testValidateWithEmptyJson() { + final var validator = new CycloneDxValidator(); + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate("{}".getBytes())) + .withMessage("Unable to determine schema version from JSON"); + } + + @Test + public void testValidateWithEmptyXml() { + final var validator = new CycloneDxValidator(); + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate("".getBytes())) + .withMessage("Unable to determine schema version from XML namespaces []"); + } + + @Test + public void testValidateJsonWithoutSpecVersion() { + final var validator = new CycloneDxValidator(); + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + { + "components": [] + } + """.getBytes())) + .withMessage("Unable to determine schema version from JSON"); + } + + @Test + public void testValidateJsonWithUnsupportedSpecVersion() { + final var validator = new CycloneDxValidator(); + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + { + "specVersion": "1.1", + "components": [] + } + """.getBytes())) + .withMessage("JSON is not supported for specVersion 1.1"); + } + + @Test + public void testValidateJsonWithUnknownSpecVersion() { + final var validator = new CycloneDxValidator(); + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + { + "specVersion": "666", + "components": [] + } + """.getBytes())) + .withMessage("Unrecognized specVersion 666"); + } + + @Test + public void testValidateXmlWithoutNamespace() { + final var validator = new CycloneDxValidator(); + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + + + + """.getBytes())) + .withMessage("Unable to determine schema version from XML namespaces []"); + } + + @Test + public void testValidateXmlWithoutNamespace2() { + final var validator = new CycloneDxValidator(); + 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 testValidateJsonFoo() { + final var validator = new CycloneDxValidator(); + 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("BOM is invalid") + .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 testValidateXmlFoo() { + final var validator = new CycloneDxValidator(); + assertThatExceptionOfType(InvalidBomException.class) + .isThrownBy(() -> validator.validate(""" + + + + + acme-library + 1.0.0 + + + + """.getBytes())) + .withMessage("BOM is invalid") + .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..764b038671 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,93 @@ 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(""" + { + "title": "BOM is invalid", + "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(""" + { + "title": "BOM is invalid", + "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..3667a58c7d 100644 --- a/src/test/java/org/dependencytrack/resources/v1/VexResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/VexResourceTest.java @@ -28,6 +28,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; @@ -40,6 +41,7 @@ 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 +126,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())) 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