From f2351116b5e75727812592f509a3d6e2b1a42bd8 Mon Sep 17 00:00:00 2001 From: Gordon Date: Thu, 7 Nov 2024 18:31:06 +0000 Subject: [PATCH] feat: address PR comments Signed-off-by: Gordon --- build.gradle.kts | 2 + .../org/cyclonedx/gradle/CycloneDxTask.java | 24 +++++- .../gradle/DependencyGraphTraverser.java | 7 +- .../cyclonedx/gradle/MavenProjectLookup.java | 2 +- .../org/cyclonedx/gradle/SbomBuilder.java | 57 ++++++++++---- .../cyclonedx/gradle/SbomGraphProvider.java | 40 ++++------ .../cyclonedx/gradle/model/SbomComponent.java | 12 +-- .../gradle/utils/CycloneDxUtils.java | 20 +++++ .../gradle/utils/DependencyUtils.java | 2 +- .../gradle/PluginConfigurationSpec.groovy | 2 +- .../gradle/utils/DependencyUtilsTest.java | 77 +++++++++++++++++++ 11 files changed, 191 insertions(+), 54 deletions(-) create mode 100644 src/test/java/org/cyclonedx/gradle/utils/DependencyUtilsTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 6d5ce22c..c11605d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,8 @@ dependencies { testImplementation("org.spockframework:spock-core:2.2-M1-groovy-3.0") { exclude(module = "groovy-all") } + testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.3") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.3") } tasks.withType { diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index 43259058..e744828b 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -247,12 +247,13 @@ public void setDestination(final File destination) { @TaskAction public void createBom() { + logParameters(); + final SbomBuilder builder = new SbomBuilder(getLogger(), this); final SbomGraph components = getComponents().get(); final Bom bom = builder.buildBom(components.getGraph(), components.getRootComponent()); getLogger().info(MESSAGE_WRITING_BOM_OUTPUT); - CycloneDxUtils.writeBom( bom, getDestination().get(), @@ -333,4 +334,25 @@ public void setLicenseChoice(final Consumer customizer) { // Definition of gradle Input via Hashmap because Hashmap is serializable (LicenseChoice isn't serializable) getInputs().property("LicenseChoice", licenseChoice); } + + private void logParameters() { + if (getLogger().isInfoEnabled()) { + getLogger().info("CycloneDX: Parameters"); + getLogger().info("------------------------------------------------------------------------"); + getLogger().info("schemaVersion : " + schemaVersion.get()); + getLogger().info("includeLicenseText : " + includeLicenseText.get()); + getLogger().info("includeBomSerialNumber : " + includeBomSerialNumber.get()); + getLogger().info("includeConfigs : " + includeConfigs.get()); + getLogger().info("skipConfigs : " + skipConfigs.get()); + getLogger().info("skipProjects : " + skipProjects.get()); + getLogger().info("includeMetadataResolution : " + includeMetadataResolution.get()); + getLogger().info("destination : " + destination.get()); + getLogger().info("outputName : " + outputName.get()); + getLogger().info("componentName : " + componentName.get()); + getLogger().info("componentVersion : " + componentVersion.get()); + getLogger().info("outputFormat : " + outputFormat.get()); + getLogger().info("projectType : " + projectType.get()); + getLogger().info("------------------------------------------------------------------------"); + } + } } diff --git a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java index 585cf26f..4ea6dc17 100644 --- a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java +++ b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java @@ -29,6 +29,7 @@ import java.util.Queue; import java.util.Set; import java.util.stream.Collectors; +import javax.annotation.Nullable; import org.apache.maven.model.License; import org.apache.maven.project.MavenProject; import org.cyclonedx.gradle.model.ConfigurationScope; @@ -125,7 +126,7 @@ private SbomComponent toSbomComponent(final GraphNode node, final Set final File artifactFile = getArtifactFile(node); final SbomComponentId id = DependencyUtils.toComponentId(node.getResult(), artifactFile); - List licenses = null; + List licenses = new ArrayList<>(); SbomMetaData metaData = null; if (includeMetaData && node.id instanceof ModuleComponentIdentifier) { logger.debug("CycloneDX: Including meta data for node {}", node.id); @@ -146,13 +147,13 @@ private SbomComponent toSbomComponent(final GraphNode node, final Set } private void extractMetaDataFromArtifactPom( - final File artifactFile, final Component component, final ResolvedComponentResult result) { + @Nullable final File artifactFile, final Component component, final ResolvedComponentResult result) { if (artifactFile == null || result.getModuleVersion() == null) { return; } - final MavenProject mavenProject = mavenHelper.extractPom(artifactFile, result.getModuleVersion()); + @Nullable final MavenProject mavenProject = mavenHelper.extractPom(artifactFile, result.getModuleVersion()); if (mavenProject != null) { logger.debug("CycloneDX: parse artifact pom file of component {}", result.getId()); mavenHelper.getClosestMetadata(artifactFile, mavenProject, component, result.getModuleVersion()); diff --git a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java index c3252210..24b8e434 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java +++ b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java @@ -56,7 +56,7 @@ class MavenProjectLookup { * * @return a MavenProject instance for this component */ - @Nullable MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { + @Nullable MavenProject getResolvedMavenProject(@Nullable final ResolvedComponentResult result) { if (result == null) { return null; diff --git a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java index e94e0645..443d96b4 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java @@ -22,9 +22,9 @@ import com.networknt.schema.utils.StringUtils; import java.io.File; import java.io.IOException; -import java.net.URLEncoder; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -33,6 +33,7 @@ import java.util.TreeSet; import java.util.UUID; import java.util.stream.Collectors; +import javax.annotation.Nullable; import org.cyclonedx.Version; import org.cyclonedx.gradle.model.ComponentComparator; import org.cyclonedx.gradle.model.DependencyComparator; @@ -134,7 +135,10 @@ private void addDependency(final Set dependencies, final SbomCompone try { dependency.addDependency(toDependency(dependencyComponent)); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for component dependency. Skipping...", e); + logger.warn( + "Error constructing packageUrl for component dependency {}. Skipping...", + dependencyComponent.getName(), + e); } }); dependencies.add(dependency); @@ -149,11 +153,14 @@ private Dependency toDependency(final SbomComponentId componentId) throws Malfor private void addComponent( final Set components, final SbomComponent component, final SbomComponent parentComponent) { if (!component.equals(parentComponent)) { - final File artifactFile = component.getArtifactFile().orElse(null); + @Nullable final File artifactFile = component.getArtifactFile().orElse(null); try { components.add(toComponent(component, artifactFile, Component.Type.LIBRARY)); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for component. Skipping...", e); + logger.warn( + "Error constructing packageUrl for component {}. Skipping...", + component.getId().getName(), + e); } } } @@ -185,10 +192,10 @@ private Component toComponent(final SbomComponent component, final File artifact }); }); - component.getLicenses().ifPresent(licenses -> { - LicenseChoice licenseChoice = mavenHelper.resolveMavenLicenses(licenses); + if (!component.getLicenses().isEmpty()) { + LicenseChoice licenseChoice = mavenHelper.resolveMavenLicenses(component.getLicenses()); resultComponent.setLicenses(licenseChoice); - }); + } logger.debug(MESSAGE_CALCULATING_HASHES); if (artifactFile != null) { @@ -199,17 +206,37 @@ private Component toComponent(final SbomComponent component, final File artifact } private List buildProperties(final SbomComponent component) { + final List inScopeProperties = buildScopeProperties(component); + final Property isTestProperty = buildIsTestProperty(component); + + final List resultProperties = new ArrayList<>(); + resultProperties.addAll(inScopeProperties); + resultProperties.add(isTestProperty); + + return resultProperties; + } + + private List buildScopeProperties(final SbomComponent component) { + return component.getInScopeConfigurations().stream() + .map(v -> { + Property property = new Property(); + property.setName("cdx:maven:package:projectsAndScopes"); + property.setValue(String.format("%s:%s", v.getProjectName(), v.getConfigName())); + return property; + }) + .sorted(Comparator.comparing(Property::getValue)) + .collect(Collectors.toList()); + } - final String value = component.getInScopeConfigurations().stream() - .map(v -> String.format( - "%s:%s", URLEncoder.encode(v.getProjectName()), URLEncoder.encode(v.getConfigName()))) - .collect(Collectors.joining(",")); + private Property buildIsTestProperty(final SbomComponent component) { - final Property property = new Property(); - property.setName("inScopeConfiguration"); - property.setValue(value); + boolean isTestComponent = component.getInScopeConfigurations().stream() + .allMatch(v -> v.getConfigName().startsWith("test")); - return Collections.singletonList(property); + Property property = new Property(); + property.setName("cdx:maven:package:test"); + property.setValue(Boolean.toString(isTestComponent)); + return property; } private List calculateHashes(final File artifactFile) { diff --git a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java index 151cd582..be309349 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -19,6 +19,7 @@ package org.cyclonedx.gradle; import java.io.File; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -45,16 +46,18 @@ public class SbomGraphProvider implements Callable { private final Project project; private final CycloneDxTask task; + private final MavenProjectLookup mavenLookup; public SbomGraphProvider(final Project project, final CycloneDxTask task) { this.project = project; this.task = task; + this.mavenLookup = new MavenProjectLookup(project); } /** - * Calculates the aggregated dependency graph across all the configurations of both the parent project and + * Calculates the aggregated dependency graph across all the configurations of both the current project and * child projects. The steps are as follows: - * 1) generate dependency graphs for the parent project, one for each configuration + * 1) generate dependency graphs for the current project, one for each configuration * 2) if child projects exist, generate dependency graphs across all the child projects * 3) merge all generated graphs from the step 1) and 2) * @@ -71,11 +74,10 @@ public SbomGraph call() throws Exception { project.getLogger().info(MESSAGE_RESOLVING_DEPS); - final DependencyGraphTraverser traverser = new DependencyGraphTraverser( - project.getLogger(), getArtifacts(), new MavenProjectLookup(project), task); - final Map graph = Stream.concat( - traverseCurrentProject(traverser), traverseChildProjects(traverser)) + Stream.of(project), project.getSubprojects().stream()) + .filter(project -> !shouldSkipProject(project)) + .flatMap(this::traverseProject) .reduce(new HashMap<>(), DependencyUtils::mergeGraphs); return buildSbomGraph(graph); @@ -99,18 +101,18 @@ private SbomGraph buildSbomGraph(final Map graph .withId(rootProjectId) .withDependencyComponents(new HashSet<>()) .withInScopeConfigurations(new HashSet<>()) + .withLicenses(new ArrayList<>()) .build(); return new SbomGraph(graph, sbomComponent); } } - private Stream> traverseCurrentProject( - final DependencyGraphTraverser traverser) { + private Stream> traverseProject(final Project project) { + + final DependencyGraphTraverser traverser = + new DependencyGraphTraverser(project.getLogger(), getArtifacts(), mavenLookup, task); - if (shouldSkipProject(project)) { - return Stream.empty(); - } return project.getConfigurations().stream() .filter(configuration -> shouldIncludeConfiguration(configuration) && !shouldSkipConfiguration(configuration) @@ -119,22 +121,8 @@ private Stream> traverseCurrentProject( config.getIncoming().getResolutionResult().getRoot(), project.getName(), config.getName())); } - private Stream> traverseChildProjects( - final DependencyGraphTraverser traverser) { - return project.getChildProjects().entrySet().stream() - .filter(project -> !shouldSkipProject(project.getValue())) - .flatMap(project -> project.getValue().getConfigurations().stream() - .filter(configuration -> shouldIncludeConfiguration(configuration) - && !shouldSkipConfiguration(configuration) - && configuration.isCanBeResolved()) - .map(config -> traverser.traverseGraph( - config.getIncoming().getResolutionResult().getRoot(), - project.getKey(), - config.getName()))); - } - private Map getArtifacts() { - return project.getAllprojects().stream() + return Stream.concat(Stream.of(project), project.getSubprojects().stream()) .filter(project -> !shouldSkipProject(project)) .flatMap(project -> project.getConfigurations().stream()) .filter(configuration -> shouldIncludeConfiguration(configuration) diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java index 5e992bb1..01f1195b 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java @@ -36,15 +36,15 @@ public final class SbomComponent implements Serializable { @Nullable private final SbomMetaData metaData; - @Nullable private final List licenses; + private final List licenses; - public SbomComponent( + private SbomComponent( final SbomComponentId id, final Set inScopeConfigurations, final Set dependencyComponents, @Nullable final File artifactFile, @Nullable final SbomMetaData metaData, - @Nullable final List licenses) { + final List licenses) { this.id = id; this.inScopeConfigurations = inScopeConfigurations; this.dependencyComponents = dependencyComponents; @@ -73,8 +73,8 @@ public Optional getSbomMetaData() { return Optional.ofNullable(metaData); } - public Optional> getLicenses() { - return Optional.ofNullable(licenses); + public List getLicenses() { + return licenses; } public static class Builder { @@ -113,7 +113,7 @@ public Builder withMetaData(@Nullable final SbomMetaData metaData) { return this; } - public Builder withLicenses(@Nullable final List licenses) { + public Builder withLicenses(final List licenses) { this.licenses = licenses; return this; } diff --git a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java index 661561a0..1fdd73e4 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java @@ -20,12 +20,17 @@ import java.io.File; import java.nio.charset.StandardCharsets; +import java.util.List; import org.apache.commons.io.FileUtils; import org.cyclonedx.Version; +import org.cyclonedx.exception.ParseException; import org.cyclonedx.generators.BomGeneratorFactory; import org.cyclonedx.generators.json.BomJsonGenerator; import org.cyclonedx.generators.xml.BomXmlGenerator; import org.cyclonedx.model.Bom; +import org.cyclonedx.parsers.JsonParser; +import org.cyclonedx.parsers.Parser; +import org.cyclonedx.parsers.XmlParser; import org.gradle.api.GradleException; public class CycloneDxUtils { @@ -84,6 +89,8 @@ private static void writeJSONBom(final Version schemaVersion, final Bom bom, fin } catch (Exception e) { throw new GradleException("Error writing json bom file", e); } + + validateBom(new JsonParser(), schemaVersion, destination); } private static void writeXmlBom(final Version schemaVersion, final Bom bom, final File destination) { @@ -95,5 +102,18 @@ private static void writeXmlBom(final Version schemaVersion, final Bom bom, fina } catch (Exception e) { throw new GradleException("Error writing xml bom file", e); } + + validateBom(new XmlParser(), schemaVersion, destination); + } + + private static void validateBom(final Parser bomParser, final Version schemaVersion, final File destination) { + try { + final List exceptions = bomParser.validate(destination, schemaVersion); + if (!exceptions.isEmpty()) { + throw exceptions.get(0); + } + } catch (Exception e) { + throw new GradleException("Error whilst validating XML BOM", e); + } } } diff --git a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java index 6a90fe9d..d0df9291 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -111,7 +111,7 @@ public static SbomComponentId toComponentId(final ResolvedComponentResult node, node.getModuleVersion().getVersion(), type); } else { - return new SbomComponentId("N/A", node.getId().getDisplayName(), "N/A", type); + return new SbomComponentId("undefined", node.getId().getDisplayName(), "undefined", type); } } diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index 2e3198e7..0dba7df4 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -141,7 +141,7 @@ class PluginConfigurationSpec extends Specification { dependencies { implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version:'2.8.11' - implementation group: 'org.springframework.boot', name: 'spring-boot-starter-web', version:'1.5.18.RELEASE' + testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-web', version:'1.5.18.RELEASE' implementation group: 'org.jetbrains.kotlin', name: 'kotlin-native-prebuilt', version: '2.0.20' }""", "rootProject.name = 'hello-world'") diff --git a/src/test/java/org/cyclonedx/gradle/utils/DependencyUtilsTest.java b/src/test/java/org/cyclonedx/gradle/utils/DependencyUtilsTest.java new file mode 100644 index 00000000..86482897 --- /dev/null +++ b/src/test/java/org/cyclonedx/gradle/utils/DependencyUtilsTest.java @@ -0,0 +1,77 @@ +/* + * This file is part of CycloneDX Gradle Plugin. + * + * 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) OWASP Foundation. All Rights Reserved. + */ +package org.cyclonedx.gradle.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.cyclonedx.gradle.model.ConfigurationScope; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; +import org.junit.jupiter.api.Test; + +class DependencyUtilsTest { + + @Test + void testShouldMergeSimpleGraphs() { + + final SbomComponent componentA = buildDefaultComponent("A", "B"); + final SbomComponent componentC = buildDefaultComponent("C", "D"); + + final Map graphA = new HashMap<>(); + graphA.put(componentA.getId(), componentA); + + final Map graphC = new HashMap<>(); + graphC.put(componentC.getId(), componentC); + + final Map resultGraph = DependencyUtils.mergeGraphs(graphA, graphC); + + final Map expectedGraph = new HashMap<>(); + expectedGraph.put(componentA.getId(), componentA); + expectedGraph.put(componentC.getId(), componentC); + + assertEquals(expectedGraph, resultGraph); + } + + private SbomComponent buildDefaultComponent(final String componentSuffix, final String dependencySuffix) { + return new SbomComponent.Builder() + .withId(new SbomComponentId("group" + componentSuffix, "component" + componentSuffix, "1.0.0", "jar")) + .withDependencyComponents(buildDependencyComponents(new SbomComponentId( + "group" + dependencySuffix, "component" + dependencySuffix, "1.0.0", "jar"))) + .withInScopeConfigurations(buildInScopeConfigurations(new ConfigurationScope("projectA", "configA"))) + .withLicenses(Collections.EMPTY_LIST) + .build(); + } + + private Set buildDependencyComponents(final SbomComponentId... ids) { + final Set componentIds = new HashSet<>(); + Collections.addAll(componentIds, ids); + return componentIds; + } + + private Set buildInScopeConfigurations(final ConfigurationScope... configs) { + final Set componentConfigs = new HashSet<>(); + Collections.addAll(componentConfigs, configs); + return componentConfigs; + } +}