From 357abd46f806412140d257594d704f824c6268ab Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 21 Oct 2024 22:42:25 +0100 Subject: [PATCH 01/26] feat: initial new task implementation Signed-off-by: Gordon --- .../org/cyclonedx/gradle/CycloneDxParser.java | 183 ++++ .../org/cyclonedx/gradle/CycloneDxPlugin.java | 61 ++ .../org/cyclonedx/gradle/CycloneDxTask.java | 841 +----------------- .../cyclonedx/gradle/model/ArtifactInfo.java | 40 + .../gradle/model/ComponentComparator.java | 30 + .../gradle/model/DependencyComparator.java | 29 + .../cyclonedx/gradle/model/ResolvedBuild.java | 63 ++ .../gradle/model/ResolvedConfiguration.java | 42 + .../gradle/utils/CycloneDxUtils.java | 28 + .../gradle/DependencyResolutionSpec.groovy | 33 + .../org/cyclonedx/gradle/TestUtils.groovy | 16 + .../componenta/1.0.0/componenta-1.0.0.jar | 1 + .../componenta/1.0.0/componenta-1.0.0.pom | 17 + .../componentb/1.0.0/componentb-1.0.0.jar | 1 + .../componentb/1.0.0/componentb-1.0.0.pom | 9 + .../componentb/1.0.1/componentb-1.0.1.jar | 1 + .../componentb/1.0.1/componentb-1.0.1.pom | 9 + 17 files changed, 587 insertions(+), 817 deletions(-) create mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxParser.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java new file mode 100644 index 00000000..b8b92c41 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java @@ -0,0 +1,183 @@ +/* + * 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; + +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURL; +import java.io.File; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import javax.annotation.Nullable; +import org.cyclonedx.Version; +import org.cyclonedx.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.ComponentComparator; +import org.cyclonedx.gradle.model.DependencyComparator; +import org.cyclonedx.gradle.utils.CycloneDxUtils; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.Dependency; +import org.cyclonedx.model.Hash; +import org.cyclonedx.util.BomUtils; +import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.gradle.api.artifacts.result.DependencyResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; +import org.gradle.api.logging.Logger; + +public class CycloneDxParser { + + private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; + + private final Set dependencies; + private final Set components; + private final Logger logger; + private final Map> artifactHashes; + private final Map resolvedArtifacts; + private final MavenHelper mavenHelper; + private final Version version; + + public CycloneDxParser(final Logger logger) { + this.logger = logger; + this.version = CycloneDxUtils.schemaVersion("1.6"); + this.dependencies = new TreeSet<>(new DependencyComparator()); + this.components = new TreeSet<>(new ComponentComparator()); + this.resolvedArtifacts = new HashMap<>(); + this.artifactHashes = new HashMap<>(); + this.mavenHelper = new MavenHelper(logger, version, false); + } + + public void registerArtifact(final ArtifactInfo artifact) { + resolvedArtifacts.put(artifact.getComponentId(), artifact.getArtifactFile()); + } + + public void visitGraph(final ResolvedComponentResult rootNode, final String projectName, final String configName) { + + final Set seen = new HashSet<>(); + final Queue queue = new ArrayDeque<>(); + queue.add(rootNode); + + while (!queue.isEmpty()) { + final ResolvedComponentResult node = queue.poll(); + if (!seen.contains(node)) { + seen.add(node); + final Dependency dependency = toDependency(node, projectName, configName); + for (DependencyResult dep : node.getDependencies()) { + if (dep instanceof ResolvedDependencyResult) { + final ResolvedComponentResult dependencyComponent = + ((ResolvedDependencyResult) dep).getSelected(); + dependency.addDependency(toDependency(dependencyComponent, projectName, configName)); + queue.add(dependencyComponent); + } + } + dependencies.add(dependency); + final File artifactFile = resolvedArtifacts.get(node.getId().getDisplayName()); + components.add(toComponent(node, artifactFile, projectName, configName)); + } + } + } + + public Bom getResultingBom() { + final Bom bom = new Bom(); + bom.setComponents(new ArrayList<>(components)); + bom.setDependencies(new ArrayList<>(dependencies)); + return bom; + } + + public Component toComponent( + final ResolvedComponentResult resolvedComponent, + final File artifactFile, + final String projectName, + final String configName) { + + final Component component = new Component(); + component.setGroup(resolvedComponent.getModuleVersion().getGroup()); + component.setName(resolvedComponent.getModuleVersion().getName()); + component.setVersion(resolvedComponent.getModuleVersion().getVersion()); + component.setType(Component.Type.LIBRARY); + logger.debug(MESSAGE_CALCULATING_HASHES); + if (artifactFile != null) { + component.setHashes(calculateHashes(artifactFile)); + } + + final TreeMap qualifiers = new TreeMap<>(); + final String packageUrl = generatePackageUrl(resolvedComponent.getModuleVersion(), qualifiers); + component.setPurl(packageUrl); + + if (version.getVersion() >= 1.1) { + component.setModified(mavenHelper.isModified(null)); + component.setBomRef(generateRef(resolvedComponent.getModuleVersion(), qualifiers, projectName, configName)); + } + return component; + } + + private List calculateHashes(final File artifactFile) { + return artifactHashes.computeIfAbsent(artifactFile, f -> { + try { + return BomUtils.calculateHashes(f, version); + } catch (IOException e) { + logger.error("Error encountered calculating hashes", e); + } + return Collections.emptyList(); + }); + } + + private Dependency toDependency( + final ResolvedComponentResult component, final String projectName, final String configName) { + + TreeMap qualifiers = new TreeMap<>(); + String ref = generateRef(component.getModuleVersion(), qualifiers, projectName, configName); + return new Dependency(ref); + } + + private String generateRef( + final ModuleVersionIdentifier version, + final TreeMap qualifiers, + final String projectName, + final String configName) { + String purl = generatePackageUrl(version, qualifiers); + return String.format("%s:%s:%s", projectName, configName, purl); + } + + @Nullable private String generatePackageUrl(final ModuleVersionIdentifier version, final TreeMap qualifiers) { + try { + return new PackageURL( + PackageURL.StandardTypes.MAVEN, + version.getGroup(), + version.getName(), + version.getVersion(), + qualifiers, + null) + .canonicalize(); + } catch (MalformedPackageURLException e) { + logger.warn("An unexpected issue occurred attempting to create a PackageURL for " + version.getGroup() + ":" + + version.getName() + ":" + version); + } + return null; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index a74579e4..d30e29b6 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -18,15 +18,76 @@ */ package org.cyclonedx.gradle; +import java.io.File; +import java.util.*; +import java.util.stream.Collectors; +import org.cyclonedx.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.ResolvedBuild; +import org.cyclonedx.gradle.model.ResolvedConfiguration; import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.provider.Provider; public class CycloneDxPlugin implements Plugin { public void apply(Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { + final ResolvedBuild resolvedBuild = getResolvedBuild(project); + final Optional>> artifacts = getArtifacts(project); + final File destination = + project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); + + task.getResolvedBuild().set(resolvedBuild); + task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); + artifacts.ifPresent(provider -> task.getArtifacts().set(provider)); }); } + + private ResolvedBuild getResolvedBuild(final Project project) { + + final ResolvedBuild resolvedBuild = new ResolvedBuild(project.getName()); + project.getConfigurations().stream() + .filter(Configuration::isCanBeResolved) + .forEach(v -> resolvedBuild.addProjectConfiguration(resolvedConfiguration(v))); + + project.getChildProjects().forEach((k, v) -> v.getConfigurations().stream() + .filter(Configuration::isCanBeResolved) + .forEach(w -> resolvedBuild.addSubProjectConfiguration(k, resolvedConfiguration(w)))); + + return resolvedBuild; + } + + private Optional>> getArtifacts(final Project project) { + + return project.getAllprojects().stream() + .flatMap(v -> v.getConfigurations().stream()) + .filter(Configuration::isCanBeResolved) + .map(v -> v.getIncoming().getArtifacts().getResolvedArtifacts()) + .reduce(this::combineArtifactsProviders) + .map(provider -> + provider.map(v -> v.stream().map(this::mapResult).collect(Collectors.toSet()))); + } + + private Provider> combineArtifactsProviders( + Provider> left, Provider> right) { + return left.flatMap(v -> right.map(w -> { + Set result = new HashSet<>(); + result.addAll(v); + result.addAll(w); + return result; + })); + } + + private ArtifactInfo mapResult(ResolvedArtifactResult result) { + return new ArtifactInfo(result.getId().getComponentIdentifier().getDisplayName(), result.getFile()); + } + + private ResolvedConfiguration resolvedConfiguration(Configuration config) { + return new ResolvedConfiguration( + config.getName(), config.getIncoming().getResolutionResult().getRootComponent()); + } } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index d6638a61..f7eeb57c 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -18,852 +18,59 @@ */ package org.cyclonedx.gradle; -import com.github.packageurl.MalformedPackageURLException; -import com.github.packageurl.PackageURL; import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Properties; import java.util.Set; -import java.util.TreeMap; -import java.util.UUID; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import javax.xml.parsers.ParserConfigurationException; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.maven.model.Model; -import org.apache.maven.project.MavenProject; -import org.cyclonedx.Version; -import org.cyclonedx.exception.GeneratorException; -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.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.ResolvedBuild; +import org.cyclonedx.gradle.model.ResolvedConfiguration; import org.cyclonedx.gradle.utils.CycloneDxUtils; -import org.cyclonedx.gradle.utils.DependencyUtils; -import org.cyclonedx.model.Bom; -import org.cyclonedx.model.Component; -import org.cyclonedx.model.Hash; -import org.cyclonedx.model.LicenseChoice; -import org.cyclonedx.model.Metadata; -import org.cyclonedx.model.OrganizationalEntity; -import org.cyclonedx.model.Tool; -import org.cyclonedx.parsers.JsonParser; -import org.cyclonedx.parsers.Parser; -import org.cyclonedx.parsers.XmlParser; -import org.cyclonedx.util.BomUtils; import org.gradle.api.DefaultTask; -import org.gradle.api.GradleException; -import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; -import org.gradle.api.artifacts.Dependency; -import org.gradle.api.artifacts.ResolvedArtifact; -import org.gradle.api.artifacts.ResolvedConfiguration; -import org.gradle.api.artifacts.ResolvedDependency; -import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; +import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; -import org.gradle.api.tasks.OutputDirectory; import org.gradle.api.tasks.TaskAction; -public class CycloneDxTask extends DefaultTask { +public abstract class CycloneDxTask extends DefaultTask { - /** - * Various messages sent to console. - */ - private static final String MESSAGE_RESOLVING_DEPS = "CycloneDX: Resolving Dependencies"; - - private static final String MESSAGE_CREATING_BOM = "CycloneDX: Creating BOM"; - private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; - private static final String MESSAGE_WRITING_BOM_XML = "CycloneDX: Writing BOM XML"; - private static final String MESSAGE_WRITING_BOM_JSON = "CycloneDX: Writing BOM JSON"; - private static final String MESSAGE_VALIDATING_BOM = "CycloneDX: Validating BOM"; - private static final String MESSAGE_VALIDATION_FAILURE = "The BOM does not conform to the CycloneDX BOM standard"; - - private static final String DEFAULT_PROJECT_TYPE = "library"; - private final Map components; - - private final MavenHelper mavenHelper; - - private final Property schemaVersion; - private final Property componentName; - private final Property componentVersion; - private final Property outputName; - private final Property outputFormat; - private final Property projectType; - private final Property includeLicenseText; - private final Property includeBomSerialNumber; - private final ListProperty includeConfigs; - private final ListProperty skipConfigs; - private final ListProperty skipProjects; - private final Property destination; - private final Property includeMetadataResolution; - private OrganizationalEntity organizationalEntity; - private LicenseChoice licenseChoice; - private final Map> artifactHashes; - private final Map resolvedMavenProjects; + private final CycloneDxParser parser; public CycloneDxTask() { - schemaVersion = getProject().getObjects().property(String.class); - schemaVersion.convention(CycloneDxUtils.DEFAULT_SCHEMA_VERSION.getVersionString()); - if (getSchemaVersion().get().equals(Version.VERSION_10.getVersionString())) { - setIncludeBomSerialNumber(false); - } - - outputName = getProject().getObjects().property(String.class); - outputName.convention("bom"); - - outputFormat = getProject().getObjects().property(String.class); - outputFormat.convention("all"); - - projectType = getProject().getObjects().property(String.class); - projectType.convention(DEFAULT_PROJECT_TYPE); - - includeLicenseText = getProject().getObjects().property(Boolean.class); - includeLicenseText.convention(true); - - includeBomSerialNumber = getProject().getObjects().property(Boolean.class); - includeBomSerialNumber.convention(true); - - componentName = getProject().getObjects().property(String.class); - componentName.convention(getProject().getName()); - - componentVersion = getProject().getObjects().property(String.class); - componentVersion.convention(getProject().getVersion().toString()); - - includeConfigs = getProject().getObjects().listProperty(String.class); - skipConfigs = getProject().getObjects().listProperty(String.class); - skipProjects = getProject().getObjects().listProperty(String.class); - - includeMetadataResolution = getProject().getObjects().property(Boolean.class); - includeMetadataResolution.convention(true); - - destination = getProject().getObjects().property(File.class); - destination.convention(getProject() - .getLayout() - .getBuildDirectory() - .dir("reports") - .get() - .getAsFile()); - - organizationalEntity = new OrganizationalEntity(); - - licenseChoice = new LicenseChoice(); - artifactHashes = Collections.synchronizedMap(new HashMap<>()); - resolvedMavenProjects = Collections.synchronizedMap(new HashMap<>()); - components = new TreeMap<>(); - mavenHelper = new MavenHelper( - getLogger(), getVersion(), getIncludeLicenseText().get()); - } - - @Input - public Property getOutputName() { - return outputName; - } - - public void setOutputName(String output) { - this.outputName.set(output); - } - - @Input - public Property getOutputFormat() { - return outputFormat; - } - - public void setOutputFormat(String format) { - this.outputFormat.set(format); - } - - @Input - public ListProperty getIncludeConfigs() { - return includeConfigs; - } - - public void setIncludeConfigs(Collection includeConfigs) { - this.includeConfigs.addAll(includeConfigs); - } - - @Input - public Property getComponentName() { - return componentName; - } - - public void setComponentName(String componentName) { - this.componentName.set(componentName); - } - - @Input - public Property getComponentVersion() { - return componentVersion; - } - - public void setComponentVersion(String componentVersion) { - this.componentVersion.set(componentVersion); - } - - @Input - public ListProperty getSkipConfigs() { - return skipConfigs; - } - - public void setSkipConfigs(Collection skipConfigs) { - this.skipConfigs.addAll(skipConfigs); - } - - @Input - public ListProperty getSkipProjects() { - return skipProjects; - } - - public void setSkipProjects(Collection skipProjects) { - this.skipProjects.addAll(skipProjects); - } - - @Input - public Property getSchemaVersion() { - return schemaVersion; - } - - public void setSchemaVersion(String schemaVersion) { - this.schemaVersion.set(schemaVersion); - } - - @Input - public Property getProjectType() { - return projectType; - } - - public void setProjectType(String projectType) { - this.projectType.set(projectType); + this.parser = new CycloneDxParser(getLogger()); } @Input - public Property getIncludeLicenseText() { - return includeLicenseText; - } - - public void setIncludeLicenseText(boolean includeLicenseText) { - this.includeLicenseText.set(includeLicenseText); - } + public abstract Property getResolvedBuild(); @Input - public Property getIncludeBomSerialNumber() { - return includeBomSerialNumber; - } - - public void setIncludeBomSerialNumber(boolean includeBomSerialNumber) { - this.includeBomSerialNumber.set(includeBomSerialNumber); - } + public abstract Property getDestination(); @Input - public Property getIncludeMetadataResolution() { - return includeMetadataResolution; - } - - public void setIncludeMetadataResolution(boolean includeMetadataResolution) { - this.includeMetadataResolution.set(includeMetadataResolution); - } - - @OutputDirectory - public Property getDestination() { - return destination; - } - - public void setDestination(File destination) { - this.destination.set(destination); - } - - public void setOrganizationalEntity(Consumer customizer) { - OrganizationalEntity origin = new OrganizationalEntity(); - customizer.accept(origin); - this.organizationalEntity = origin; - - Map organizationalEntity = new HashMap<>(); - - organizationalEntity.put("name", this.organizationalEntity.getName()); - if (this.organizationalEntity.getUrls() != null) { - for (int i = 0; i < this.organizationalEntity.getUrls().size(); i++) { - organizationalEntity.put( - "url" + i, this.organizationalEntity.getUrls().get(i)); - } - } - if (this.organizationalEntity.getContacts() != null) { - for (int i = 0; i < this.organizationalEntity.getContacts().size(); i++) { - organizationalEntity.put( - "contact_name" + i, - this.organizationalEntity.getContacts().get(i).getName()); - organizationalEntity.put( - "contact_email" + i, - this.organizationalEntity.getContacts().get(i).getEmail()); - organizationalEntity.put( - "contact_phone" + i, - this.organizationalEntity.getContacts().get(i).getPhone()); - } - } - // Definition of gradle Input via Hashmap because Hashmap is serializable - // (OrganizationalEntity isn't serializable) - getInputs().property("OrganizationalEntity", organizationalEntity); - } - - public void setLicenseChoice(Consumer customizer) { - LicenseChoice origin = new LicenseChoice(); - customizer.accept(origin); - this.licenseChoice = origin; - - Map licenseChoice = new HashMap<>(); - - if (this.licenseChoice.getLicenses() != null) { - for (int i = 0; i < this.licenseChoice.getLicenses().size(); i++) { - if (this.licenseChoice.getLicenses().get(i).getName() != null) { - licenseChoice.put( - "licenseChoice" + i + "name", - this.licenseChoice.getLicenses().get(i).getName()); - } - if (this.licenseChoice.getLicenses().get(i).getId() != null) { - licenseChoice.put( - "licenseChoice" + i + "id", - this.licenseChoice.getLicenses().get(i).getId()); - } - licenseChoice.put( - "licenseChoice" + i + "text", - this.licenseChoice - .getLicenses() - .get(i) - .getAttachmentText() - .getText()); - licenseChoice.put( - "licenseChoice" + i + "url", - this.licenseChoice.getLicenses().get(i).getUrl()); - } - } - - if (this.licenseChoice.getExpression() != null) { - licenseChoice.put( - "licenseChoice_Expression", - this.licenseChoice.getExpression().getValue()); - } - // Definition of gradle Input via Hashmap because Hashmap is serializable - // (LicenseChoice isn't serializable) - getInputs().property("LicenseChoice", licenseChoice); - } + public abstract SetProperty getArtifacts(); @TaskAction public void createBom() { - if (!outputFormat.get().trim().equalsIgnoreCase("all") - && !outputFormat.get().trim().equalsIgnoreCase("xml") - && !outputFormat.get().trim().equalsIgnoreCase("json")) { - throw new GradleException("Unsupported output format. Must be one of all, xml, or json"); - } - if (getProject().getGroup().equals("") - || getProject().getName().isEmpty() - || getProject().getVersion().equals("")) { - throw new GradleException("Project group, name, and version must be set for the root project"); - } - logParameters(); - getLogger().info(MESSAGE_RESOLVING_DEPS); - final Set builtDependencies = allBuiltProjects(); - - final Map dependencies = new TreeMap<>(); - - final Metadata metadata = createMetadata(); - Project pluginProject = getProject(); - org.cyclonedx.model.Dependency rootDependency = - new org.cyclonedx.model.Dependency(generatePackageUrl(pluginProject)); - dependencies.put(generatePackageUrl(pluginProject), rootDependency); - Stream.concat(Stream.of(pluginProject), pluginProject.getSubprojects().stream()) - .forEach(project -> processProject(project, rootDependency, dependencies, builtDependencies)); - writeBom(metadata, new HashSet<>(components.values()), dependencies.values()); - } - - private void processProject( - Project project, - org.cyclonedx.model.Dependency rootDependency, - Map dependencies, - Set builtDependencies) { - getLogger().debug("Processing project [{}]", project.getName()); - if (shouldSkipProject(project)) { - getLogger().debug("Project [{}] skipped due to plugin configuration", project.getName()); - return; - } - String projectReference = generatePackageUrl(project); - org.cyclonedx.model.Dependency projectDependency = new org.cyclonedx.model.Dependency(projectReference); - for (Configuration configuration : project.getConfigurations()) { - processConfiguration(configuration, projectReference, projectDependency, dependencies, builtDependencies); - } - - if (!getProject().equals(project)) { - rootDependency.addDependency(projectDependency); - // declare sub-project as component - Component component = generateProjectComponent(project); - String bomRef = component.getBomRef(); - components.putIfAbsent(bomRef, component); - } - } - - private void processConfiguration( - Configuration configuration, - String projectReference, - org.cyclonedx.model.Dependency moduleDependency, - Map dependencies, - Set builtDependencies) { - getLogger().debug("Processing configuration [{}]", configuration.getName()); - if (!shouldIncludeConfiguration(configuration) || shouldSkipConfiguration(configuration)) { - getLogger().debug("Configuration [{}] skipped due to plugin configuration", configuration.getName()); - return; - } - if (!configuration.isCanBeResolved()) { - getLogger().debug("Configuration [{}] because it cannot be resolved", configuration.getName()); - return; - } - final ResolvedConfiguration resolvedConfiguration = configuration.getResolvedConfiguration(); - final Set directModuleDependencies = - configuration.getResolvedConfiguration().getFirstLevelModuleDependencies(); - - while (directModuleDependencies.stream().anyMatch(this::dependencyWithoutJarArtifact)) { - Set depWithNoArtifacts = directModuleDependencies.stream() - .filter(this::dependencyWithoutJarArtifact) - .collect(Collectors.toSet()); - - directModuleDependencies.removeAll(depWithNoArtifacts); - depWithNoArtifacts.forEach(dmd -> directModuleDependencies.addAll(dmd.getChildren())); - } - - for (ResolvedDependency directModuleDependency : directModuleDependencies) { - @Nullable ResolvedArtifact directJarArtifact = getJarOrZipArtifact(directModuleDependency); - if (directJarArtifact != null) { - moduleDependency.addDependency( - new org.cyclonedx.model.Dependency(generatePackageUrl(directJarArtifact))); - buildDependencyGraph(dependencies, directModuleDependency, directJarArtifact); - } - } - dependencies.compute(projectReference, (k, v) -> { - if (v == null) { - return moduleDependency; - } else if (moduleDependency.getDependencies() != null) { - moduleDependency.getDependencies().forEach(v::addDependency); - } - return v; - }); - - resolvedConfiguration.getResolvedArtifacts().forEach(artifact -> { - String dependencyName = DependencyUtils.getDependencyName(artifact); - Component component = convertArtifact(artifact); - - // Resources not built as part of this Gradle project will be augmented with - // metadata from their poms - if (!builtDependencies.contains(dependencyName)) { - if (getIncludeMetadataResolution().get()) { - augmentComponentMetadata(artifact, component, dependencyName); - } - } - components.putIfAbsent(component.getBomRef(), component); - }); - } - - private Set allBuiltProjects() { - return getProject().getRootProject().getAllprojects().stream() - .filter(it -> !Objects.equals(it.getVersion(), "unspecified")) - .map(it -> it.getGroup() + ":" + it.getName() + ":" + it.getVersion()) - .collect(Collectors.toSet()); - } - - private void addSubProjectsAsComponents( - org.cyclonedx.model.Dependency rootDependency, - Version version, - Set projectsToScan, - Map components) { - Project rootProject = getProject(); - for (Project project : projectsToScan) { - String projectReference = generatePackageUrl(project); - if (!rootProject.equals(project)) { - rootDependency.addDependency(new org.cyclonedx.model.Dependency(projectReference)); - // declare sub-project as component - Component component = generateProjectComponent(project); - String bomRef = component.getBomRef(); - components.putIfAbsent(bomRef, component); - } - } - } - - private boolean dependencyWithoutJarArtifact(ResolvedDependency dependency) { - return getJarOrZipArtifact(dependency) == null; - } - - private Map buildDependencyGraph( - Map dependenciesSoFar, - ResolvedDependency resolvedDependency, - ResolvedArtifact jarArtifact) { - String dependencyPurl = generatePackageUrl(jarArtifact); - org.cyclonedx.model.Dependency dependency = new org.cyclonedx.model.Dependency(dependencyPurl); - if (dependenciesSoFar.containsKey(dependencyPurl)) { - return dependenciesSoFar; - } - dependenciesSoFar.put(dependencyPurl, dependency); - - for (ResolvedDependency childDependency : resolvedDependency.getChildren()) { - @Nullable ResolvedArtifact childJarArtifact = getJarOrZipArtifact(childDependency); - if (childJarArtifact != null) { - dependency.addDependency(new org.cyclonedx.model.Dependency(generatePackageUrl(childJarArtifact))); - buildDependencyGraph(dependenciesSoFar, childDependency, childJarArtifact); - } - } - return dependenciesSoFar; - } - - @Nullable private ResolvedArtifact getJarOrZipArtifact(ResolvedDependency dependency) { - for (ResolvedArtifact artifact : dependency.getModuleArtifacts()) { - if (Objects.equals(artifact.getType(), "jar") - || Objects.equals(artifact.getType(), "aar") - || Objects.equals(artifact.getType(), "zip")) { - return artifact; - } - } - return null; - } - - /** - * @param dependencyName - * coordinate of a module dependency in the group:name:version format - * @return the resolved maven POM file, or null upon resolve error - */ - @Nullable private MavenProject getResolvedMavenProject(String dependencyName) { - synchronized (resolvedMavenProjects) { - if (resolvedMavenProjects.containsKey(dependencyName)) { - return resolvedMavenProjects.get(dependencyName); - } - } - final Dependency pomDep = getProject().getDependencies().create(dependencyName + "@pom"); - final Configuration pomCfg = getProject().getConfigurations().detachedConfiguration(pomDep); - - try { - @Nullable final File pomFile = pomCfg.resolve().stream().findFirst().orElse(null); - if (pomFile != null) { - @Nullable final MavenProject project = mavenHelper.readPom(pomFile); - resolvedMavenProjects.put(dependencyName, project); - if (project != null) { - @Nullable Model model = mavenHelper.resolveEffectivePom(pomFile, getProject()); - if (model != null) { - project.setLicenses(model.getLicenses()); - } - - return project; - } - } - } catch (Exception err) { - getLogger().error("Unable to resolve POM for " + dependencyName + ": " + err); - } - resolvedMavenProjects.put(dependencyName, null); - return null; - } - - private void augmentComponentMetadata(ResolvedArtifact artifact, Component component, String dependencyName) { - if (!mavenHelper.isDescribedArtifact(artifact)) { - MavenProject project = mavenHelper.extractPom(artifact); - if (project != null) { - mavenHelper.getClosestMetadata(artifact, project, component); - } - } - final MavenProject project = getResolvedMavenProject(dependencyName); - - if (project != null) { - if (project.getOrganization() != null) { - component.setPublisher(project.getOrganization().getName()); - } - component.setDescription(project.getDescription()); - component.setLicenseChoice(mavenHelper.resolveMavenLicenses(project.getLicenses())); - // Update external references by the resolved POM - mavenHelper.extractMetadata(project, component); - } - } + final ResolvedBuild resolvedBuild = getResolvedBuild().get(); + final Map> configurations = new HashMap<>(); + configurations.put(resolvedBuild.getProjectName(), resolvedBuild.getProjectConfigurations()); + configurations.putAll(resolvedBuild.getSubProjectsConfigurations()); - /** - * Converts a MavenProject into a Metadata object. - * - * @return a CycloneDX Metadata object - */ - protected Metadata createMetadata() { - final Project project = getProject(); - final Properties properties = readPluginProperties(); - final Metadata metadata = new Metadata(); - final Tool tool = new Tool(); - tool.setVendor(properties.getProperty("vendor")); - tool.setName(properties.getProperty("name")); - tool.setVersion(properties.getProperty("version")); - metadata.addTool(tool); - - final Component component = new Component(); - component.setGroup( - (StringUtils.trimToNull(project.getGroup().toString()) != null) - ? project.getGroup().toString() - : null); - component.setName(componentName.get()); - component.setVersion(componentVersion.get()); - component.setType(resolveProjectType()); - component.setPurl(generatePackageUrl(project)); - component.setBomRef(component.getPurl()); - metadata.setComponent(component); - - if (organizationalEntity.getName() != null - || organizationalEntity.getUrls() != null - || organizationalEntity.getContacts() != null) { - metadata.setManufacture(organizationalEntity); - } - - if (licenseChoice.getLicenses() != null || licenseChoice.getExpression() != null) { - metadata.setLicenseChoice(licenseChoice); - } - - return metadata; - } - - private Properties readPluginProperties() { - final Properties props = new Properties(); - try (InputStream inputStream = this.getClass().getResourceAsStream("plugin.properties")) { - if (inputStream == null) { - getLogger().warn("Failed to locate plugin.properties"); - } else { - props.load(inputStream); - } - } catch (NullPointerException | IOException e) { - getLogger().warn("Unable to load plugin.properties", e); - } - return props; - } - - private Component.Type resolveProjectType() { - for (Component.Type type : Component.Type.values()) { - if (type.getTypeName().equalsIgnoreCase(getProjectType().get())) { - return type; - } - } - getLogger().warn("Invalid project type. Defaulting to 'library'"); - getLogger().warn("Valid types are:"); - for (Component.Type type : Component.Type.values()) { - getLogger().warn(" " + type.getTypeName()); - } - return Component.Type.LIBRARY; - } - - private Component generateProjectComponent(Project project) { - final Component component = new Component(); - component.setGroup(project.getGroup().toString()); - component.setName(project.getName()); - component.setVersion(project.getVersion().toString()); - component.setType(Component.Type.LIBRARY); - - String projectReference = generatePackageUrl(project); - - component.setPurl(projectReference); - if (getVersion().getVersion() >= 1.1) { - component.setBomRef(projectReference); - } - - return component; - } - - private Component convertArtifact(ResolvedArtifact artifact) { - final Component component = new Component(); - component.setGroup(artifact.getModuleVersion().getId().getGroup()); - component.setName(artifact.getModuleVersion().getId().getName()); - component.setVersion(artifact.getModuleVersion().getId().getVersion()); - component.setType(Component.Type.LIBRARY); - getLogger().debug(MESSAGE_CALCULATING_HASHES); - List hashes = artifactHashes.computeIfAbsent(artifact.getFile(), f -> { - try { - return BomUtils.calculateHashes(f, getVersion()); - } catch (IOException e) { - getLogger().error("Error encountered calculating hashes", e); - } - return Collections.emptyList(); - }); - component.setHashes(hashes); - - final String packageUrl = generatePackageUrl(artifact); - component.setPurl(packageUrl); - - if (getVersion().getVersion() >= 1.1) { - component.setModified(mavenHelper.isModified(artifact)); - component.setBomRef(packageUrl); - } - return component; - } - - private boolean shouldIncludeConfiguration(Configuration configuration) { - return getIncludeConfigs().get().isEmpty() - || getIncludeConfigs().get().stream().anyMatch(configuration.getName()::matches); - } - - private boolean shouldSkipConfiguration(Configuration configuration) { - return getSkipConfigs().get().stream().anyMatch(configuration.getName()::matches); - } - - private boolean shouldSkipProject(Project project) { - return getSkipProjects().get().contains(project.getName()); - } - - private String generatePackageUrl(final ResolvedArtifact artifact) { - TreeMap qualifiers = new TreeMap<>(); - qualifiers.put("type", artifact.getType()); - if (artifact.getClassifier() != null) { - qualifiers.put("classifier", artifact.getClassifier()); - } - return generatePackageUrl( - artifact.getModuleVersion().getId().getGroup(), - artifact.getModuleVersion().getId().getName(), - artifact.getModuleVersion().getId().getVersion(), - qualifiers); - } - - private String generatePackageUrl(final Project project) { - TreeMap qualifiers = new TreeMap<>(); - if (project.getChildProjects().isEmpty()) { - qualifiers.put("type", "jar"); - } else { - qualifiers.put("type", "pom"); - } - return generatePackageUrl( - project.getGroup().toString(), - project.getName(), - project.getVersion().toString(), - qualifiers); - } - - @Nullable private String generatePackageUrl( - String groupId, String artifactId, String version, TreeMap qualifiers) { - try { - return new PackageURL(PackageURL.StandardTypes.MAVEN, groupId, artifactId, version, qualifiers, null) - .canonicalize(); - } catch (MalformedPackageURLException e) { - getLogger() - .warn("An unexpected issue occurred attempting to create a PackageURL for " + groupId + ":" - + artifactId + ":" + version); - } - return null; - } - - /** - * Ported from Maven plugin. - * - * @param metadata The CycloneDX metadata object - * @param components The CycloneDX components extracted from gradle dependencies - */ - protected void writeBom( - Metadata metadata, Set components, Collection dependencies) - throws GradleException { - try { - getLogger().info(MESSAGE_CREATING_BOM); - final Bom bom = new Bom(); - - boolean includeSerialNumber = getBooleanParameter( - "cyclonedx.includeBomSerialNumber", - getIncludeBomSerialNumber().get()); - Version version = getVersion(); - if (Version.VERSION_10 != version && includeSerialNumber) { - bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); - } - bom.setMetadata(metadata); - bom.setComponents(new ArrayList<>(components)); - bom.setDependencies(new ArrayList<>(dependencies)); - if (outputFormat.get().equals("all") || outputFormat.get().equals("xml")) { - writeXMLBom(version, bom); - } - if (getVersion().getVersion() >= 1.2 - && (outputFormat.get().equals("all") || outputFormat.get().equals("json"))) { - writeJSONBom(version, bom); - } - } catch (GeneratorException | ParserConfigurationException | IOException e) { - throw new GradleException( - "An error occurred executing " + this.getClass().getName(), e); - } - } - - private Version getVersion() { - return CycloneDxUtils.schemaVersion(getSchemaVersion().get()); - } - - private void writeXMLBom(final Version schemaVersion, final Bom bom) - throws GeneratorException, ParserConfigurationException, IOException { - final BomXmlGenerator bomGenerator = BomGeneratorFactory.createXml(schemaVersion, bom); - bomGenerator.generate(); - final String bomString = bomGenerator.toXmlString(); - final File bomFile = new File(getDestination().get(), getOutputName().get() + ".xml"); - getLogger().info(MESSAGE_WRITING_BOM_XML); - FileUtils.write(bomFile, bomString, StandardCharsets.UTF_8, false); - getLogger().info(MESSAGE_VALIDATING_BOM); - final Parser bomParser = new XmlParser(); - try { - final List exceptions = bomParser.validate(bomFile, schemaVersion); - exceptions.forEach(it -> getLogger().error(it.getMessage())); - if (!exceptions.isEmpty()) { - throw exceptions.get(0); - } - } catch (Exception e) { // Changed to Exception. - // Gradle will erroneously report "exception IOException is never thrown in body - // of corresponding try statement" - throw new GradleException(MESSAGE_VALIDATION_FAILURE, e); - } - } + registerArtifacts(); + buildDependencies(configurations); - private void writeJSONBom(final Version schemaVersion, final Bom bom) throws IOException { - final BomJsonGenerator bomGenerator = BomGeneratorFactory.createJson(schemaVersion, bom); - try { - final String bomString = bomGenerator.toJsonString(); - final File bomFile = - new File(getDestination().get(), getOutputName().get() + ".json"); - getLogger().info(MESSAGE_WRITING_BOM_JSON); - FileUtils.write(bomFile, bomString, StandardCharsets.UTF_8, false); - getLogger().info(MESSAGE_VALIDATING_BOM); - final Parser bomParser = new JsonParser(); - final List exceptions = bomParser.validate(bomFile, schemaVersion); - exceptions.forEach(it -> getLogger().error(it.getMessage())); - if (!exceptions.isEmpty()) { - throw exceptions.get(0); - } - } catch (Exception e) { // Changed to Exception. - // Gradle will erroneously report "exception IOException is never thrown in body - // of corresponding try statement" - throw new GradleException(MESSAGE_VALIDATION_FAILURE, e); - } + File destination = new File(getDestination().get(), "bom.json"); + CycloneDxUtils.writeBom(parser.getResultingBom(), destination); } - private boolean getBooleanParameter(String parameter, boolean defaultValue) { - final Project project = super.getProject(); - if (project.hasProperty(parameter)) { - final Object o = project.getProperties().get(parameter); - if (o instanceof String) { - return Boolean.parseBoolean((String) o); - } - } - return defaultValue; + private void buildDependencies(final Map> configurations) { + configurations.entrySet().forEach(project -> project.getValue() + .forEach(config -> parser.visitGraph( + config.getDependencyGraph().get(), project.getKey(), config.getConfigurationName()))); } - protected 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("------------------------------------------------------------------------"); - } + private void registerArtifacts() { + getArtifacts().get().forEach(parser::registerArtifact); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java new file mode 100644 index 00000000..44f8f80a --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java @@ -0,0 +1,40 @@ +/* + * 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.model; + +import java.io.File; + +public class ArtifactInfo { + + private final String componentId; + private final File artifactFile; + + public ArtifactInfo(final String componentId, final File artifactFile) { + this.componentId = componentId; + this.artifactFile = artifactFile; + } + + public String getComponentId() { + return componentId; + } + + public File getArtifactFile() { + return artifactFile; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java b/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java new file mode 100644 index 00000000..07b8e7b4 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java @@ -0,0 +1,30 @@ +/* + * 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.model; + +import java.util.Comparator; +import org.cyclonedx.model.Component; + +public class ComponentComparator implements Comparator { + + @Override + public int compare(Component o1, Component o2) { + return o1.getBomRef().compareTo(o2.getBomRef()); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java b/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java new file mode 100644 index 00000000..c64ba7b2 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java @@ -0,0 +1,29 @@ +/* + * 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.model; + +import java.util.Comparator; +import org.cyclonedx.model.Dependency; + +public class DependencyComparator implements Comparator { + @Override + public int compare(Dependency o1, Dependency o2) { + return o1.getRef().compareTo(o2.getRef()); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java new file mode 100644 index 00000000..18c47e69 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java @@ -0,0 +1,63 @@ +/* + * 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.model; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class ResolvedBuild { + + private final String projectName; + private final Set projectConfigurations; + private final Map> subProjectsConfigurations; + + public ResolvedBuild(final String projectName) { + this.projectName = projectName; + this.projectConfigurations = new HashSet<>(); + this.subProjectsConfigurations = new HashMap<>(); + } + + public String getProjectName() { + return projectName; + } + + public void addProjectConfiguration(final ResolvedConfiguration configuration) { + projectConfigurations.add(configuration); + } + + public Set getProjectConfigurations() { + return projectConfigurations; + } + + public void addSubProjectConfiguration(final String projectName, final ResolvedConfiguration configuration) { + if (subProjectsConfigurations.containsKey(projectName)) { + subProjectsConfigurations.get(projectName).add(configuration); + } else { + final Set subProjectConfigurations = new HashSet<>(); + subProjectConfigurations.add(configuration); + subProjectsConfigurations.put(projectName, subProjectConfigurations); + } + } + + public Map> getSubProjectsConfigurations() { + return subProjectsConfigurations; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java new file mode 100644 index 00000000..385bbadf --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java @@ -0,0 +1,42 @@ +/* + * 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.model; + +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.provider.Provider; + +public class ResolvedConfiguration { + + private final String configurationName; + private final Provider dependencyGraph; + + public ResolvedConfiguration( + final String configurationName, final Provider dependencyGraph) { + this.configurationName = configurationName; + this.dependencyGraph = dependencyGraph; + } + + public String getConfigurationName() { + return configurationName; + } + + public Provider getDependencyGraph() { + return dependencyGraph; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java index 3d405f4a..945024f0 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java @@ -18,7 +18,15 @@ */ package org.cyclonedx.gradle.utils; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.apache.commons.io.FileUtils; import org.cyclonedx.Version; +import org.cyclonedx.generators.BomGeneratorFactory; +import org.cyclonedx.generators.json.BomJsonGenerator; +import org.cyclonedx.model.Bom; +import org.gradle.api.GradleException; public class CycloneDxUtils { @@ -49,4 +57,24 @@ public static Version schemaVersion(String version) { return DEFAULT_SCHEMA_VERSION; } } + + public static void writeBom(final Bom bom, final File destination) { + try { + writeJSONBom(Version.VERSION_16, bom, destination); + } catch (IOException e) { + throw new GradleException("An error occurred writing BOM", e); + } + } + + private static void writeJSONBom(final Version schemaVersion, final Bom bom, final File destination) + throws IOException { + + final BomJsonGenerator bomGenerator = BomGeneratorFactory.createJson(schemaVersion, bom); + try { + final String bomString = bomGenerator.toJsonString(); + FileUtils.write(destination, bomString, StandardCharsets.UTF_8, false); + } catch (Exception e) { + throw new GradleException("Valid message", e); + } + } } diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index bf8de433..dc496187 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -78,4 +78,37 @@ class DependencyResolutionSpec extends Specification { assert root.getDependencies().size() == 1 assert root.getDependencies().get(0).getRef() == "pkg:maven/org.hibernate/hibernate-core@5.6.15.Final?type=jar" } + + def "should contain correct components"() { + given: + File testRepoDir = TestUtils.duplicateRepo("test1") + + File testDir = TestUtils.createFromString(""" + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + repositories { + maven{ + url 'file://${testRepoDir.absolutePath.replace("\\","/")}/repository' + } + } + group = 'com.example' + version = '1.0.0' + + dependencies { + implementation("com.test:componentb:1.0.0") + }""", "rootProject.name = 'hello-world'") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--stacktrace", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + println(result.output) + } } diff --git a/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy b/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy index e42ea063..91be132f 100644 --- a/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy @@ -20,6 +20,22 @@ class TestUtils { return tmpDir } + static File duplicateRepo(String testProject) { + def tmpDir = File.createTempDir( "copy", testProject) + def baseDir = new File("src/test/resources/test-repos/$testProject").toPath() + + baseDir.eachFileRecurse {path -> + def relativePath = baseDir.relativize(path) + def targetPath = tmpDir.toPath().resolve(relativePath) + if (Files.isDirectory(path)) { + targetPath.toFile().mkdirs() + } else { + Files.copy(path, targetPath) + } + } + return tmpDir + } + static File createFromString(String buildContent, String settingsContent) { def tmpDir = File.createTempDir( "from-literal") def settingsFile = new File(tmpDir, "settings.gradle") diff --git a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar new file mode 100644 index 00000000..8663d286 --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar @@ -0,0 +1 @@ +randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom new file mode 100644 index 00000000..6555122a --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom @@ -0,0 +1,17 @@ + + + 4.0.0 + com.test + componenta + 1.0.0 + + + + com.test + componentb + 1.0.0 + + + + diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar new file mode 100644 index 00000000..8663d286 --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar @@ -0,0 +1 @@ +randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom new file mode 100644 index 00000000..ba456008 --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + com.test + componentb + 1.0.0 + + diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar new file mode 100644 index 00000000..8663d286 --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar @@ -0,0 +1 @@ +randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom new file mode 100644 index 00000000..46aab1ef --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + com.test + componentb + 1.0.1 + + From 09e73cea5b42bdc712ece4c92ea87e5afc641c1a Mon Sep 17 00:00:00 2001 From: Gordon Date: Wed, 23 Oct 2024 19:13:46 +0100 Subject: [PATCH 02/26] feat: use single graph by merging all graphs for every configuration Signed-off-by: Gordon --- .../cyclonedx/gradle/CycloneDxBomBuilder.java | 194 ++++++++++++++++++ .../gradle/CycloneDxDependencyTraverser.java | 143 +++++++++++++ .../org/cyclonedx/gradle/CycloneDxParser.java | 183 ----------------- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 20 +- .../org/cyclonedx/gradle/CycloneDxTask.java | 27 +-- .../cyclonedx/gradle/model/ArtifactInfo.java | 7 +- .../org/cyclonedx/gradle/model/GraphNode.java | 90 ++++++++ .../cyclonedx/gradle/model/ResolvedBuild.java | 1 - .../gradle/utils/CycloneDxUtils.java | 4 +- .../gradle/utils/DependencyUtils.java | 47 ++--- .../org/cyclonedx/gradle/CycloneDxSpec.groovy | 4 +- .../gradle/DependencyResolutionSpec.groovy | 61 +++++- .../gradle/PluginConfigurationSpec.groovy | 37 +++- .../org/cyclonedx/gradle/TestUtils.groovy | 5 +- .../componenta/1.0.0/componenta-1.0.0.jar | 1 + .../componenta/1.0.0/componenta-1.0.0.pom | 0 .../componentb/1.0.0/componentb-1.0.0.jar | 1 + .../componentb/1.0.0/componentb-1.0.0.pom | 0 .../componentb/1.0.1/componentb-1.0.1.jar | 1 + .../componentb/1.0.1/componentb-1.0.1.pom | 0 .../componentc/1.0.0/componentc-1.0.0.pom | 18 ++ .../componentc/1.0.0/componentc-1.0.0.tgz | 1 + .../componenta/1.0.0/componenta-1.0.0.jar | 1 - .../componentb/1.0.0/componentb-1.0.0.jar | 1 - .../componentb/1.0.1/componentb-1.0.1.jar | 1 - 25 files changed, 578 insertions(+), 270 deletions(-) create mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java create mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java delete mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxParser.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/GraphNode.java create mode 100644 src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar rename src/test/resources/test-repos/{test1 => local}/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom (100%) create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar rename src/test/resources/test-repos/{test1 => local}/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom (100%) create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar rename src/test/resources/test-repos/{test1 => local}/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom (100%) create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.pom create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.tgz delete mode 100644 src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar delete mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar delete mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java new file mode 100644 index 00000000..87c9b06b --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java @@ -0,0 +1,194 @@ +/* + * 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; + +import com.github.packageurl.MalformedPackageURLException; +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; +import org.apache.commons.io.FilenameUtils; +import org.cyclonedx.Version; +import org.cyclonedx.gradle.model.ComponentComparator; +import org.cyclonedx.gradle.model.DependencyComparator; +import org.cyclonedx.gradle.model.GraphNode; +import org.cyclonedx.gradle.utils.CycloneDxUtils; +import org.cyclonedx.gradle.utils.DependencyUtils; +import org.cyclonedx.model.*; +import org.cyclonedx.util.BomUtils; +import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.logging.Logger; + +public class CycloneDxBomBuilder { + + private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; + private static final TreeMap DEFAULT_TYPE = new TreeMap<>(); + + static { + DEFAULT_TYPE.put("type", "jar"); + } + + private final Logger logger; + private final Map> artifactHashes; + private final MavenHelper mavenHelper; + private final Version version; + + public CycloneDxBomBuilder(final Logger logger) { + this.logger = logger; + this.version = CycloneDxUtils.DEFAULT_SCHEMA_VERSION; + this.artifactHashes = new HashMap<>(); + this.mavenHelper = new MavenHelper(logger, version, false); + } + + public Bom buildBom( + final Map> resultGraph, + final GraphNode parentNode, + final Map resolvedArtifacts) { + + final Set dependencies = new TreeSet<>(new DependencyComparator()); + final Set components = new TreeSet<>(new ComponentComparator()); + + resultGraph.keySet().forEach(node -> { + addDependency(dependencies, resultGraph.get(node), node, resolvedArtifacts); + addComponent(components, node, parentNode, resolvedArtifacts); + }); + + final Bom bom = new Bom(); + bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); + bom.setMetadata(buildMetadata(parentNode)); + bom.setComponents(new ArrayList<>(components)); + bom.setDependencies(new ArrayList<>(dependencies)); + return bom; + } + + private Metadata buildMetadata(final GraphNode parentNode) { + final Metadata metadata = new Metadata(); + try { + metadata.setComponent(toComponent(parentNode, null)); + } catch (MalformedPackageURLException e) { + logger.warn("Error constructing packageUrl for parent component. Skipping...", e); + } + return metadata; + } + + private void addDependency( + final Set dependencies, + final Set dependencyNodes, + final GraphNode node, + final Map resolvedArtifacts) { + + final Dependency dependency; + try { + dependency = toDependency(node.getResult(), resolvedArtifacts); + } catch (MalformedPackageURLException e) { + logger.warn("Error constructing packageUrl for node. Skipping...", e); + return; + } + dependencyNodes.forEach(dependencyNode -> { + try { + dependency.addDependency(toDependency(dependencyNode.getResult(), resolvedArtifacts)); + } catch (MalformedPackageURLException e) { + logger.warn("Error constructing packageUrl for node dependency. Skipping...", e); + } + }); + dependencies.add(dependency); + } + + private Dependency toDependency( + final ResolvedComponentResult component, final Map resolvedArtifacts) + throws MalformedPackageURLException { + + final File artifactFile = resolvedArtifacts.get(component.getId()); + final String ref = DependencyUtils.generatePackageUrl(component.getModuleVersion(), getType(artifactFile)); + return new Dependency(ref); + } + + private void addComponent( + final Set components, + final GraphNode node, + final GraphNode parentNode, + final Map resolvedArtifacts) { + if (!node.equals(parentNode)) { + final File artifactFile = resolvedArtifacts.get(node.getResult().getId()); + try { + components.add(toComponent(node, artifactFile)); + } catch (MalformedPackageURLException e) { + logger.warn("Error constructing packageUrl for node component. Skipping...", e); + } + } + } + + private Component toComponent(final GraphNode node, final File artifactFile) throws MalformedPackageURLException { + + final ModuleVersionIdentifier moduleVersion = node.getResult().getModuleVersion(); + final String packageUrl = DependencyUtils.generatePackageUrl(moduleVersion, getType(artifactFile)); + + final Component component = new Component(); + component.setGroup(moduleVersion.getGroup()); + component.setName(moduleVersion.getName()); + component.setVersion(moduleVersion.getVersion()); + component.setType(Component.Type.LIBRARY); + component.setPurl(packageUrl); + component.setProperties(buildProperties(node)); + if (version.getVersion() >= 1.1) { + component.setModified(mavenHelper.isModified(null)); + component.setBomRef(packageUrl); + } + + logger.debug(MESSAGE_CALCULATING_HASHES); + if (artifactFile != null) { + component.setHashes(calculateHashes(artifactFile)); + } + + return component; + } + + private List buildProperties(GraphNode node) { + return node.getInScopeConfigurations().stream() + .map(v -> { + Property property = new Property(); + property.setName("inScopeConfiguration"); + property.setValue(String.format("%s:%s", v.getProjectName(), v.getConfigName())); + return property; + }) + .collect(Collectors.toList()); + } + + private List calculateHashes(final File artifactFile) { + return artifactHashes.computeIfAbsent(artifactFile, f -> { + try { + return BomUtils.calculateHashes(f, version); + } catch (IOException e) { + logger.error("Error encountered calculating hashes", e); + } + return Collections.emptyList(); + }); + } + + private TreeMap getType(final File file) { + if (file == null) { + return DEFAULT_TYPE; + } + final TreeMap type = new TreeMap<>(); + type.put("type", FilenameUtils.getExtension(file.getName())); + return type; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java new file mode 100644 index 00000000..b368a84c --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java @@ -0,0 +1,143 @@ +/* + * 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; + +import com.networknt.schema.utils.StringUtils; +import java.io.File; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import org.cyclonedx.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.GraphNode; +import org.cyclonedx.model.*; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.result.DependencyResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; +import org.gradle.api.logging.Logger; + +public class CycloneDxDependencyTraverser { + + private final Map> resultGraph; + private final Logger logger; + private final Map resolvedArtifacts; + private final CycloneDxBomBuilder builder; + private GraphNode parentNode; + + public CycloneDxDependencyTraverser(final Logger logger, final CycloneDxBomBuilder builder) { + this.builder = builder; + this.logger = logger; + this.resolvedArtifacts = new HashMap<>(); + this.resultGraph = new HashMap<>(); + } + + public void registerArtifact(final ArtifactInfo artifact) { + resolvedArtifacts.put(artifact.getComponentId(), artifact.getArtifactFile()); + } + + public void traverseParentGraph( + final ResolvedComponentResult rootNode, final String projectName, final String configName) { + final String parentRef = getRef(rootNode.getModuleVersion()); + this.parentNode = new GraphNode(parentRef, rootNode); + traverseGraph(rootNode, projectName, configName); + } + + public void traverseChildGraph( + final ResolvedComponentResult rootNode, final String projectName, final String configName) { + + if (this.parentNode == null) { + throw new GradleException("Parent graphs has to be traversed first"); + } + + final String childRef = getRef(rootNode.getModuleVersion()); + final GraphNode childNode = new GraphNode(childRef, rootNode); + this.resultGraph.get(this.parentNode).add(childNode); + traverseGraph(rootNode, projectName, configName); + } + + public void traverseGraph( + final ResolvedComponentResult rootNode, final String projectName, final String configName) { + + final Map> graph = new TreeMap<>(); + final Queue queue = new ArrayDeque<>(); + + final String rootRef = getRef(rootNode.getModuleVersion()); + final GraphNode rootGraphNode = new GraphNode(rootRef, rootNode); + queue.add(rootGraphNode); + + while (!queue.isEmpty()) { + final GraphNode graphNode = queue.poll(); + if (!graph.containsKey(graphNode)) { + graph.put(graphNode, new TreeSet<>()); + for (DependencyResult dep : graphNode.getResult().getDependencies()) { + if (dep instanceof ResolvedDependencyResult) { + final ResolvedComponentResult dependencyComponent = + ((ResolvedDependencyResult) dep).getSelected(); + String ref = getRef(dependencyComponent.getModuleVersion()); + GraphNode dependencyNode = new GraphNode(ref, dependencyComponent); + graph.get(graphNode).add(dependencyNode); + queue.add(dependencyNode); + } + } + } + } + + mergeIntoResultGraph(graph, projectName, configName); + } + + private void mergeIntoResultGraph( + final Map> graph, final String projectName, final String configName) { + + graph.keySet().forEach(node -> { + if (resultGraph.containsKey(node)) { + resultGraph.get(node).addAll(graph.get(node)); + } else { + resultGraph.put(node, graph.get(node)); + } + }); + + resultGraph.keySet().stream() + .filter(graph::containsKey) + .forEach(v -> v.inScopeConfiguration(projectName, configName)); + } + + public Bom toBom() { + return builder.buildBom(this.resultGraph, this.parentNode, this.resolvedArtifacts); + } + + private String getRef(final ModuleVersionIdentifier identifier) { + + // The cause for this failure is mainly if the group/name/project of the build isn't set + if (StringUtils.isBlank(identifier.getGroup()) + || StringUtils.isBlank(identifier.getName()) + || StringUtils.isBlank(identifier.getVersion())) { + throw new GradleException(String.format( + "Invalid module identifier provided. Group: %s, Name: %s, Version: %s", + identifier.getGroup(), identifier.getName(), identifier.getVersion())); + } + + return String.format("%s:%s:%s", identifier.getGroup(), identifier.getName(), identifier.getVersion()); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java deleted file mode 100644 index b8b92c41..00000000 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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; - -import com.github.packageurl.MalformedPackageURLException; -import com.github.packageurl.PackageURL; -import java.io.File; -import java.io.IOException; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import javax.annotation.Nullable; -import org.cyclonedx.Version; -import org.cyclonedx.gradle.model.ArtifactInfo; -import org.cyclonedx.gradle.model.ComponentComparator; -import org.cyclonedx.gradle.model.DependencyComparator; -import org.cyclonedx.gradle.utils.CycloneDxUtils; -import org.cyclonedx.model.Bom; -import org.cyclonedx.model.Component; -import org.cyclonedx.model.Dependency; -import org.cyclonedx.model.Hash; -import org.cyclonedx.util.BomUtils; -import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.result.DependencyResult; -import org.gradle.api.artifacts.result.ResolvedComponentResult; -import org.gradle.api.artifacts.result.ResolvedDependencyResult; -import org.gradle.api.logging.Logger; - -public class CycloneDxParser { - - private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; - - private final Set dependencies; - private final Set components; - private final Logger logger; - private final Map> artifactHashes; - private final Map resolvedArtifacts; - private final MavenHelper mavenHelper; - private final Version version; - - public CycloneDxParser(final Logger logger) { - this.logger = logger; - this.version = CycloneDxUtils.schemaVersion("1.6"); - this.dependencies = new TreeSet<>(new DependencyComparator()); - this.components = new TreeSet<>(new ComponentComparator()); - this.resolvedArtifacts = new HashMap<>(); - this.artifactHashes = new HashMap<>(); - this.mavenHelper = new MavenHelper(logger, version, false); - } - - public void registerArtifact(final ArtifactInfo artifact) { - resolvedArtifacts.put(artifact.getComponentId(), artifact.getArtifactFile()); - } - - public void visitGraph(final ResolvedComponentResult rootNode, final String projectName, final String configName) { - - final Set seen = new HashSet<>(); - final Queue queue = new ArrayDeque<>(); - queue.add(rootNode); - - while (!queue.isEmpty()) { - final ResolvedComponentResult node = queue.poll(); - if (!seen.contains(node)) { - seen.add(node); - final Dependency dependency = toDependency(node, projectName, configName); - for (DependencyResult dep : node.getDependencies()) { - if (dep instanceof ResolvedDependencyResult) { - final ResolvedComponentResult dependencyComponent = - ((ResolvedDependencyResult) dep).getSelected(); - dependency.addDependency(toDependency(dependencyComponent, projectName, configName)); - queue.add(dependencyComponent); - } - } - dependencies.add(dependency); - final File artifactFile = resolvedArtifacts.get(node.getId().getDisplayName()); - components.add(toComponent(node, artifactFile, projectName, configName)); - } - } - } - - public Bom getResultingBom() { - final Bom bom = new Bom(); - bom.setComponents(new ArrayList<>(components)); - bom.setDependencies(new ArrayList<>(dependencies)); - return bom; - } - - public Component toComponent( - final ResolvedComponentResult resolvedComponent, - final File artifactFile, - final String projectName, - final String configName) { - - final Component component = new Component(); - component.setGroup(resolvedComponent.getModuleVersion().getGroup()); - component.setName(resolvedComponent.getModuleVersion().getName()); - component.setVersion(resolvedComponent.getModuleVersion().getVersion()); - component.setType(Component.Type.LIBRARY); - logger.debug(MESSAGE_CALCULATING_HASHES); - if (artifactFile != null) { - component.setHashes(calculateHashes(artifactFile)); - } - - final TreeMap qualifiers = new TreeMap<>(); - final String packageUrl = generatePackageUrl(resolvedComponent.getModuleVersion(), qualifiers); - component.setPurl(packageUrl); - - if (version.getVersion() >= 1.1) { - component.setModified(mavenHelper.isModified(null)); - component.setBomRef(generateRef(resolvedComponent.getModuleVersion(), qualifiers, projectName, configName)); - } - return component; - } - - private List calculateHashes(final File artifactFile) { - return artifactHashes.computeIfAbsent(artifactFile, f -> { - try { - return BomUtils.calculateHashes(f, version); - } catch (IOException e) { - logger.error("Error encountered calculating hashes", e); - } - return Collections.emptyList(); - }); - } - - private Dependency toDependency( - final ResolvedComponentResult component, final String projectName, final String configName) { - - TreeMap qualifiers = new TreeMap<>(); - String ref = generateRef(component.getModuleVersion(), qualifiers, projectName, configName); - return new Dependency(ref); - } - - private String generateRef( - final ModuleVersionIdentifier version, - final TreeMap qualifiers, - final String projectName, - final String configName) { - String purl = generatePackageUrl(version, qualifiers); - return String.format("%s:%s:%s", projectName, configName, purl); - } - - @Nullable private String generatePackageUrl(final ModuleVersionIdentifier version, final TreeMap qualifiers) { - try { - return new PackageURL( - PackageURL.StandardTypes.MAVEN, - version.getGroup(), - version.getName(), - version.getVersion(), - qualifiers, - null) - .canonicalize(); - } catch (MalformedPackageURLException e) { - logger.warn("An unexpected issue occurred attempting to create a PackageURL for " + version.getGroup() + ":" - + version.getName() + ":" + version); - } - return null; - } -} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index d30e29b6..fee1b309 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -69,24 +69,22 @@ private Optional>> getArtifacts(final Project project .map(v -> v.getIncoming().getArtifacts().getResolvedArtifacts()) .reduce(this::combineArtifactsProviders) .map(provider -> - provider.map(v -> v.stream().map(this::mapResult).collect(Collectors.toSet()))); + provider.map(v -> v.stream().map(this::toArtifactInfo).collect(Collectors.toSet()))); } private Provider> combineArtifactsProviders( - Provider> left, Provider> right) { - return left.flatMap(v -> right.map(w -> { - Set result = new HashSet<>(); - result.addAll(v); - result.addAll(w); - return result; - })); + final Provider> left, final Provider> right) { + return left.zip(right, (u, v) -> { + u.addAll(v); + return u; + }); } - private ArtifactInfo mapResult(ResolvedArtifactResult result) { - return new ArtifactInfo(result.getId().getComponentIdentifier().getDisplayName(), result.getFile()); + private ArtifactInfo toArtifactInfo(final ResolvedArtifactResult result) { + return new ArtifactInfo(result.getId().getComponentIdentifier(), result.getFile()); } - private ResolvedConfiguration resolvedConfiguration(Configuration config) { + private ResolvedConfiguration resolvedConfiguration(final Configuration config) { return new ResolvedConfiguration( config.getName(), config.getIncoming().getResolutionResult().getRootComponent()); } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index f7eeb57c..9335289b 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -19,7 +19,6 @@ package org.cyclonedx.gradle; import java.io.File; -import java.util.HashMap; import java.util.Map; import java.util.Set; import org.cyclonedx.gradle.model.ArtifactInfo; @@ -34,10 +33,10 @@ public abstract class CycloneDxTask extends DefaultTask { - private final CycloneDxParser parser; + private final CycloneDxDependencyTraverser traverser; public CycloneDxTask() { - this.parser = new CycloneDxParser(getLogger()); + this.traverser = new CycloneDxDependencyTraverser(getLogger(), new CycloneDxBomBuilder(getLogger())); } @Input @@ -53,24 +52,26 @@ public CycloneDxTask() { public void createBom() { final ResolvedBuild resolvedBuild = getResolvedBuild().get(); - final Map> configurations = new HashMap<>(); - configurations.put(resolvedBuild.getProjectName(), resolvedBuild.getProjectConfigurations()); - configurations.putAll(resolvedBuild.getSubProjectsConfigurations()); registerArtifacts(); - buildDependencies(configurations); + buildParentDependencies(resolvedBuild.getProjectName(), resolvedBuild.getProjectConfigurations()); + buildChildDependencies(resolvedBuild.getSubProjectsConfigurations()); File destination = new File(getDestination().get(), "bom.json"); - CycloneDxUtils.writeBom(parser.getResultingBom(), destination); + CycloneDxUtils.writeBom(traverser.toBom(), destination); } - private void buildDependencies(final Map> configurations) { - configurations.entrySet().forEach(project -> project.getValue() - .forEach(config -> parser.visitGraph( - config.getDependencyGraph().get(), project.getKey(), config.getConfigurationName()))); + private void buildParentDependencies(final String projectName, Set configurations) { + configurations.forEach(config -> traverser.traverseParentGraph( + config.getDependencyGraph().get(), projectName, config.getConfigurationName())); + } + + private void buildChildDependencies(final Map> configurations) { + configurations.forEach((key, value) -> value.forEach(config -> + traverser.traverseChildGraph(config.getDependencyGraph().get(), key, config.getConfigurationName()))); } private void registerArtifacts() { - getArtifacts().get().forEach(parser::registerArtifact); + getArtifacts().get().forEach(traverser::registerArtifact); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java index 44f8f80a..8a9c40fa 100644 --- a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java +++ b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java @@ -19,18 +19,19 @@ package org.cyclonedx.gradle.model; import java.io.File; +import org.gradle.api.artifacts.component.ComponentIdentifier; public class ArtifactInfo { - private final String componentId; + private final ComponentIdentifier componentId; private final File artifactFile; - public ArtifactInfo(final String componentId, final File artifactFile) { + public ArtifactInfo(final ComponentIdentifier componentId, final File artifactFile) { this.componentId = componentId; this.artifactFile = artifactFile; } - public String getComponentId() { + public ComponentIdentifier getComponentId() { return componentId; } diff --git a/src/main/java/org/cyclonedx/gradle/model/GraphNode.java b/src/main/java/org/cyclonedx/gradle/model/GraphNode.java new file mode 100644 index 00000000..a43539fc --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/GraphNode.java @@ -0,0 +1,90 @@ +/* + * 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.model; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.jetbrains.annotations.NotNull; + +public class GraphNode implements Comparable { + + private final String ref; + private final ResolvedComponentResult result; + private final Set inScopeConfigurations; + + public GraphNode(final String ref, final ResolvedComponentResult result) { + this.ref = ref; + this.result = result; + this.inScopeConfigurations = new HashSet<>(); + } + + public String getRef() { + return ref; + } + + public ResolvedComponentResult getResult() { + return result; + } + + public void inScopeConfiguration(final String projectName, final String configName) { + inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); + } + + public Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + @Override + public int compareTo(@NotNull GraphNode o) { + return this.ref.compareTo(o.ref); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GraphNode graphNode = (GraphNode) o; + return Objects.equals(ref, graphNode.ref); + } + + @Override + public int hashCode() { + return Objects.hashCode(ref); + } + + public static class ConfigurationScope { + private final String projectName; + private final String configName; + + private ConfigurationScope(final String projectName, final String configName) { + this.projectName = projectName; + this.configName = configName; + } + + public String getProjectName() { + return projectName; + } + + public String getConfigName() { + return configName; + } + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java index 18c47e69..22509fc4 100644 --- a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java +++ b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java @@ -24,7 +24,6 @@ import java.util.Set; public class ResolvedBuild { - private final String projectName; private final Set projectConfigurations; private final Map> subProjectsConfigurations; diff --git a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java index 945024f0..507323f6 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java @@ -30,7 +30,7 @@ public class CycloneDxUtils { - public static final Version DEFAULT_SCHEMA_VERSION = Version.VERSION_15; + public static final Version DEFAULT_SCHEMA_VERSION = Version.VERSION_16; /** * Resolves the CycloneDX schema the mojo has been requested to use. @@ -60,7 +60,7 @@ public static Version schemaVersion(String version) { public static void writeBom(final Bom bom, final File destination) { try { - writeJSONBom(Version.VERSION_16, bom, destination); + writeJSONBom(DEFAULT_SCHEMA_VERSION, bom, destination); } catch (IOException e) { throw new GradleException("An error occurred writing 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 16f36982..016b6185 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -18,42 +18,23 @@ */ package org.cyclonedx.gradle.utils; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import org.gradle.api.artifacts.Configuration; +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURL; +import java.util.TreeMap; import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.ResolvedArtifact; -import org.gradle.api.artifacts.ResolvedDependency; public class DependencyUtils { - public static String getDependencyName(ResolvedDependency resolvedDependencies) { - final ModuleVersionIdentifier m = resolvedDependencies.getModule().getId(); - return getDependencyName(m); - } - - public static String getDependencyName(ResolvedArtifact artifact) { - final ModuleVersionIdentifier m = artifact.getModuleVersion().getId(); - return getDependencyName(m); - } - - public static boolean canBeResolved(Configuration configuration) { - // Configuration.isCanBeResolved() has been introduced with Gradle 3.3, - // thus we need to check for the method's existence first - try { - Method method = Configuration.class.getMethod("isCanBeResolved"); - try { - return (Boolean) method.invoke(configuration); - } catch (IllegalAccessException | InvocationTargetException e) { - return true; - } - } catch (NoSuchMethodException e) { - // prior to Gradle 3.3 all configurations were resolvable - return true; - } - } - - private static String getDependencyName(ModuleVersionIdentifier moduleVersion) { - return String.format("%s:%s:%s", moduleVersion.getGroup(), moduleVersion.getName(), moduleVersion.getVersion()); + public static String generatePackageUrl( + final ModuleVersionIdentifier version, final TreeMap qualifiers) + throws MalformedPackageURLException { + return new PackageURL( + PackageURL.StandardTypes.MAVEN, + version.getGroup(), + version.getName(), + version.getVersion(), + qualifiers, + null) + .canonicalize(); } } diff --git a/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy index b1534779..e888ba5c 100644 --- a/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy @@ -20,11 +20,12 @@ package org.cyclonedx.gradle import org.cyclonedx.model.Metadata import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Ignore import spock.lang.Specification class CycloneDxSpec extends Specification { static final String PLUGIN_ID = 'org.cyclonedx.bom' - + def rootProject = ProjectBuilder.builder().withName("root").build() def parentProject = ProjectBuilder.builder().withName("parent").withParent(rootProject).build() def childProject = ProjectBuilder.builder().withName("child").withParent(parentProject).build() @@ -53,6 +54,7 @@ class CycloneDxSpec extends Specification { leafProject.tasks.findByName('cyclonedxBom') } + @Ignore def "cyclonedxBom metadata creation uses project specific values"() { expect: Metadata root = rootProject.tasks.findByName('cyclonedxBom').createMetadata() diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index dc496187..beed3a4a 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.cyclonedx.model.Bom import org.cyclonedx.model.Component import org.cyclonedx.model.Dependency +import org.cyclonedx.model.Hash import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification @@ -79,9 +80,9 @@ class DependencyResolutionSpec extends Specification { assert root.getDependencies().get(0).getRef() == "pkg:maven/org.hibernate/hibernate-core@5.6.15.Final?type=jar" } - def "should contain correct components"() { + def "should contain correct hashes"() { given: - File testRepoDir = TestUtils.duplicateRepo("test1") + String localRepoUri = TestUtils.duplicateRepo("local") File testDir = TestUtils.createFromString(""" plugins { @@ -90,25 +91,71 @@ class DependencyResolutionSpec extends Specification { } repositories { maven{ - url 'file://${testRepoDir.absolutePath.replace("\\","/")}/repository' + url '$localRepoUri' } } group = 'com.example' version = '1.0.0' dependencies { - implementation("com.test:componentb:1.0.0") - }""", "rootProject.name = 'hello-world'") + implementation("com.test:componenta:1.0.0") + testImplementation("com.test:componentb:1.0.1") + }""", "rootProject.name = 'simple-project'") when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--stacktrace", "--configuration-cache") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() then: result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS - println(result.output) + File jsonBom = new File(testDir, "build/reports/bom.json") + Bom bom = new ObjectMapper().readValue(jsonBom, Bom.class) + Component componenta = bom.getComponents().find(c -> c.name == 'componenta') + Hash hasha = + componenta.hashes.find(c -> c.algorithm == "SHA-256" && c.value == "8b6a28fbdb87b7a521b61bc15d265820fb8dd1273cb44dd44a8efdcd6cd40848") + assert hasha != null + Component componentb = bom.getComponents().find(c -> c.name == 'componentb') + Hash hashb = + componentb.hashes.find(c -> c.algorithm == "SHA-256" && c.value == "5a5407bd92e71336b546642b8b62b6a9544bca5c4ab2fbb8864d9faa5400ba48") + assert hashb != null + } + + def "should generate bom for non-jar artrifacts"() { + given: + String localRepoUri = TestUtils.duplicateRepo("local") + + File testDir = TestUtils.createFromString(""" + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + repositories { + maven{ + url '$localRepoUri' + } + } + group = 'com.example' + version = '1.0.0' + + dependencies { + implementation("com.test:componentc:1.0.0") + }""", "rootProject.name = 'simple-project'") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File jsonBom = new File(testDir, "build/reports/bom.json") + Bom bom = new ObjectMapper().readValue(jsonBom, Bom.class) + Component componentc = bom.getComponents().find(c -> c.bomRef == 'pkg:maven/com.test/componentc@1.0.0?type=tgz') + assert componentc != null } } diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index 82cfd049..13b6ca35 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -8,6 +8,7 @@ import org.cyclonedx.model.Component import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification +import spock.lang.Ignore class PluginConfigurationSpec extends Specification { @@ -44,7 +45,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"${CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString}\"") } @@ -64,9 +65,10 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "output-dir") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 } + @Ignore def "custom-output project should write boms under my-bom"() { given: File testDir = TestUtils.duplicate("custom-outputname") @@ -99,11 +101,12 @@ class PluginConfigurationSpec extends Specification { result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 assert !result.output.contains("An error occurred attempting to read POM") } + @Ignore def "should use configured schemaVersion"() { given: File testDir = TestUtils.createFromString(""" @@ -173,11 +176,12 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"name\" : \"hello-world\"") } + @Ignore def "should use configured componentName"() { given: File testDir = TestUtils.createFromString(""" @@ -215,6 +219,7 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"name\" : \"customized-component-name\"") } + @Ignore def "should use configured componentVersion"() { given: File testDir = TestUtils.createFromString(""" @@ -252,6 +257,7 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"version\" : \"999-SNAPSHOT\"") } + @Ignore def "should use configured outputFormat to limit generated file"() { given: File testDir = TestUtils.createFromString(""" @@ -289,6 +295,7 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.exists() } + @Ignore def "includes component bom-ref when schema version greater than 1.0"() { given: File testDir = TestUtils.createFromString(""" @@ -336,10 +343,11 @@ class PluginConfigurationSpec extends Specification { .build() then: result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + println(result.output) File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString @@ -368,7 +376,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "app-a/build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString @@ -404,7 +412,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "app-b/build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString @@ -427,6 +435,7 @@ class PluginConfigurationSpec extends Specification { assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") } + @Ignore def "kotlin-dsl-project should allow configuring all properties"() { given: File testDir = TestUtils.duplicate("kotlin-project") @@ -448,6 +457,7 @@ class PluginConfigurationSpec extends Specification { assert !jsonBom.text.contains("serialNumber") } + @Ignore def "kotlin-dsl-project-manufacture-licenses should allow definition of manufacture-data and licenses-data"() { given: File testDir = TestUtils.duplicate("kotlin-project-manufacture-licenses") @@ -480,6 +490,7 @@ class PluginConfigurationSpec extends Specification { } + @Ignore def "groovy-project-manufacture-licenses should allow definition of manufacture-data and licenses-data"() { given: File testDir = TestUtils.duplicate("groovy-project-manufacture-licenses") @@ -512,6 +523,7 @@ class PluginConfigurationSpec extends Specification { } + @Ignore def "should skip configurations with regex"() { given: File testDir = TestUtils.createFromString(""" @@ -548,6 +560,7 @@ class PluginConfigurationSpec extends Specification { assert log4jCore == null } + @Ignore def "should include configurations with regex"() { given: File testDir = TestUtils.createFromString(""" @@ -584,7 +597,7 @@ class PluginConfigurationSpec extends Specification { assert log4jCore.getBomRef() == 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0?type=jar' } - def "should use 1.5 is default schema version"() { + def "should use 1.6 is default schema version"() { given: File testDir = TestUtils.createFromString(""" plugins { @@ -610,9 +623,9 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 File jsonBom = new File(reportDir, "bom.json") - assert jsonBom.text.contains("\"specVersion\" : \"1.5\"") + assert jsonBom.text.contains("\"specVersion\" : \"1.6\"") } def "should print error if project group, name, or version unset"() { @@ -638,9 +651,10 @@ class PluginConfigurationSpec extends Specification { then: result.task(":cyclonedxBom").outcome == TaskOutcome.FAILED - assert result.output.contains("Project group, name, and version must be set for the root project") + assert result.output.contains("Invalid module identifier provided.") } + @Ignore def "should include metadata by default"() { given: File testDir = TestUtils.createFromString(""" @@ -670,6 +684,7 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"id\" : \"Apache-2.0\"") } + @Ignore def "should not include metadata when includeMetadataResolution is false"() { given: File testDir = TestUtils.createFromString(""" diff --git a/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy b/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy index 91be132f..af393848 100644 --- a/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy @@ -20,7 +20,7 @@ class TestUtils { return tmpDir } - static File duplicateRepo(String testProject) { + static String duplicateRepo(String testProject) { def tmpDir = File.createTempDir( "copy", testProject) def baseDir = new File("src/test/resources/test-repos/$testProject").toPath() @@ -33,7 +33,8 @@ class TestUtils { Files.copy(path, targetPath) } } - return tmpDir + + return """file://${tmpDir.absolutePath.replace("\\","/")}/repository""" } static File createFromString(String buildContent, String settingsContent) { diff --git a/src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar b/src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar new file mode 100644 index 00000000..2a82c406 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar @@ -0,0 +1 @@ +component a version 1.0.0 \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom b/src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom similarity index 100% rename from src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom rename to src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom diff --git a/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar new file mode 100644 index 00000000..74396664 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar @@ -0,0 +1 @@ +component b version 1.0.0 \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom similarity index 100% rename from src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom rename to src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom diff --git a/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar new file mode 100644 index 00000000..274c11c9 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar @@ -0,0 +1 @@ +component b version 1.0.1 \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom similarity index 100% rename from src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom rename to src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom diff --git a/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.pom b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.pom new file mode 100644 index 00000000..6c7deef0 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.pom @@ -0,0 +1,18 @@ + + + 4.0.0 + com.test + componentc + 1.0.0 + tgz + + + + com.test + componentb + 1.0.0 + + + + diff --git a/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.tgz b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.tgz new file mode 100644 index 00000000..84c271a7 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.tgz @@ -0,0 +1 @@ +component c version 1.0.0 diff --git a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar deleted file mode 100644 index 8663d286..00000000 --- a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar +++ /dev/null @@ -1 +0,0 @@ -randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar deleted file mode 100644 index 8663d286..00000000 --- a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar +++ /dev/null @@ -1 +0,0 @@ -randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar deleted file mode 100644 index 8663d286..00000000 --- a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar +++ /dev/null @@ -1 +0,0 @@ -randomvalue \ No newline at end of file From f82f1cec7799b0148377d97878a9a9e07550608c Mon Sep 17 00:00:00 2001 From: Gordon Date: Wed, 23 Oct 2024 21:39:23 +0100 Subject: [PATCH 03/26] fix: enable cache for multi node project and return empty classifier for non existing file extensions Signed-off-by: Gordon --- .../cyclonedx/gradle/CycloneDxBomBuilder.java | 17 ++++---- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 31 +++++++-------- .../org/cyclonedx/gradle/CycloneDxTask.java | 7 ++-- .../gradle/model/ArtifactInfoSet.java | 39 +++++++++++++++++++ .../gradle/model/ResolvedArtifacts.java | 35 +++++++++++++++++ .../gradle/PluginConfigurationSpec.groovy | 20 +++++----- 6 files changed, 110 insertions(+), 39 deletions(-) create mode 100644 src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java index 87c9b06b..12133979 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java @@ -19,6 +19,7 @@ package org.cyclonedx.gradle; import com.github.packageurl.MalformedPackageURLException; +import com.networknt.schema.utils.StringUtils; import java.io.File; import java.io.IOException; import java.util.*; @@ -40,11 +41,7 @@ public class CycloneDxBomBuilder { private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; - private static final TreeMap DEFAULT_TYPE = new TreeMap<>(); - - static { - DEFAULT_TYPE.put("type", "jar"); - } + private static final TreeMap EMPTY_TYPE = new TreeMap<>(); private final Logger logger; private final Map> artifactHashes; @@ -185,10 +182,16 @@ private List calculateHashes(final File artifactFile) { private TreeMap getType(final File file) { if (file == null) { - return DEFAULT_TYPE; + return EMPTY_TYPE; } + + String fileExtension = FilenameUtils.getExtension(file.getName()); + if (StringUtils.isBlank(fileExtension)) { + return EMPTY_TYPE; + } + final TreeMap type = new TreeMap<>(); - type.put("type", FilenameUtils.getExtension(file.getName())); + type.put("type", fileExtension); return type; } } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index fee1b309..d2b191cd 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -21,9 +21,7 @@ import java.io.File; import java.util.*; import java.util.stream.Collectors; -import org.cyclonedx.gradle.model.ArtifactInfo; -import org.cyclonedx.gradle.model.ResolvedBuild; -import org.cyclonedx.gradle.model.ResolvedConfiguration; +import org.cyclonedx.gradle.model.*; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.artifacts.Configuration; @@ -35,7 +33,7 @@ public class CycloneDxPlugin implements Plugin { public void apply(Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { final ResolvedBuild resolvedBuild = getResolvedBuild(project); - final Optional>> artifacts = getArtifacts(project); + final Set> artifacts = getArtifacts(project); final File destination = project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); @@ -43,7 +41,7 @@ public void apply(Project project) { task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); - artifacts.ifPresent(provider -> task.getArtifacts().set(provider)); + task.getArtifacts().set(new ResolvedArtifacts(artifacts)); }); } @@ -61,23 +59,20 @@ private ResolvedBuild getResolvedBuild(final Project project) { return resolvedBuild; } - private Optional>> getArtifacts(final Project project) { + private Set> getArtifacts(final Project project) { return project.getAllprojects().stream() .flatMap(v -> v.getConfigurations().stream()) .filter(Configuration::isCanBeResolved) - .map(v -> v.getIncoming().getArtifacts().getResolvedArtifacts()) - .reduce(this::combineArtifactsProviders) - .map(provider -> - provider.map(v -> v.stream().map(this::toArtifactInfo).collect(Collectors.toSet()))); - } - - private Provider> combineArtifactsProviders( - final Provider> left, final Provider> right) { - return left.zip(right, (u, v) -> { - u.addAll(v); - return u; - }); + .map(config -> config.getIncoming() + .getArtifacts() + .getResolvedArtifacts() + .map(artifacts -> { + ArtifactInfoSet infoSet = new ArtifactInfoSet(); + artifacts.forEach(artifact -> infoSet.addInfo(toArtifactInfo(artifact))); + return infoSet; + })) + .collect(Collectors.toSet()); } private ArtifactInfo toArtifactInfo(final ResolvedArtifactResult result) { diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index 9335289b..bd6732af 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -21,13 +21,12 @@ import java.io.File; import java.util.Map; import java.util.Set; -import org.cyclonedx.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.ResolvedArtifacts; import org.cyclonedx.gradle.model.ResolvedBuild; import org.cyclonedx.gradle.model.ResolvedConfiguration; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.gradle.api.DefaultTask; import org.gradle.api.provider.Property; -import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.TaskAction; @@ -46,7 +45,7 @@ public CycloneDxTask() { public abstract Property getDestination(); @Input - public abstract SetProperty getArtifacts(); + public abstract Property getArtifacts(); @TaskAction public void createBom() { @@ -72,6 +71,6 @@ private void buildChildDependencies(final Map } private void registerArtifacts() { - getArtifacts().get().forEach(traverser::registerArtifact); + getArtifacts().get().getArtifacts().forEach(v -> v.get().getInfoSet().forEach(traverser::registerArtifact)); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java new file mode 100644 index 00000000..322b4f44 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java @@ -0,0 +1,39 @@ +/* + * 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.model; + +import java.util.HashSet; +import java.util.Set; + +public class ArtifactInfoSet { + + private final Set infoSet; + + public ArtifactInfoSet() { + this.infoSet = new HashSet<>(); + } + + public void addInfo(final ArtifactInfo info) { + this.infoSet.add(info); + } + + public Set getInfoSet() { + return infoSet; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java new file mode 100644 index 00000000..3d9f649f --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java @@ -0,0 +1,35 @@ +/* + * 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.model; + +import java.util.Set; +import org.gradle.api.provider.Provider; + +public class ResolvedArtifacts { + + private final Set> artifacts; + + public ResolvedArtifacts(Set> artifacts) { + this.artifacts = artifacts; + } + + public Set> getArtifacts() { + return artifacts; + } +} diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index 13b6ca35..ae27bbe8 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -338,7 +338,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S") + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") .withPluginClasspath() .build() then: @@ -354,9 +354,9 @@ class PluginConfigurationSpec extends Specification { def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") assert appAComponent.hasComponentDefined() - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0?type=jar") + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") assert appBComponent.hasComponentDefined() assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") } @@ -382,13 +382,13 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString assert jsonBom.metadata.component.type == "library" - assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-a@1.0.0?type=jar" + assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-a@1.0.0" assert jsonBom.metadata.component.group == "com.example" assert jsonBom.metadata.component.name == "app-a" assert jsonBom.metadata.component.version == "1.0.0" - assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-a@1.0.0?type=jar" + assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-a@1.0.0" - def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") + def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") assert !appAComponent.hasComponentDefined() assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") @@ -418,19 +418,19 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString assert jsonBom.metadata.component.type == "library" - assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-b@1.0.0?type=jar" + assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-b@1.0.0" assert jsonBom.metadata.component.group == "com.example" assert jsonBom.metadata.component.name == "app-b" assert jsonBom.metadata.component.version == "1.0.0" - assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-b@1.0.0?type=jar" + assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-b@1.0.0" def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") assert appAComponent.hasComponentDefined() assert appAComponent.component.hashes != null assert !appAComponent.component.hashes.empty - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0?type=jar") + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") assert !appBComponent.hasComponentDefined() assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") } From 010b8d110c06023c9dd75e766107a5d9bd635f8e Mon Sep 17 00:00:00 2001 From: Gordon Date: Fri, 25 Oct 2024 15:46:18 +0100 Subject: [PATCH 04/26] fix: provide artifacts lazily Signed-off-by: Gordon --- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 23 +++++------ .../org/cyclonedx/gradle/CycloneDxTask.java | 7 ++-- .../gradle/model/ArtifactInfoSet.java | 39 ------------------- .../gradle/model/ResolvedArtifacts.java | 35 ----------------- .../gradle/PluginConfigurationSpec.groovy | 23 ++++++++++- .../native-kotlin-project/build.gradle | 25 ++++++++++++ .../native-kotlin-project/settings.gradle | 6 +++ .../src/nativeMain/kotlin/hello.kt | 3 ++ 8 files changed, 69 insertions(+), 92 deletions(-) delete mode 100644 src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java delete mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java create mode 100644 src/test/resources/test-projects/native-kotlin-project/build.gradle create mode 100644 src/test/resources/test-projects/native-kotlin-project/settings.gradle create mode 100644 src/test/resources/test-projects/native-kotlin-project/src/nativeMain/kotlin/hello.kt diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index d2b191cd..a9dfc54d 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -33,7 +33,7 @@ public class CycloneDxPlugin implements Plugin { public void apply(Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { final ResolvedBuild resolvedBuild = getResolvedBuild(project); - final Set> artifacts = getArtifacts(project); + final Provider> artifacts = getArtifacts(project); final File destination = project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); @@ -41,7 +41,7 @@ public void apply(Project project) { task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); - task.getArtifacts().set(new ResolvedArtifacts(artifacts)); + task.getArtifacts().set(artifacts); }); } @@ -59,20 +59,17 @@ private ResolvedBuild getResolvedBuild(final Project project) { return resolvedBuild; } - private Set> getArtifacts(final Project project) { + private Provider> getArtifacts(final Project project) { - return project.getAllprojects().stream() + final List configurations = project.getAllprojects().stream() .flatMap(v -> v.getConfigurations().stream()) + .collect(Collectors.toList()); + + return project.getProviders().provider(() -> configurations.stream() .filter(Configuration::isCanBeResolved) - .map(config -> config.getIncoming() - .getArtifacts() - .getResolvedArtifacts() - .map(artifacts -> { - ArtifactInfoSet infoSet = new ArtifactInfoSet(); - artifacts.forEach(artifact -> infoSet.addInfo(toArtifactInfo(artifact))); - return infoSet; - })) - .collect(Collectors.toSet()); + .flatMap(config -> config.getIncoming().getArtifacts().getArtifacts().stream() + .map(artifact -> toArtifactInfo(artifact))) + .collect(Collectors.toSet())); } private ArtifactInfo toArtifactInfo(final ResolvedArtifactResult result) { diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index bd6732af..9335289b 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -21,12 +21,13 @@ import java.io.File; import java.util.Map; import java.util.Set; -import org.cyclonedx.gradle.model.ResolvedArtifacts; +import org.cyclonedx.gradle.model.ArtifactInfo; import org.cyclonedx.gradle.model.ResolvedBuild; import org.cyclonedx.gradle.model.ResolvedConfiguration; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.gradle.api.DefaultTask; import org.gradle.api.provider.Property; +import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.TaskAction; @@ -45,7 +46,7 @@ public CycloneDxTask() { public abstract Property getDestination(); @Input - public abstract Property getArtifacts(); + public abstract SetProperty getArtifacts(); @TaskAction public void createBom() { @@ -71,6 +72,6 @@ private void buildChildDependencies(final Map } private void registerArtifacts() { - getArtifacts().get().getArtifacts().forEach(v -> v.get().getInfoSet().forEach(traverser::registerArtifact)); + getArtifacts().get().forEach(traverser::registerArtifact); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java deleted file mode 100644 index 322b4f44..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.model; - -import java.util.HashSet; -import java.util.Set; - -public class ArtifactInfoSet { - - private final Set infoSet; - - public ArtifactInfoSet() { - this.infoSet = new HashSet<>(); - } - - public void addInfo(final ArtifactInfo info) { - this.infoSet.add(info); - } - - public Set getInfoSet() { - return infoSet; - } -} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java deleted file mode 100644 index 3d9f649f..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.model; - -import java.util.Set; -import org.gradle.api.provider.Provider; - -public class ResolvedArtifacts { - - private final Set> artifacts; - - public ResolvedArtifacts(Set> artifacts) { - this.artifacts = artifacts; - } - - public Set> getArtifacts() { - return artifacts; - } -} diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index ae27bbe8..2ccd86b6 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -162,12 +162,13 @@ 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' + implementation group: 'org.jetbrains.kotlin', name: 'kotlin-native-prebuilt', version: '2.0.20' }""", "rootProject.name = 'hello-world'") when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -181,6 +182,25 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"name\" : \"hello-world\"") } + def "should build bom successfully for native kotlin project"() { + given: + File testDir = TestUtils.duplicate("native-kotlin-project") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "build/reports") + + assert reportDir.exists() + reportDir.listFiles().length == 1 + } + @Ignore def "should use configured componentName"() { given: @@ -343,7 +363,6 @@ class PluginConfigurationSpec extends Specification { .build() then: result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS - println(result.output) File reportDir = new File(testDir, "build/reports") assert reportDir.exists() diff --git a/src/test/resources/test-projects/native-kotlin-project/build.gradle b/src/test/resources/test-projects/native-kotlin-project/build.gradle new file mode 100644 index 00000000..5ff89e72 --- /dev/null +++ b/src/test/resources/test-projects/native-kotlin-project/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'org.cyclonedx.bom' + id 'org.jetbrains.kotlin.multiplatform' version '2.0.21' +} + +repositories { + mavenCentral() +} + +group = 'com.example' +version = '1.0.0' +kotlin { + // macosX64('native') { // on macOS + // linuxX64('native') // on Linux + mingwX64('native'){ // on Windows + binaries { + executable() + } + } +} + +wrapper { + gradleVersion = '8.5' + distributionType = 'BIN' +} diff --git a/src/test/resources/test-projects/native-kotlin-project/settings.gradle b/src/test/resources/test-projects/native-kotlin-project/settings.gradle new file mode 100644 index 00000000..3b582363 --- /dev/null +++ b/src/test/resources/test-projects/native-kotlin-project/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} diff --git a/src/test/resources/test-projects/native-kotlin-project/src/nativeMain/kotlin/hello.kt b/src/test/resources/test-projects/native-kotlin-project/src/nativeMain/kotlin/hello.kt new file mode 100644 index 00000000..64472529 --- /dev/null +++ b/src/test/resources/test-projects/native-kotlin-project/src/nativeMain/kotlin/hello.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello, Kotlin/Native!") +} From e016f017735302fbd9ca87734855f9d44090ac0e Mon Sep 17 00:00:00 2001 From: Gordon Date: Sat, 26 Oct 2024 21:03:20 +0100 Subject: [PATCH 05/26] feat: support build configuration cache Signed-off-by: Gordon --- .../cyclonedx/gradle/ComponentProvider.java | 69 +++++++++++ .../cyclonedx/gradle/CycloneDxBomBuilder.java | 107 +++++++++--------- .../gradle/CycloneDxDependencyTraverser.java | 92 +++++++++++++-- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 49 +------- .../org/cyclonedx/gradle/CycloneDxTask.java | 40 ++----- ...ifactInfo.java => ConfigurationScope.java} | 23 ++-- .../org/cyclonedx/gradle/model/GraphNode.java | 90 --------------- .../cyclonedx/gradle/model/ResolvedBuild.java | 62 ---------- .../gradle/model/ResolvedConfiguration.java | 42 ------- .../gradle/model/SerializableComponent.java | 91 +++++++++++++++ .../gradle/model/SerializableComponents.java | 44 +++++++ .../gradle/utils/DependencyUtils.java | 10 +- .../gradle/PluginConfigurationSpec.groovy | 1 + 13 files changed, 373 insertions(+), 347 deletions(-) create mode 100644 src/main/java/org/cyclonedx/gradle/ComponentProvider.java rename src/main/java/org/cyclonedx/gradle/model/{ArtifactInfo.java => ConfigurationScope.java} (60%) delete mode 100644 src/main/java/org/cyclonedx/gradle/model/GraphNode.java delete mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java delete mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java diff --git a/src/main/java/org/cyclonedx/gradle/ComponentProvider.java b/src/main/java/org/cyclonedx/gradle/ComponentProvider.java new file mode 100644 index 00000000..73ce5eb9 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/ComponentProvider.java @@ -0,0 +1,69 @@ +/* + * 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; + +import java.util.concurrent.Callable; +import org.cyclonedx.gradle.model.SerializableComponents; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; + +public class ComponentProvider implements Callable { + + private final Project project; + + public ComponentProvider(final Project project) { + this.project = project; + } + + @Override + public SerializableComponents call() throws Exception { + + final CycloneDxDependencyTraverser traverser = + new CycloneDxDependencyTraverser(project.getLogger(), new CycloneDxBomBuilder(project.getLogger())); + + traverseParentProject(traverser); + traverseChildProjects(traverser); + registerArtifacts(traverser); + + return traverser.serializableComponents(); + } + + private void traverseParentProject(final CycloneDxDependencyTraverser traverser) { + project.getConfigurations().stream() + .filter(Configuration::isCanBeResolved) + .forEach(config -> traverser.traverseParentGraph( + config.getIncoming().getResolutionResult().getRoot(), project.getName(), config.getName())); + } + + private void traverseChildProjects(final CycloneDxDependencyTraverser traverser) { + project.getChildProjects().forEach((k, v) -> v.getConfigurations().stream() + .filter(Configuration::isCanBeResolved) + .forEach(config -> traverser.traverseChildGraph( + config.getIncoming().getResolutionResult().getRoot(), k, config.getName()))); + } + + private void registerArtifacts(final CycloneDxDependencyTraverser traverser) { + project.getAllprojects().stream() + .flatMap(project -> project.getConfigurations().stream()) + .filter(Configuration::isCanBeResolved) + .forEach(config -> config.getIncoming().getArtifacts().getArtifacts().stream() + .forEach(artifact -> traverser.registerArtifact( + artifact.getId().getComponentIdentifier(), artifact.getFile()))); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java index 12133979..791bb463 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java @@ -22,20 +22,30 @@ import com.networknt.schema.utils.StringUtils; import java.io.File; import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.UUID; import java.util.stream.Collectors; import org.apache.commons.io.FilenameUtils; import org.cyclonedx.Version; import org.cyclonedx.gradle.model.ComponentComparator; import org.cyclonedx.gradle.model.DependencyComparator; -import org.cyclonedx.gradle.model.GraphNode; +import org.cyclonedx.gradle.model.SerializableComponent; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.cyclonedx.gradle.utils.DependencyUtils; -import org.cyclonedx.model.*; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.Dependency; +import org.cyclonedx.model.Hash; +import org.cyclonedx.model.Metadata; +import org.cyclonedx.model.Property; import org.cyclonedx.util.BomUtils; -import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.component.ComponentIdentifier; -import org.gradle.api.artifacts.result.ResolvedComponentResult; import org.gradle.api.logging.Logger; public class CycloneDxBomBuilder { @@ -56,30 +66,29 @@ public CycloneDxBomBuilder(final Logger logger) { } public Bom buildBom( - final Map> resultGraph, - final GraphNode parentNode, - final Map resolvedArtifacts) { + final Map> resultGraph, + final SerializableComponent parentComponent) { final Set dependencies = new TreeSet<>(new DependencyComparator()); final Set components = new TreeSet<>(new ComponentComparator()); - resultGraph.keySet().forEach(node -> { - addDependency(dependencies, resultGraph.get(node), node, resolvedArtifacts); - addComponent(components, node, parentNode, resolvedArtifacts); + resultGraph.keySet().forEach(component -> { + addDependency(dependencies, resultGraph.get(component), component); + addComponent(components, component, parentComponent); }); final Bom bom = new Bom(); bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); - bom.setMetadata(buildMetadata(parentNode)); + bom.setMetadata(buildMetadata(parentComponent)); bom.setComponents(new ArrayList<>(components)); bom.setDependencies(new ArrayList<>(dependencies)); return bom; } - private Metadata buildMetadata(final GraphNode parentNode) { + private Metadata buildMetadata(final SerializableComponent parentComponent) { final Metadata metadata = new Metadata(); try { - metadata.setComponent(toComponent(parentNode, null)); + metadata.setComponent(toComponent(parentComponent, null)); } catch (MalformedPackageURLException e) { logger.warn("Error constructing packageUrl for parent component. Skipping...", e); } @@ -88,78 +97,74 @@ private Metadata buildMetadata(final GraphNode parentNode) { private void addDependency( final Set dependencies, - final Set dependencyNodes, - final GraphNode node, - final Map resolvedArtifacts) { + final Set dependencyComponents, + final SerializableComponent component) { final Dependency dependency; try { - dependency = toDependency(node.getResult(), resolvedArtifacts); + dependency = toDependency(component); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for node. Skipping...", e); + logger.warn("Error constructing packageUrl for component. Skipping...", e); return; } - dependencyNodes.forEach(dependencyNode -> { + dependencyComponents.forEach(dependencyComponent -> { try { - dependency.addDependency(toDependency(dependencyNode.getResult(), resolvedArtifacts)); + dependency.addDependency(toDependency(dependencyComponent)); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for node dependency. Skipping...", e); + logger.warn("Error constructing packageUrl for component dependency. Skipping...", e); } }); dependencies.add(dependency); } - private Dependency toDependency( - final ResolvedComponentResult component, final Map resolvedArtifacts) - throws MalformedPackageURLException { + private Dependency toDependency(final SerializableComponent component) throws MalformedPackageURLException { - final File artifactFile = resolvedArtifacts.get(component.getId()); - final String ref = DependencyUtils.generatePackageUrl(component.getModuleVersion(), getType(artifactFile)); + final String ref = DependencyUtils.generatePackageUrl( + component, getType(component.getArtifactFile().orElse(null))); return new Dependency(ref); } private void addComponent( final Set components, - final GraphNode node, - final GraphNode parentNode, - final Map resolvedArtifacts) { - if (!node.equals(parentNode)) { - final File artifactFile = resolvedArtifacts.get(node.getResult().getId()); + final SerializableComponent component, + final SerializableComponent parentComponent) { + if (!component.equals(parentComponent)) { + final File artifactFile = component.getArtifactFile().orElse(null); try { - components.add(toComponent(node, artifactFile)); + components.add(toComponent(component, artifactFile)); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for node component. Skipping...", e); + logger.warn("Error constructing packageUrl for component. Skipping...", e); } } } - private Component toComponent(final GraphNode node, final File artifactFile) throws MalformedPackageURLException { + private Component toComponent(final SerializableComponent component, final File artifactFile) + throws MalformedPackageURLException { - final ModuleVersionIdentifier moduleVersion = node.getResult().getModuleVersion(); - final String packageUrl = DependencyUtils.generatePackageUrl(moduleVersion, getType(artifactFile)); + final String packageUrl = DependencyUtils.generatePackageUrl(component, getType(artifactFile)); - final Component component = new Component(); - component.setGroup(moduleVersion.getGroup()); - component.setName(moduleVersion.getName()); - component.setVersion(moduleVersion.getVersion()); - component.setType(Component.Type.LIBRARY); - component.setPurl(packageUrl); - component.setProperties(buildProperties(node)); + final Component resultComponent = new Component(); + resultComponent.setGroup(component.getGroup()); + resultComponent.setName(component.getName()); + resultComponent.setVersion(component.getVersion()); + resultComponent.setType(Component.Type.LIBRARY); + resultComponent.setPurl(packageUrl); + resultComponent.setProperties(buildProperties(component)); if (version.getVersion() >= 1.1) { - component.setModified(mavenHelper.isModified(null)); - component.setBomRef(packageUrl); + resultComponent.setModified(mavenHelper.isModified(null)); + resultComponent.setBomRef(packageUrl); } logger.debug(MESSAGE_CALCULATING_HASHES); if (artifactFile != null) { - component.setHashes(calculateHashes(artifactFile)); + resultComponent.setHashes(calculateHashes(artifactFile)); } - return component; + return resultComponent; } - private List buildProperties(GraphNode node) { - return node.getInScopeConfigurations().stream() + private List buildProperties(SerializableComponent component) { + return component.getInScopeConfigurations().stream() .map(v -> { Property property = new Property(); property.setName("inScopeConfiguration"); diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java index b368a84c..7d2d5d84 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java @@ -22,14 +22,17 @@ import java.io.File; import java.util.ArrayDeque; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Queue; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; -import org.cyclonedx.gradle.model.ArtifactInfo; -import org.cyclonedx.gradle.model.GraphNode; -import org.cyclonedx.model.*; +import java.util.stream.Collectors; +import org.cyclonedx.gradle.model.ConfigurationScope; +import org.cyclonedx.gradle.model.SerializableComponent; +import org.cyclonedx.gradle.model.SerializableComponents; import org.gradle.api.GradleException; import org.gradle.api.artifacts.ModuleVersionIdentifier; import org.gradle.api.artifacts.component.ComponentIdentifier; @@ -37,6 +40,7 @@ import org.gradle.api.artifacts.result.ResolvedComponentResult; import org.gradle.api.artifacts.result.ResolvedDependencyResult; import org.gradle.api.logging.Logger; +import org.jetbrains.annotations.NotNull; public class CycloneDxDependencyTraverser { @@ -53,8 +57,8 @@ public CycloneDxDependencyTraverser(final Logger logger, final CycloneDxBomBuild this.resultGraph = new HashMap<>(); } - public void registerArtifact(final ArtifactInfo artifact) { - resolvedArtifacts.put(artifact.getComponentId(), artifact.getArtifactFile()); + public void registerArtifact(final ComponentIdentifier componentId, final File artifactFile) { + resolvedArtifacts.put(componentId, artifactFile); } public void traverseParentGraph( @@ -123,8 +127,35 @@ private void mergeIntoResultGraph( .forEach(v -> v.inScopeConfiguration(projectName, configName)); } - public Bom toBom() { - return builder.buildBom(this.resultGraph, this.parentNode, this.resolvedArtifacts); + public SerializableComponents serializableComponents() { + + Map> result = new HashMap<>(); + this.resultGraph.forEach((k, v) -> { + result.put( + serializableComponent(k), + v.stream().map(w -> serializableComponent(w)).collect(Collectors.toSet())); + }); + + return new SerializableComponents(result, serializableComponent(this.parentNode)); + } + + private SerializableComponent serializableComponent(final GraphNode node) { + + ResolvedComponentResult resolvedComponent = node.getResult(); + if (this.resolvedArtifacts.containsKey(resolvedComponent.getId())) { + return new SerializableComponent( + resolvedComponent.getModuleVersion().getGroup(), + resolvedComponent.getModuleVersion().getName(), + resolvedComponent.getModuleVersion().getVersion(), + node.getInScopeConfigurations(), + this.resolvedArtifacts.get(resolvedComponent.getId())); + } else { + return new SerializableComponent( + resolvedComponent.getModuleVersion().getGroup(), + resolvedComponent.getModuleVersion().getName(), + resolvedComponent.getModuleVersion().getVersion(), + node.getInScopeConfigurations()); + } } private String getRef(final ModuleVersionIdentifier identifier) { @@ -140,4 +171,51 @@ private String getRef(final ModuleVersionIdentifier identifier) { return String.format("%s:%s:%s", identifier.getGroup(), identifier.getName(), identifier.getVersion()); } + + private static class GraphNode implements Comparable { + + private final String ref; + private final ResolvedComponentResult result; + private final Set inScopeConfigurations; + + private GraphNode(final String ref, final ResolvedComponentResult result) { + this.ref = ref; + this.result = result; + this.inScopeConfigurations = new HashSet<>(); + } + + private String getRef() { + return ref; + } + + private ResolvedComponentResult getResult() { + return result; + } + + private void inScopeConfiguration(final String projectName, final String configName) { + inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); + } + + private Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + @Override + public int compareTo(@NotNull GraphNode o) { + return this.ref.compareTo(o.ref); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GraphNode graphNode = (GraphNode) o; + return Objects.equals(ref, graphNode.ref); + } + + @Override + public int hashCode() { + return Objects.hashCode(ref); + } + } } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index a9dfc54d..f066da2d 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -19,65 +19,24 @@ package org.cyclonedx.gradle; import java.io.File; -import java.util.*; -import java.util.stream.Collectors; -import org.cyclonedx.gradle.model.*; +import org.cyclonedx.gradle.model.SerializableComponents; import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; -import org.gradle.api.artifacts.result.ResolvedArtifactResult; import org.gradle.api.provider.Provider; public class CycloneDxPlugin implements Plugin { public void apply(Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { - final ResolvedBuild resolvedBuild = getResolvedBuild(project); - final Provider> artifacts = getArtifacts(project); + final Provider components = + project.getProviders().provider(new ComponentProvider(project)); final File destination = project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); - task.getResolvedBuild().set(resolvedBuild); + task.getComponents().set(components); task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); - task.getArtifacts().set(artifacts); }); } - - private ResolvedBuild getResolvedBuild(final Project project) { - - final ResolvedBuild resolvedBuild = new ResolvedBuild(project.getName()); - project.getConfigurations().stream() - .filter(Configuration::isCanBeResolved) - .forEach(v -> resolvedBuild.addProjectConfiguration(resolvedConfiguration(v))); - - project.getChildProjects().forEach((k, v) -> v.getConfigurations().stream() - .filter(Configuration::isCanBeResolved) - .forEach(w -> resolvedBuild.addSubProjectConfiguration(k, resolvedConfiguration(w)))); - - return resolvedBuild; - } - - private Provider> getArtifacts(final Project project) { - - final List configurations = project.getAllprojects().stream() - .flatMap(v -> v.getConfigurations().stream()) - .collect(Collectors.toList()); - - return project.getProviders().provider(() -> configurations.stream() - .filter(Configuration::isCanBeResolved) - .flatMap(config -> config.getIncoming().getArtifacts().getArtifacts().stream() - .map(artifact -> toArtifactInfo(artifact))) - .collect(Collectors.toSet())); - } - - private ArtifactInfo toArtifactInfo(final ResolvedArtifactResult result) { - return new ArtifactInfo(result.getId().getComponentIdentifier(), result.getFile()); - } - - private ResolvedConfiguration resolvedConfiguration(final Configuration config) { - return new ResolvedConfiguration( - config.getName(), config.getIncoming().getResolutionResult().getRootComponent()); - } } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index 9335289b..5aa7c5f8 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -19,59 +19,33 @@ package org.cyclonedx.gradle; import java.io.File; -import java.util.Map; -import java.util.Set; -import org.cyclonedx.gradle.model.ArtifactInfo; -import org.cyclonedx.gradle.model.ResolvedBuild; -import org.cyclonedx.gradle.model.ResolvedConfiguration; +import org.cyclonedx.gradle.model.SerializableComponents; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.gradle.api.DefaultTask; import org.gradle.api.provider.Property; -import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.TaskAction; public abstract class CycloneDxTask extends DefaultTask { - private final CycloneDxDependencyTraverser traverser; + private final CycloneDxBomBuilder builder; public CycloneDxTask() { - this.traverser = new CycloneDxDependencyTraverser(getLogger(), new CycloneDxBomBuilder(getLogger())); + this.builder = new CycloneDxBomBuilder(getLogger()); } @Input - public abstract Property getResolvedBuild(); + public abstract Property getComponents(); @Input public abstract Property getDestination(); - @Input - public abstract SetProperty getArtifacts(); - @TaskAction public void createBom() { - final ResolvedBuild resolvedBuild = getResolvedBuild().get(); - - registerArtifacts(); - buildParentDependencies(resolvedBuild.getProjectName(), resolvedBuild.getProjectConfigurations()); - buildChildDependencies(resolvedBuild.getSubProjectsConfigurations()); - File destination = new File(getDestination().get(), "bom.json"); - CycloneDxUtils.writeBom(traverser.toBom(), destination); - } - - private void buildParentDependencies(final String projectName, Set configurations) { - configurations.forEach(config -> traverser.traverseParentGraph( - config.getDependencyGraph().get(), projectName, config.getConfigurationName())); - } - - private void buildChildDependencies(final Map> configurations) { - configurations.forEach((key, value) -> value.forEach(config -> - traverser.traverseChildGraph(config.getDependencyGraph().get(), key, config.getConfigurationName()))); - } - - private void registerArtifacts() { - getArtifacts().get().forEach(traverser::registerArtifact); + SerializableComponents components = getComponents().get(); + CycloneDxUtils.writeBom( + builder.buildBom(components.getSerializableComponents(), components.getRootComponent()), destination); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java b/src/main/java/org/cyclonedx/gradle/model/ConfigurationScope.java similarity index 60% rename from src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java rename to src/main/java/org/cyclonedx/gradle/model/ConfigurationScope.java index 8a9c40fa..9dca898e 100644 --- a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java +++ b/src/main/java/org/cyclonedx/gradle/model/ConfigurationScope.java @@ -18,24 +18,23 @@ */ package org.cyclonedx.gradle.model; -import java.io.File; -import org.gradle.api.artifacts.component.ComponentIdentifier; +import java.io.Serializable; -public class ArtifactInfo { +public class ConfigurationScope implements Serializable { - private final ComponentIdentifier componentId; - private final File artifactFile; + private final String projectName; + private final String configName; - public ArtifactInfo(final ComponentIdentifier componentId, final File artifactFile) { - this.componentId = componentId; - this.artifactFile = artifactFile; + public ConfigurationScope(final String projectName, final String configName) { + this.projectName = projectName; + this.configName = configName; } - public ComponentIdentifier getComponentId() { - return componentId; + public String getProjectName() { + return projectName; } - public File getArtifactFile() { - return artifactFile; + public String getConfigName() { + return configName; } } diff --git a/src/main/java/org/cyclonedx/gradle/model/GraphNode.java b/src/main/java/org/cyclonedx/gradle/model/GraphNode.java deleted file mode 100644 index a43539fc..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/GraphNode.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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.model; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; -import org.gradle.api.artifacts.result.ResolvedComponentResult; -import org.jetbrains.annotations.NotNull; - -public class GraphNode implements Comparable { - - private final String ref; - private final ResolvedComponentResult result; - private final Set inScopeConfigurations; - - public GraphNode(final String ref, final ResolvedComponentResult result) { - this.ref = ref; - this.result = result; - this.inScopeConfigurations = new HashSet<>(); - } - - public String getRef() { - return ref; - } - - public ResolvedComponentResult getResult() { - return result; - } - - public void inScopeConfiguration(final String projectName, final String configName) { - inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); - } - - public Set getInScopeConfigurations() { - return inScopeConfigurations; - } - - @Override - public int compareTo(@NotNull GraphNode o) { - return this.ref.compareTo(o.ref); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - GraphNode graphNode = (GraphNode) o; - return Objects.equals(ref, graphNode.ref); - } - - @Override - public int hashCode() { - return Objects.hashCode(ref); - } - - public static class ConfigurationScope { - private final String projectName; - private final String configName; - - private ConfigurationScope(final String projectName, final String configName) { - this.projectName = projectName; - this.configName = configName; - } - - public String getProjectName() { - return projectName; - } - - public String getConfigName() { - return configName; - } - } -} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java deleted file mode 100644 index 22509fc4..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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.model; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -public class ResolvedBuild { - private final String projectName; - private final Set projectConfigurations; - private final Map> subProjectsConfigurations; - - public ResolvedBuild(final String projectName) { - this.projectName = projectName; - this.projectConfigurations = new HashSet<>(); - this.subProjectsConfigurations = new HashMap<>(); - } - - public String getProjectName() { - return projectName; - } - - public void addProjectConfiguration(final ResolvedConfiguration configuration) { - projectConfigurations.add(configuration); - } - - public Set getProjectConfigurations() { - return projectConfigurations; - } - - public void addSubProjectConfiguration(final String projectName, final ResolvedConfiguration configuration) { - if (subProjectsConfigurations.containsKey(projectName)) { - subProjectsConfigurations.get(projectName).add(configuration); - } else { - final Set subProjectConfigurations = new HashSet<>(); - subProjectConfigurations.add(configuration); - subProjectsConfigurations.put(projectName, subProjectConfigurations); - } - } - - public Map> getSubProjectsConfigurations() { - return subProjectsConfigurations; - } -} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java deleted file mode 100644 index 385bbadf..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.model; - -import org.gradle.api.artifacts.result.ResolvedComponentResult; -import org.gradle.api.provider.Provider; - -public class ResolvedConfiguration { - - private final String configurationName; - private final Provider dependencyGraph; - - public ResolvedConfiguration( - final String configurationName, final Provider dependencyGraph) { - this.configurationName = configurationName; - this.dependencyGraph = dependencyGraph; - } - - public String getConfigurationName() { - return configurationName; - } - - public Provider getDependencyGraph() { - return dependencyGraph; - } -} diff --git a/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java b/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java new file mode 100644 index 00000000..3c65ebcb --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java @@ -0,0 +1,91 @@ +/* + * 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.model; + +import java.io.File; +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class SerializableComponent implements Serializable { + + private final String group; + private final String name; + private final String version; + private final File artifactFile; + private final Set inScopeConfigurations; + + public SerializableComponent( + final String group, + final String name, + final String version, + final Set inScopeConfigurations) { + this(group, name, version, inScopeConfigurations, null); + } + + public SerializableComponent( + final String group, + final String name, + final String version, + final Set inScopeConfigurations, + final File artifactFile) { + this.group = group; + this.name = name; + this.version = version; + this.artifactFile = artifactFile; + this.inScopeConfigurations = inScopeConfigurations; + } + + public String getGroup() { + return group; + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + + public Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + public Optional getArtifactFile() { + return Optional.ofNullable(artifactFile); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SerializableComponent that = (SerializableComponent) o; + return Objects.equals(group, that.group) + && Objects.equals(name, that.name) + && Objects.equals(version, that.version) + && Objects.equals(artifactFile, that.artifactFile); + } + + @Override + public int hashCode() { + return Objects.hash(group, name, version, artifactFile); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java b/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java new file mode 100644 index 00000000..77089ae6 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java @@ -0,0 +1,44 @@ +/* + * 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.model; + +import java.io.Serializable; +import java.util.Map; +import java.util.Set; + +public class SerializableComponents implements Serializable { + + private final Map> serializableComponents; + private final SerializableComponent rootComponent; + + public SerializableComponents( + Map> serializableComponents, + SerializableComponent rootComponent) { + this.serializableComponents = serializableComponents; + this.rootComponent = rootComponent; + } + + public Map> getSerializableComponents() { + return serializableComponents; + } + + public SerializableComponent getRootComponent() { + return rootComponent; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java index 016b6185..68e353ce 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -21,18 +21,18 @@ import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; import java.util.TreeMap; -import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.cyclonedx.gradle.model.SerializableComponent; public class DependencyUtils { public static String generatePackageUrl( - final ModuleVersionIdentifier version, final TreeMap qualifiers) + final SerializableComponent component, final TreeMap qualifiers) throws MalformedPackageURLException { return new PackageURL( PackageURL.StandardTypes.MAVEN, - version.getGroup(), - version.getName(), - version.getVersion(), + component.getGroup(), + component.getName(), + component.getVersion(), qualifiers, null) .canonicalize(); diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index 2ccd86b6..cef7b0db 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -159,6 +159,7 @@ class PluginConfigurationSpec extends Specification { cyclonedxBom { // No componentName override -> Use rootProject.name } + 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' From ff26c9fb31d966ada2aa95fa96c75c25c1e7e1f0 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 4 Nov 2024 08:39:02 +0000 Subject: [PATCH 06/26] feat: implement custom configuration Signed-off-by: Gordon --- .../cyclonedx/gradle/ComponentProvider.java | 69 ----- .../gradle/CycloneDxDependencyTraverser.java | 221 -------------- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 13 +- .../org/cyclonedx/gradle/CycloneDxTask.java | 287 +++++++++++++++++- .../gradle/DependencyGraphTraverser.java | 197 ++++++++++++ .../org/cyclonedx/gradle/MavenHelper.java | 192 ++++++------ .../cyclonedx/gradle/MavenProjectLookup.java | 102 +++++++ ...loneDxBomBuilder.java => SbomBuilder.java} | 123 +++++--- .../cyclonedx/gradle/SbomGraphProvider.java | 138 +++++++++ .../gradle/model/ComponentComparator.java | 2 +- .../gradle/model/DependencyComparator.java | 2 +- .../cyclonedx/gradle/model/SbomComponent.java | 121 ++++++++ ...bleComponent.java => SbomComponentId.java} | 44 +-- ...alizableComponents.java => SbomGraph.java} | 19 +- .../cyclonedx/gradle/model/SbomMetaData.java | 89 ++++++ .../gradle/utils/CycloneDxUtils.java | 39 ++- .../gradle/utils/DependencyUtils.java | 101 +++++- .../org/cyclonedx/gradle/CycloneDxSpec.groovy | 19 -- .../gradle/DependencyResolutionSpec.groovy | 214 ++++++++++++- .../gradle/PluginConfigurationSpec.groovy | 268 ++++------------ .../componentc/1.0.1/componentc-1.0.1.pom | 18 ++ .../componentc/1.0.1/componentc-1.0.1.tgz | 1 + .../componentd/1.0.0/componentd-1.0.0.pom | 18 ++ .../componentd/1.0.0/componentd-1.0.0.tgz | 1 + 24 files changed, 1552 insertions(+), 746 deletions(-) delete mode 100644 src/main/java/org/cyclonedx/gradle/ComponentProvider.java delete mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java create mode 100644 src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java create mode 100644 src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java rename src/main/java/org/cyclonedx/gradle/{CycloneDxBomBuilder.java => SbomBuilder.java} (55%) create mode 100644 src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/SbomComponent.java rename src/main/java/org/cyclonedx/gradle/model/{SerializableComponent.java => SbomComponentId.java} (55%) rename src/main/java/org/cyclonedx/gradle/model/{SerializableComponents.java => SbomGraph.java} (57%) create mode 100644 src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.pom create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.tgz create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.pom create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.tgz diff --git a/src/main/java/org/cyclonedx/gradle/ComponentProvider.java b/src/main/java/org/cyclonedx/gradle/ComponentProvider.java deleted file mode 100644 index 73ce5eb9..00000000 --- a/src/main/java/org/cyclonedx/gradle/ComponentProvider.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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; - -import java.util.concurrent.Callable; -import org.cyclonedx.gradle.model.SerializableComponents; -import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; - -public class ComponentProvider implements Callable { - - private final Project project; - - public ComponentProvider(final Project project) { - this.project = project; - } - - @Override - public SerializableComponents call() throws Exception { - - final CycloneDxDependencyTraverser traverser = - new CycloneDxDependencyTraverser(project.getLogger(), new CycloneDxBomBuilder(project.getLogger())); - - traverseParentProject(traverser); - traverseChildProjects(traverser); - registerArtifacts(traverser); - - return traverser.serializableComponents(); - } - - private void traverseParentProject(final CycloneDxDependencyTraverser traverser) { - project.getConfigurations().stream() - .filter(Configuration::isCanBeResolved) - .forEach(config -> traverser.traverseParentGraph( - config.getIncoming().getResolutionResult().getRoot(), project.getName(), config.getName())); - } - - private void traverseChildProjects(final CycloneDxDependencyTraverser traverser) { - project.getChildProjects().forEach((k, v) -> v.getConfigurations().stream() - .filter(Configuration::isCanBeResolved) - .forEach(config -> traverser.traverseChildGraph( - config.getIncoming().getResolutionResult().getRoot(), k, config.getName()))); - } - - private void registerArtifacts(final CycloneDxDependencyTraverser traverser) { - project.getAllprojects().stream() - .flatMap(project -> project.getConfigurations().stream()) - .filter(Configuration::isCanBeResolved) - .forEach(config -> config.getIncoming().getArtifacts().getArtifacts().stream() - .forEach(artifact -> traverser.registerArtifact( - artifact.getId().getComponentIdentifier(), artifact.getFile()))); - } -} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java deleted file mode 100644 index 7d2d5d84..00000000 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * 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; - -import com.networknt.schema.utils.StringUtils; -import java.io.File; -import java.util.ArrayDeque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Queue; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.stream.Collectors; -import org.cyclonedx.gradle.model.ConfigurationScope; -import org.cyclonedx.gradle.model.SerializableComponent; -import org.cyclonedx.gradle.model.SerializableComponents; -import org.gradle.api.GradleException; -import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.component.ComponentIdentifier; -import org.gradle.api.artifacts.result.DependencyResult; -import org.gradle.api.artifacts.result.ResolvedComponentResult; -import org.gradle.api.artifacts.result.ResolvedDependencyResult; -import org.gradle.api.logging.Logger; -import org.jetbrains.annotations.NotNull; - -public class CycloneDxDependencyTraverser { - - private final Map> resultGraph; - private final Logger logger; - private final Map resolvedArtifacts; - private final CycloneDxBomBuilder builder; - private GraphNode parentNode; - - public CycloneDxDependencyTraverser(final Logger logger, final CycloneDxBomBuilder builder) { - this.builder = builder; - this.logger = logger; - this.resolvedArtifacts = new HashMap<>(); - this.resultGraph = new HashMap<>(); - } - - public void registerArtifact(final ComponentIdentifier componentId, final File artifactFile) { - resolvedArtifacts.put(componentId, artifactFile); - } - - public void traverseParentGraph( - final ResolvedComponentResult rootNode, final String projectName, final String configName) { - final String parentRef = getRef(rootNode.getModuleVersion()); - this.parentNode = new GraphNode(parentRef, rootNode); - traverseGraph(rootNode, projectName, configName); - } - - public void traverseChildGraph( - final ResolvedComponentResult rootNode, final String projectName, final String configName) { - - if (this.parentNode == null) { - throw new GradleException("Parent graphs has to be traversed first"); - } - - final String childRef = getRef(rootNode.getModuleVersion()); - final GraphNode childNode = new GraphNode(childRef, rootNode); - this.resultGraph.get(this.parentNode).add(childNode); - traverseGraph(rootNode, projectName, configName); - } - - public void traverseGraph( - final ResolvedComponentResult rootNode, final String projectName, final String configName) { - - final Map> graph = new TreeMap<>(); - final Queue queue = new ArrayDeque<>(); - - final String rootRef = getRef(rootNode.getModuleVersion()); - final GraphNode rootGraphNode = new GraphNode(rootRef, rootNode); - queue.add(rootGraphNode); - - while (!queue.isEmpty()) { - final GraphNode graphNode = queue.poll(); - if (!graph.containsKey(graphNode)) { - graph.put(graphNode, new TreeSet<>()); - for (DependencyResult dep : graphNode.getResult().getDependencies()) { - if (dep instanceof ResolvedDependencyResult) { - final ResolvedComponentResult dependencyComponent = - ((ResolvedDependencyResult) dep).getSelected(); - String ref = getRef(dependencyComponent.getModuleVersion()); - GraphNode dependencyNode = new GraphNode(ref, dependencyComponent); - graph.get(graphNode).add(dependencyNode); - queue.add(dependencyNode); - } - } - } - } - - mergeIntoResultGraph(graph, projectName, configName); - } - - private void mergeIntoResultGraph( - final Map> graph, final String projectName, final String configName) { - - graph.keySet().forEach(node -> { - if (resultGraph.containsKey(node)) { - resultGraph.get(node).addAll(graph.get(node)); - } else { - resultGraph.put(node, graph.get(node)); - } - }); - - resultGraph.keySet().stream() - .filter(graph::containsKey) - .forEach(v -> v.inScopeConfiguration(projectName, configName)); - } - - public SerializableComponents serializableComponents() { - - Map> result = new HashMap<>(); - this.resultGraph.forEach((k, v) -> { - result.put( - serializableComponent(k), - v.stream().map(w -> serializableComponent(w)).collect(Collectors.toSet())); - }); - - return new SerializableComponents(result, serializableComponent(this.parentNode)); - } - - private SerializableComponent serializableComponent(final GraphNode node) { - - ResolvedComponentResult resolvedComponent = node.getResult(); - if (this.resolvedArtifacts.containsKey(resolvedComponent.getId())) { - return new SerializableComponent( - resolvedComponent.getModuleVersion().getGroup(), - resolvedComponent.getModuleVersion().getName(), - resolvedComponent.getModuleVersion().getVersion(), - node.getInScopeConfigurations(), - this.resolvedArtifacts.get(resolvedComponent.getId())); - } else { - return new SerializableComponent( - resolvedComponent.getModuleVersion().getGroup(), - resolvedComponent.getModuleVersion().getName(), - resolvedComponent.getModuleVersion().getVersion(), - node.getInScopeConfigurations()); - } - } - - private String getRef(final ModuleVersionIdentifier identifier) { - - // The cause for this failure is mainly if the group/name/project of the build isn't set - if (StringUtils.isBlank(identifier.getGroup()) - || StringUtils.isBlank(identifier.getName()) - || StringUtils.isBlank(identifier.getVersion())) { - throw new GradleException(String.format( - "Invalid module identifier provided. Group: %s, Name: %s, Version: %s", - identifier.getGroup(), identifier.getName(), identifier.getVersion())); - } - - return String.format("%s:%s:%s", identifier.getGroup(), identifier.getName(), identifier.getVersion()); - } - - private static class GraphNode implements Comparable { - - private final String ref; - private final ResolvedComponentResult result; - private final Set inScopeConfigurations; - - private GraphNode(final String ref, final ResolvedComponentResult result) { - this.ref = ref; - this.result = result; - this.inScopeConfigurations = new HashSet<>(); - } - - private String getRef() { - return ref; - } - - private ResolvedComponentResult getResult() { - return result; - } - - private void inScopeConfiguration(final String projectName, final String configName) { - inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); - } - - private Set getInScopeConfigurations() { - return inScopeConfigurations; - } - - @Override - public int compareTo(@NotNull GraphNode o) { - return this.ref.compareTo(o.ref); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - GraphNode graphNode = (GraphNode) o; - return Objects.equals(ref, graphNode.ref); - } - - @Override - public int hashCode() { - return Objects.hashCode(ref); - } - } -} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index f066da2d..e2df81b0 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -18,23 +18,20 @@ */ package org.cyclonedx.gradle; -import java.io.File; -import org.cyclonedx.gradle.model.SerializableComponents; +import org.cyclonedx.gradle.model.SbomGraph; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.provider.Provider; public class CycloneDxPlugin implements Plugin { - public void apply(Project project) { + public void apply(final Project project) { + project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { - final Provider components = - project.getProviders().provider(new ComponentProvider(project)); - final File destination = - project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); + final Provider components = + project.getProviders().provider(new SbomGraphProvider(project, task)); task.getComponents().set(components); - task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); }); diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index 5aa7c5f8..ebf4bdbc 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -19,33 +19,304 @@ package org.cyclonedx.gradle; import java.io.File; -import org.cyclonedx.gradle.model.SerializableComponents; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import org.cyclonedx.gradle.model.SbomGraph; import org.cyclonedx.gradle.utils.CycloneDxUtils; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.LicenseChoice; +import org.cyclonedx.model.OrganizationalEntity; import org.gradle.api.DefaultTask; +import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.OutputDirectory; import org.gradle.api.tasks.TaskAction; public abstract class CycloneDxTask extends DefaultTask { - private final CycloneDxBomBuilder builder; + private static final String DEFAULT_PROJECT_TYPE = "library"; + + private final Property outputName; + private final Property schemaVersion; + private final Property componentName; + private final Property componentVersion; + private final Property outputFormat; + private final Property includeBomSerialNumber; + private final ListProperty skipConfigs; + private final ListProperty includeConfigs; + private final Property includeMetadataResolution; + private final Property includeLicenseText; + private final Property projectType; + private final ListProperty skipProjects; + private final Property destination; + private OrganizationalEntity organizationalEntity; + private LicenseChoice licenseChoice; public CycloneDxTask() { - this.builder = new CycloneDxBomBuilder(getLogger()); + + outputName = getProject().getObjects().property(String.class); + outputName.convention("bom"); + + schemaVersion = getProject().getObjects().property(String.class); + schemaVersion.convention(CycloneDxUtils.DEFAULT_SCHEMA_VERSION.getVersionString()); + + componentName = getProject().getObjects().property(String.class); + componentName.convention(getProject().getName()); + + componentVersion = getProject().getObjects().property(String.class); + componentVersion.convention(getProject().getVersion().toString()); + + outputFormat = getProject().getObjects().property(String.class); + outputFormat.convention("all"); + + includeBomSerialNumber = getProject().getObjects().property(Boolean.class); + includeBomSerialNumber.convention(true); + + skipConfigs = getProject().getObjects().listProperty(String.class); + includeConfigs = getProject().getObjects().listProperty(String.class); + + includeMetadataResolution = getProject().getObjects().property(Boolean.class); + includeMetadataResolution.convention(true); + + includeLicenseText = getProject().getObjects().property(Boolean.class); + includeLicenseText.convention(true); + + projectType = getProject().getObjects().property(String.class); + projectType.convention(DEFAULT_PROJECT_TYPE); + + skipProjects = getProject().getObjects().listProperty(String.class); + + organizationalEntity = new OrganizationalEntity(); + licenseChoice = new LicenseChoice(); + + destination = getProject().getObjects().property(File.class); + destination.convention(getProject() + .getLayout() + .getBuildDirectory() + .dir("reports") + .get() + .getAsFile()); + } + + @Input + public Property getOutputName() { + return outputName; + } + + public void setOutputName(final String output) { + this.outputName.set(output); + } + + @Input + public Property getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(final String schemaVersion) { + this.schemaVersion.set(schemaVersion); + } + + @Input + public Property getComponentName() { + return componentName; + } + + public void setComponentName(final String componentName) { + this.componentName.set(componentName); + } + + @Input + public Property getComponentVersion() { + return componentVersion; + } + + public void setComponentVersion(final String componentVersion) { + this.componentVersion.set(componentVersion); + } + + @Input + public Property getOutputFormat() { + return outputFormat; + } + + public void setOutputFormat(final String format) { + this.outputFormat.set(format); + } + + @Input + public Property getIncludeBomSerialNumber() { + return includeBomSerialNumber; + } + + public void setIncludeBomSerialNumber(final boolean includeBomSerialNumber) { + this.includeBomSerialNumber.set(includeBomSerialNumber); + } + + @Input + public ListProperty getSkipConfigs() { + return skipConfigs; + } + + public void setSkipConfigs(final Collection skipConfigs) { + this.skipConfigs.addAll(skipConfigs); + } + + @Input + public ListProperty getIncludeConfigs() { + return includeConfigs; + } + + public void setIncludeConfigs(final Collection includeConfigs) { + this.includeConfigs.addAll(includeConfigs); } @Input - public abstract Property getComponents(); + public Property getIncludeMetadataResolution() { + return includeMetadataResolution; + } + + public void setIncludeMetadataResolution(final boolean includeMetadataResolution) { + this.includeMetadataResolution.set(includeMetadataResolution); + } @Input - public abstract Property getDestination(); + public Property getIncludeLicenseText() { + return includeLicenseText; + } + + public void setIncludeLicenseText(final boolean includeLicenseText) { + this.includeLicenseText.set(includeLicenseText); + } + + @Input + public Property getProjectType() { + return projectType; + } + + public void setProjectType(final String projectType) { + this.projectType.set(projectType); + } + + @Input + public ListProperty getSkipProjects() { + return skipProjects; + } + + public void setSkipProjects(final Collection skipProjects) { + this.skipProjects.addAll(skipProjects); + } + + @Internal + OrganizationalEntity getOrganizationalEntity() { + return organizationalEntity; + } + + @Internal + LicenseChoice getLicenseChoice() { + return licenseChoice; + } + + @Input + public abstract Property getComponents(); + + @OutputDirectory + public Property getDestination() { + return destination; + } + + public void setDestination(final File destination) { + this.destination.set(destination); + } @TaskAction public void createBom() { - File destination = new File(getDestination().get(), "bom.json"); - SerializableComponents components = getComponents().get(); + final SbomBuilder builder = new SbomBuilder(getLogger(), this); + final SbomGraph components = getComponents().get(); + final Bom bom = builder.buildBom(components.getGraph(), components.getRootComponent()); + CycloneDxUtils.writeBom( - builder.buildBom(components.getSerializableComponents(), components.getRootComponent()), destination); + bom, + getDestination().get(), + getOutputName().get(), + CycloneDxUtils.schemaVersion(getSchemaVersion().get()), + getOutputFormat().get()); + } + + public void setOrganizationalEntity(final Consumer customizer) { + final OrganizationalEntity origin = new OrganizationalEntity(); + customizer.accept(origin); + this.organizationalEntity = origin; + + final Map organizationalEntity = new HashMap<>(); + + organizationalEntity.put("name", this.organizationalEntity.getName()); + if (this.organizationalEntity.getUrls() != null) { + for (int i = 0; i < this.organizationalEntity.getUrls().size(); i++) { + organizationalEntity.put( + "url" + i, this.organizationalEntity.getUrls().get(i)); + } + } + if (this.organizationalEntity.getContacts() != null) { + for (int i = 0; i < this.organizationalEntity.getContacts().size(); i++) { + organizationalEntity.put( + "contact_name" + i, + this.organizationalEntity.getContacts().get(i).getName()); + organizationalEntity.put( + "contact_email" + i, + this.organizationalEntity.getContacts().get(i).getEmail()); + organizationalEntity.put( + "contact_phone" + i, + this.organizationalEntity.getContacts().get(i).getPhone()); + } + } + // Definition of gradle Input via Hashmap because Hashmap is serializable (OrganizationalEntity isn't + // serializable) + getInputs().property("OrganizationalEntity", organizationalEntity); + } + + public void setLicenseChoice(final Consumer customizer) { + final LicenseChoice origin = new LicenseChoice(); + customizer.accept(origin); + this.licenseChoice = origin; + + final Map licenseChoice = new HashMap<>(); + + if (this.licenseChoice.getLicenses() != null) { + for (int i = 0; i < this.licenseChoice.getLicenses().size(); i++) { + if (this.licenseChoice.getLicenses().get(i).getName() != null) { + licenseChoice.put( + "licenseChoice" + i + "name", + this.licenseChoice.getLicenses().get(i).getName()); + } + if (this.licenseChoice.getLicenses().get(i).getId() != null) { + licenseChoice.put( + "licenseChoice" + i + "id", + this.licenseChoice.getLicenses().get(i).getId()); + } + licenseChoice.put( + "licenseChoice" + i + "text", + this.licenseChoice + .getLicenses() + .get(i) + .getAttachmentText() + .getText()); + licenseChoice.put( + "licenseChoice" + i + "url", + this.licenseChoice.getLicenses().get(i).getUrl()); + } + } + + if (this.licenseChoice.getExpression() != null) { + licenseChoice.put( + "licenseChoice_Expression", + this.licenseChoice.getExpression().getValue()); + } + // Definition of gradle Input via Hashmap because Hashmap is serializable (LicenseChoice isn't serializable) + getInputs().property("LicenseChoice", licenseChoice); } } diff --git a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java new file mode 100644 index 00000000..421c7b40 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java @@ -0,0 +1,197 @@ +/* + * 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; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.maven.model.License; +import org.apache.maven.project.MavenProject; +import org.cyclonedx.gradle.model.ConfigurationScope; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; +import org.cyclonedx.gradle.model.SbomMetaData; +import org.cyclonedx.gradle.utils.DependencyUtils; +import org.cyclonedx.model.Component; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.result.DependencyResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; +import org.gradle.api.logging.Logger; + +class DependencyGraphTraverser { + + private final Logger logger; + private final Map resolvedArtifacts; + private final MavenProjectLookup mavenLookup; + private final boolean includeMetaData; + private final MavenHelper mavenHelper; + + public DependencyGraphTraverser( + final Logger logger, + final Map resolvedArtifacts, + final MavenProjectLookup mavenLookup, + final CycloneDxTask task) { + this.logger = logger; + this.resolvedArtifacts = resolvedArtifacts; + this.mavenLookup = mavenLookup; + this.includeMetaData = task.getIncludeMetadataResolution().get(); + this.mavenHelper = new MavenHelper(logger, task.getIncludeLicenseText().get()); + } + + Map traverseGraph( + final ResolvedComponentResult rootNode, final String projectName, final String configName) { + + final Map> graph = new HashMap<>(); + final Queue queue = new ArrayDeque<>(); + + final GraphNode rootGraphNode = new GraphNode(rootNode); + rootGraphNode.inScopeConfiguration(projectName, configName); + queue.add(rootGraphNode); + + while (!queue.isEmpty()) { + final GraphNode graphNode = queue.poll(); + if (!graph.containsKey(graphNode)) { + graph.put(graphNode, new HashSet<>()); + for (DependencyResult dep : graphNode.getResult().getDependencies()) { + if (dep instanceof ResolvedDependencyResult) { + final ResolvedComponentResult dependencyComponent = + ((ResolvedDependencyResult) dep).getSelected(); + final GraphNode dependencyNode = new GraphNode(dependencyComponent); + dependencyNode.inScopeConfiguration(projectName, configName); + graph.get(graphNode).add(dependencyNode); + queue.add(dependencyNode); + } + } + } + } + + return toSbomComponents(graph); + } + + private Map toSbomComponents(final Map> graph) { + return graph.entrySet().stream() + .map(entry -> toSbomComponent(entry.getKey(), entry.getValue())) + .collect(Collectors.toMap(v -> v.getId(), v -> v)); + } + + private SbomComponent toSbomComponent(final GraphNode node, final Set dependencyNodes) { + + final File artifactFile = getArtifactFile(node); + final SbomComponentId id = DependencyUtils.toComponentId(node.getResult(), artifactFile); + + List licenses = null; + SbomMetaData metaData = null; + if (includeMetaData && node.id instanceof ModuleComponentIdentifier) { + final Component component = new Component(); + extractMetaDataFromArtifactPom(artifactFile, component, node.getResult()); + licenses = extractMetaDataFromRepository(component, node.getResult()); + metaData = SbomMetaData.fromComponent(component); + } + + return new SbomComponent.Builder() + .withId(id) + .withDependencyComponents(getSbomDependencies(dependencyNodes)) + .withInScopeConfigurations(node.getInScopeConfigurations()) + .withArtifactFile(artifactFile) + .withMetaData(metaData) + .withLicenses(licenses) + .build(); + } + + private void extractMetaDataFromArtifactPom( + final File artifactFile, final Component component, final ResolvedComponentResult result) { + + if (artifactFile == null) { + return; + } + + final MavenProject mavenProject = mavenHelper.extractPom(artifactFile, result.getModuleVersion()); + if (mavenProject != null) { + mavenHelper.getClosestMetadata(artifactFile, mavenProject, component, result.getModuleVersion()); + } + } + + private List extractMetaDataFromRepository( + final Component component, final ResolvedComponentResult result) { + final MavenProject mavenProject = mavenLookup.getResolvedMavenProject(result); + if (mavenProject != null) { + mavenHelper.extractMetadata(mavenProject, component); + return mavenProject.getLicenses(); + } + + return null; + } + + private Set getSbomDependencies(final Set dependencyNodes) { + return dependencyNodes.stream() + .map(dependency -> DependencyUtils.toComponentId(dependency.getResult(), getArtifactFile(dependency))) + .collect(Collectors.toSet()); + } + + private File getArtifactFile(final GraphNode node) { + return this.resolvedArtifacts.get(node.getResult().getId()); + } + + private static class GraphNode { + + private final ComponentIdentifier id; + private final ResolvedComponentResult result; + private final Set inScopeConfigurations; + + private GraphNode(final ResolvedComponentResult result) { + this.id = result.getId(); + this.result = result; + this.inScopeConfigurations = new HashSet<>(); + } + + private ResolvedComponentResult getResult() { + return result; + } + + private void inScopeConfiguration(final String projectName, final String configName) { + inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); + } + + private Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final GraphNode graphNode = (GraphNode) o; + return Objects.equals(id, graphNode.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + } +} diff --git a/src/main/java/org/cyclonedx/gradle/MavenHelper.java b/src/main/java/org/cyclonedx/gradle/MavenHelper.java index 3db45800..9a736e60 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenHelper.java +++ b/src/main/java/org/cyclonedx/gradle/MavenHelper.java @@ -39,7 +39,6 @@ import org.apache.maven.project.MavenProject; import org.codehaus.plexus.util.ReaderFactory; import org.codehaus.plexus.util.xml.pull.XmlPullParserException; -import org.cyclonedx.Version; import org.cyclonedx.model.Component; import org.cyclonedx.model.ExternalReference; import org.cyclonedx.model.LicenseChoice; @@ -54,13 +53,11 @@ */ class MavenHelper { - private Logger logger; - private Version schemaVersion; - private Boolean includeLicenseText; + private final Logger logger; + private final Boolean includeLicenseText; - public MavenHelper(Logger logger, Version schemaVersion, Boolean includeLicenseText) { + public MavenHelper(final Logger logger, final Boolean includeLicenseText) { this.logger = logger; - this.schemaVersion = schemaVersion; this.includeLicenseText = includeLicenseText; } @@ -77,14 +74,18 @@ public MavenHelper(Logger logger, Version schemaVersion, Boolean includeLicenseT * @param component * the component to populate data for */ - void getClosestMetadata(ResolvedArtifact artifact, MavenProject project, Component component) { + void getClosestMetadata( + final File artifact, + final MavenProject project, + final Component component, + final ModuleVersionIdentifier mid) { extractMetadata(project, component); if (project.getParent() != null) { - getClosestMetadata(artifact, project.getParent(), component); + getClosestMetadata(artifact, project.getParent(), component, mid); } else if (project.getModel().getParent() != null) { - @Nullable final MavenProject parentProject = retrieveParentProject(artifact, project); + @Nullable final MavenProject parentProject = retrieveParentProject(artifact, project, mid); if (parentProject != null) { - getClosestMetadata(artifact, parentProject, component); + getClosestMetadata(artifact, parentProject, component, mid); } } } @@ -97,7 +98,7 @@ void getClosestMetadata(ResolvedArtifact artifact, MavenProject project, Compone * @param component * the component to add data to */ - public void extractMetadata(MavenProject project, Component component) { + public void extractMetadata(final MavenProject project, final Component component) { if (component.getPublisher() == null) { // If we don't already have publisher information, retrieve it. if (project.getOrganization() != null) { @@ -108,76 +109,65 @@ public void extractMetadata(MavenProject project, Component component) { // If we don't already have description information, retrieve it. component.setDescription(project.getDescription()); } - if (component.getLicenseChoice() == null - || component.getLicenseChoice().getLicenses() == null - || component.getLicenseChoice().getLicenses().isEmpty()) { - // If we don't already have license information, retrieve it. - if (project.getLicenses() != null) { - component.setLicenseChoice(resolveMavenLicenses(project.getLicenses())); + if (project.getOrganization() != null && project.getOrganization().getUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.WEBSITE)) { + addExternalReference( + ExternalReference.Type.WEBSITE, + project.getOrganization().getUrl(), + component); } } - if (Version.VERSION_10 != schemaVersion) { - if (project.getOrganization() != null && project.getOrganization().getUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.WEBSITE)) { - addExternalReference( - ExternalReference.Type.WEBSITE, - project.getOrganization().getUrl(), - component); - } - } - if (project.getCiManagement() != null && project.getCiManagement().getUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.BUILD_SYSTEM)) { - addExternalReference( - ExternalReference.Type.BUILD_SYSTEM, - project.getCiManagement().getUrl(), - component); - } + if (project.getCiManagement() != null && project.getCiManagement().getUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.BUILD_SYSTEM)) { + addExternalReference( + ExternalReference.Type.BUILD_SYSTEM, + project.getCiManagement().getUrl(), + component); } - if (project.getDistributionManagement() != null - && project.getDistributionManagement().getDownloadUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.DISTRIBUTION)) { - addExternalReference( - ExternalReference.Type.DISTRIBUTION, - project.getDistributionManagement().getDownloadUrl(), - component); - } + } + if (project.getDistributionManagement() != null + && project.getDistributionManagement().getDownloadUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.DISTRIBUTION)) { + addExternalReference( + ExternalReference.Type.DISTRIBUTION, + project.getDistributionManagement().getDownloadUrl(), + component); } - if (project.getDistributionManagement() != null - && project.getDistributionManagement().getRepository() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.DISTRIBUTION)) { - addExternalReference( - ExternalReference.Type.DISTRIBUTION, - project.getDistributionManagement().getRepository().getUrl(), - component); - } + } + if (project.getDistributionManagement() != null + && project.getDistributionManagement().getRepository() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.DISTRIBUTION)) { + addExternalReference( + ExternalReference.Type.DISTRIBUTION, + project.getDistributionManagement().getRepository().getUrl(), + component); } - if (project.getIssueManagement() != null - && project.getIssueManagement().getUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.ISSUE_TRACKER)) { - addExternalReference( - ExternalReference.Type.ISSUE_TRACKER, - project.getIssueManagement().getUrl(), - component); - } + } + if (project.getIssueManagement() != null && project.getIssueManagement().getUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.ISSUE_TRACKER)) { + addExternalReference( + ExternalReference.Type.ISSUE_TRACKER, + project.getIssueManagement().getUrl(), + component); } - if (project.getMailingLists() != null && project.getMailingLists().size() > 0) { - for (MailingList list : project.getMailingLists()) { - if (list.getArchive() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.MAILING_LIST)) { - addExternalReference(ExternalReference.Type.MAILING_LIST, list.getArchive(), component); - } - } else if (list.getSubscribe() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.MAILING_LIST)) { - addExternalReference(ExternalReference.Type.MAILING_LIST, list.getSubscribe(), component); - } + } + if (project.getMailingLists() != null && project.getMailingLists().size() > 0) { + for (MailingList list : project.getMailingLists()) { + if (list.getArchive() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.MAILING_LIST)) { + addExternalReference(ExternalReference.Type.MAILING_LIST, list.getArchive(), component); + } + } else if (list.getSubscribe() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.MAILING_LIST)) { + addExternalReference(ExternalReference.Type.MAILING_LIST, list.getSubscribe(), component); } } } - if (project.getScm() != null && project.getScm().getUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.VCS)) { - addExternalReference( - ExternalReference.Type.VCS, project.getScm().getUrl(), component); - } + } + if (project.getScm() != null && project.getScm().getUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.VCS)) { + addExternalReference( + ExternalReference.Type.VCS, project.getScm().getUrl(), component); } } } @@ -213,13 +203,13 @@ private boolean doesComponentHaveExternalReference(final Component component, fi boolean resolved = false; if (artifactLicense.getName() != null) { final LicenseChoice resolvedByName = - LicenseResolver.resolve(artifactLicense.getName(), this.includeLicenseText); + LicenseResolver.resolve(artifactLicense.getName(), includeLicenseText); if (resolvedByName != null) { if (resolvedByName.getLicenses() != null && !resolvedByName.getLicenses().isEmpty()) { resolved = true; licenseChoice.addLicense(resolvedByName.getLicenses().get(0)); - } else if (resolvedByName.getExpression() != null && Version.VERSION_10 != schemaVersion) { + } else if (resolvedByName.getExpression() != null) { resolved = true; licenseChoice.setExpression(resolvedByName.getExpression()); } @@ -233,7 +223,7 @@ private boolean doesComponentHaveExternalReference(final Component component, fi && !resolvedByUrl.getLicenses().isEmpty()) { resolved = true; licenseChoice.addLicense(resolvedByUrl.getLicenses().get(0)); - } else if (resolvedByUrl.getExpression() != null && Version.VERSION_10 != schemaVersion) { + } else if (resolvedByUrl.getExpression() != null) { resolved = true; licenseChoice.setExpression(resolvedByUrl.getExpression()); } @@ -269,10 +259,9 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * @param project * the maven project the artifact is part of */ - @Nullable private MavenProject retrieveParentProject(ResolvedArtifact artifact, MavenProject project) { - if (artifact.getFile() == null - || artifact.getFile().getParentFile() == null - || !isDescribedArtifact(artifact)) { + @Nullable private MavenProject retrieveParentProject( + final File artifact, MavenProject project, final ModuleVersionIdentifier mid) { + if (artifact == null || artifact.getParentFile() == null) { return null; } final Model model = project.getModel(); @@ -281,14 +270,13 @@ private boolean doesComponentHaveExternalReference(final Component component, fi // Navigate out of version, artifactId, and first (possibly only) level of // groupId final StringBuilder getout = new StringBuilder("../../../"); - final ModuleVersionIdentifier mid = artifact.getModuleVersion().getId(); final int periods = mid.getGroup().length() - mid.getGroup().replace(".", "").length(); for (int i = 0; i < periods; i++) { getout.append("../"); } final File parentFile = new File( - artifact.getFile().getParentFile(), + artifact.getParentFile(), getout + parent.getGroupId().replace(".", "/") + "/" + parent.getArtifactId() + "/" + parent.getVersion() + "/" + parent.getArtifactId() + "-" + parent.getVersion() + ".pom"); if (parentFile.exists() && parentFile.isFile()) { @@ -309,14 +297,10 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * the artifact to extract the pom from * @return a Maven project */ - @Nullable MavenProject extractPom(ResolvedArtifact artifact) { - if (!isDescribedArtifact(artifact)) { - return null; - } - if (artifact.getFile() != null && artifact.getFile().exists()) { + @Nullable MavenProject extractPom(final File artifact, final ModuleVersionIdentifier mid) { + if (artifact != null && artifact.exists()) { try { - final JarFile jarFile = new JarFile(artifact.getFile()); - final ModuleVersionIdentifier mid = artifact.getModuleVersion().getId(); + final JarFile jarFile = new JarFile(artifact); final JarEntry entry = jarFile.getJarEntry("META-INF/maven/" + mid.getGroup() + "/" + mid.getName() + "/pom.xml"); if (entry != null) { @@ -340,7 +324,12 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * @throws IOException * oops */ - @Nullable MavenProject readPom(File file) { + @Nullable static MavenProject readPom(final File file) { + + if (file == null) { + return null; + } + try { final MavenXpp3Reader mavenreader = new MavenXpp3Reader(); try (final Reader reader = ReaderFactory.newXmlReader(file)) { @@ -348,9 +337,8 @@ private boolean doesComponentHaveExternalReference(final Component component, fi return new MavenProject(model); } } catch (XmlPullParserException | IOException e) { - logger.error("An error occurred attempting to read POM", e); + throw new IllegalStateException("An error occurred attempting to read POM", e); } - return null; } /** @@ -360,7 +348,7 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * the inputstream to read from * @return a MavenProject */ - @Nullable MavenProject readPom(InputStream in) { + @Nullable static MavenProject readPom(final InputStream in) { try { final MavenXpp3Reader mavenreader = new MavenXpp3Reader(); try (final Reader reader = ReaderFactory.newXmlReader(in)) { @@ -368,7 +356,7 @@ private boolean doesComponentHaveExternalReference(final Component component, fi return new MavenProject(model); } } catch (XmlPullParserException | IOException e) { - logger.error("An error occurred attempting to read POM", e); + // logger.error("An error occurred attempting to read POM", e); } return null; } @@ -383,23 +371,23 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * the current gradle project which gets used as the base resolver * @return model for effective pom */ - Model resolveEffectivePom(File pomFile, Project gradleProject) { + static Model resolveEffectivePom(final File pomFile, final Project gradleProject) { // force the parent POMs and BOMs to be resolved - ModelResolver modelResolver = new GradleAssistedMavenModelResolverImpl(gradleProject); - ModelBuildingRequest req = new DefaultModelBuildingRequest(); + final ModelResolver modelResolver = new GradleAssistedMavenModelResolverImpl(gradleProject); + final ModelBuildingRequest req = new DefaultModelBuildingRequest(); req.setModelResolver(modelResolver); req.setPomFile(pomFile); req.getSystemProperties().putAll(System.getProperties()); req.setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); // execute the model building request - DefaultModelBuilderFactory factory = new DefaultModelBuilderFactory(); - DefaultModelBuilder builder = factory.newInstance(); + final DefaultModelBuilderFactory factory = new DefaultModelBuilderFactory(); + final DefaultModelBuilder builder = factory.newInstance(); Model effectiveModel = null; try { effectiveModel = builder.build(req).getEffectiveModel(); } catch (ModelBuildingException e) { - logger.error("An error occurred attempting to resolve effective POM", e); + throw new IllegalStateException("An error occurred attempting to resolve effective POM", e); } return effectiveModel; } @@ -412,7 +400,7 @@ Model resolveEffectivePom(File pomFile, Project gradleProject) { * the artifact * @return true if artifact will have a POM, false if not */ - boolean isDescribedArtifact(Artifact artifact) { + boolean isDescribedArtifact(final Artifact artifact) { return artifact.getType().equalsIgnoreCase("jar"); } @@ -424,11 +412,11 @@ boolean isDescribedArtifact(Artifact artifact) { * the artifact * @return true if artifact will have a POM, false if not */ - boolean isDescribedArtifact(ResolvedArtifact artifact) { + static boolean isDescribedArtifact(final ResolvedArtifact artifact) { return artifact.getType().equalsIgnoreCase("jar"); } - boolean isModified(ResolvedArtifact artifact) { + boolean isModified(final ResolvedArtifact artifact) { // todo: compare hashes + GAV with what the artifact says against Maven Central // to determine if component has been modified. return false; diff --git a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java new file mode 100644 index 00000000..80f9db6a --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java @@ -0,0 +1,102 @@ +/* + * 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; + +import java.io.File; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import org.apache.maven.model.Model; +import org.apache.maven.project.MavenProject; +import org.gradle.api.Project; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.result.ArtifactResolutionResult; +import org.gradle.api.artifacts.result.ArtifactResult; +import org.gradle.api.artifacts.result.ComponentArtifactsResult; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.maven.MavenModule; +import org.gradle.maven.MavenPomArtifact; + +class MavenProjectLookup { + + private final Project project; + private final Map cache; + + MavenProjectLookup(final Project project) { + this.project = project; + this.cache = new HashMap<>(); + } + + MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { + + if (result == null) { + return null; + } + + if (cache.containsKey(result.getId())) { + return cache.get(result.getId()); + } + + try { + final File pomFile = buildMavenProject(result.getId()); + final MavenProject mavenProject = MavenHelper.readPom(pomFile); + if (mavenProject != null) { + final Model model = MavenHelper.resolveEffectivePom(pomFile, project); + if (model != null) { + mavenProject.setLicenses(model.getLicenses()); + } + + return mavenProject; + } + } catch (Exception err) { + project.getLogger().error("Unable to resolve POM for {}: {}", result.getId(), err); + } + return null; + } + + private File buildMavenProject(final ComponentIdentifier id) { + + final ArtifactResolutionResult result = project.getDependencies() + .createArtifactResolutionQuery() + .forComponents(id) + .withArtifacts(MavenModule.class, MavenPomArtifact.class) + .execute(); + + final Iterator componentIt = + result.getResolvedComponents().iterator(); + if (!componentIt.hasNext()) { + return null; + } + + final Iterator artifactIt = + componentIt.next().getArtifacts(MavenPomArtifact.class).iterator(); + if (!artifactIt.hasNext()) { + return null; + } + + final ArtifactResult artifact = artifactIt.next(); + if (artifact instanceof ResolvedArtifactResult) { + final ResolvedArtifactResult resolvedArtifact = (ResolvedArtifactResult) artifact; + return resolvedArtifact.getFile(); + } + + return null; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java similarity index 55% rename from src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java rename to src/main/java/org/cyclonedx/gradle/SbomBuilder.java index 791bb463..f59d2b7c 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java @@ -24,6 +24,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -32,23 +33,25 @@ import java.util.TreeSet; import java.util.UUID; import java.util.stream.Collectors; -import org.apache.commons.io.FilenameUtils; import org.cyclonedx.Version; import org.cyclonedx.gradle.model.ComponentComparator; import org.cyclonedx.gradle.model.DependencyComparator; -import org.cyclonedx.gradle.model.SerializableComponent; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.cyclonedx.gradle.utils.DependencyUtils; import org.cyclonedx.model.Bom; import org.cyclonedx.model.Component; import org.cyclonedx.model.Dependency; +import org.cyclonedx.model.ExternalReference; import org.cyclonedx.model.Hash; +import org.cyclonedx.model.LicenseChoice; import org.cyclonedx.model.Metadata; import org.cyclonedx.model.Property; import org.cyclonedx.util.BomUtils; import org.gradle.api.logging.Logger; -public class CycloneDxBomBuilder { +class SbomBuilder { private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; private static final TreeMap EMPTY_TYPE = new TreeMap<>(); @@ -57,57 +60,61 @@ public class CycloneDxBomBuilder { private final Map> artifactHashes; private final MavenHelper mavenHelper; private final Version version; + private final CycloneDxTask task; - public CycloneDxBomBuilder(final Logger logger) { + SbomBuilder(final Logger logger, final CycloneDxTask task) { this.logger = logger; this.version = CycloneDxUtils.DEFAULT_SCHEMA_VERSION; this.artifactHashes = new HashMap<>(); - this.mavenHelper = new MavenHelper(logger, version, false); + this.mavenHelper = new MavenHelper(logger, task.getIncludeLicenseText().get()); + this.task = task; } - public Bom buildBom( - final Map> resultGraph, - final SerializableComponent parentComponent) { + Bom buildBom(final Map resultGraph, final SbomComponent rootComponent) { final Set dependencies = new TreeSet<>(new DependencyComparator()); final Set components = new TreeSet<>(new ComponentComparator()); - resultGraph.keySet().forEach(component -> { - addDependency(dependencies, resultGraph.get(component), component); - addComponent(components, component, parentComponent); + resultGraph.keySet().forEach(componentId -> { + addDependency(dependencies, resultGraph.get(componentId)); + addComponent(components, resultGraph.get(componentId), rootComponent); }); final Bom bom = new Bom(); - bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); - bom.setMetadata(buildMetadata(parentComponent)); + if (task.getIncludeBomSerialNumber().get()) bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); + bom.setMetadata(buildMetadata(rootComponent)); bom.setComponents(new ArrayList<>(components)); bom.setDependencies(new ArrayList<>(dependencies)); return bom; } - private Metadata buildMetadata(final SerializableComponent parentComponent) { + private Metadata buildMetadata(final SbomComponent parentComponent) { final Metadata metadata = new Metadata(); try { - metadata.setComponent(toComponent(parentComponent, null)); + final Component component = toComponent(parentComponent, null, resolveProjectType()); + component.setProperties(null); + component.setName(task.getComponentName().get()); + component.setVersion(task.getComponentVersion().get()); + metadata.setComponent(component); } catch (MalformedPackageURLException e) { logger.warn("Error constructing packageUrl for parent component. Skipping...", e); } + metadata.setLicenseChoice(task.getLicenseChoice()); + metadata.setManufacture(task.getOrganizationalEntity()); + return metadata; } - private void addDependency( - final Set dependencies, - final Set dependencyComponents, - final SerializableComponent component) { + private void addDependency(final Set dependencies, final SbomComponent component) { final Dependency dependency; try { - dependency = toDependency(component); + dependency = toDependency(component.getId()); } catch (MalformedPackageURLException e) { logger.warn("Error constructing packageUrl for component. Skipping...", e); return; } - dependencyComponents.forEach(dependencyComponent -> { + component.getDependencyComponents().forEach(dependencyComponent -> { try { dependency.addDependency(toDependency(dependencyComponent)); } catch (MalformedPackageURLException e) { @@ -117,43 +124,55 @@ private void addDependency( dependencies.add(dependency); } - private Dependency toDependency(final SerializableComponent component) throws MalformedPackageURLException { + private Dependency toDependency(final SbomComponentId componentId) throws MalformedPackageURLException { - final String ref = DependencyUtils.generatePackageUrl( - component, getType(component.getArtifactFile().orElse(null))); + final String ref = DependencyUtils.generatePackageUrl(componentId, getQualifiers(componentId.getType())); return new Dependency(ref); } private void addComponent( - final Set components, - final SerializableComponent component, - final SerializableComponent parentComponent) { + final Set components, final SbomComponent component, final SbomComponent parentComponent) { if (!component.equals(parentComponent)) { final File artifactFile = component.getArtifactFile().orElse(null); try { - components.add(toComponent(component, artifactFile)); + components.add(toComponent(component, artifactFile, Component.Type.LIBRARY)); } catch (MalformedPackageURLException e) { logger.warn("Error constructing packageUrl for component. Skipping...", e); } } } - private Component toComponent(final SerializableComponent component, final File artifactFile) + private Component toComponent(final SbomComponent component, final File artifactFile, final Component.Type type) throws MalformedPackageURLException { - final String packageUrl = DependencyUtils.generatePackageUrl(component, getType(artifactFile)); + final String packageUrl = DependencyUtils.generatePackageUrl( + component.getId(), getQualifiers(component.getId().getType())); final Component resultComponent = new Component(); - resultComponent.setGroup(component.getGroup()); - resultComponent.setName(component.getName()); - resultComponent.setVersion(component.getVersion()); - resultComponent.setType(Component.Type.LIBRARY); + resultComponent.setGroup(component.getId().getGroup()); + resultComponent.setName(component.getId().getName()); + resultComponent.setVersion(component.getId().getVersion()); + resultComponent.setType(type); resultComponent.setPurl(packageUrl); resultComponent.setProperties(buildProperties(component)); - if (version.getVersion() >= 1.1) { - resultComponent.setModified(mavenHelper.isModified(null)); - resultComponent.setBomRef(packageUrl); - } + resultComponent.setModified(mavenHelper.isModified(null)); + resultComponent.setBomRef(packageUrl); + + component.getSbomMetaData().ifPresent(metaData -> { + resultComponent.setDescription(metaData.getDescription()); + resultComponent.setPublisher(metaData.getPublisher()); + metaData.getExternalReferences().forEach(reference -> { + final ExternalReference ref = new ExternalReference(); + ref.setType(ExternalReference.Type.valueOf(reference.getType())); + ref.setUrl(reference.getUrl()); + resultComponent.addExternalReference(ref); + }); + }); + + component.getLicenses().ifPresent(licenses -> { + LicenseChoice licenseChoice = mavenHelper.resolveMavenLicenses(licenses); + resultComponent.setLicenses(licenseChoice); + }); logger.debug(MESSAGE_CALCULATING_HASHES); if (artifactFile != null) { @@ -163,7 +182,7 @@ private Component toComponent(final SerializableComponent component, final File return resultComponent; } - private List buildProperties(SerializableComponent component) { + private List buildProperties(final SbomComponent component) { return component.getInScopeConfigurations().stream() .map(v -> { Property property = new Property(); @@ -171,6 +190,7 @@ private List buildProperties(SerializableComponent component) { property.setValue(String.format("%s:%s", v.getProjectName(), v.getConfigName())); return property; }) + .sorted(Comparator.comparing(Property::getValue)) .collect(Collectors.toList()); } @@ -185,18 +205,27 @@ private List calculateHashes(final File artifactFile) { }); } - private TreeMap getType(final File file) { - if (file == null) { - return EMPTY_TYPE; + private Component.Type resolveProjectType() { + for (Component.Type type : Component.Type.values()) { + if (type.getTypeName().equalsIgnoreCase(task.getProjectType().get())) { + return type; + } } + logger.warn("Invalid project type. Defaulting to 'library'"); + logger.warn("Valid types are:"); + for (Component.Type type : Component.Type.values()) { + logger.warn(" " + type.getTypeName()); + } + return Component.Type.LIBRARY; + } - String fileExtension = FilenameUtils.getExtension(file.getName()); - if (StringUtils.isBlank(fileExtension)) { + private TreeMap getQualifiers(final String type) { + if (StringUtils.isBlank(type)) { return EMPTY_TYPE; } - final TreeMap type = new TreeMap<>(); - type.put("type", fileExtension); - return type; + final TreeMap qualifiers = new TreeMap<>(); + qualifiers.put("type", type); + return qualifiers; } } diff --git a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java new file mode 100644 index 00000000..cbb592da --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -0,0 +1,138 @@ +/* + * 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; + +import java.io.File; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.lang3.StringUtils; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; +import org.cyclonedx.gradle.model.SbomGraph; +import org.cyclonedx.gradle.utils.DependencyUtils; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.component.ComponentIdentifier; + +public class SbomGraphProvider implements Callable { + + private final Project project; + private final CycloneDxTask task; + + public SbomGraphProvider(final Project project, final CycloneDxTask task) { + this.project = project; + this.task = task; + } + + @Override + public SbomGraph call() throws Exception { + + if (StringUtils.isBlank((String) project.getGroup()) || StringUtils.isBlank((String) project.getVersion())) { + throw new IllegalStateException("Project group and version are required for the CycloneDx task"); + } + + final DependencyGraphTraverser traverser = new DependencyGraphTraverser( + project.getLogger(), getArtifacts(), new MavenProjectLookup(project), task); + + final Map graph = Stream.concat( + traverseParentProject(traverser), traverseChildProjects(traverser)) + .reduce(new HashMap<>(), DependencyUtils::mergeGraphs); + + return buildSbomGraph(graph); + } + + private SbomGraph buildSbomGraph(final Map graph) { + + final Optional rootProject = DependencyUtils.findRootComponent(project, graph); + if (rootProject.isPresent()) { + DependencyUtils.connectRootWithSubProjects( + project, rootProject.get().getId(), graph); + return new SbomGraph(graph, rootProject.get()); + } else { + final SbomComponentId rootProjectId = new SbomComponentId( + (String) project.getGroup(), project.getName(), (String) project.getVersion(), ""); + final SbomComponent sbomComponent = new SbomComponent.Builder() + .withId(rootProjectId) + .withDependencyComponents(new HashSet<>()) + .withInScopeConfigurations(new HashSet<>()) + .build(); + + return new SbomGraph(graph, sbomComponent); + } + } + + private Stream> traverseParentProject( + final DependencyGraphTraverser traverser) { + + if (shouldSkipProject(project)) { + return Stream.empty(); + } + return project.getConfigurations().stream() + .filter(configuration -> shouldIncludeConfiguration(configuration) + && !shouldSkipConfiguration(configuration) + && configuration.isCanBeResolved()) + .map(config -> traverser.traverseGraph( + config.getIncoming().getResolutionResult().getRoot(), project.getName(), config.getName())); + } + + private Stream> traverseChildProjects( + final DependencyGraphTraverser traverser) { + return project.getChildProjects().entrySet().stream() + .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() + .filter(project -> !shouldSkipProject(project)) + .flatMap(project -> project.getConfigurations().stream()) + .filter(configuration -> shouldIncludeConfiguration(configuration) + && !shouldSkipConfiguration(configuration) + && configuration.isCanBeResolved()) + .flatMap(config -> config.getIncoming().getArtifacts().getArtifacts().stream()) + .collect(Collectors.toMap( + artifact -> artifact.getId().getComponentIdentifier(), + artifact -> artifact.getFile(), + (v1, v2) -> v1)); + } + + private boolean shouldSkipConfiguration(final Configuration configuration) { + return task.getSkipConfigs().get().stream().anyMatch(configuration.getName()::matches); + } + + private boolean shouldIncludeConfiguration(final Configuration configuration) { + return task.getIncludeConfigs().get().isEmpty() + || task.getIncludeConfigs().get().stream().anyMatch(configuration.getName()::matches); + } + + private boolean shouldSkipProject(final Project project) { + return task.getSkipProjects().get().contains(project.getName()); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java b/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java index 07b8e7b4..19c06269 100644 --- a/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java +++ b/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java @@ -24,7 +24,7 @@ public class ComponentComparator implements Comparator { @Override - public int compare(Component o1, Component o2) { + public int compare(final Component o1, final Component o2) { return o1.getBomRef().compareTo(o2.getBomRef()); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java b/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java index c64ba7b2..3460372b 100644 --- a/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java +++ b/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java @@ -23,7 +23,7 @@ public class DependencyComparator implements Comparator { @Override - public int compare(Dependency o1, Dependency o2) { + public int compare(final Dependency o1, final Dependency o2) { return o1.getRef().compareTo(o2.getRef()); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java new file mode 100644 index 00000000..0eea5539 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java @@ -0,0 +1,121 @@ +/* + * 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.model; + +import java.io.File; +import java.io.Serializable; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.apache.maven.model.License; + +public final class SbomComponent implements Serializable { + + private final SbomComponentId id; + private final Set inScopeConfigurations; + private final Set dependencyComponents; + private final File artifactFile; + private final SbomMetaData metaData; + private final List licenses; + + public SbomComponent( + final SbomComponentId id, + final Set inScopeConfigurations, + final Set dependencyComponents, + final File artifactFile, + final SbomMetaData metaData, + final List licenses) { + this.id = id; + this.inScopeConfigurations = inScopeConfigurations; + this.dependencyComponents = dependencyComponents; + this.artifactFile = artifactFile; + this.metaData = metaData; + this.licenses = licenses; + } + + public SbomComponentId getId() { + return id; + } + + public Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + public Set getDependencyComponents() { + return dependencyComponents; + } + + public Optional getArtifactFile() { + return Optional.ofNullable(artifactFile); + } + + public Optional getSbomMetaData() { + return Optional.ofNullable(metaData); + } + + public Optional> getLicenses() { + return Optional.ofNullable(licenses); + } + + public static class Builder { + + private SbomComponentId id; + private Set inScopeConfigurations; + private Set dependencyComponents; + private File artifactFile; + private SbomMetaData metaData; + private List licenses; + + public Builder() {} + + public Builder withId(final SbomComponentId id) { + this.id = id; + return this; + } + + public Builder withInScopeConfigurations(final Set inScopeConfigurations) { + this.inScopeConfigurations = inScopeConfigurations; + return this; + } + + public Builder withDependencyComponents(final Set dependencyComponents) { + this.dependencyComponents = dependencyComponents; + return this; + } + + public Builder withArtifactFile(final File artifactFile) { + this.artifactFile = artifactFile; + return this; + } + + public Builder withMetaData(final SbomMetaData metaData) { + this.metaData = metaData; + return this; + } + + public Builder withLicenses(final List licenses) { + this.licenses = licenses; + return this; + } + + public SbomComponent build() { + return new SbomComponent(id, inScopeConfigurations, dependencyComponents, artifactFile, metaData, licenses); + } + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java b/src/main/java/org/cyclonedx/gradle/model/SbomComponentId.java similarity index 55% rename from src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java rename to src/main/java/org/cyclonedx/gradle/model/SbomComponentId.java index 3c65ebcb..ee47ea9f 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomComponentId.java @@ -18,43 +18,21 @@ */ package org.cyclonedx.gradle.model; -import java.io.File; import java.io.Serializable; import java.util.Objects; -import java.util.Optional; -import java.util.Set; -public final class SerializableComponent implements Serializable { +public class SbomComponentId implements Serializable { private final String group; private final String name; private final String version; - private final File artifactFile; - private final Set inScopeConfigurations; + private final String type; - public SerializableComponent( - final String group, - final String name, - final String version, - final Set inScopeConfigurations) { - this(group, name, version, inScopeConfigurations, null); - } - - public SerializableComponent( - final String group, - final String name, - final String version, - final Set inScopeConfigurations, - final File artifactFile) { + public SbomComponentId(final String group, final String name, final String version, final String type) { this.group = group; this.name = name; this.version = version; - this.artifactFile = artifactFile; - this.inScopeConfigurations = inScopeConfigurations; - } - - public String getGroup() { - return group; + this.type = type; } public String getName() { @@ -65,27 +43,27 @@ public String getVersion() { return version; } - public Set getInScopeConfigurations() { - return inScopeConfigurations; + public String getGroup() { + return group; } - public Optional getArtifactFile() { - return Optional.ofNullable(artifactFile); + public String getType() { + return type; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - SerializableComponent that = (SerializableComponent) o; + final SbomComponentId that = (SbomComponentId) o; return Objects.equals(group, that.group) && Objects.equals(name, that.name) && Objects.equals(version, that.version) - && Objects.equals(artifactFile, that.artifactFile); + && Objects.equals(type, that.type); } @Override public int hashCode() { - return Objects.hash(group, name, version, artifactFile); + return Objects.hash(group, name, version, type); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java b/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java similarity index 57% rename from src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java rename to src/main/java/org/cyclonedx/gradle/model/SbomGraph.java index 77089ae6..a490a3d7 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java @@ -20,25 +20,22 @@ import java.io.Serializable; import java.util.Map; -import java.util.Set; -public class SerializableComponents implements Serializable { +public class SbomGraph implements Serializable { - private final Map> serializableComponents; - private final SerializableComponent rootComponent; + private final Map graph; + private final SbomComponent rootComponent; - public SerializableComponents( - Map> serializableComponents, - SerializableComponent rootComponent) { - this.serializableComponents = serializableComponents; + public SbomGraph(final Map graph, final SbomComponent rootComponent) { + this.graph = graph; this.rootComponent = rootComponent; } - public Map> getSerializableComponents() { - return serializableComponents; + public Map getGraph() { + return graph; } - public SerializableComponent getRootComponent() { + public SbomComponent getRootComponent() { return rootComponent; } } diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java b/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java new file mode 100644 index 00000000..bce3c8fa --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java @@ -0,0 +1,89 @@ +/* + * 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.model; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import org.cyclonedx.model.Component; + +public class SbomMetaData implements Serializable { + + private String publisher; + private String description; + private List externalReferences = new ArrayList<>(); + + private SbomMetaData() {} + + public String getPublisher() { + return publisher; + } + + public void setPublisher(final String publisher) { + this.publisher = publisher; + } + + public String getDescription() { + return description; + } + + public void setDescription(final String description) { + this.description = description; + } + + public void addExternalReference(final String type, final String url) { + externalReferences.add(new ExternalReference(type, url)); + } + + public List getExternalReferences() { + return externalReferences; + } + + public static SbomMetaData fromComponent(final Component component) { + + final SbomMetaData metaData = new SbomMetaData(); + metaData.setDescription(component.getDescription()); + metaData.setPublisher(component.getPublisher()); + if (component.getExternalReferences() != null) { + component.getExternalReferences().forEach(reference -> { + metaData.addExternalReference(reference.getType().toString(), reference.getUrl()); + }); + } + return metaData; + } + + public static class ExternalReference implements Serializable { + + private final String type; + private final String url; + + private ExternalReference(final String type, final String url) { + this.type = type; + this.url = url; + } + + public String getType() { + return type; + } + + public String getUrl() { + return url; + } + } +} diff --git a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java index 507323f6..661561a0 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java @@ -19,12 +19,12 @@ package org.cyclonedx.gradle.utils; import java.io.File; -import java.io.IOException; import java.nio.charset.StandardCharsets; import org.apache.commons.io.FileUtils; import org.cyclonedx.Version; import org.cyclonedx.generators.BomGeneratorFactory; import org.cyclonedx.generators.json.BomJsonGenerator; +import org.cyclonedx.generators.xml.BomXmlGenerator; import org.cyclonedx.model.Bom; import org.gradle.api.GradleException; @@ -58,23 +58,42 @@ public static Version schemaVersion(String version) { } } - public static void writeBom(final Bom bom, final File destination) { - try { - writeJSONBom(DEFAULT_SCHEMA_VERSION, bom, destination); - } catch (IOException e) { - throw new GradleException("An error occurred writing BOM", e); + public static void writeBom( + final Bom bom, + final File destination, + final String outputName, + final Version version, + final String formats) { + + if (formats.equals("all") || formats.equals("json")) { + final File jsonFile = new File(destination, String.format("%s.json", outputName)); + writeJSONBom(version, bom, jsonFile); } - } - private static void writeJSONBom(final Version schemaVersion, final Bom bom, final File destination) - throws IOException { + if (formats.equals("all") || formats.equals("xml")) { + final File xmlFile = new File(destination, String.format("%s.xml", outputName)); + writeXmlBom(version, bom, xmlFile); + } + } + private static void writeJSONBom(final Version schemaVersion, final Bom bom, final File destination) { final BomJsonGenerator bomGenerator = BomGeneratorFactory.createJson(schemaVersion, bom); try { final String bomString = bomGenerator.toJsonString(); FileUtils.write(destination, bomString, StandardCharsets.UTF_8, false); } catch (Exception e) { - throw new GradleException("Valid message", e); + throw new GradleException("Error writing json bom file", e); + } + } + + private static void writeXmlBom(final Version schemaVersion, final Bom bom, final File destination) { + + final BomXmlGenerator bomGenerator = BomGeneratorFactory.createXml(schemaVersion, bom); + try { + final String bomString = bomGenerator.toXmlString(); + FileUtils.write(destination, bomString, StandardCharsets.UTF_8, false); + } catch (Exception e) { + throw new GradleException("Error writing xml bom file", e); } } } diff --git a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java index 68e353ce..d69fc005 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -20,19 +20,108 @@ import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.TreeMap; -import org.cyclonedx.gradle.model.SerializableComponent; +import java.util.stream.Collectors; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; +import org.gradle.api.Project; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedComponentResult; public class DependencyUtils { - public static String generatePackageUrl( - final SerializableComponent component, final TreeMap qualifiers) + public static Map mergeGraphs( + final Map firstGraph, + final Map secondGraph) { + + final Map mergedGraph = new HashMap<>(firstGraph); + secondGraph.keySet().stream().forEach(id -> { + if (firstGraph.containsKey(id)) { + SbomComponent resultComponent = mergedGraph.get(id); + SbomComponent targetComponent = secondGraph.get(id); + resultComponent.getDependencyComponents().addAll(targetComponent.getDependencyComponents()); + resultComponent.getInScopeConfigurations().addAll(targetComponent.getInScopeConfigurations()); + } else { + mergedGraph.put(id, secondGraph.get(id)); + } + }); + + return mergedGraph; + } + + public static void connectRootWithSubProjects( + final Project project, + final SbomComponentId rootProjectId, + final Map graph) { + + if (project.getSubprojects().isEmpty()) { + return; + } + + final Set dependencyComponentIds = project.getSubprojects().stream() + .map(subProject -> new SbomComponentId( + (String) subProject.getGroup(), subProject.getName(), (String) subProject.getVersion(), "")) + .filter(componentId -> graph.containsKey(componentId)) + .collect(Collectors.toSet()); + + graph.get(rootProjectId).getDependencyComponents().addAll(dependencyComponentIds); + } + + public static Optional findRootComponent( + final Project project, final Map graph) { + + final SbomComponentId rootProjectId = + new SbomComponentId((String) project.getGroup(), project.getName(), (String) project.getVersion(), ""); + + if (!graph.containsKey(rootProjectId)) { + return Optional.empty(); + } else { + return Optional.of(graph.get(rootProjectId)); + } + } + + public static SbomComponentId toComponentId(final ResolvedComponentResult node, final File file) { + + String type = ""; + if (node.getId() instanceof ModuleComponentIdentifier) { + if (file != null) { + type = getType(file); + } else { + type = "pom"; + } + } + + return new SbomComponentId( + node.getModuleVersion().getGroup(), + node.getModuleVersion().getName(), + node.getModuleVersion().getVersion(), + type); + } + + private static String getType(final File file) { + + final String fileExtension = FilenameUtils.getExtension(file.getName()); + if (StringUtils.isBlank(fileExtension)) { + return "pom"; + } + + return fileExtension; + } + + public static String generatePackageUrl(final SbomComponentId componentId, final TreeMap qualifiers) throws MalformedPackageURLException { return new PackageURL( PackageURL.StandardTypes.MAVEN, - component.getGroup(), - component.getName(), - component.getVersion(), + componentId.getGroup(), + componentId.getName(), + componentId.getVersion(), qualifiers, null) .canonicalize(); diff --git a/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy index e888ba5c..1cbe4469 100644 --- a/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy @@ -53,23 +53,4 @@ class CycloneDxSpec extends Specification { expect: leafProject.tasks.findByName('cyclonedxBom') } - - @Ignore - def "cyclonedxBom metadata creation uses project specific values"() { - expect: - Metadata root = rootProject.tasks.findByName('cyclonedxBom').createMetadata() - root.component.group == 'group' - root.component.name == 'root' - root.component.version == '1.3' - - Metadata parent = parentProject.tasks.findByName('cyclonedxBom').createMetadata() - parent.component.group == 'group' - parent.component.name == 'parent' - parent.component.version == '1.3' - - Metadata leaf = leafProject.tasks.findByName('cyclonedxBom').createMetadata() - leaf.component.group == 'group' - leaf.component.name == 'leaf' - leaf.component.version == '1.3.1' - } } diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index beed3a4a..c172be03 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -1,6 +1,8 @@ package org.cyclonedx.gradle import com.fasterxml.jackson.databind.ObjectMapper +import groovy.json.JsonSlurper +import org.cyclonedx.gradle.utils.CycloneDxUtils import org.cyclonedx.model.Bom import org.cyclonedx.model.Component import org.cyclonedx.model.Dependency @@ -32,7 +34,7 @@ class DependencyResolutionSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -65,7 +67,7 @@ class DependencyResolutionSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -158,4 +160,212 @@ class DependencyResolutionSpec extends Specification { Component componentc = bom.getComponents().find(c -> c.bomRef == 'pkg:maven/com.test/componentc@1.0.0?type=tgz') assert componentc != null } + + def "should build bom successfully for native kotlin project"() { + given: + File testDir = TestUtils.duplicate("native-kotlin-project") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "build/reports") + + assert reportDir.exists() + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + } + + def "loops between jar dependencies in the dependency graph should be processed"() { + given: + File testDir = TestUtils.duplicate("dependency-graph-loop") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "build/reports") + + assert reportDir.exists() + } + + def "loops between non-jar dependencies in the dependency graph should be processed"() { + given: + String localRepoUri = TestUtils.duplicateRepo("local") + + File testDir = TestUtils.createFromString(""" + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + repositories { + maven{ + url '$localRepoUri' + } + } + group = 'com.example' + version = '1.0.0' + + dependencies { + implementation("com.test:componentc:1.0.1") + }""", "rootProject.name = 'simple-project'") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File jsonBom = new File(testDir, "build/reports/bom.json") + Bom bom = new ObjectMapper().readValue(jsonBom, Bom.class) + Component componentc = bom.getComponents().find(c -> c.bomRef == 'pkg:maven/com.test/componentc@1.0.1?type=tgz') + assert componentc != null + Component componentd = bom.getComponents().find(c -> c.bomRef == 'pkg:maven/com.test/componentd@1.0.0?type=tgz') + assert componentd != null + } + + def "multi-module with plugin at root should output boms in build/reports with default version including sub-projects as components"() { + given: + File testDir = TestUtils.duplicate("multi-module") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") + .withPluginClasspath() + .build() + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "build/reports") + + assert reportDir.exists() + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + + def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) + assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString + + def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") + assert appAComponent.hasComponentDefined() + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") + + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") + assert appBComponent.hasComponentDefined() + assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0") + } + + def "multi-module with plugin in subproject should output boms in build/reports with for sub-project app-a"() { + given: + File testDir = TestUtils.duplicate("multi-module-subproject") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments(":app-a:cyclonedxBom", "--info", "-S", "--configuration-cache") + .withPluginClasspath() + .build() + then: + result.task(":app-a:cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "app-a/build/reports") + + assert reportDir.exists() + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + + def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) + assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString + + assert jsonBom.metadata.component.type == "library" + assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-a@1.0.0" + assert jsonBom.metadata.component.group == "com.example" + assert jsonBom.metadata.component.name == "app-a" + assert jsonBom.metadata.component.version == "1.0.0" + assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-a@1.0.0" + + def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") + assert !appAComponent.hasComponentDefined() + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") + + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0?type=jar") + assert !appBComponent.hasComponentDefined() + assert appBComponent.dependencies == null + } + + def "multi-module with plugin in subproject should output boms in build/reports with for sub-project app-b"() { + given: + File testDir = TestUtils.duplicate("multi-module-subproject") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments(":app-a:assemble", ":app-b:cyclonedxBom", "--info", "-S") + .withPluginClasspath() + .build() + then: + result.task(":app-b:cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "app-b/build/reports") + + assert reportDir.exists() + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + + def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) + assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString + + assert jsonBom.metadata.component.type == "library" + assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-b@1.0.0" + assert jsonBom.metadata.component.group == "com.example" + assert jsonBom.metadata.component.name == "app-b" + assert jsonBom.metadata.component.version == "1.0.0" + assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-b@1.0.0" + + def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") + assert appAComponent.hasComponentDefined() + assert appAComponent.component.hashes != null + assert !appAComponent.component.hashes.empty + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") + + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") + assert !appBComponent.hasComponentDefined() + assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0") + } + + private static def loadJsonBom(File file) { + return new JsonSlurper().parse(file) + } + + private static class JsonBomComponent { + + def component + def dependencies + + boolean hasComponentDefined() { + return component != null + && ["library", "application"].contains(component.type) + && !component.group.empty + && !component.name.empty + && !component.version.empty + && !component.purl.empty + } + + boolean dependsOn(String ref) { + return dependencies != null && dependencies.dependsOn.contains(ref) + } + + static JsonBomComponent of(jsonBom, String ref) { + return new JsonBomComponent( + component: jsonBom.components.find { it."bom-ref".equals(ref) }, + dependencies: jsonBom.dependencies.find { it.ref.equals(ref) } + ) + } + } } diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index cef7b0db..2e3198e7 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -1,34 +1,15 @@ package org.cyclonedx.gradle import com.fasterxml.jackson.databind.ObjectMapper -import groovy.json.JsonSlurper import org.cyclonedx.gradle.utils.CycloneDxUtils import org.cyclonedx.model.Bom import org.cyclonedx.model.Component import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification -import spock.lang.Ignore class PluginConfigurationSpec extends Specification { - def "loops in the dependency graph should be processed"() { - given: - File testDir = TestUtils.duplicate("dependency-graph-loop") - - when: - def result = GradleRunner.create() - .withProjectDir(testDir) - .withArguments("cyclonedxBom") - .withPluginClasspath() - .build() - - then: - result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS - File reportDir = new File(testDir, "build/reports") - - assert reportDir.exists() - } def "simple-project should output boms in build/reports with default schema version"() { given: @@ -37,7 +18,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() then: @@ -45,7 +26,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"${CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString}\"") } @@ -57,7 +38,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() then: @@ -65,10 +46,9 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "output-dir") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 } - @Ignore def "custom-output project should write boms under my-bom"() { given: File testDir = TestUtils.duplicate("custom-outputname") @@ -76,7 +56,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() then: @@ -93,7 +73,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -101,12 +81,11 @@ class PluginConfigurationSpec extends Specification { result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 assert !result.output.contains("An error occurred attempting to read POM") } - @Ignore def "should use configured schemaVersion"() { given: File testDir = TestUtils.createFromString(""" @@ -130,7 +109,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -139,7 +118,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"1.3\"") } @@ -178,19 +157,35 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"name\" : \"hello-world\"") } - def "should build bom successfully for native kotlin project"() { + def "should use configured componentName"() { given: - File testDir = TestUtils.duplicate("native-kotlin-project") + File testDir = TestUtils.createFromString(""" + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + repositories { + mavenCentral() + } + group = 'com.example' + version = '1.0.0' + cyclonedxBom { + componentName = 'customized-component-name' + } + 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' + }""", "rootProject.name = 'hello-world'") when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -199,11 +194,12 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + File jsonBom = new File(reportDir, "bom.json") + assert jsonBom.text.contains("\"name\" : \"customized-component-name\"") } - @Ignore - def "should use configured componentName"() { + def "should use configured componentVersion"() { given: File testDir = TestUtils.createFromString(""" plugins { @@ -216,7 +212,7 @@ class PluginConfigurationSpec extends Specification { group = 'com.example' version = '1.0.0' cyclonedxBom { - componentName = 'customized-component-name' + componentVersion = '999-SNAPSHOT' } dependencies { implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version:'2.8.11' @@ -226,7 +222,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -235,13 +231,12 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") - assert jsonBom.text.contains("\"name\" : \"customized-component-name\"") + assert jsonBom.text.contains("\"version\" : \"999-SNAPSHOT\"") } - @Ignore - def "should use configured componentVersion"() { + def "should use configured projectType"() { given: File testDir = TestUtils.createFromString(""" plugins { @@ -254,17 +249,16 @@ class PluginConfigurationSpec extends Specification { group = 'com.example' version = '1.0.0' cyclonedxBom { - componentVersion = '999-SNAPSHOT' + projectType = 'framework' } 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' }""", "rootProject.name = 'hello-world'") when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -273,12 +267,11 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") - assert jsonBom.text.contains("\"version\" : \"999-SNAPSHOT\"") + assert jsonBom.text.contains("\"type\" : \"framework\"") } - @Ignore def "should use configured outputFormat to limit generated file"() { given: File testDir = TestUtils.createFromString(""" @@ -302,7 +295,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -311,12 +304,11 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 1 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.exists() } - @Ignore def "includes component bom-ref when schema version greater than 1.0"() { given: File testDir = TestUtils.createFromString(""" @@ -339,7 +331,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -352,110 +344,6 @@ class PluginConfigurationSpec extends Specification { assert log4jCore.getBomRef() == 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0?type=jar' } - def "multi-module with plugin at root should output boms in build/reports with default version including sub-projects as components"() { - given: - File testDir = TestUtils.duplicate("multi-module") - - when: - def result = GradleRunner.create() - .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") - .withPluginClasspath() - .build() - then: - result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS - File reportDir = new File(testDir, "build/reports") - - assert reportDir.exists() - reportDir.listFiles().length == 1 - - def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) - assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString - - def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") - assert appAComponent.hasComponentDefined() - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") - - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") - assert appBComponent.hasComponentDefined() - assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") - } - - def "multi-module with plugin in subproject should output boms in build/reports with for sub-project app-a"() { - given: - File testDir = TestUtils.duplicate("multi-module-subproject") - - when: - def result = GradleRunner.create() - .withProjectDir(testDir) - .withArguments(":app-a:cyclonedxBom", "--info", "-S") - .withPluginClasspath() - .build() - then: - result.task(":app-a:cyclonedxBom").outcome == TaskOutcome.SUCCESS - File reportDir = new File(testDir, "app-a/build/reports") - - assert reportDir.exists() - reportDir.listFiles().length == 1 - - def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) - assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString - - assert jsonBom.metadata.component.type == "library" - assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-a@1.0.0" - assert jsonBom.metadata.component.group == "com.example" - assert jsonBom.metadata.component.name == "app-a" - assert jsonBom.metadata.component.version == "1.0.0" - assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-a@1.0.0" - - def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") - assert !appAComponent.hasComponentDefined() - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") - - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0?type=jar") - assert !appBComponent.hasComponentDefined() - assert appBComponent.dependencies == null - } - - def "multi-module with plugin in subproject should output boms in build/reports with for sub-project app-b"() { - given: - File testDir = TestUtils.duplicate("multi-module-subproject") - - when: - def result = GradleRunner.create() - .withProjectDir(testDir) - .withArguments(":app-a:assemble", ":app-b:cyclonedxBom", "--info", "-S") - .withPluginClasspath() - .build() - then: - result.task(":app-b:cyclonedxBom").outcome == TaskOutcome.SUCCESS - File reportDir = new File(testDir, "app-b/build/reports") - - assert reportDir.exists() - reportDir.listFiles().length == 1 - - def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) - assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString - - assert jsonBom.metadata.component.type == "library" - assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-b@1.0.0" - assert jsonBom.metadata.component.group == "com.example" - assert jsonBom.metadata.component.name == "app-b" - assert jsonBom.metadata.component.version == "1.0.0" - assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-b@1.0.0" - - def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") - assert appAComponent.hasComponentDefined() - assert appAComponent.component.hashes != null - assert !appAComponent.component.hashes.empty - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") - - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") - assert !appBComponent.hasComponentDefined() - assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") - } - - @Ignore def "kotlin-dsl-project should allow configuring all properties"() { given: File testDir = TestUtils.duplicate("kotlin-project") @@ -463,21 +351,20 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S") + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") .withPluginClasspath() .build() then: - // result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert !jsonBom.text.contains("serialNumber") } - @Ignore def "kotlin-dsl-project-manufacture-licenses should allow definition of manufacture-data and licenses-data"() { given: File testDir = TestUtils.duplicate("kotlin-project-manufacture-licenses") @@ -485,7 +372,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S") + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") .withPluginClasspath() .build() @@ -494,7 +381,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") //check Manufacture Data assert jsonBom.text.contains("\"name\" : \"Test\"") @@ -510,7 +397,6 @@ class PluginConfigurationSpec extends Specification { } - @Ignore def "groovy-project-manufacture-licenses should allow definition of manufacture-data and licenses-data"() { given: File testDir = TestUtils.duplicate("groovy-project-manufacture-licenses") @@ -518,7 +404,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S") + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") .withPluginClasspath() .build() @@ -527,7 +413,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") //check Manufacture Data assert jsonBom.text.contains("\"name\" : \"Test\"") @@ -543,7 +429,6 @@ class PluginConfigurationSpec extends Specification { } - @Ignore def "should skip configurations with regex"() { given: File testDir = TestUtils.createFromString(""" @@ -567,7 +452,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--stacktrace", "--configuration-cache") .withPluginClasspath() .build() @@ -580,7 +465,6 @@ class PluginConfigurationSpec extends Specification { assert log4jCore == null } - @Ignore def "should include configurations with regex"() { given: File testDir = TestUtils.createFromString(""" @@ -604,7 +488,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -634,7 +518,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -643,7 +527,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles().length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"1.6\"") } @@ -665,16 +549,15 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--stacktrace") .withPluginClasspath() .run() then: result.task(":cyclonedxBom").outcome == TaskOutcome.FAILED - assert result.output.contains("Invalid module identifier provided.") + assert result.output.contains("Project group and version are required for the CycloneDx task") } - @Ignore def "should include metadata by default"() { given: File testDir = TestUtils.createFromString(""" @@ -694,7 +577,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--stacktrace", "--configuration-cache") .withPluginClasspath() .build() @@ -704,7 +587,6 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"id\" : \"Apache-2.0\"") } - @Ignore def "should not include metadata when includeMetadataResolution is false"() { given: File testDir = TestUtils.createFromString(""" @@ -727,7 +609,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -736,34 +618,4 @@ class PluginConfigurationSpec extends Specification { File jsonBom = new File(testDir, "build/reports/bom.json") assert !jsonBom.text.contains("\"id\" : \"Apache-2.0\"") } - - private static def loadJsonBom(File file) { - return new JsonSlurper().parse(file) - } - - private static class JsonBomComponent { - - def component - def dependencies - - boolean hasComponentDefined() { - return component != null - && ["library", "application"].contains(component.type) - && !component.group.empty - && !component.name.empty - && !component.version.empty - && !component.purl.empty - } - - boolean dependsOn(String ref) { - return dependencies != null && dependencies.dependsOn.contains(ref) - } - - static JsonBomComponent of(jsonBom, String ref) { - return new JsonBomComponent( - component: jsonBom.components.find { it."bom-ref".equals(ref) }, - dependencies: jsonBom.dependencies.find { it.ref.equals(ref) } - ) - } - } } diff --git a/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.pom b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.pom new file mode 100644 index 00000000..afec76c8 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.pom @@ -0,0 +1,18 @@ + + + 4.0.0 + com.test + componentc + 1.0.1 + tgz + + + + com.test + componentd + 1.0.0 + + + + diff --git a/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.tgz b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.tgz new file mode 100644 index 00000000..6971eb4f --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.tgz @@ -0,0 +1 @@ +component c version 1.0.1 diff --git a/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.pom b/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.pom new file mode 100644 index 00000000..e14bbdd0 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.pom @@ -0,0 +1,18 @@ + + + 4.0.0 + com.test + componentd + 1.0.0 + tgz + + + + com.test + componentc + 1.0.1 + + + + diff --git a/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.tgz b/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.tgz new file mode 100644 index 00000000..c8c2e020 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.tgz @@ -0,0 +1 @@ +component d version 1.0.0 From ac1c3c0290ce54e1efbafa6cef7ffb0fa02c7f7b Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 4 Nov 2024 08:44:21 +0000 Subject: [PATCH 07/26] fix: add entry into cache Signed-off-by: Gordon --- src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java index 80f9db6a..16f15499 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java +++ b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java @@ -63,10 +63,11 @@ MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { mavenProject.setLicenses(model.getLicenses()); } + cache.put(result.getId(), mavenProject); return mavenProject; } } catch (Exception err) { - project.getLogger().error("Unable to resolve POM for {}: {}", result.getId(), err); + project.getLogger().error("Unable to resolve POM for {}", result.getId(), err); } return null; } From 4f0b1798258543166475ee25530107e3e3ed0879 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 4 Nov 2024 10:05:27 +0000 Subject: [PATCH 08/26] fix: getGroup and getVersion do not return String objects Signed-off-by: Gordon --- .../java/org/cyclonedx/gradle/SbomGraphProvider.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java index cbb592da..76fb5db5 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -26,7 +26,6 @@ import java.util.concurrent.Callable; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.apache.commons.lang3.StringUtils; import org.cyclonedx.gradle.model.SbomComponent; import org.cyclonedx.gradle.model.SbomComponentId; import org.cyclonedx.gradle.model.SbomGraph; @@ -48,7 +47,9 @@ public SbomGraphProvider(final Project project, final CycloneDxTask task) { @Override public SbomGraph call() throws Exception { - if (StringUtils.isBlank((String) project.getGroup()) || StringUtils.isBlank((String) project.getVersion())) { + if (project.getGroup().equals("") + || project.getName().isEmpty() + || project.getVersion().equals("")) { throw new IllegalStateException("Project group and version are required for the CycloneDx task"); } @@ -71,7 +72,10 @@ private SbomGraph buildSbomGraph(final Map graph return new SbomGraph(graph, rootProject.get()); } else { final SbomComponentId rootProjectId = new SbomComponentId( - (String) project.getGroup(), project.getName(), (String) project.getVersion(), ""); + project.getGroup().toString(), + project.getName(), + project.getVersion().toString(), + ""); final SbomComponent sbomComponent = new SbomComponent.Builder() .withId(rootProjectId) .withDependencyComponents(new HashSet<>()) From e2a229004172f62634efdc236ec0c4e51d1f5d9c Mon Sep 17 00:00:00 2001 From: Gordon Date: Tue, 5 Nov 2024 09:44:09 +0000 Subject: [PATCH 09/26] feat: add small fixes, javadoc, logging and nullables. Signed-off-by: Gordon --- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 3 ++ .../org/cyclonedx/gradle/CycloneDxTask.java | 22 +++++++++-- .../gradle/DependencyGraphTraverser.java | 29 ++++++++++++-- .../cyclonedx/gradle/MavenProjectLookup.java | 18 ++++++++- .../org/cyclonedx/gradle/SbomBuilder.java | 38 ++++++++++++++----- .../cyclonedx/gradle/SbomGraphProvider.java | 26 +++++++++++-- .../cyclonedx/gradle/model/SbomComponent.java | 22 ++++++----- .../org/cyclonedx/gradle/model/SbomGraph.java | 4 ++ .../cyclonedx/gradle/model/SbomMetaData.java | 17 +++++---- .../gradle/utils/DependencyUtils.java | 28 +++++++++----- 10 files changed, 160 insertions(+), 47 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index e2df81b0..115d0f58 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -23,6 +23,9 @@ import org.gradle.api.Project; import org.gradle.api.provider.Provider; +/** + * Entrypoint of the plugin which simply configures one task + */ public class CycloneDxPlugin implements Plugin { public void apply(final Project project) { diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index ebf4bdbc..43259058 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.Map; import java.util.function.Consumer; +import javax.annotation.Nullable; import org.cyclonedx.gradle.model.SbomGraph; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.cyclonedx.model.Bom; @@ -36,8 +37,13 @@ import org.gradle.api.tasks.OutputDirectory; import org.gradle.api.tasks.TaskAction; +/** + * This task mainly acts a container for the user configurations (includeConfigs, projectType, schemaVersion, ...) + * and orchestrating the calls between the core objects (SbomGraphProvider and SbomBuilder) + */ public abstract class CycloneDxTask extends DefaultTask { + private static final String MESSAGE_WRITING_BOM_OUTPUT = "CycloneDX: Writing BOM output"; private static final String DEFAULT_PROJECT_TYPE = "library"; private final Property outputName; @@ -53,8 +59,10 @@ public abstract class CycloneDxTask extends DefaultTask { private final Property projectType; private final ListProperty skipProjects; private final Property destination; - private OrganizationalEntity organizationalEntity; - private LicenseChoice licenseChoice; + + @Nullable private OrganizationalEntity organizationalEntity; + + @Nullable private LicenseChoice licenseChoice; public CycloneDxTask() { @@ -211,12 +219,12 @@ public void setSkipProjects(final Collection skipProjects) { } @Internal - OrganizationalEntity getOrganizationalEntity() { + @Nullable OrganizationalEntity getOrganizationalEntity() { return organizationalEntity; } @Internal - LicenseChoice getLicenseChoice() { + @Nullable LicenseChoice getLicenseChoice() { return licenseChoice; } @@ -232,6 +240,10 @@ public void setDestination(final File destination) { this.destination.set(destination); } + /** + * Executes the main logic of the plugin by loading the dependency graph (SbomGraphProvider.get()) + * and providing the result to SbomBuilder + */ @TaskAction public void createBom() { @@ -239,6 +251,8 @@ public void createBom() { 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(), diff --git a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java index 421c7b40..585cf26f 100644 --- a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java +++ b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java @@ -20,6 +20,7 @@ import java.io.File; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -43,6 +44,10 @@ import org.gradle.api.artifacts.result.ResolvedDependencyResult; import org.gradle.api.logging.Logger; +/** + * Traverses the dependency graph of a configuration which returns a data model that 1) contains all the information + * required to generate the CycloneDX Bom and 2) is fully serializable to support the build cache + */ class DependencyGraphTraverser { private final Logger logger; @@ -63,6 +68,16 @@ public DependencyGraphTraverser( this.mavenHelper = new MavenHelper(logger, task.getIncludeLicenseText().get()); } + /** + * Traverses the dependency graph of a configuration belonging to the specified project + * + * @param rootNode entry point into the graph which is typically represents a project + * @param projectName project to which the configuration belongs to + * @param configName name of the configuration + * + * @return a graph represented as map which is fully serializable. The graph nodes are instances of + * SbomComponent which contain the necessary information to generate the Bom + */ Map traverseGraph( final ResolvedComponentResult rootNode, final String projectName, final String configName) { @@ -73,14 +88,20 @@ Map traverseGraph( rootGraphNode.inScopeConfiguration(projectName, configName); queue.add(rootGraphNode); + logger.debug("CycloneDX: Traversal of graph for configuration {} of project {}", configName, projectName); while (!queue.isEmpty()) { final GraphNode graphNode = queue.poll(); if (!graph.containsKey(graphNode)) { graph.put(graphNode, new HashSet<>()); - for (DependencyResult dep : graphNode.getResult().getDependencies()) { + logger.debug("CycloneDX: Traversing node with ID {}", graphNode.id); + for (final DependencyResult dep : graphNode.getResult().getDependencies()) { if (dep instanceof ResolvedDependencyResult) { final ResolvedComponentResult dependencyComponent = ((ResolvedDependencyResult) dep).getSelected(); + logger.debug( + "CycloneDX: Node with ID {} has dependency with ID {}", + graphNode.id, + dependencyComponent); final GraphNode dependencyNode = new GraphNode(dependencyComponent); dependencyNode.inScopeConfiguration(projectName, configName); graph.get(graphNode).add(dependencyNode); @@ -107,6 +128,7 @@ private SbomComponent toSbomComponent(final GraphNode node, final Set List licenses = null; SbomMetaData metaData = null; if (includeMetaData && node.id instanceof ModuleComponentIdentifier) { + logger.debug("CycloneDX: Including meta data for node {}", node.id); final Component component = new Component(); extractMetaDataFromArtifactPom(artifactFile, component, node.getResult()); licenses = extractMetaDataFromRepository(component, node.getResult()); @@ -126,12 +148,13 @@ private SbomComponent toSbomComponent(final GraphNode node, final Set private void extractMetaDataFromArtifactPom( final File artifactFile, final Component component, final ResolvedComponentResult result) { - if (artifactFile == null) { + if (artifactFile == null || result.getModuleVersion() == null) { return; } 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()); } } @@ -144,7 +167,7 @@ private List extractMetaDataFromRepository( return mavenProject.getLicenses(); } - return null; + return new ArrayList<>(); } private Set getSbomDependencies(final Set dependencyNodes) { diff --git a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java index 16f15499..c3252210 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java +++ b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java @@ -22,6 +22,7 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import javax.annotation.Nullable; import org.apache.maven.model.Model; import org.apache.maven.project.MavenProject; import org.gradle.api.Project; @@ -34,6 +35,9 @@ import org.gradle.maven.MavenModule; import org.gradle.maven.MavenPomArtifact; +/** + * Finds the pom.xml of a maven project in the gradle repositories and, if exists, instantiates a MavenProject object + */ class MavenProjectLookup { private final Project project; @@ -44,7 +48,15 @@ class MavenProjectLookup { this.cache = new HashMap<>(); } - MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { + /** + * Retrieve the MavenProject instance for the provided component + * + * @param result the resolved component for which to find the maven project, + * or null if the pom.xml is not found + * + * @return a MavenProject instance for this component + */ + @Nullable MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { if (result == null) { return null; @@ -58,6 +70,7 @@ MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { final File pomFile = buildMavenProject(result.getId()); final MavenProject mavenProject = MavenHelper.readPom(pomFile); if (mavenProject != null) { + project.getLogger().debug("CycloneDX: parse queried pom file for component {}", result.getId()); final Model model = MavenHelper.resolveEffectivePom(pomFile, project); if (model != null) { mavenProject.setLicenses(model.getLicenses()); @@ -72,7 +85,7 @@ MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { return null; } - private File buildMavenProject(final ComponentIdentifier id) { + @Nullable File buildMavenProject(final ComponentIdentifier id) { final ArtifactResolutionResult result = project.getDependencies() .createArtifactResolutionQuery() @@ -94,6 +107,7 @@ private File buildMavenProject(final ComponentIdentifier id) { final ArtifactResult artifact = artifactIt.next(); if (artifact instanceof ResolvedArtifactResult) { + project.getLogger().debug("CycloneDX: found pom file for component {}", id); final ResolvedArtifactResult resolvedArtifact = (ResolvedArtifactResult) artifact; return resolvedArtifact.getFile(); } diff --git a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java index f59d2b7c..e94e0645 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; @@ -51,9 +51,15 @@ import org.cyclonedx.util.BomUtils; import org.gradle.api.logging.Logger; +/** + * Generates the CycloneDX Bom from the aggregated dependency graph taking into account the provided + * user configuration (componentName, includeBomSerialNumber,...) + */ class SbomBuilder { private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; + private static final String MESSAGE_CREATING_BOM = "CycloneDX: Creating BOM"; + private static final TreeMap EMPTY_TYPE = new TreeMap<>(); private final Logger logger; @@ -70,8 +76,18 @@ class SbomBuilder { this.task = task; } + /** + * Builds the CycloneDX Bom from the aggregated dependency graph + * + * @param resultGraph the aggregated dependency graph across all the configurations + * @param rootComponent the root component of the graph which is the parent project + * + * @return the CycloneDX Bom + */ Bom buildBom(final Map resultGraph, final SbomComponent rootComponent) { + task.getLogger().info(MESSAGE_CREATING_BOM); + final Set dependencies = new TreeSet<>(new DependencyComparator()); final Set components = new TreeSet<>(new ComponentComparator()); @@ -183,15 +199,17 @@ private Component toComponent(final SbomComponent component, final File artifact } private List buildProperties(final SbomComponent component) { - return component.getInScopeConfigurations().stream() - .map(v -> { - Property property = new Property(); - property.setName("inScopeConfiguration"); - 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(",")); + + final Property property = new Property(); + property.setName("inScopeConfiguration"); + property.setValue(value); + + return Collections.singletonList(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 76fb5db5..151cd582 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -33,9 +33,16 @@ import org.gradle.api.Project; import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +/** + * Provider that lazily calculates the aggregated dependency graph. The usage of a provider is essential to support + * configuration cache and also to ensure that all dependencies have been resolved when the CycloneDxTask is executed. + */ public class SbomGraphProvider implements Callable { + private static final String MESSAGE_RESOLVING_DEPS = "CycloneDX: Resolving Dependencies"; + private final Project project; private final CycloneDxTask task; @@ -44,6 +51,15 @@ public SbomGraphProvider(final Project project, final CycloneDxTask task) { this.task = task; } + /** + * Calculates the aggregated dependency graph across all the configurations of both the parent project and + * child projects. The steps are as follows: + * 1) generate dependency graphs for the parent 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) + * + * @return the aggregated dependency graph + */ @Override public SbomGraph call() throws Exception { @@ -53,11 +69,13 @@ public SbomGraph call() throws Exception { throw new IllegalStateException("Project group and version are required for the CycloneDx task"); } + project.getLogger().info(MESSAGE_RESOLVING_DEPS); + final DependencyGraphTraverser traverser = new DependencyGraphTraverser( project.getLogger(), getArtifacts(), new MavenProjectLookup(project), task); final Map graph = Stream.concat( - traverseParentProject(traverser), traverseChildProjects(traverser)) + traverseCurrentProject(traverser), traverseChildProjects(traverser)) .reduce(new HashMap<>(), DependencyUtils::mergeGraphs); return buildSbomGraph(graph); @@ -71,6 +89,7 @@ private SbomGraph buildSbomGraph(final Map graph project, rootProject.get().getId(), graph); return new SbomGraph(graph, rootProject.get()); } else { + project.getLogger().debug("CycloneDX: root project not found. Constructing it."); final SbomComponentId rootProjectId = new SbomComponentId( project.getGroup().toString(), project.getName(), @@ -86,7 +105,7 @@ private SbomGraph buildSbomGraph(final Map graph } } - private Stream> traverseParentProject( + private Stream> traverseCurrentProject( final DependencyGraphTraverser traverser) { if (shouldSkipProject(project)) { @@ -103,6 +122,7 @@ private Stream> traverseParentProject( 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) @@ -123,7 +143,7 @@ private Map getArtifacts() { .flatMap(config -> config.getIncoming().getArtifacts().getArtifacts().stream()) .collect(Collectors.toMap( artifact -> artifact.getId().getComponentIdentifier(), - artifact -> artifact.getFile(), + ResolvedArtifactResult::getFile, (v1, v2) -> v1)); } diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java index 0eea5539..5e992bb1 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import javax.annotation.Nullable; import org.apache.maven.model.License; public final class SbomComponent implements Serializable { @@ -30,17 +31,20 @@ public final class SbomComponent implements Serializable { private final SbomComponentId id; private final Set inScopeConfigurations; private final Set dependencyComponents; - private final File artifactFile; - private final SbomMetaData metaData; - private final List licenses; + + @Nullable private final File artifactFile; + + @Nullable private final SbomMetaData metaData; + + @Nullable private final List licenses; public SbomComponent( final SbomComponentId id, final Set inScopeConfigurations, final Set dependencyComponents, - final File artifactFile, - final SbomMetaData metaData, - final List licenses) { + @Nullable final File artifactFile, + @Nullable final SbomMetaData metaData, + @Nullable final List licenses) { this.id = id; this.inScopeConfigurations = inScopeConfigurations; this.dependencyComponents = dependencyComponents; @@ -99,17 +103,17 @@ public Builder withDependencyComponents(final Set dependencyCom return this; } - public Builder withArtifactFile(final File artifactFile) { + public Builder withArtifactFile(@Nullable final File artifactFile) { this.artifactFile = artifactFile; return this; } - public Builder withMetaData(final SbomMetaData metaData) { + public Builder withMetaData(@Nullable final SbomMetaData metaData) { this.metaData = metaData; return this; } - public Builder withLicenses(final List licenses) { + public Builder withLicenses(@Nullable final List licenses) { this.licenses = licenses; return this; } diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java b/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java index a490a3d7..838f5b52 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java @@ -21,6 +21,10 @@ import java.io.Serializable; import java.util.Map; +/** + * Represents the aggregated dependency graph across all the configurations of the projects in scope. It is fully + * serializable to support the build cache. + */ public class SbomGraph implements Serializable { private final Map graph; diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java b/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java index bce3c8fa..918f3625 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java @@ -21,29 +21,32 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; import org.cyclonedx.model.Component; public class SbomMetaData implements Serializable { - private String publisher; - private String description; - private List externalReferences = new ArrayList<>(); + @Nullable private String publisher; + + @Nullable private String description; + + private final List externalReferences = new ArrayList<>(); private SbomMetaData() {} - public String getPublisher() { + @Nullable public String getPublisher() { return publisher; } - public void setPublisher(final String publisher) { + public void setPublisher(@Nullable final String publisher) { this.publisher = publisher; } - public String getDescription() { + @Nullable public String getDescription() { return description; } - public void setDescription(final String description) { + public void setDescription(@Nullable final String description) { this.description = description; } diff --git a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java index d69fc005..6a90fe9d 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -67,8 +67,11 @@ public static void connectRootWithSubProjects( final Set dependencyComponentIds = project.getSubprojects().stream() .map(subProject -> new SbomComponentId( - (String) subProject.getGroup(), subProject.getName(), (String) subProject.getVersion(), "")) - .filter(componentId -> graph.containsKey(componentId)) + subProject.getGroup().toString(), + subProject.getName(), + subProject.getVersion().toString(), + "")) + .filter(graph::containsKey) .collect(Collectors.toSet()); graph.get(rootProjectId).getDependencyComponents().addAll(dependencyComponentIds); @@ -77,8 +80,11 @@ public static void connectRootWithSubProjects( public static Optional findRootComponent( final Project project, final Map graph) { - final SbomComponentId rootProjectId = - new SbomComponentId((String) project.getGroup(), project.getName(), (String) project.getVersion(), ""); + final SbomComponentId rootProjectId = new SbomComponentId( + project.getGroup().toString(), + project.getName(), + project.getVersion().toString(), + ""); if (!graph.containsKey(rootProjectId)) { return Optional.empty(); @@ -98,11 +104,15 @@ public static SbomComponentId toComponentId(final ResolvedComponentResult node, } } - return new SbomComponentId( - node.getModuleVersion().getGroup(), - node.getModuleVersion().getName(), - node.getModuleVersion().getVersion(), - type); + if (node.getModuleVersion() != null) { + return new SbomComponentId( + node.getModuleVersion().getGroup(), + node.getModuleVersion().getName(), + node.getModuleVersion().getVersion(), + type); + } else { + return new SbomComponentId("N/A", node.getId().getDisplayName(), "N/A", type); + } } private static String getType(final File file) { From be08c09ca246286cded8297f4e48e997845cc9ed Mon Sep 17 00:00:00 2001 From: Gordon Date: Thu, 7 Nov 2024 18:31:06 +0000 Subject: [PATCH 10/26] feat: log configuration parameters, use empty list for licenses, validate sbom, missing nullables and remove duplicated code 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; + } +} From 5a5596bbe3bb89f90ef095dfe94b8ef0a0bcc4af Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 11 Nov 2024 20:08:05 +0000 Subject: [PATCH 11/26] feat: add additional logging for purl failures and remove cdx:maven:package:projectsAndScopes properties (adding back in later PR) Signed-off-by: Gordon --- .../org/cyclonedx/gradle/SbomBuilder.java | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java index 443d96b4..57472f8d 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java @@ -24,7 +24,6 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -32,7 +31,6 @@ import java.util.TreeMap; 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; @@ -114,7 +112,10 @@ private Metadata buildMetadata(final SbomComponent parentComponent) { component.setVersion(task.getComponentVersion().get()); metadata.setComponent(component); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for parent component. Skipping...", e); + logger.warn( + "Error constructing packageUrl for parent component {}. Skipping...", + parentComponent.getId().getName(), + e); } metadata.setLicenseChoice(task.getLicenseChoice()); metadata.setManufacture(task.getOrganizationalEntity()); @@ -128,7 +129,10 @@ private void addDependency(final Set dependencies, final SbomCompone try { dependency = toDependency(component.getId()); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for component. Skipping...", e); + logger.warn( + "Error constructing packageUrl for component {}. Skipping...", + component.getId().getName(), + e); return; } component.getDependencyComponents().forEach(dependencyComponent -> { @@ -206,28 +210,13 @@ 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()); - } - private Property buildIsTestProperty(final SbomComponent component) { boolean isTestComponent = component.getInScopeConfigurations().stream() From a2c603f26ba55801ae2dc5193b19fdf912c996f8 Mon Sep 17 00:00:00 2001 From: Gordon Date: Wed, 13 Nov 2024 15:10:56 +0000 Subject: [PATCH 12/26] feat: parse plugin.properties for tools metadata and make SbomGraphProvider package private Signed-off-by: Gordon --- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 6 ----- .../org/cyclonedx/gradle/CycloneDxTask.java | 9 ++++--- .../gradle/DependencyGraphTraverser.java | 2 +- .../org/cyclonedx/gradle/SbomBuilder.java | 27 +++++++++++++++++++ .../cyclonedx/gradle/SbomGraphProvider.java | 4 +-- .../gradle/DependencyResolutionSpec.groovy | 3 ++- 6 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index 115d0f58..1e91d863 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -18,10 +18,8 @@ */ package org.cyclonedx.gradle; -import org.cyclonedx.gradle.model.SbomGraph; import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.provider.Provider; /** * Entrypoint of the plugin which simply configures one task @@ -31,10 +29,6 @@ public class CycloneDxPlugin implements Plugin { public void apply(final Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { - final Provider components = - project.getProviders().provider(new SbomGraphProvider(project, task)); - - task.getComponents().set(components); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); }); diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index e744828b..c0f25c0c 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -32,6 +32,7 @@ import org.gradle.api.DefaultTask; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.OutputDirectory; @@ -59,6 +60,7 @@ public abstract class CycloneDxTask extends DefaultTask { private final Property projectType; private final ListProperty skipProjects; private final Property destination; + private final Provider componentsProvider; @Nullable private OrganizationalEntity organizationalEntity; @@ -66,6 +68,8 @@ public abstract class CycloneDxTask extends DefaultTask { public CycloneDxTask() { + componentsProvider = getProject().getProviders().provider(new SbomGraphProvider(getProject(), this)); + outputName = getProject().getObjects().property(String.class); outputName.convention("bom"); @@ -228,9 +232,6 @@ public void setSkipProjects(final Collection skipProjects) { return licenseChoice; } - @Input - public abstract Property getComponents(); - @OutputDirectory public Property getDestination() { return destination; @@ -250,7 +251,7 @@ public void createBom() { logParameters(); final SbomBuilder builder = new SbomBuilder(getLogger(), this); - final SbomGraph components = getComponents().get(); + final SbomGraph components = componentsProvider.get(); final Bom bom = builder.buildBom(components.getGraph(), components.getRootComponent()); getLogger().info(MESSAGE_WRITING_BOM_OUTPUT); diff --git a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java index 4ea6dc17..b0225c34 100644 --- a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java +++ b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java @@ -57,7 +57,7 @@ class DependencyGraphTraverser { private final boolean includeMetaData; private final MavenHelper mavenHelper; - public DependencyGraphTraverser( + DependencyGraphTraverser( final Logger logger, final Map resolvedArtifacts, final MavenProjectLookup mavenLookup, diff --git a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java index 57472f8d..fa3f5681 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java @@ -22,11 +22,13 @@ import com.networknt.schema.utils.StringUtils; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Properties; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; @@ -47,6 +49,7 @@ import org.cyclonedx.model.LicenseChoice; import org.cyclonedx.model.Metadata; import org.cyclonedx.model.Property; +import org.cyclonedx.model.Tool; import org.cyclonedx.util.BomUtils; import org.gradle.api.logging.Logger; @@ -120,6 +123,15 @@ private Metadata buildMetadata(final SbomComponent parentComponent) { metadata.setLicenseChoice(task.getLicenseChoice()); metadata.setManufacture(task.getOrganizationalEntity()); + final Properties pluginProperties = readPluginProperties(); + if (!pluginProperties.isEmpty()) { + final Tool tool = new Tool(); + tool.setVendor(pluginProperties.getProperty("vendor")); + tool.setName(pluginProperties.getProperty("name")); + tool.setVersion(pluginProperties.getProperty("version")); + metadata.addTool(tool); + } + return metadata; } @@ -262,4 +274,19 @@ private TreeMap getQualifiers(final String type) { qualifiers.put("type", type); return qualifiers; } + + private Properties readPluginProperties() { + + final Properties props = new Properties(); + try (final InputStream inputStream = this.getClass().getResourceAsStream("plugin.properties")) { + if (inputStream == null) { + logger.info("plugin.properties is not found on the classpath"); + } else { + props.load(inputStream); + } + } catch (Exception e) { + logger.warn("Error whilst loading plugin.properties", e); + } + return props; + } } diff --git a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java index be309349..e53d5e34 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -40,7 +40,7 @@ * Provider that lazily calculates the aggregated dependency graph. The usage of a provider is essential to support * configuration cache and also to ensure that all dependencies have been resolved when the CycloneDxTask is executed. */ -public class SbomGraphProvider implements Callable { +class SbomGraphProvider implements Callable { private static final String MESSAGE_RESOLVING_DEPS = "CycloneDX: Resolving Dependencies"; @@ -48,7 +48,7 @@ public class SbomGraphProvider implements Callable { private final CycloneDxTask task; private final MavenProjectLookup mavenLookup; - public SbomGraphProvider(final Project project, final CycloneDxTask task) { + SbomGraphProvider(final Project project, final CycloneDxTask task) { this.project = project; this.task = task; this.mavenLookup = new MavenProjectLookup(project); diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index c172be03..ca3f624a 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -187,11 +187,12 @@ class DependencyResolutionSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--configuration-cache") + .withArguments("cyclonedxBom", "--configuration-cache", "--info", "--stacktrace") .withPluginClasspath() .build() then: + println(result.output) result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS File reportDir = new File(testDir, "build/reports") From 9ea6d0e82436e8982f56fa2bd76db9f8129a5871 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 21 Oct 2024 22:42:25 +0100 Subject: [PATCH 13/26] feat: initial new task implementation Signed-off-by: Gordon --- .../org/cyclonedx/gradle/CycloneDxParser.java | 183 ++++ .../org/cyclonedx/gradle/CycloneDxPlugin.java | 61 ++ .../org/cyclonedx/gradle/CycloneDxTask.java | 841 +----------------- .../cyclonedx/gradle/model/ArtifactInfo.java | 40 + .../gradle/model/ComponentComparator.java | 30 + .../gradle/model/DependencyComparator.java | 29 + .../cyclonedx/gradle/model/ResolvedBuild.java | 63 ++ .../gradle/model/ResolvedConfiguration.java | 42 + .../gradle/utils/CycloneDxUtils.java | 28 + .../gradle/DependencyResolutionSpec.groovy | 33 + .../org/cyclonedx/gradle/TestUtils.groovy | 16 + .../componenta/1.0.0/componenta-1.0.0.jar | 1 + .../componenta/1.0.0/componenta-1.0.0.pom | 17 + .../componentb/1.0.0/componentb-1.0.0.jar | 1 + .../componentb/1.0.0/componentb-1.0.0.pom | 9 + .../componentb/1.0.1/componentb-1.0.1.jar | 1 + .../componentb/1.0.1/componentb-1.0.1.pom | 9 + 17 files changed, 587 insertions(+), 817 deletions(-) create mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxParser.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar create mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java new file mode 100644 index 00000000..b8b92c41 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java @@ -0,0 +1,183 @@ +/* + * 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; + +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURL; +import java.io.File; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import javax.annotation.Nullable; +import org.cyclonedx.Version; +import org.cyclonedx.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.ComponentComparator; +import org.cyclonedx.gradle.model.DependencyComparator; +import org.cyclonedx.gradle.utils.CycloneDxUtils; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.Dependency; +import org.cyclonedx.model.Hash; +import org.cyclonedx.util.BomUtils; +import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.gradle.api.artifacts.result.DependencyResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; +import org.gradle.api.logging.Logger; + +public class CycloneDxParser { + + private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; + + private final Set dependencies; + private final Set components; + private final Logger logger; + private final Map> artifactHashes; + private final Map resolvedArtifacts; + private final MavenHelper mavenHelper; + private final Version version; + + public CycloneDxParser(final Logger logger) { + this.logger = logger; + this.version = CycloneDxUtils.schemaVersion("1.6"); + this.dependencies = new TreeSet<>(new DependencyComparator()); + this.components = new TreeSet<>(new ComponentComparator()); + this.resolvedArtifacts = new HashMap<>(); + this.artifactHashes = new HashMap<>(); + this.mavenHelper = new MavenHelper(logger, version, false); + } + + public void registerArtifact(final ArtifactInfo artifact) { + resolvedArtifacts.put(artifact.getComponentId(), artifact.getArtifactFile()); + } + + public void visitGraph(final ResolvedComponentResult rootNode, final String projectName, final String configName) { + + final Set seen = new HashSet<>(); + final Queue queue = new ArrayDeque<>(); + queue.add(rootNode); + + while (!queue.isEmpty()) { + final ResolvedComponentResult node = queue.poll(); + if (!seen.contains(node)) { + seen.add(node); + final Dependency dependency = toDependency(node, projectName, configName); + for (DependencyResult dep : node.getDependencies()) { + if (dep instanceof ResolvedDependencyResult) { + final ResolvedComponentResult dependencyComponent = + ((ResolvedDependencyResult) dep).getSelected(); + dependency.addDependency(toDependency(dependencyComponent, projectName, configName)); + queue.add(dependencyComponent); + } + } + dependencies.add(dependency); + final File artifactFile = resolvedArtifacts.get(node.getId().getDisplayName()); + components.add(toComponent(node, artifactFile, projectName, configName)); + } + } + } + + public Bom getResultingBom() { + final Bom bom = new Bom(); + bom.setComponents(new ArrayList<>(components)); + bom.setDependencies(new ArrayList<>(dependencies)); + return bom; + } + + public Component toComponent( + final ResolvedComponentResult resolvedComponent, + final File artifactFile, + final String projectName, + final String configName) { + + final Component component = new Component(); + component.setGroup(resolvedComponent.getModuleVersion().getGroup()); + component.setName(resolvedComponent.getModuleVersion().getName()); + component.setVersion(resolvedComponent.getModuleVersion().getVersion()); + component.setType(Component.Type.LIBRARY); + logger.debug(MESSAGE_CALCULATING_HASHES); + if (artifactFile != null) { + component.setHashes(calculateHashes(artifactFile)); + } + + final TreeMap qualifiers = new TreeMap<>(); + final String packageUrl = generatePackageUrl(resolvedComponent.getModuleVersion(), qualifiers); + component.setPurl(packageUrl); + + if (version.getVersion() >= 1.1) { + component.setModified(mavenHelper.isModified(null)); + component.setBomRef(generateRef(resolvedComponent.getModuleVersion(), qualifiers, projectName, configName)); + } + return component; + } + + private List calculateHashes(final File artifactFile) { + return artifactHashes.computeIfAbsent(artifactFile, f -> { + try { + return BomUtils.calculateHashes(f, version); + } catch (IOException e) { + logger.error("Error encountered calculating hashes", e); + } + return Collections.emptyList(); + }); + } + + private Dependency toDependency( + final ResolvedComponentResult component, final String projectName, final String configName) { + + TreeMap qualifiers = new TreeMap<>(); + String ref = generateRef(component.getModuleVersion(), qualifiers, projectName, configName); + return new Dependency(ref); + } + + private String generateRef( + final ModuleVersionIdentifier version, + final TreeMap qualifiers, + final String projectName, + final String configName) { + String purl = generatePackageUrl(version, qualifiers); + return String.format("%s:%s:%s", projectName, configName, purl); + } + + @Nullable private String generatePackageUrl(final ModuleVersionIdentifier version, final TreeMap qualifiers) { + try { + return new PackageURL( + PackageURL.StandardTypes.MAVEN, + version.getGroup(), + version.getName(), + version.getVersion(), + qualifiers, + null) + .canonicalize(); + } catch (MalformedPackageURLException e) { + logger.warn("An unexpected issue occurred attempting to create a PackageURL for " + version.getGroup() + ":" + + version.getName() + ":" + version); + } + return null; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index a74579e4..d30e29b6 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -18,15 +18,76 @@ */ package org.cyclonedx.gradle; +import java.io.File; +import java.util.*; +import java.util.stream.Collectors; +import org.cyclonedx.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.ResolvedBuild; +import org.cyclonedx.gradle.model.ResolvedConfiguration; import org.gradle.api.Plugin; import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.provider.Provider; public class CycloneDxPlugin implements Plugin { public void apply(Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { + final ResolvedBuild resolvedBuild = getResolvedBuild(project); + final Optional>> artifacts = getArtifacts(project); + final File destination = + project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); + + task.getResolvedBuild().set(resolvedBuild); + task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); + artifacts.ifPresent(provider -> task.getArtifacts().set(provider)); }); } + + private ResolvedBuild getResolvedBuild(final Project project) { + + final ResolvedBuild resolvedBuild = new ResolvedBuild(project.getName()); + project.getConfigurations().stream() + .filter(Configuration::isCanBeResolved) + .forEach(v -> resolvedBuild.addProjectConfiguration(resolvedConfiguration(v))); + + project.getChildProjects().forEach((k, v) -> v.getConfigurations().stream() + .filter(Configuration::isCanBeResolved) + .forEach(w -> resolvedBuild.addSubProjectConfiguration(k, resolvedConfiguration(w)))); + + return resolvedBuild; + } + + private Optional>> getArtifacts(final Project project) { + + return project.getAllprojects().stream() + .flatMap(v -> v.getConfigurations().stream()) + .filter(Configuration::isCanBeResolved) + .map(v -> v.getIncoming().getArtifacts().getResolvedArtifacts()) + .reduce(this::combineArtifactsProviders) + .map(provider -> + provider.map(v -> v.stream().map(this::mapResult).collect(Collectors.toSet()))); + } + + private Provider> combineArtifactsProviders( + Provider> left, Provider> right) { + return left.flatMap(v -> right.map(w -> { + Set result = new HashSet<>(); + result.addAll(v); + result.addAll(w); + return result; + })); + } + + private ArtifactInfo mapResult(ResolvedArtifactResult result) { + return new ArtifactInfo(result.getId().getComponentIdentifier().getDisplayName(), result.getFile()); + } + + private ResolvedConfiguration resolvedConfiguration(Configuration config) { + return new ResolvedConfiguration( + config.getName(), config.getIncoming().getResolutionResult().getRootComponent()); + } } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index d6638a61..f7eeb57c 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -18,852 +18,59 @@ */ package org.cyclonedx.gradle; -import com.github.packageurl.MalformedPackageURLException; -import com.github.packageurl.PackageURL; import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; -import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Properties; import java.util.Set; -import java.util.TreeMap; -import java.util.UUID; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.annotation.Nullable; -import javax.xml.parsers.ParserConfigurationException; -import org.apache.commons.io.FileUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.maven.model.Model; -import org.apache.maven.project.MavenProject; -import org.cyclonedx.Version; -import org.cyclonedx.exception.GeneratorException; -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.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.ResolvedBuild; +import org.cyclonedx.gradle.model.ResolvedConfiguration; import org.cyclonedx.gradle.utils.CycloneDxUtils; -import org.cyclonedx.gradle.utils.DependencyUtils; -import org.cyclonedx.model.Bom; -import org.cyclonedx.model.Component; -import org.cyclonedx.model.Hash; -import org.cyclonedx.model.LicenseChoice; -import org.cyclonedx.model.Metadata; -import org.cyclonedx.model.OrganizationalEntity; -import org.cyclonedx.model.Tool; -import org.cyclonedx.parsers.JsonParser; -import org.cyclonedx.parsers.Parser; -import org.cyclonedx.parsers.XmlParser; -import org.cyclonedx.util.BomUtils; import org.gradle.api.DefaultTask; -import org.gradle.api.GradleException; -import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; -import org.gradle.api.artifacts.Dependency; -import org.gradle.api.artifacts.ResolvedArtifact; -import org.gradle.api.artifacts.ResolvedConfiguration; -import org.gradle.api.artifacts.ResolvedDependency; -import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; +import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; -import org.gradle.api.tasks.OutputDirectory; import org.gradle.api.tasks.TaskAction; -public class CycloneDxTask extends DefaultTask { +public abstract class CycloneDxTask extends DefaultTask { - /** - * Various messages sent to console. - */ - private static final String MESSAGE_RESOLVING_DEPS = "CycloneDX: Resolving Dependencies"; - - private static final String MESSAGE_CREATING_BOM = "CycloneDX: Creating BOM"; - private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; - private static final String MESSAGE_WRITING_BOM_XML = "CycloneDX: Writing BOM XML"; - private static final String MESSAGE_WRITING_BOM_JSON = "CycloneDX: Writing BOM JSON"; - private static final String MESSAGE_VALIDATING_BOM = "CycloneDX: Validating BOM"; - private static final String MESSAGE_VALIDATION_FAILURE = "The BOM does not conform to the CycloneDX BOM standard"; - - private static final String DEFAULT_PROJECT_TYPE = "library"; - private final Map components; - - private final MavenHelper mavenHelper; - - private final Property schemaVersion; - private final Property componentName; - private final Property componentVersion; - private final Property outputName; - private final Property outputFormat; - private final Property projectType; - private final Property includeLicenseText; - private final Property includeBomSerialNumber; - private final ListProperty includeConfigs; - private final ListProperty skipConfigs; - private final ListProperty skipProjects; - private final Property destination; - private final Property includeMetadataResolution; - private OrganizationalEntity organizationalEntity; - private LicenseChoice licenseChoice; - private final Map> artifactHashes; - private final Map resolvedMavenProjects; + private final CycloneDxParser parser; public CycloneDxTask() { - schemaVersion = getProject().getObjects().property(String.class); - schemaVersion.convention(CycloneDxUtils.DEFAULT_SCHEMA_VERSION.getVersionString()); - if (getSchemaVersion().get().equals(Version.VERSION_10.getVersionString())) { - setIncludeBomSerialNumber(false); - } - - outputName = getProject().getObjects().property(String.class); - outputName.convention("bom"); - - outputFormat = getProject().getObjects().property(String.class); - outputFormat.convention("all"); - - projectType = getProject().getObjects().property(String.class); - projectType.convention(DEFAULT_PROJECT_TYPE); - - includeLicenseText = getProject().getObjects().property(Boolean.class); - includeLicenseText.convention(true); - - includeBomSerialNumber = getProject().getObjects().property(Boolean.class); - includeBomSerialNumber.convention(true); - - componentName = getProject().getObjects().property(String.class); - componentName.convention(getProject().getName()); - - componentVersion = getProject().getObjects().property(String.class); - componentVersion.convention(getProject().getVersion().toString()); - - includeConfigs = getProject().getObjects().listProperty(String.class); - skipConfigs = getProject().getObjects().listProperty(String.class); - skipProjects = getProject().getObjects().listProperty(String.class); - - includeMetadataResolution = getProject().getObjects().property(Boolean.class); - includeMetadataResolution.convention(true); - - destination = getProject().getObjects().property(File.class); - destination.convention(getProject() - .getLayout() - .getBuildDirectory() - .dir("reports") - .get() - .getAsFile()); - - organizationalEntity = new OrganizationalEntity(); - - licenseChoice = new LicenseChoice(); - artifactHashes = Collections.synchronizedMap(new HashMap<>()); - resolvedMavenProjects = Collections.synchronizedMap(new HashMap<>()); - components = new TreeMap<>(); - mavenHelper = new MavenHelper( - getLogger(), getVersion(), getIncludeLicenseText().get()); - } - - @Input - public Property getOutputName() { - return outputName; - } - - public void setOutputName(String output) { - this.outputName.set(output); - } - - @Input - public Property getOutputFormat() { - return outputFormat; - } - - public void setOutputFormat(String format) { - this.outputFormat.set(format); - } - - @Input - public ListProperty getIncludeConfigs() { - return includeConfigs; - } - - public void setIncludeConfigs(Collection includeConfigs) { - this.includeConfigs.addAll(includeConfigs); - } - - @Input - public Property getComponentName() { - return componentName; - } - - public void setComponentName(String componentName) { - this.componentName.set(componentName); - } - - @Input - public Property getComponentVersion() { - return componentVersion; - } - - public void setComponentVersion(String componentVersion) { - this.componentVersion.set(componentVersion); - } - - @Input - public ListProperty getSkipConfigs() { - return skipConfigs; - } - - public void setSkipConfigs(Collection skipConfigs) { - this.skipConfigs.addAll(skipConfigs); - } - - @Input - public ListProperty getSkipProjects() { - return skipProjects; - } - - public void setSkipProjects(Collection skipProjects) { - this.skipProjects.addAll(skipProjects); - } - - @Input - public Property getSchemaVersion() { - return schemaVersion; - } - - public void setSchemaVersion(String schemaVersion) { - this.schemaVersion.set(schemaVersion); - } - - @Input - public Property getProjectType() { - return projectType; - } - - public void setProjectType(String projectType) { - this.projectType.set(projectType); + this.parser = new CycloneDxParser(getLogger()); } @Input - public Property getIncludeLicenseText() { - return includeLicenseText; - } - - public void setIncludeLicenseText(boolean includeLicenseText) { - this.includeLicenseText.set(includeLicenseText); - } + public abstract Property getResolvedBuild(); @Input - public Property getIncludeBomSerialNumber() { - return includeBomSerialNumber; - } - - public void setIncludeBomSerialNumber(boolean includeBomSerialNumber) { - this.includeBomSerialNumber.set(includeBomSerialNumber); - } + public abstract Property getDestination(); @Input - public Property getIncludeMetadataResolution() { - return includeMetadataResolution; - } - - public void setIncludeMetadataResolution(boolean includeMetadataResolution) { - this.includeMetadataResolution.set(includeMetadataResolution); - } - - @OutputDirectory - public Property getDestination() { - return destination; - } - - public void setDestination(File destination) { - this.destination.set(destination); - } - - public void setOrganizationalEntity(Consumer customizer) { - OrganizationalEntity origin = new OrganizationalEntity(); - customizer.accept(origin); - this.organizationalEntity = origin; - - Map organizationalEntity = new HashMap<>(); - - organizationalEntity.put("name", this.organizationalEntity.getName()); - if (this.organizationalEntity.getUrls() != null) { - for (int i = 0; i < this.organizationalEntity.getUrls().size(); i++) { - organizationalEntity.put( - "url" + i, this.organizationalEntity.getUrls().get(i)); - } - } - if (this.organizationalEntity.getContacts() != null) { - for (int i = 0; i < this.organizationalEntity.getContacts().size(); i++) { - organizationalEntity.put( - "contact_name" + i, - this.organizationalEntity.getContacts().get(i).getName()); - organizationalEntity.put( - "contact_email" + i, - this.organizationalEntity.getContacts().get(i).getEmail()); - organizationalEntity.put( - "contact_phone" + i, - this.organizationalEntity.getContacts().get(i).getPhone()); - } - } - // Definition of gradle Input via Hashmap because Hashmap is serializable - // (OrganizationalEntity isn't serializable) - getInputs().property("OrganizationalEntity", organizationalEntity); - } - - public void setLicenseChoice(Consumer customizer) { - LicenseChoice origin = new LicenseChoice(); - customizer.accept(origin); - this.licenseChoice = origin; - - Map licenseChoice = new HashMap<>(); - - if (this.licenseChoice.getLicenses() != null) { - for (int i = 0; i < this.licenseChoice.getLicenses().size(); i++) { - if (this.licenseChoice.getLicenses().get(i).getName() != null) { - licenseChoice.put( - "licenseChoice" + i + "name", - this.licenseChoice.getLicenses().get(i).getName()); - } - if (this.licenseChoice.getLicenses().get(i).getId() != null) { - licenseChoice.put( - "licenseChoice" + i + "id", - this.licenseChoice.getLicenses().get(i).getId()); - } - licenseChoice.put( - "licenseChoice" + i + "text", - this.licenseChoice - .getLicenses() - .get(i) - .getAttachmentText() - .getText()); - licenseChoice.put( - "licenseChoice" + i + "url", - this.licenseChoice.getLicenses().get(i).getUrl()); - } - } - - if (this.licenseChoice.getExpression() != null) { - licenseChoice.put( - "licenseChoice_Expression", - this.licenseChoice.getExpression().getValue()); - } - // Definition of gradle Input via Hashmap because Hashmap is serializable - // (LicenseChoice isn't serializable) - getInputs().property("LicenseChoice", licenseChoice); - } + public abstract SetProperty getArtifacts(); @TaskAction public void createBom() { - if (!outputFormat.get().trim().equalsIgnoreCase("all") - && !outputFormat.get().trim().equalsIgnoreCase("xml") - && !outputFormat.get().trim().equalsIgnoreCase("json")) { - throw new GradleException("Unsupported output format. Must be one of all, xml, or json"); - } - if (getProject().getGroup().equals("") - || getProject().getName().isEmpty() - || getProject().getVersion().equals("")) { - throw new GradleException("Project group, name, and version must be set for the root project"); - } - logParameters(); - getLogger().info(MESSAGE_RESOLVING_DEPS); - final Set builtDependencies = allBuiltProjects(); - - final Map dependencies = new TreeMap<>(); - - final Metadata metadata = createMetadata(); - Project pluginProject = getProject(); - org.cyclonedx.model.Dependency rootDependency = - new org.cyclonedx.model.Dependency(generatePackageUrl(pluginProject)); - dependencies.put(generatePackageUrl(pluginProject), rootDependency); - Stream.concat(Stream.of(pluginProject), pluginProject.getSubprojects().stream()) - .forEach(project -> processProject(project, rootDependency, dependencies, builtDependencies)); - writeBom(metadata, new HashSet<>(components.values()), dependencies.values()); - } - - private void processProject( - Project project, - org.cyclonedx.model.Dependency rootDependency, - Map dependencies, - Set builtDependencies) { - getLogger().debug("Processing project [{}]", project.getName()); - if (shouldSkipProject(project)) { - getLogger().debug("Project [{}] skipped due to plugin configuration", project.getName()); - return; - } - String projectReference = generatePackageUrl(project); - org.cyclonedx.model.Dependency projectDependency = new org.cyclonedx.model.Dependency(projectReference); - for (Configuration configuration : project.getConfigurations()) { - processConfiguration(configuration, projectReference, projectDependency, dependencies, builtDependencies); - } - - if (!getProject().equals(project)) { - rootDependency.addDependency(projectDependency); - // declare sub-project as component - Component component = generateProjectComponent(project); - String bomRef = component.getBomRef(); - components.putIfAbsent(bomRef, component); - } - } - - private void processConfiguration( - Configuration configuration, - String projectReference, - org.cyclonedx.model.Dependency moduleDependency, - Map dependencies, - Set builtDependencies) { - getLogger().debug("Processing configuration [{}]", configuration.getName()); - if (!shouldIncludeConfiguration(configuration) || shouldSkipConfiguration(configuration)) { - getLogger().debug("Configuration [{}] skipped due to plugin configuration", configuration.getName()); - return; - } - if (!configuration.isCanBeResolved()) { - getLogger().debug("Configuration [{}] because it cannot be resolved", configuration.getName()); - return; - } - final ResolvedConfiguration resolvedConfiguration = configuration.getResolvedConfiguration(); - final Set directModuleDependencies = - configuration.getResolvedConfiguration().getFirstLevelModuleDependencies(); - - while (directModuleDependencies.stream().anyMatch(this::dependencyWithoutJarArtifact)) { - Set depWithNoArtifacts = directModuleDependencies.stream() - .filter(this::dependencyWithoutJarArtifact) - .collect(Collectors.toSet()); - - directModuleDependencies.removeAll(depWithNoArtifacts); - depWithNoArtifacts.forEach(dmd -> directModuleDependencies.addAll(dmd.getChildren())); - } - - for (ResolvedDependency directModuleDependency : directModuleDependencies) { - @Nullable ResolvedArtifact directJarArtifact = getJarOrZipArtifact(directModuleDependency); - if (directJarArtifact != null) { - moduleDependency.addDependency( - new org.cyclonedx.model.Dependency(generatePackageUrl(directJarArtifact))); - buildDependencyGraph(dependencies, directModuleDependency, directJarArtifact); - } - } - dependencies.compute(projectReference, (k, v) -> { - if (v == null) { - return moduleDependency; - } else if (moduleDependency.getDependencies() != null) { - moduleDependency.getDependencies().forEach(v::addDependency); - } - return v; - }); - - resolvedConfiguration.getResolvedArtifacts().forEach(artifact -> { - String dependencyName = DependencyUtils.getDependencyName(artifact); - Component component = convertArtifact(artifact); - - // Resources not built as part of this Gradle project will be augmented with - // metadata from their poms - if (!builtDependencies.contains(dependencyName)) { - if (getIncludeMetadataResolution().get()) { - augmentComponentMetadata(artifact, component, dependencyName); - } - } - components.putIfAbsent(component.getBomRef(), component); - }); - } - - private Set allBuiltProjects() { - return getProject().getRootProject().getAllprojects().stream() - .filter(it -> !Objects.equals(it.getVersion(), "unspecified")) - .map(it -> it.getGroup() + ":" + it.getName() + ":" + it.getVersion()) - .collect(Collectors.toSet()); - } - - private void addSubProjectsAsComponents( - org.cyclonedx.model.Dependency rootDependency, - Version version, - Set projectsToScan, - Map components) { - Project rootProject = getProject(); - for (Project project : projectsToScan) { - String projectReference = generatePackageUrl(project); - if (!rootProject.equals(project)) { - rootDependency.addDependency(new org.cyclonedx.model.Dependency(projectReference)); - // declare sub-project as component - Component component = generateProjectComponent(project); - String bomRef = component.getBomRef(); - components.putIfAbsent(bomRef, component); - } - } - } - - private boolean dependencyWithoutJarArtifact(ResolvedDependency dependency) { - return getJarOrZipArtifact(dependency) == null; - } - - private Map buildDependencyGraph( - Map dependenciesSoFar, - ResolvedDependency resolvedDependency, - ResolvedArtifact jarArtifact) { - String dependencyPurl = generatePackageUrl(jarArtifact); - org.cyclonedx.model.Dependency dependency = new org.cyclonedx.model.Dependency(dependencyPurl); - if (dependenciesSoFar.containsKey(dependencyPurl)) { - return dependenciesSoFar; - } - dependenciesSoFar.put(dependencyPurl, dependency); - - for (ResolvedDependency childDependency : resolvedDependency.getChildren()) { - @Nullable ResolvedArtifact childJarArtifact = getJarOrZipArtifact(childDependency); - if (childJarArtifact != null) { - dependency.addDependency(new org.cyclonedx.model.Dependency(generatePackageUrl(childJarArtifact))); - buildDependencyGraph(dependenciesSoFar, childDependency, childJarArtifact); - } - } - return dependenciesSoFar; - } - - @Nullable private ResolvedArtifact getJarOrZipArtifact(ResolvedDependency dependency) { - for (ResolvedArtifact artifact : dependency.getModuleArtifacts()) { - if (Objects.equals(artifact.getType(), "jar") - || Objects.equals(artifact.getType(), "aar") - || Objects.equals(artifact.getType(), "zip")) { - return artifact; - } - } - return null; - } - - /** - * @param dependencyName - * coordinate of a module dependency in the group:name:version format - * @return the resolved maven POM file, or null upon resolve error - */ - @Nullable private MavenProject getResolvedMavenProject(String dependencyName) { - synchronized (resolvedMavenProjects) { - if (resolvedMavenProjects.containsKey(dependencyName)) { - return resolvedMavenProjects.get(dependencyName); - } - } - final Dependency pomDep = getProject().getDependencies().create(dependencyName + "@pom"); - final Configuration pomCfg = getProject().getConfigurations().detachedConfiguration(pomDep); - - try { - @Nullable final File pomFile = pomCfg.resolve().stream().findFirst().orElse(null); - if (pomFile != null) { - @Nullable final MavenProject project = mavenHelper.readPom(pomFile); - resolvedMavenProjects.put(dependencyName, project); - if (project != null) { - @Nullable Model model = mavenHelper.resolveEffectivePom(pomFile, getProject()); - if (model != null) { - project.setLicenses(model.getLicenses()); - } - - return project; - } - } - } catch (Exception err) { - getLogger().error("Unable to resolve POM for " + dependencyName + ": " + err); - } - resolvedMavenProjects.put(dependencyName, null); - return null; - } - - private void augmentComponentMetadata(ResolvedArtifact artifact, Component component, String dependencyName) { - if (!mavenHelper.isDescribedArtifact(artifact)) { - MavenProject project = mavenHelper.extractPom(artifact); - if (project != null) { - mavenHelper.getClosestMetadata(artifact, project, component); - } - } - final MavenProject project = getResolvedMavenProject(dependencyName); - - if (project != null) { - if (project.getOrganization() != null) { - component.setPublisher(project.getOrganization().getName()); - } - component.setDescription(project.getDescription()); - component.setLicenseChoice(mavenHelper.resolveMavenLicenses(project.getLicenses())); - // Update external references by the resolved POM - mavenHelper.extractMetadata(project, component); - } - } + final ResolvedBuild resolvedBuild = getResolvedBuild().get(); + final Map> configurations = new HashMap<>(); + configurations.put(resolvedBuild.getProjectName(), resolvedBuild.getProjectConfigurations()); + configurations.putAll(resolvedBuild.getSubProjectsConfigurations()); - /** - * Converts a MavenProject into a Metadata object. - * - * @return a CycloneDX Metadata object - */ - protected Metadata createMetadata() { - final Project project = getProject(); - final Properties properties = readPluginProperties(); - final Metadata metadata = new Metadata(); - final Tool tool = new Tool(); - tool.setVendor(properties.getProperty("vendor")); - tool.setName(properties.getProperty("name")); - tool.setVersion(properties.getProperty("version")); - metadata.addTool(tool); - - final Component component = new Component(); - component.setGroup( - (StringUtils.trimToNull(project.getGroup().toString()) != null) - ? project.getGroup().toString() - : null); - component.setName(componentName.get()); - component.setVersion(componentVersion.get()); - component.setType(resolveProjectType()); - component.setPurl(generatePackageUrl(project)); - component.setBomRef(component.getPurl()); - metadata.setComponent(component); - - if (organizationalEntity.getName() != null - || organizationalEntity.getUrls() != null - || organizationalEntity.getContacts() != null) { - metadata.setManufacture(organizationalEntity); - } - - if (licenseChoice.getLicenses() != null || licenseChoice.getExpression() != null) { - metadata.setLicenseChoice(licenseChoice); - } - - return metadata; - } - - private Properties readPluginProperties() { - final Properties props = new Properties(); - try (InputStream inputStream = this.getClass().getResourceAsStream("plugin.properties")) { - if (inputStream == null) { - getLogger().warn("Failed to locate plugin.properties"); - } else { - props.load(inputStream); - } - } catch (NullPointerException | IOException e) { - getLogger().warn("Unable to load plugin.properties", e); - } - return props; - } - - private Component.Type resolveProjectType() { - for (Component.Type type : Component.Type.values()) { - if (type.getTypeName().equalsIgnoreCase(getProjectType().get())) { - return type; - } - } - getLogger().warn("Invalid project type. Defaulting to 'library'"); - getLogger().warn("Valid types are:"); - for (Component.Type type : Component.Type.values()) { - getLogger().warn(" " + type.getTypeName()); - } - return Component.Type.LIBRARY; - } - - private Component generateProjectComponent(Project project) { - final Component component = new Component(); - component.setGroup(project.getGroup().toString()); - component.setName(project.getName()); - component.setVersion(project.getVersion().toString()); - component.setType(Component.Type.LIBRARY); - - String projectReference = generatePackageUrl(project); - - component.setPurl(projectReference); - if (getVersion().getVersion() >= 1.1) { - component.setBomRef(projectReference); - } - - return component; - } - - private Component convertArtifact(ResolvedArtifact artifact) { - final Component component = new Component(); - component.setGroup(artifact.getModuleVersion().getId().getGroup()); - component.setName(artifact.getModuleVersion().getId().getName()); - component.setVersion(artifact.getModuleVersion().getId().getVersion()); - component.setType(Component.Type.LIBRARY); - getLogger().debug(MESSAGE_CALCULATING_HASHES); - List hashes = artifactHashes.computeIfAbsent(artifact.getFile(), f -> { - try { - return BomUtils.calculateHashes(f, getVersion()); - } catch (IOException e) { - getLogger().error("Error encountered calculating hashes", e); - } - return Collections.emptyList(); - }); - component.setHashes(hashes); - - final String packageUrl = generatePackageUrl(artifact); - component.setPurl(packageUrl); - - if (getVersion().getVersion() >= 1.1) { - component.setModified(mavenHelper.isModified(artifact)); - component.setBomRef(packageUrl); - } - return component; - } - - private boolean shouldIncludeConfiguration(Configuration configuration) { - return getIncludeConfigs().get().isEmpty() - || getIncludeConfigs().get().stream().anyMatch(configuration.getName()::matches); - } - - private boolean shouldSkipConfiguration(Configuration configuration) { - return getSkipConfigs().get().stream().anyMatch(configuration.getName()::matches); - } - - private boolean shouldSkipProject(Project project) { - return getSkipProjects().get().contains(project.getName()); - } - - private String generatePackageUrl(final ResolvedArtifact artifact) { - TreeMap qualifiers = new TreeMap<>(); - qualifiers.put("type", artifact.getType()); - if (artifact.getClassifier() != null) { - qualifiers.put("classifier", artifact.getClassifier()); - } - return generatePackageUrl( - artifact.getModuleVersion().getId().getGroup(), - artifact.getModuleVersion().getId().getName(), - artifact.getModuleVersion().getId().getVersion(), - qualifiers); - } - - private String generatePackageUrl(final Project project) { - TreeMap qualifiers = new TreeMap<>(); - if (project.getChildProjects().isEmpty()) { - qualifiers.put("type", "jar"); - } else { - qualifiers.put("type", "pom"); - } - return generatePackageUrl( - project.getGroup().toString(), - project.getName(), - project.getVersion().toString(), - qualifiers); - } - - @Nullable private String generatePackageUrl( - String groupId, String artifactId, String version, TreeMap qualifiers) { - try { - return new PackageURL(PackageURL.StandardTypes.MAVEN, groupId, artifactId, version, qualifiers, null) - .canonicalize(); - } catch (MalformedPackageURLException e) { - getLogger() - .warn("An unexpected issue occurred attempting to create a PackageURL for " + groupId + ":" - + artifactId + ":" + version); - } - return null; - } - - /** - * Ported from Maven plugin. - * - * @param metadata The CycloneDX metadata object - * @param components The CycloneDX components extracted from gradle dependencies - */ - protected void writeBom( - Metadata metadata, Set components, Collection dependencies) - throws GradleException { - try { - getLogger().info(MESSAGE_CREATING_BOM); - final Bom bom = new Bom(); - - boolean includeSerialNumber = getBooleanParameter( - "cyclonedx.includeBomSerialNumber", - getIncludeBomSerialNumber().get()); - Version version = getVersion(); - if (Version.VERSION_10 != version && includeSerialNumber) { - bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); - } - bom.setMetadata(metadata); - bom.setComponents(new ArrayList<>(components)); - bom.setDependencies(new ArrayList<>(dependencies)); - if (outputFormat.get().equals("all") || outputFormat.get().equals("xml")) { - writeXMLBom(version, bom); - } - if (getVersion().getVersion() >= 1.2 - && (outputFormat.get().equals("all") || outputFormat.get().equals("json"))) { - writeJSONBom(version, bom); - } - } catch (GeneratorException | ParserConfigurationException | IOException e) { - throw new GradleException( - "An error occurred executing " + this.getClass().getName(), e); - } - } - - private Version getVersion() { - return CycloneDxUtils.schemaVersion(getSchemaVersion().get()); - } - - private void writeXMLBom(final Version schemaVersion, final Bom bom) - throws GeneratorException, ParserConfigurationException, IOException { - final BomXmlGenerator bomGenerator = BomGeneratorFactory.createXml(schemaVersion, bom); - bomGenerator.generate(); - final String bomString = bomGenerator.toXmlString(); - final File bomFile = new File(getDestination().get(), getOutputName().get() + ".xml"); - getLogger().info(MESSAGE_WRITING_BOM_XML); - FileUtils.write(bomFile, bomString, StandardCharsets.UTF_8, false); - getLogger().info(MESSAGE_VALIDATING_BOM); - final Parser bomParser = new XmlParser(); - try { - final List exceptions = bomParser.validate(bomFile, schemaVersion); - exceptions.forEach(it -> getLogger().error(it.getMessage())); - if (!exceptions.isEmpty()) { - throw exceptions.get(0); - } - } catch (Exception e) { // Changed to Exception. - // Gradle will erroneously report "exception IOException is never thrown in body - // of corresponding try statement" - throw new GradleException(MESSAGE_VALIDATION_FAILURE, e); - } - } + registerArtifacts(); + buildDependencies(configurations); - private void writeJSONBom(final Version schemaVersion, final Bom bom) throws IOException { - final BomJsonGenerator bomGenerator = BomGeneratorFactory.createJson(schemaVersion, bom); - try { - final String bomString = bomGenerator.toJsonString(); - final File bomFile = - new File(getDestination().get(), getOutputName().get() + ".json"); - getLogger().info(MESSAGE_WRITING_BOM_JSON); - FileUtils.write(bomFile, bomString, StandardCharsets.UTF_8, false); - getLogger().info(MESSAGE_VALIDATING_BOM); - final Parser bomParser = new JsonParser(); - final List exceptions = bomParser.validate(bomFile, schemaVersion); - exceptions.forEach(it -> getLogger().error(it.getMessage())); - if (!exceptions.isEmpty()) { - throw exceptions.get(0); - } - } catch (Exception e) { // Changed to Exception. - // Gradle will erroneously report "exception IOException is never thrown in body - // of corresponding try statement" - throw new GradleException(MESSAGE_VALIDATION_FAILURE, e); - } + File destination = new File(getDestination().get(), "bom.json"); + CycloneDxUtils.writeBom(parser.getResultingBom(), destination); } - private boolean getBooleanParameter(String parameter, boolean defaultValue) { - final Project project = super.getProject(); - if (project.hasProperty(parameter)) { - final Object o = project.getProperties().get(parameter); - if (o instanceof String) { - return Boolean.parseBoolean((String) o); - } - } - return defaultValue; + private void buildDependencies(final Map> configurations) { + configurations.entrySet().forEach(project -> project.getValue() + .forEach(config -> parser.visitGraph( + config.getDependencyGraph().get(), project.getKey(), config.getConfigurationName()))); } - protected 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("------------------------------------------------------------------------"); - } + private void registerArtifacts() { + getArtifacts().get().forEach(parser::registerArtifact); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java new file mode 100644 index 00000000..44f8f80a --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java @@ -0,0 +1,40 @@ +/* + * 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.model; + +import java.io.File; + +public class ArtifactInfo { + + private final String componentId; + private final File artifactFile; + + public ArtifactInfo(final String componentId, final File artifactFile) { + this.componentId = componentId; + this.artifactFile = artifactFile; + } + + public String getComponentId() { + return componentId; + } + + public File getArtifactFile() { + return artifactFile; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java b/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java new file mode 100644 index 00000000..07b8e7b4 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java @@ -0,0 +1,30 @@ +/* + * 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.model; + +import java.util.Comparator; +import org.cyclonedx.model.Component; + +public class ComponentComparator implements Comparator { + + @Override + public int compare(Component o1, Component o2) { + return o1.getBomRef().compareTo(o2.getBomRef()); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java b/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java new file mode 100644 index 00000000..c64ba7b2 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java @@ -0,0 +1,29 @@ +/* + * 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.model; + +import java.util.Comparator; +import org.cyclonedx.model.Dependency; + +public class DependencyComparator implements Comparator { + @Override + public int compare(Dependency o1, Dependency o2) { + return o1.getRef().compareTo(o2.getRef()); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java new file mode 100644 index 00000000..18c47e69 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java @@ -0,0 +1,63 @@ +/* + * 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.model; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +public class ResolvedBuild { + + private final String projectName; + private final Set projectConfigurations; + private final Map> subProjectsConfigurations; + + public ResolvedBuild(final String projectName) { + this.projectName = projectName; + this.projectConfigurations = new HashSet<>(); + this.subProjectsConfigurations = new HashMap<>(); + } + + public String getProjectName() { + return projectName; + } + + public void addProjectConfiguration(final ResolvedConfiguration configuration) { + projectConfigurations.add(configuration); + } + + public Set getProjectConfigurations() { + return projectConfigurations; + } + + public void addSubProjectConfiguration(final String projectName, final ResolvedConfiguration configuration) { + if (subProjectsConfigurations.containsKey(projectName)) { + subProjectsConfigurations.get(projectName).add(configuration); + } else { + final Set subProjectConfigurations = new HashSet<>(); + subProjectConfigurations.add(configuration); + subProjectsConfigurations.put(projectName, subProjectConfigurations); + } + } + + public Map> getSubProjectsConfigurations() { + return subProjectsConfigurations; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java new file mode 100644 index 00000000..385bbadf --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java @@ -0,0 +1,42 @@ +/* + * 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.model; + +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.provider.Provider; + +public class ResolvedConfiguration { + + private final String configurationName; + private final Provider dependencyGraph; + + public ResolvedConfiguration( + final String configurationName, final Provider dependencyGraph) { + this.configurationName = configurationName; + this.dependencyGraph = dependencyGraph; + } + + public String getConfigurationName() { + return configurationName; + } + + public Provider getDependencyGraph() { + return dependencyGraph; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java index 3d405f4a..945024f0 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java @@ -18,7 +18,15 @@ */ package org.cyclonedx.gradle.utils; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.apache.commons.io.FileUtils; import org.cyclonedx.Version; +import org.cyclonedx.generators.BomGeneratorFactory; +import org.cyclonedx.generators.json.BomJsonGenerator; +import org.cyclonedx.model.Bom; +import org.gradle.api.GradleException; public class CycloneDxUtils { @@ -49,4 +57,24 @@ public static Version schemaVersion(String version) { return DEFAULT_SCHEMA_VERSION; } } + + public static void writeBom(final Bom bom, final File destination) { + try { + writeJSONBom(Version.VERSION_16, bom, destination); + } catch (IOException e) { + throw new GradleException("An error occurred writing BOM", e); + } + } + + private static void writeJSONBom(final Version schemaVersion, final Bom bom, final File destination) + throws IOException { + + final BomJsonGenerator bomGenerator = BomGeneratorFactory.createJson(schemaVersion, bom); + try { + final String bomString = bomGenerator.toJsonString(); + FileUtils.write(destination, bomString, StandardCharsets.UTF_8, false); + } catch (Exception e) { + throw new GradleException("Valid message", e); + } + } } diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index bf8de433..dc496187 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -78,4 +78,37 @@ class DependencyResolutionSpec extends Specification { assert root.getDependencies().size() == 1 assert root.getDependencies().get(0).getRef() == "pkg:maven/org.hibernate/hibernate-core@5.6.15.Final?type=jar" } + + def "should contain correct components"() { + given: + File testRepoDir = TestUtils.duplicateRepo("test1") + + File testDir = TestUtils.createFromString(""" + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + repositories { + maven{ + url 'file://${testRepoDir.absolutePath.replace("\\","/")}/repository' + } + } + group = 'com.example' + version = '1.0.0' + + dependencies { + implementation("com.test:componentb:1.0.0") + }""", "rootProject.name = 'hello-world'") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--stacktrace", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + println(result.output) + } } diff --git a/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy b/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy index e42ea063..91be132f 100644 --- a/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy @@ -20,6 +20,22 @@ class TestUtils { return tmpDir } + static File duplicateRepo(String testProject) { + def tmpDir = File.createTempDir( "copy", testProject) + def baseDir = new File("src/test/resources/test-repos/$testProject").toPath() + + baseDir.eachFileRecurse {path -> + def relativePath = baseDir.relativize(path) + def targetPath = tmpDir.toPath().resolve(relativePath) + if (Files.isDirectory(path)) { + targetPath.toFile().mkdirs() + } else { + Files.copy(path, targetPath) + } + } + return tmpDir + } + static File createFromString(String buildContent, String settingsContent) { def tmpDir = File.createTempDir( "from-literal") def settingsFile = new File(tmpDir, "settings.gradle") diff --git a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar new file mode 100644 index 00000000..8663d286 --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar @@ -0,0 +1 @@ +randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom new file mode 100644 index 00000000..6555122a --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom @@ -0,0 +1,17 @@ + + + 4.0.0 + com.test + componenta + 1.0.0 + + + + com.test + componentb + 1.0.0 + + + + diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar new file mode 100644 index 00000000..8663d286 --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar @@ -0,0 +1 @@ +randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom new file mode 100644 index 00000000..ba456008 --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + com.test + componentb + 1.0.0 + + diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar new file mode 100644 index 00000000..8663d286 --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar @@ -0,0 +1 @@ +randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom new file mode 100644 index 00000000..46aab1ef --- /dev/null +++ b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + com.test + componentb + 1.0.1 + + From b01382123d01d3d4869c4c486499182ece3b4b93 Mon Sep 17 00:00:00 2001 From: Gordon Date: Wed, 23 Oct 2024 19:13:46 +0100 Subject: [PATCH 14/26] feat: use single graph by merging all graphs for every configuration Signed-off-by: Gordon --- .../cyclonedx/gradle/CycloneDxBomBuilder.java | 194 ++++++++++++++++++ .../gradle/CycloneDxDependencyTraverser.java | 143 +++++++++++++ .../org/cyclonedx/gradle/CycloneDxParser.java | 183 ----------------- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 20 +- .../org/cyclonedx/gradle/CycloneDxTask.java | 27 +-- .../cyclonedx/gradle/model/ArtifactInfo.java | 7 +- .../org/cyclonedx/gradle/model/GraphNode.java | 90 ++++++++ .../cyclonedx/gradle/model/ResolvedBuild.java | 1 - .../gradle/utils/CycloneDxUtils.java | 4 +- .../gradle/utils/DependencyUtils.java | 47 ++--- .../org/cyclonedx/gradle/CycloneDxSpec.groovy | 4 +- .../gradle/DependencyResolutionSpec.groovy | 61 +++++- .../gradle/PluginConfigurationSpec.groovy | 37 +++- .../org/cyclonedx/gradle/TestUtils.groovy | 5 +- .../componenta/1.0.0/componenta-1.0.0.jar | 1 + .../componenta/1.0.0/componenta-1.0.0.pom | 0 .../componentb/1.0.0/componentb-1.0.0.jar | 1 + .../componentb/1.0.0/componentb-1.0.0.pom | 0 .../componentb/1.0.1/componentb-1.0.1.jar | 1 + .../componentb/1.0.1/componentb-1.0.1.pom | 0 .../componentc/1.0.0/componentc-1.0.0.pom | 18 ++ .../componentc/1.0.0/componentc-1.0.0.tgz | 1 + .../componenta/1.0.0/componenta-1.0.0.jar | 1 - .../componentb/1.0.0/componentb-1.0.0.jar | 1 - .../componentb/1.0.1/componentb-1.0.1.jar | 1 - 25 files changed, 578 insertions(+), 270 deletions(-) create mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java create mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java delete mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxParser.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/GraphNode.java create mode 100644 src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar rename src/test/resources/test-repos/{test1 => local}/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom (100%) create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar rename src/test/resources/test-repos/{test1 => local}/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom (100%) create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar rename src/test/resources/test-repos/{test1 => local}/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom (100%) create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.pom create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.tgz delete mode 100644 src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar delete mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar delete mode 100644 src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java new file mode 100644 index 00000000..87c9b06b --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java @@ -0,0 +1,194 @@ +/* + * 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; + +import com.github.packageurl.MalformedPackageURLException; +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; +import org.apache.commons.io.FilenameUtils; +import org.cyclonedx.Version; +import org.cyclonedx.gradle.model.ComponentComparator; +import org.cyclonedx.gradle.model.DependencyComparator; +import org.cyclonedx.gradle.model.GraphNode; +import org.cyclonedx.gradle.utils.CycloneDxUtils; +import org.cyclonedx.gradle.utils.DependencyUtils; +import org.cyclonedx.model.*; +import org.cyclonedx.util.BomUtils; +import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.logging.Logger; + +public class CycloneDxBomBuilder { + + private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; + private static final TreeMap DEFAULT_TYPE = new TreeMap<>(); + + static { + DEFAULT_TYPE.put("type", "jar"); + } + + private final Logger logger; + private final Map> artifactHashes; + private final MavenHelper mavenHelper; + private final Version version; + + public CycloneDxBomBuilder(final Logger logger) { + this.logger = logger; + this.version = CycloneDxUtils.DEFAULT_SCHEMA_VERSION; + this.artifactHashes = new HashMap<>(); + this.mavenHelper = new MavenHelper(logger, version, false); + } + + public Bom buildBom( + final Map> resultGraph, + final GraphNode parentNode, + final Map resolvedArtifacts) { + + final Set dependencies = new TreeSet<>(new DependencyComparator()); + final Set components = new TreeSet<>(new ComponentComparator()); + + resultGraph.keySet().forEach(node -> { + addDependency(dependencies, resultGraph.get(node), node, resolvedArtifacts); + addComponent(components, node, parentNode, resolvedArtifacts); + }); + + final Bom bom = new Bom(); + bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); + bom.setMetadata(buildMetadata(parentNode)); + bom.setComponents(new ArrayList<>(components)); + bom.setDependencies(new ArrayList<>(dependencies)); + return bom; + } + + private Metadata buildMetadata(final GraphNode parentNode) { + final Metadata metadata = new Metadata(); + try { + metadata.setComponent(toComponent(parentNode, null)); + } catch (MalformedPackageURLException e) { + logger.warn("Error constructing packageUrl for parent component. Skipping...", e); + } + return metadata; + } + + private void addDependency( + final Set dependencies, + final Set dependencyNodes, + final GraphNode node, + final Map resolvedArtifacts) { + + final Dependency dependency; + try { + dependency = toDependency(node.getResult(), resolvedArtifacts); + } catch (MalformedPackageURLException e) { + logger.warn("Error constructing packageUrl for node. Skipping...", e); + return; + } + dependencyNodes.forEach(dependencyNode -> { + try { + dependency.addDependency(toDependency(dependencyNode.getResult(), resolvedArtifacts)); + } catch (MalformedPackageURLException e) { + logger.warn("Error constructing packageUrl for node dependency. Skipping...", e); + } + }); + dependencies.add(dependency); + } + + private Dependency toDependency( + final ResolvedComponentResult component, final Map resolvedArtifacts) + throws MalformedPackageURLException { + + final File artifactFile = resolvedArtifacts.get(component.getId()); + final String ref = DependencyUtils.generatePackageUrl(component.getModuleVersion(), getType(artifactFile)); + return new Dependency(ref); + } + + private void addComponent( + final Set components, + final GraphNode node, + final GraphNode parentNode, + final Map resolvedArtifacts) { + if (!node.equals(parentNode)) { + final File artifactFile = resolvedArtifacts.get(node.getResult().getId()); + try { + components.add(toComponent(node, artifactFile)); + } catch (MalformedPackageURLException e) { + logger.warn("Error constructing packageUrl for node component. Skipping...", e); + } + } + } + + private Component toComponent(final GraphNode node, final File artifactFile) throws MalformedPackageURLException { + + final ModuleVersionIdentifier moduleVersion = node.getResult().getModuleVersion(); + final String packageUrl = DependencyUtils.generatePackageUrl(moduleVersion, getType(artifactFile)); + + final Component component = new Component(); + component.setGroup(moduleVersion.getGroup()); + component.setName(moduleVersion.getName()); + component.setVersion(moduleVersion.getVersion()); + component.setType(Component.Type.LIBRARY); + component.setPurl(packageUrl); + component.setProperties(buildProperties(node)); + if (version.getVersion() >= 1.1) { + component.setModified(mavenHelper.isModified(null)); + component.setBomRef(packageUrl); + } + + logger.debug(MESSAGE_CALCULATING_HASHES); + if (artifactFile != null) { + component.setHashes(calculateHashes(artifactFile)); + } + + return component; + } + + private List buildProperties(GraphNode node) { + return node.getInScopeConfigurations().stream() + .map(v -> { + Property property = new Property(); + property.setName("inScopeConfiguration"); + property.setValue(String.format("%s:%s", v.getProjectName(), v.getConfigName())); + return property; + }) + .collect(Collectors.toList()); + } + + private List calculateHashes(final File artifactFile) { + return artifactHashes.computeIfAbsent(artifactFile, f -> { + try { + return BomUtils.calculateHashes(f, version); + } catch (IOException e) { + logger.error("Error encountered calculating hashes", e); + } + return Collections.emptyList(); + }); + } + + private TreeMap getType(final File file) { + if (file == null) { + return DEFAULT_TYPE; + } + final TreeMap type = new TreeMap<>(); + type.put("type", FilenameUtils.getExtension(file.getName())); + return type; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java new file mode 100644 index 00000000..b368a84c --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java @@ -0,0 +1,143 @@ +/* + * 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; + +import com.networknt.schema.utils.StringUtils; +import java.io.File; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import org.cyclonedx.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.GraphNode; +import org.cyclonedx.model.*; +import org.gradle.api.GradleException; +import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.result.DependencyResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; +import org.gradle.api.logging.Logger; + +public class CycloneDxDependencyTraverser { + + private final Map> resultGraph; + private final Logger logger; + private final Map resolvedArtifacts; + private final CycloneDxBomBuilder builder; + private GraphNode parentNode; + + public CycloneDxDependencyTraverser(final Logger logger, final CycloneDxBomBuilder builder) { + this.builder = builder; + this.logger = logger; + this.resolvedArtifacts = new HashMap<>(); + this.resultGraph = new HashMap<>(); + } + + public void registerArtifact(final ArtifactInfo artifact) { + resolvedArtifacts.put(artifact.getComponentId(), artifact.getArtifactFile()); + } + + public void traverseParentGraph( + final ResolvedComponentResult rootNode, final String projectName, final String configName) { + final String parentRef = getRef(rootNode.getModuleVersion()); + this.parentNode = new GraphNode(parentRef, rootNode); + traverseGraph(rootNode, projectName, configName); + } + + public void traverseChildGraph( + final ResolvedComponentResult rootNode, final String projectName, final String configName) { + + if (this.parentNode == null) { + throw new GradleException("Parent graphs has to be traversed first"); + } + + final String childRef = getRef(rootNode.getModuleVersion()); + final GraphNode childNode = new GraphNode(childRef, rootNode); + this.resultGraph.get(this.parentNode).add(childNode); + traverseGraph(rootNode, projectName, configName); + } + + public void traverseGraph( + final ResolvedComponentResult rootNode, final String projectName, final String configName) { + + final Map> graph = new TreeMap<>(); + final Queue queue = new ArrayDeque<>(); + + final String rootRef = getRef(rootNode.getModuleVersion()); + final GraphNode rootGraphNode = new GraphNode(rootRef, rootNode); + queue.add(rootGraphNode); + + while (!queue.isEmpty()) { + final GraphNode graphNode = queue.poll(); + if (!graph.containsKey(graphNode)) { + graph.put(graphNode, new TreeSet<>()); + for (DependencyResult dep : graphNode.getResult().getDependencies()) { + if (dep instanceof ResolvedDependencyResult) { + final ResolvedComponentResult dependencyComponent = + ((ResolvedDependencyResult) dep).getSelected(); + String ref = getRef(dependencyComponent.getModuleVersion()); + GraphNode dependencyNode = new GraphNode(ref, dependencyComponent); + graph.get(graphNode).add(dependencyNode); + queue.add(dependencyNode); + } + } + } + } + + mergeIntoResultGraph(graph, projectName, configName); + } + + private void mergeIntoResultGraph( + final Map> graph, final String projectName, final String configName) { + + graph.keySet().forEach(node -> { + if (resultGraph.containsKey(node)) { + resultGraph.get(node).addAll(graph.get(node)); + } else { + resultGraph.put(node, graph.get(node)); + } + }); + + resultGraph.keySet().stream() + .filter(graph::containsKey) + .forEach(v -> v.inScopeConfiguration(projectName, configName)); + } + + public Bom toBom() { + return builder.buildBom(this.resultGraph, this.parentNode, this.resolvedArtifacts); + } + + private String getRef(final ModuleVersionIdentifier identifier) { + + // The cause for this failure is mainly if the group/name/project of the build isn't set + if (StringUtils.isBlank(identifier.getGroup()) + || StringUtils.isBlank(identifier.getName()) + || StringUtils.isBlank(identifier.getVersion())) { + throw new GradleException(String.format( + "Invalid module identifier provided. Group: %s, Name: %s, Version: %s", + identifier.getGroup(), identifier.getName(), identifier.getVersion())); + } + + return String.format("%s:%s:%s", identifier.getGroup(), identifier.getName(), identifier.getVersion()); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java deleted file mode 100644 index b8b92c41..00000000 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxParser.java +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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; - -import com.github.packageurl.MalformedPackageURLException; -import com.github.packageurl.PackageURL; -import java.io.File; -import java.io.IOException; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import javax.annotation.Nullable; -import org.cyclonedx.Version; -import org.cyclonedx.gradle.model.ArtifactInfo; -import org.cyclonedx.gradle.model.ComponentComparator; -import org.cyclonedx.gradle.model.DependencyComparator; -import org.cyclonedx.gradle.utils.CycloneDxUtils; -import org.cyclonedx.model.Bom; -import org.cyclonedx.model.Component; -import org.cyclonedx.model.Dependency; -import org.cyclonedx.model.Hash; -import org.cyclonedx.util.BomUtils; -import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.result.DependencyResult; -import org.gradle.api.artifacts.result.ResolvedComponentResult; -import org.gradle.api.artifacts.result.ResolvedDependencyResult; -import org.gradle.api.logging.Logger; - -public class CycloneDxParser { - - private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; - - private final Set dependencies; - private final Set components; - private final Logger logger; - private final Map> artifactHashes; - private final Map resolvedArtifacts; - private final MavenHelper mavenHelper; - private final Version version; - - public CycloneDxParser(final Logger logger) { - this.logger = logger; - this.version = CycloneDxUtils.schemaVersion("1.6"); - this.dependencies = new TreeSet<>(new DependencyComparator()); - this.components = new TreeSet<>(new ComponentComparator()); - this.resolvedArtifacts = new HashMap<>(); - this.artifactHashes = new HashMap<>(); - this.mavenHelper = new MavenHelper(logger, version, false); - } - - public void registerArtifact(final ArtifactInfo artifact) { - resolvedArtifacts.put(artifact.getComponentId(), artifact.getArtifactFile()); - } - - public void visitGraph(final ResolvedComponentResult rootNode, final String projectName, final String configName) { - - final Set seen = new HashSet<>(); - final Queue queue = new ArrayDeque<>(); - queue.add(rootNode); - - while (!queue.isEmpty()) { - final ResolvedComponentResult node = queue.poll(); - if (!seen.contains(node)) { - seen.add(node); - final Dependency dependency = toDependency(node, projectName, configName); - for (DependencyResult dep : node.getDependencies()) { - if (dep instanceof ResolvedDependencyResult) { - final ResolvedComponentResult dependencyComponent = - ((ResolvedDependencyResult) dep).getSelected(); - dependency.addDependency(toDependency(dependencyComponent, projectName, configName)); - queue.add(dependencyComponent); - } - } - dependencies.add(dependency); - final File artifactFile = resolvedArtifacts.get(node.getId().getDisplayName()); - components.add(toComponent(node, artifactFile, projectName, configName)); - } - } - } - - public Bom getResultingBom() { - final Bom bom = new Bom(); - bom.setComponents(new ArrayList<>(components)); - bom.setDependencies(new ArrayList<>(dependencies)); - return bom; - } - - public Component toComponent( - final ResolvedComponentResult resolvedComponent, - final File artifactFile, - final String projectName, - final String configName) { - - final Component component = new Component(); - component.setGroup(resolvedComponent.getModuleVersion().getGroup()); - component.setName(resolvedComponent.getModuleVersion().getName()); - component.setVersion(resolvedComponent.getModuleVersion().getVersion()); - component.setType(Component.Type.LIBRARY); - logger.debug(MESSAGE_CALCULATING_HASHES); - if (artifactFile != null) { - component.setHashes(calculateHashes(artifactFile)); - } - - final TreeMap qualifiers = new TreeMap<>(); - final String packageUrl = generatePackageUrl(resolvedComponent.getModuleVersion(), qualifiers); - component.setPurl(packageUrl); - - if (version.getVersion() >= 1.1) { - component.setModified(mavenHelper.isModified(null)); - component.setBomRef(generateRef(resolvedComponent.getModuleVersion(), qualifiers, projectName, configName)); - } - return component; - } - - private List calculateHashes(final File artifactFile) { - return artifactHashes.computeIfAbsent(artifactFile, f -> { - try { - return BomUtils.calculateHashes(f, version); - } catch (IOException e) { - logger.error("Error encountered calculating hashes", e); - } - return Collections.emptyList(); - }); - } - - private Dependency toDependency( - final ResolvedComponentResult component, final String projectName, final String configName) { - - TreeMap qualifiers = new TreeMap<>(); - String ref = generateRef(component.getModuleVersion(), qualifiers, projectName, configName); - return new Dependency(ref); - } - - private String generateRef( - final ModuleVersionIdentifier version, - final TreeMap qualifiers, - final String projectName, - final String configName) { - String purl = generatePackageUrl(version, qualifiers); - return String.format("%s:%s:%s", projectName, configName, purl); - } - - @Nullable private String generatePackageUrl(final ModuleVersionIdentifier version, final TreeMap qualifiers) { - try { - return new PackageURL( - PackageURL.StandardTypes.MAVEN, - version.getGroup(), - version.getName(), - version.getVersion(), - qualifiers, - null) - .canonicalize(); - } catch (MalformedPackageURLException e) { - logger.warn("An unexpected issue occurred attempting to create a PackageURL for " + version.getGroup() + ":" - + version.getName() + ":" + version); - } - return null; - } -} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index d30e29b6..fee1b309 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -69,24 +69,22 @@ private Optional>> getArtifacts(final Project project .map(v -> v.getIncoming().getArtifacts().getResolvedArtifacts()) .reduce(this::combineArtifactsProviders) .map(provider -> - provider.map(v -> v.stream().map(this::mapResult).collect(Collectors.toSet()))); + provider.map(v -> v.stream().map(this::toArtifactInfo).collect(Collectors.toSet()))); } private Provider> combineArtifactsProviders( - Provider> left, Provider> right) { - return left.flatMap(v -> right.map(w -> { - Set result = new HashSet<>(); - result.addAll(v); - result.addAll(w); - return result; - })); + final Provider> left, final Provider> right) { + return left.zip(right, (u, v) -> { + u.addAll(v); + return u; + }); } - private ArtifactInfo mapResult(ResolvedArtifactResult result) { - return new ArtifactInfo(result.getId().getComponentIdentifier().getDisplayName(), result.getFile()); + private ArtifactInfo toArtifactInfo(final ResolvedArtifactResult result) { + return new ArtifactInfo(result.getId().getComponentIdentifier(), result.getFile()); } - private ResolvedConfiguration resolvedConfiguration(Configuration config) { + private ResolvedConfiguration resolvedConfiguration(final Configuration config) { return new ResolvedConfiguration( config.getName(), config.getIncoming().getResolutionResult().getRootComponent()); } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index f7eeb57c..9335289b 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -19,7 +19,6 @@ package org.cyclonedx.gradle; import java.io.File; -import java.util.HashMap; import java.util.Map; import java.util.Set; import org.cyclonedx.gradle.model.ArtifactInfo; @@ -34,10 +33,10 @@ public abstract class CycloneDxTask extends DefaultTask { - private final CycloneDxParser parser; + private final CycloneDxDependencyTraverser traverser; public CycloneDxTask() { - this.parser = new CycloneDxParser(getLogger()); + this.traverser = new CycloneDxDependencyTraverser(getLogger(), new CycloneDxBomBuilder(getLogger())); } @Input @@ -53,24 +52,26 @@ public CycloneDxTask() { public void createBom() { final ResolvedBuild resolvedBuild = getResolvedBuild().get(); - final Map> configurations = new HashMap<>(); - configurations.put(resolvedBuild.getProjectName(), resolvedBuild.getProjectConfigurations()); - configurations.putAll(resolvedBuild.getSubProjectsConfigurations()); registerArtifacts(); - buildDependencies(configurations); + buildParentDependencies(resolvedBuild.getProjectName(), resolvedBuild.getProjectConfigurations()); + buildChildDependencies(resolvedBuild.getSubProjectsConfigurations()); File destination = new File(getDestination().get(), "bom.json"); - CycloneDxUtils.writeBom(parser.getResultingBom(), destination); + CycloneDxUtils.writeBom(traverser.toBom(), destination); } - private void buildDependencies(final Map> configurations) { - configurations.entrySet().forEach(project -> project.getValue() - .forEach(config -> parser.visitGraph( - config.getDependencyGraph().get(), project.getKey(), config.getConfigurationName()))); + private void buildParentDependencies(final String projectName, Set configurations) { + configurations.forEach(config -> traverser.traverseParentGraph( + config.getDependencyGraph().get(), projectName, config.getConfigurationName())); + } + + private void buildChildDependencies(final Map> configurations) { + configurations.forEach((key, value) -> value.forEach(config -> + traverser.traverseChildGraph(config.getDependencyGraph().get(), key, config.getConfigurationName()))); } private void registerArtifacts() { - getArtifacts().get().forEach(parser::registerArtifact); + getArtifacts().get().forEach(traverser::registerArtifact); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java index 44f8f80a..8a9c40fa 100644 --- a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java +++ b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java @@ -19,18 +19,19 @@ package org.cyclonedx.gradle.model; import java.io.File; +import org.gradle.api.artifacts.component.ComponentIdentifier; public class ArtifactInfo { - private final String componentId; + private final ComponentIdentifier componentId; private final File artifactFile; - public ArtifactInfo(final String componentId, final File artifactFile) { + public ArtifactInfo(final ComponentIdentifier componentId, final File artifactFile) { this.componentId = componentId; this.artifactFile = artifactFile; } - public String getComponentId() { + public ComponentIdentifier getComponentId() { return componentId; } diff --git a/src/main/java/org/cyclonedx/gradle/model/GraphNode.java b/src/main/java/org/cyclonedx/gradle/model/GraphNode.java new file mode 100644 index 00000000..a43539fc --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/GraphNode.java @@ -0,0 +1,90 @@ +/* + * 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.model; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.jetbrains.annotations.NotNull; + +public class GraphNode implements Comparable { + + private final String ref; + private final ResolvedComponentResult result; + private final Set inScopeConfigurations; + + public GraphNode(final String ref, final ResolvedComponentResult result) { + this.ref = ref; + this.result = result; + this.inScopeConfigurations = new HashSet<>(); + } + + public String getRef() { + return ref; + } + + public ResolvedComponentResult getResult() { + return result; + } + + public void inScopeConfiguration(final String projectName, final String configName) { + inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); + } + + public Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + @Override + public int compareTo(@NotNull GraphNode o) { + return this.ref.compareTo(o.ref); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GraphNode graphNode = (GraphNode) o; + return Objects.equals(ref, graphNode.ref); + } + + @Override + public int hashCode() { + return Objects.hashCode(ref); + } + + public static class ConfigurationScope { + private final String projectName; + private final String configName; + + private ConfigurationScope(final String projectName, final String configName) { + this.projectName = projectName; + this.configName = configName; + } + + public String getProjectName() { + return projectName; + } + + public String getConfigName() { + return configName; + } + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java index 18c47e69..22509fc4 100644 --- a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java +++ b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java @@ -24,7 +24,6 @@ import java.util.Set; public class ResolvedBuild { - private final String projectName; private final Set projectConfigurations; private final Map> subProjectsConfigurations; diff --git a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java index 945024f0..507323f6 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java @@ -30,7 +30,7 @@ public class CycloneDxUtils { - public static final Version DEFAULT_SCHEMA_VERSION = Version.VERSION_15; + public static final Version DEFAULT_SCHEMA_VERSION = Version.VERSION_16; /** * Resolves the CycloneDX schema the mojo has been requested to use. @@ -60,7 +60,7 @@ public static Version schemaVersion(String version) { public static void writeBom(final Bom bom, final File destination) { try { - writeJSONBom(Version.VERSION_16, bom, destination); + writeJSONBom(DEFAULT_SCHEMA_VERSION, bom, destination); } catch (IOException e) { throw new GradleException("An error occurred writing 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 16f36982..016b6185 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -18,42 +18,23 @@ */ package org.cyclonedx.gradle.utils; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import org.gradle.api.artifacts.Configuration; +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURL; +import java.util.TreeMap; import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.ResolvedArtifact; -import org.gradle.api.artifacts.ResolvedDependency; public class DependencyUtils { - public static String getDependencyName(ResolvedDependency resolvedDependencies) { - final ModuleVersionIdentifier m = resolvedDependencies.getModule().getId(); - return getDependencyName(m); - } - - public static String getDependencyName(ResolvedArtifact artifact) { - final ModuleVersionIdentifier m = artifact.getModuleVersion().getId(); - return getDependencyName(m); - } - - public static boolean canBeResolved(Configuration configuration) { - // Configuration.isCanBeResolved() has been introduced with Gradle 3.3, - // thus we need to check for the method's existence first - try { - Method method = Configuration.class.getMethod("isCanBeResolved"); - try { - return (Boolean) method.invoke(configuration); - } catch (IllegalAccessException | InvocationTargetException e) { - return true; - } - } catch (NoSuchMethodException e) { - // prior to Gradle 3.3 all configurations were resolvable - return true; - } - } - - private static String getDependencyName(ModuleVersionIdentifier moduleVersion) { - return String.format("%s:%s:%s", moduleVersion.getGroup(), moduleVersion.getName(), moduleVersion.getVersion()); + public static String generatePackageUrl( + final ModuleVersionIdentifier version, final TreeMap qualifiers) + throws MalformedPackageURLException { + return new PackageURL( + PackageURL.StandardTypes.MAVEN, + version.getGroup(), + version.getName(), + version.getVersion(), + qualifiers, + null) + .canonicalize(); } } diff --git a/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy index b1534779..e888ba5c 100644 --- a/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy @@ -20,11 +20,12 @@ package org.cyclonedx.gradle import org.cyclonedx.model.Metadata import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Ignore import spock.lang.Specification class CycloneDxSpec extends Specification { static final String PLUGIN_ID = 'org.cyclonedx.bom' - + def rootProject = ProjectBuilder.builder().withName("root").build() def parentProject = ProjectBuilder.builder().withName("parent").withParent(rootProject).build() def childProject = ProjectBuilder.builder().withName("child").withParent(parentProject).build() @@ -53,6 +54,7 @@ class CycloneDxSpec extends Specification { leafProject.tasks.findByName('cyclonedxBom') } + @Ignore def "cyclonedxBom metadata creation uses project specific values"() { expect: Metadata root = rootProject.tasks.findByName('cyclonedxBom').createMetadata() diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index dc496187..beed3a4a 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import org.cyclonedx.model.Bom import org.cyclonedx.model.Component import org.cyclonedx.model.Dependency +import org.cyclonedx.model.Hash import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification @@ -79,9 +80,9 @@ class DependencyResolutionSpec extends Specification { assert root.getDependencies().get(0).getRef() == "pkg:maven/org.hibernate/hibernate-core@5.6.15.Final?type=jar" } - def "should contain correct components"() { + def "should contain correct hashes"() { given: - File testRepoDir = TestUtils.duplicateRepo("test1") + String localRepoUri = TestUtils.duplicateRepo("local") File testDir = TestUtils.createFromString(""" plugins { @@ -90,25 +91,71 @@ class DependencyResolutionSpec extends Specification { } repositories { maven{ - url 'file://${testRepoDir.absolutePath.replace("\\","/")}/repository' + url '$localRepoUri' } } group = 'com.example' version = '1.0.0' dependencies { - implementation("com.test:componentb:1.0.0") - }""", "rootProject.name = 'hello-world'") + implementation("com.test:componenta:1.0.0") + testImplementation("com.test:componentb:1.0.1") + }""", "rootProject.name = 'simple-project'") when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--stacktrace", "--configuration-cache") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() then: result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS - println(result.output) + File jsonBom = new File(testDir, "build/reports/bom.json") + Bom bom = new ObjectMapper().readValue(jsonBom, Bom.class) + Component componenta = bom.getComponents().find(c -> c.name == 'componenta') + Hash hasha = + componenta.hashes.find(c -> c.algorithm == "SHA-256" && c.value == "8b6a28fbdb87b7a521b61bc15d265820fb8dd1273cb44dd44a8efdcd6cd40848") + assert hasha != null + Component componentb = bom.getComponents().find(c -> c.name == 'componentb') + Hash hashb = + componentb.hashes.find(c -> c.algorithm == "SHA-256" && c.value == "5a5407bd92e71336b546642b8b62b6a9544bca5c4ab2fbb8864d9faa5400ba48") + assert hashb != null + } + + def "should generate bom for non-jar artrifacts"() { + given: + String localRepoUri = TestUtils.duplicateRepo("local") + + File testDir = TestUtils.createFromString(""" + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + repositories { + maven{ + url '$localRepoUri' + } + } + group = 'com.example' + version = '1.0.0' + + dependencies { + implementation("com.test:componentc:1.0.0") + }""", "rootProject.name = 'simple-project'") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File jsonBom = new File(testDir, "build/reports/bom.json") + Bom bom = new ObjectMapper().readValue(jsonBom, Bom.class) + Component componentc = bom.getComponents().find(c -> c.bomRef == 'pkg:maven/com.test/componentc@1.0.0?type=tgz') + assert componentc != null } } diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index 82cfd049..13b6ca35 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -8,6 +8,7 @@ import org.cyclonedx.model.Component import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification +import spock.lang.Ignore class PluginConfigurationSpec extends Specification { @@ -44,7 +45,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"${CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString}\"") } @@ -64,9 +65,10 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "output-dir") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 } + @Ignore def "custom-output project should write boms under my-bom"() { given: File testDir = TestUtils.duplicate("custom-outputname") @@ -99,11 +101,12 @@ class PluginConfigurationSpec extends Specification { result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 assert !result.output.contains("An error occurred attempting to read POM") } + @Ignore def "should use configured schemaVersion"() { given: File testDir = TestUtils.createFromString(""" @@ -173,11 +176,12 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"name\" : \"hello-world\"") } + @Ignore def "should use configured componentName"() { given: File testDir = TestUtils.createFromString(""" @@ -215,6 +219,7 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"name\" : \"customized-component-name\"") } + @Ignore def "should use configured componentVersion"() { given: File testDir = TestUtils.createFromString(""" @@ -252,6 +257,7 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"version\" : \"999-SNAPSHOT\"") } + @Ignore def "should use configured outputFormat to limit generated file"() { given: File testDir = TestUtils.createFromString(""" @@ -289,6 +295,7 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.exists() } + @Ignore def "includes component bom-ref when schema version greater than 1.0"() { given: File testDir = TestUtils.createFromString(""" @@ -336,10 +343,11 @@ class PluginConfigurationSpec extends Specification { .build() then: result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + println(result.output) File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString @@ -368,7 +376,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "app-a/build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString @@ -404,7 +412,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "app-b/build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString @@ -427,6 +435,7 @@ class PluginConfigurationSpec extends Specification { assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") } + @Ignore def "kotlin-dsl-project should allow configuring all properties"() { given: File testDir = TestUtils.duplicate("kotlin-project") @@ -448,6 +457,7 @@ class PluginConfigurationSpec extends Specification { assert !jsonBom.text.contains("serialNumber") } + @Ignore def "kotlin-dsl-project-manufacture-licenses should allow definition of manufacture-data and licenses-data"() { given: File testDir = TestUtils.duplicate("kotlin-project-manufacture-licenses") @@ -480,6 +490,7 @@ class PluginConfigurationSpec extends Specification { } + @Ignore def "groovy-project-manufacture-licenses should allow definition of manufacture-data and licenses-data"() { given: File testDir = TestUtils.duplicate("groovy-project-manufacture-licenses") @@ -512,6 +523,7 @@ class PluginConfigurationSpec extends Specification { } + @Ignore def "should skip configurations with regex"() { given: File testDir = TestUtils.createFromString(""" @@ -548,6 +560,7 @@ class PluginConfigurationSpec extends Specification { assert log4jCore == null } + @Ignore def "should include configurations with regex"() { given: File testDir = TestUtils.createFromString(""" @@ -584,7 +597,7 @@ class PluginConfigurationSpec extends Specification { assert log4jCore.getBomRef() == 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0?type=jar' } - def "should use 1.5 is default schema version"() { + def "should use 1.6 is default schema version"() { given: File testDir = TestUtils.createFromString(""" plugins { @@ -610,9 +623,9 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles().length == 1 File jsonBom = new File(reportDir, "bom.json") - assert jsonBom.text.contains("\"specVersion\" : \"1.5\"") + assert jsonBom.text.contains("\"specVersion\" : \"1.6\"") } def "should print error if project group, name, or version unset"() { @@ -638,9 +651,10 @@ class PluginConfigurationSpec extends Specification { then: result.task(":cyclonedxBom").outcome == TaskOutcome.FAILED - assert result.output.contains("Project group, name, and version must be set for the root project") + assert result.output.contains("Invalid module identifier provided.") } + @Ignore def "should include metadata by default"() { given: File testDir = TestUtils.createFromString(""" @@ -670,6 +684,7 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"id\" : \"Apache-2.0\"") } + @Ignore def "should not include metadata when includeMetadataResolution is false"() { given: File testDir = TestUtils.createFromString(""" diff --git a/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy b/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy index 91be132f..af393848 100644 --- a/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/TestUtils.groovy @@ -20,7 +20,7 @@ class TestUtils { return tmpDir } - static File duplicateRepo(String testProject) { + static String duplicateRepo(String testProject) { def tmpDir = File.createTempDir( "copy", testProject) def baseDir = new File("src/test/resources/test-repos/$testProject").toPath() @@ -33,7 +33,8 @@ class TestUtils { Files.copy(path, targetPath) } } - return tmpDir + + return """file://${tmpDir.absolutePath.replace("\\","/")}/repository""" } static File createFromString(String buildContent, String settingsContent) { diff --git a/src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar b/src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar new file mode 100644 index 00000000..2a82c406 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar @@ -0,0 +1 @@ +component a version 1.0.0 \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom b/src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom similarity index 100% rename from src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom rename to src/test/resources/test-repos/local/repository/com/test/componenta/1.0.0/componenta-1.0.0.pom diff --git a/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar new file mode 100644 index 00000000..74396664 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar @@ -0,0 +1 @@ +component b version 1.0.0 \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom similarity index 100% rename from src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom rename to src/test/resources/test-repos/local/repository/com/test/componentb/1.0.0/componentb-1.0.0.pom diff --git a/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar new file mode 100644 index 00000000..274c11c9 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar @@ -0,0 +1 @@ +component b version 1.0.1 \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom b/src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom similarity index 100% rename from src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom rename to src/test/resources/test-repos/local/repository/com/test/componentb/1.0.1/componentb-1.0.1.pom diff --git a/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.pom b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.pom new file mode 100644 index 00000000..6c7deef0 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.pom @@ -0,0 +1,18 @@ + + + 4.0.0 + com.test + componentc + 1.0.0 + tgz + + + + com.test + componentb + 1.0.0 + + + + diff --git a/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.tgz b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.tgz new file mode 100644 index 00000000..84c271a7 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.0/componentc-1.0.0.tgz @@ -0,0 +1 @@ +component c version 1.0.0 diff --git a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar b/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar deleted file mode 100644 index 8663d286..00000000 --- a/src/test/resources/test-repos/test1/repository/com/test/componenta/1.0.0/componenta-1.0.0.jar +++ /dev/null @@ -1 +0,0 @@ -randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar deleted file mode 100644 index 8663d286..00000000 --- a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.0/componentb-1.0.0.jar +++ /dev/null @@ -1 +0,0 @@ -randomvalue \ No newline at end of file diff --git a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar b/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar deleted file mode 100644 index 8663d286..00000000 --- a/src/test/resources/test-repos/test1/repository/com/test/componentb/1.0.1/componentb-1.0.1.jar +++ /dev/null @@ -1 +0,0 @@ -randomvalue \ No newline at end of file From 069d3161351df49d3ec298dbc3035af9a260a458 Mon Sep 17 00:00:00 2001 From: Gordon Date: Wed, 23 Oct 2024 21:39:23 +0100 Subject: [PATCH 15/26] fix: enable cache for multi node project and return empty classifier for non existing file extensions Signed-off-by: Gordon --- .../cyclonedx/gradle/CycloneDxBomBuilder.java | 17 ++++---- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 31 +++++++-------- .../org/cyclonedx/gradle/CycloneDxTask.java | 7 ++-- .../gradle/model/ArtifactInfoSet.java | 39 +++++++++++++++++++ .../gradle/model/ResolvedArtifacts.java | 35 +++++++++++++++++ .../gradle/PluginConfigurationSpec.groovy | 20 +++++----- 6 files changed, 110 insertions(+), 39 deletions(-) create mode 100644 src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java index 87c9b06b..12133979 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java @@ -19,6 +19,7 @@ package org.cyclonedx.gradle; import com.github.packageurl.MalformedPackageURLException; +import com.networknt.schema.utils.StringUtils; import java.io.File; import java.io.IOException; import java.util.*; @@ -40,11 +41,7 @@ public class CycloneDxBomBuilder { private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; - private static final TreeMap DEFAULT_TYPE = new TreeMap<>(); - - static { - DEFAULT_TYPE.put("type", "jar"); - } + private static final TreeMap EMPTY_TYPE = new TreeMap<>(); private final Logger logger; private final Map> artifactHashes; @@ -185,10 +182,16 @@ private List calculateHashes(final File artifactFile) { private TreeMap getType(final File file) { if (file == null) { - return DEFAULT_TYPE; + return EMPTY_TYPE; } + + String fileExtension = FilenameUtils.getExtension(file.getName()); + if (StringUtils.isBlank(fileExtension)) { + return EMPTY_TYPE; + } + final TreeMap type = new TreeMap<>(); - type.put("type", FilenameUtils.getExtension(file.getName())); + type.put("type", fileExtension); return type; } } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index fee1b309..d2b191cd 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -21,9 +21,7 @@ import java.io.File; import java.util.*; import java.util.stream.Collectors; -import org.cyclonedx.gradle.model.ArtifactInfo; -import org.cyclonedx.gradle.model.ResolvedBuild; -import org.cyclonedx.gradle.model.ResolvedConfiguration; +import org.cyclonedx.gradle.model.*; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.artifacts.Configuration; @@ -35,7 +33,7 @@ public class CycloneDxPlugin implements Plugin { public void apply(Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { final ResolvedBuild resolvedBuild = getResolvedBuild(project); - final Optional>> artifacts = getArtifacts(project); + final Set> artifacts = getArtifacts(project); final File destination = project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); @@ -43,7 +41,7 @@ public void apply(Project project) { task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); - artifacts.ifPresent(provider -> task.getArtifacts().set(provider)); + task.getArtifacts().set(new ResolvedArtifacts(artifacts)); }); } @@ -61,23 +59,20 @@ private ResolvedBuild getResolvedBuild(final Project project) { return resolvedBuild; } - private Optional>> getArtifacts(final Project project) { + private Set> getArtifacts(final Project project) { return project.getAllprojects().stream() .flatMap(v -> v.getConfigurations().stream()) .filter(Configuration::isCanBeResolved) - .map(v -> v.getIncoming().getArtifacts().getResolvedArtifacts()) - .reduce(this::combineArtifactsProviders) - .map(provider -> - provider.map(v -> v.stream().map(this::toArtifactInfo).collect(Collectors.toSet()))); - } - - private Provider> combineArtifactsProviders( - final Provider> left, final Provider> right) { - return left.zip(right, (u, v) -> { - u.addAll(v); - return u; - }); + .map(config -> config.getIncoming() + .getArtifacts() + .getResolvedArtifacts() + .map(artifacts -> { + ArtifactInfoSet infoSet = new ArtifactInfoSet(); + artifacts.forEach(artifact -> infoSet.addInfo(toArtifactInfo(artifact))); + return infoSet; + })) + .collect(Collectors.toSet()); } private ArtifactInfo toArtifactInfo(final ResolvedArtifactResult result) { diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index 9335289b..bd6732af 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -21,13 +21,12 @@ import java.io.File; import java.util.Map; import java.util.Set; -import org.cyclonedx.gradle.model.ArtifactInfo; +import org.cyclonedx.gradle.model.ResolvedArtifacts; import org.cyclonedx.gradle.model.ResolvedBuild; import org.cyclonedx.gradle.model.ResolvedConfiguration; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.gradle.api.DefaultTask; import org.gradle.api.provider.Property; -import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.TaskAction; @@ -46,7 +45,7 @@ public CycloneDxTask() { public abstract Property getDestination(); @Input - public abstract SetProperty getArtifacts(); + public abstract Property getArtifacts(); @TaskAction public void createBom() { @@ -72,6 +71,6 @@ private void buildChildDependencies(final Map } private void registerArtifacts() { - getArtifacts().get().forEach(traverser::registerArtifact); + getArtifacts().get().getArtifacts().forEach(v -> v.get().getInfoSet().forEach(traverser::registerArtifact)); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java new file mode 100644 index 00000000..322b4f44 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java @@ -0,0 +1,39 @@ +/* + * 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.model; + +import java.util.HashSet; +import java.util.Set; + +public class ArtifactInfoSet { + + private final Set infoSet; + + public ArtifactInfoSet() { + this.infoSet = new HashSet<>(); + } + + public void addInfo(final ArtifactInfo info) { + this.infoSet.add(info); + } + + public Set getInfoSet() { + return infoSet; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java new file mode 100644 index 00000000..3d9f649f --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java @@ -0,0 +1,35 @@ +/* + * 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.model; + +import java.util.Set; +import org.gradle.api.provider.Provider; + +public class ResolvedArtifacts { + + private final Set> artifacts; + + public ResolvedArtifacts(Set> artifacts) { + this.artifacts = artifacts; + } + + public Set> getArtifacts() { + return artifacts; + } +} diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index 13b6ca35..ae27bbe8 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -338,7 +338,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S") + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") .withPluginClasspath() .build() then: @@ -354,9 +354,9 @@ class PluginConfigurationSpec extends Specification { def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") assert appAComponent.hasComponentDefined() - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0?type=jar") + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") assert appBComponent.hasComponentDefined() assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") } @@ -382,13 +382,13 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString assert jsonBom.metadata.component.type == "library" - assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-a@1.0.0?type=jar" + assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-a@1.0.0" assert jsonBom.metadata.component.group == "com.example" assert jsonBom.metadata.component.name == "app-a" assert jsonBom.metadata.component.version == "1.0.0" - assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-a@1.0.0?type=jar" + assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-a@1.0.0" - def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") + def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") assert !appAComponent.hasComponentDefined() assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") @@ -418,19 +418,19 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString assert jsonBom.metadata.component.type == "library" - assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-b@1.0.0?type=jar" + assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-b@1.0.0" assert jsonBom.metadata.component.group == "com.example" assert jsonBom.metadata.component.name == "app-b" assert jsonBom.metadata.component.version == "1.0.0" - assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-b@1.0.0?type=jar" + assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-b@1.0.0" def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") assert appAComponent.hasComponentDefined() assert appAComponent.component.hashes != null assert !appAComponent.component.hashes.empty - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0?type=jar") + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") assert !appBComponent.hasComponentDefined() assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") } From 3a3ec8964881750798ed3f827d275bb93462ddb9 Mon Sep 17 00:00:00 2001 From: Gordon Date: Fri, 25 Oct 2024 15:46:18 +0100 Subject: [PATCH 16/26] fix: provide artifacts lazily Signed-off-by: Gordon --- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 23 +++++------ .../org/cyclonedx/gradle/CycloneDxTask.java | 7 ++-- .../gradle/model/ArtifactInfoSet.java | 39 ------------------- .../gradle/model/ResolvedArtifacts.java | 35 ----------------- .../gradle/PluginConfigurationSpec.groovy | 23 ++++++++++- .../native-kotlin-project/build.gradle | 25 ++++++++++++ .../native-kotlin-project/settings.gradle | 6 +++ .../src/nativeMain/kotlin/hello.kt | 3 ++ 8 files changed, 69 insertions(+), 92 deletions(-) delete mode 100644 src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java delete mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java create mode 100644 src/test/resources/test-projects/native-kotlin-project/build.gradle create mode 100644 src/test/resources/test-projects/native-kotlin-project/settings.gradle create mode 100644 src/test/resources/test-projects/native-kotlin-project/src/nativeMain/kotlin/hello.kt diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index d2b191cd..a9dfc54d 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -33,7 +33,7 @@ public class CycloneDxPlugin implements Plugin { public void apply(Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { final ResolvedBuild resolvedBuild = getResolvedBuild(project); - final Set> artifacts = getArtifacts(project); + final Provider> artifacts = getArtifacts(project); final File destination = project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); @@ -41,7 +41,7 @@ public void apply(Project project) { task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); - task.getArtifacts().set(new ResolvedArtifacts(artifacts)); + task.getArtifacts().set(artifacts); }); } @@ -59,20 +59,17 @@ private ResolvedBuild getResolvedBuild(final Project project) { return resolvedBuild; } - private Set> getArtifacts(final Project project) { + private Provider> getArtifacts(final Project project) { - return project.getAllprojects().stream() + final List configurations = project.getAllprojects().stream() .flatMap(v -> v.getConfigurations().stream()) + .collect(Collectors.toList()); + + return project.getProviders().provider(() -> configurations.stream() .filter(Configuration::isCanBeResolved) - .map(config -> config.getIncoming() - .getArtifacts() - .getResolvedArtifacts() - .map(artifacts -> { - ArtifactInfoSet infoSet = new ArtifactInfoSet(); - artifacts.forEach(artifact -> infoSet.addInfo(toArtifactInfo(artifact))); - return infoSet; - })) - .collect(Collectors.toSet()); + .flatMap(config -> config.getIncoming().getArtifacts().getArtifacts().stream() + .map(artifact -> toArtifactInfo(artifact))) + .collect(Collectors.toSet())); } private ArtifactInfo toArtifactInfo(final ResolvedArtifactResult result) { diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index bd6732af..9335289b 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -21,12 +21,13 @@ import java.io.File; import java.util.Map; import java.util.Set; -import org.cyclonedx.gradle.model.ResolvedArtifacts; +import org.cyclonedx.gradle.model.ArtifactInfo; import org.cyclonedx.gradle.model.ResolvedBuild; import org.cyclonedx.gradle.model.ResolvedConfiguration; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.gradle.api.DefaultTask; import org.gradle.api.provider.Property; +import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.TaskAction; @@ -45,7 +46,7 @@ public CycloneDxTask() { public abstract Property getDestination(); @Input - public abstract Property getArtifacts(); + public abstract SetProperty getArtifacts(); @TaskAction public void createBom() { @@ -71,6 +72,6 @@ private void buildChildDependencies(final Map } private void registerArtifacts() { - getArtifacts().get().getArtifacts().forEach(v -> v.get().getInfoSet().forEach(traverser::registerArtifact)); + getArtifacts().get().forEach(traverser::registerArtifact); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java b/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java deleted file mode 100644 index 322b4f44..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfoSet.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.model; - -import java.util.HashSet; -import java.util.Set; - -public class ArtifactInfoSet { - - private final Set infoSet; - - public ArtifactInfoSet() { - this.infoSet = new HashSet<>(); - } - - public void addInfo(final ArtifactInfo info) { - this.infoSet.add(info); - } - - public Set getInfoSet() { - return infoSet; - } -} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java deleted file mode 100644 index 3d9f649f..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/ResolvedArtifacts.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.model; - -import java.util.Set; -import org.gradle.api.provider.Provider; - -public class ResolvedArtifacts { - - private final Set> artifacts; - - public ResolvedArtifacts(Set> artifacts) { - this.artifacts = artifacts; - } - - public Set> getArtifacts() { - return artifacts; - } -} diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index ae27bbe8..2ccd86b6 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -162,12 +162,13 @@ 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' + implementation group: 'org.jetbrains.kotlin', name: 'kotlin-native-prebuilt', version: '2.0.20' }""", "rootProject.name = 'hello-world'") when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -181,6 +182,25 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"name\" : \"hello-world\"") } + def "should build bom successfully for native kotlin project"() { + given: + File testDir = TestUtils.duplicate("native-kotlin-project") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "build/reports") + + assert reportDir.exists() + reportDir.listFiles().length == 1 + } + @Ignore def "should use configured componentName"() { given: @@ -343,7 +363,6 @@ class PluginConfigurationSpec extends Specification { .build() then: result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS - println(result.output) File reportDir = new File(testDir, "build/reports") assert reportDir.exists() diff --git a/src/test/resources/test-projects/native-kotlin-project/build.gradle b/src/test/resources/test-projects/native-kotlin-project/build.gradle new file mode 100644 index 00000000..5ff89e72 --- /dev/null +++ b/src/test/resources/test-projects/native-kotlin-project/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'org.cyclonedx.bom' + id 'org.jetbrains.kotlin.multiplatform' version '2.0.21' +} + +repositories { + mavenCentral() +} + +group = 'com.example' +version = '1.0.0' +kotlin { + // macosX64('native') { // on macOS + // linuxX64('native') // on Linux + mingwX64('native'){ // on Windows + binaries { + executable() + } + } +} + +wrapper { + gradleVersion = '8.5' + distributionType = 'BIN' +} diff --git a/src/test/resources/test-projects/native-kotlin-project/settings.gradle b/src/test/resources/test-projects/native-kotlin-project/settings.gradle new file mode 100644 index 00000000..3b582363 --- /dev/null +++ b/src/test/resources/test-projects/native-kotlin-project/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { + repositories { + mavenLocal() + gradlePluginPortal() + } +} diff --git a/src/test/resources/test-projects/native-kotlin-project/src/nativeMain/kotlin/hello.kt b/src/test/resources/test-projects/native-kotlin-project/src/nativeMain/kotlin/hello.kt new file mode 100644 index 00000000..64472529 --- /dev/null +++ b/src/test/resources/test-projects/native-kotlin-project/src/nativeMain/kotlin/hello.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello, Kotlin/Native!") +} From 78e6584f63327156c7bdf26f971256650b2f0907 Mon Sep 17 00:00:00 2001 From: Gordon Date: Sat, 26 Oct 2024 21:03:20 +0100 Subject: [PATCH 17/26] feat: support build configuration cache Signed-off-by: Gordon --- .../cyclonedx/gradle/ComponentProvider.java | 69 +++++++++++ .../cyclonedx/gradle/CycloneDxBomBuilder.java | 107 +++++++++--------- .../gradle/CycloneDxDependencyTraverser.java | 92 +++++++++++++-- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 49 +------- .../org/cyclonedx/gradle/CycloneDxTask.java | 40 ++----- ...ifactInfo.java => ConfigurationScope.java} | 23 ++-- .../org/cyclonedx/gradle/model/GraphNode.java | 90 --------------- .../cyclonedx/gradle/model/ResolvedBuild.java | 62 ---------- .../gradle/model/ResolvedConfiguration.java | 42 ------- .../gradle/model/SerializableComponent.java | 91 +++++++++++++++ .../gradle/model/SerializableComponents.java | 44 +++++++ .../gradle/utils/DependencyUtils.java | 10 +- .../gradle/PluginConfigurationSpec.groovy | 1 + 13 files changed, 373 insertions(+), 347 deletions(-) create mode 100644 src/main/java/org/cyclonedx/gradle/ComponentProvider.java rename src/main/java/org/cyclonedx/gradle/model/{ArtifactInfo.java => ConfigurationScope.java} (60%) delete mode 100644 src/main/java/org/cyclonedx/gradle/model/GraphNode.java delete mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java delete mode 100644 src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java diff --git a/src/main/java/org/cyclonedx/gradle/ComponentProvider.java b/src/main/java/org/cyclonedx/gradle/ComponentProvider.java new file mode 100644 index 00000000..73ce5eb9 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/ComponentProvider.java @@ -0,0 +1,69 @@ +/* + * 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; + +import java.util.concurrent.Callable; +import org.cyclonedx.gradle.model.SerializableComponents; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; + +public class ComponentProvider implements Callable { + + private final Project project; + + public ComponentProvider(final Project project) { + this.project = project; + } + + @Override + public SerializableComponents call() throws Exception { + + final CycloneDxDependencyTraverser traverser = + new CycloneDxDependencyTraverser(project.getLogger(), new CycloneDxBomBuilder(project.getLogger())); + + traverseParentProject(traverser); + traverseChildProjects(traverser); + registerArtifacts(traverser); + + return traverser.serializableComponents(); + } + + private void traverseParentProject(final CycloneDxDependencyTraverser traverser) { + project.getConfigurations().stream() + .filter(Configuration::isCanBeResolved) + .forEach(config -> traverser.traverseParentGraph( + config.getIncoming().getResolutionResult().getRoot(), project.getName(), config.getName())); + } + + private void traverseChildProjects(final CycloneDxDependencyTraverser traverser) { + project.getChildProjects().forEach((k, v) -> v.getConfigurations().stream() + .filter(Configuration::isCanBeResolved) + .forEach(config -> traverser.traverseChildGraph( + config.getIncoming().getResolutionResult().getRoot(), k, config.getName()))); + } + + private void registerArtifacts(final CycloneDxDependencyTraverser traverser) { + project.getAllprojects().stream() + .flatMap(project -> project.getConfigurations().stream()) + .filter(Configuration::isCanBeResolved) + .forEach(config -> config.getIncoming().getArtifacts().getArtifacts().stream() + .forEach(artifact -> traverser.registerArtifact( + artifact.getId().getComponentIdentifier(), artifact.getFile()))); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java index 12133979..791bb463 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java @@ -22,20 +22,30 @@ import com.networknt.schema.utils.StringUtils; import java.io.File; import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.UUID; import java.util.stream.Collectors; import org.apache.commons.io.FilenameUtils; import org.cyclonedx.Version; import org.cyclonedx.gradle.model.ComponentComparator; import org.cyclonedx.gradle.model.DependencyComparator; -import org.cyclonedx.gradle.model.GraphNode; +import org.cyclonedx.gradle.model.SerializableComponent; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.cyclonedx.gradle.utils.DependencyUtils; -import org.cyclonedx.model.*; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.Dependency; +import org.cyclonedx.model.Hash; +import org.cyclonedx.model.Metadata; +import org.cyclonedx.model.Property; import org.cyclonedx.util.BomUtils; -import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.component.ComponentIdentifier; -import org.gradle.api.artifacts.result.ResolvedComponentResult; import org.gradle.api.logging.Logger; public class CycloneDxBomBuilder { @@ -56,30 +66,29 @@ public CycloneDxBomBuilder(final Logger logger) { } public Bom buildBom( - final Map> resultGraph, - final GraphNode parentNode, - final Map resolvedArtifacts) { + final Map> resultGraph, + final SerializableComponent parentComponent) { final Set dependencies = new TreeSet<>(new DependencyComparator()); final Set components = new TreeSet<>(new ComponentComparator()); - resultGraph.keySet().forEach(node -> { - addDependency(dependencies, resultGraph.get(node), node, resolvedArtifacts); - addComponent(components, node, parentNode, resolvedArtifacts); + resultGraph.keySet().forEach(component -> { + addDependency(dependencies, resultGraph.get(component), component); + addComponent(components, component, parentComponent); }); final Bom bom = new Bom(); bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); - bom.setMetadata(buildMetadata(parentNode)); + bom.setMetadata(buildMetadata(parentComponent)); bom.setComponents(new ArrayList<>(components)); bom.setDependencies(new ArrayList<>(dependencies)); return bom; } - private Metadata buildMetadata(final GraphNode parentNode) { + private Metadata buildMetadata(final SerializableComponent parentComponent) { final Metadata metadata = new Metadata(); try { - metadata.setComponent(toComponent(parentNode, null)); + metadata.setComponent(toComponent(parentComponent, null)); } catch (MalformedPackageURLException e) { logger.warn("Error constructing packageUrl for parent component. Skipping...", e); } @@ -88,78 +97,74 @@ private Metadata buildMetadata(final GraphNode parentNode) { private void addDependency( final Set dependencies, - final Set dependencyNodes, - final GraphNode node, - final Map resolvedArtifacts) { + final Set dependencyComponents, + final SerializableComponent component) { final Dependency dependency; try { - dependency = toDependency(node.getResult(), resolvedArtifacts); + dependency = toDependency(component); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for node. Skipping...", e); + logger.warn("Error constructing packageUrl for component. Skipping...", e); return; } - dependencyNodes.forEach(dependencyNode -> { + dependencyComponents.forEach(dependencyComponent -> { try { - dependency.addDependency(toDependency(dependencyNode.getResult(), resolvedArtifacts)); + dependency.addDependency(toDependency(dependencyComponent)); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for node dependency. Skipping...", e); + logger.warn("Error constructing packageUrl for component dependency. Skipping...", e); } }); dependencies.add(dependency); } - private Dependency toDependency( - final ResolvedComponentResult component, final Map resolvedArtifacts) - throws MalformedPackageURLException { + private Dependency toDependency(final SerializableComponent component) throws MalformedPackageURLException { - final File artifactFile = resolvedArtifacts.get(component.getId()); - final String ref = DependencyUtils.generatePackageUrl(component.getModuleVersion(), getType(artifactFile)); + final String ref = DependencyUtils.generatePackageUrl( + component, getType(component.getArtifactFile().orElse(null))); return new Dependency(ref); } private void addComponent( final Set components, - final GraphNode node, - final GraphNode parentNode, - final Map resolvedArtifacts) { - if (!node.equals(parentNode)) { - final File artifactFile = resolvedArtifacts.get(node.getResult().getId()); + final SerializableComponent component, + final SerializableComponent parentComponent) { + if (!component.equals(parentComponent)) { + final File artifactFile = component.getArtifactFile().orElse(null); try { - components.add(toComponent(node, artifactFile)); + components.add(toComponent(component, artifactFile)); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for node component. Skipping...", e); + logger.warn("Error constructing packageUrl for component. Skipping...", e); } } } - private Component toComponent(final GraphNode node, final File artifactFile) throws MalformedPackageURLException { + private Component toComponent(final SerializableComponent component, final File artifactFile) + throws MalformedPackageURLException { - final ModuleVersionIdentifier moduleVersion = node.getResult().getModuleVersion(); - final String packageUrl = DependencyUtils.generatePackageUrl(moduleVersion, getType(artifactFile)); + final String packageUrl = DependencyUtils.generatePackageUrl(component, getType(artifactFile)); - final Component component = new Component(); - component.setGroup(moduleVersion.getGroup()); - component.setName(moduleVersion.getName()); - component.setVersion(moduleVersion.getVersion()); - component.setType(Component.Type.LIBRARY); - component.setPurl(packageUrl); - component.setProperties(buildProperties(node)); + final Component resultComponent = new Component(); + resultComponent.setGroup(component.getGroup()); + resultComponent.setName(component.getName()); + resultComponent.setVersion(component.getVersion()); + resultComponent.setType(Component.Type.LIBRARY); + resultComponent.setPurl(packageUrl); + resultComponent.setProperties(buildProperties(component)); if (version.getVersion() >= 1.1) { - component.setModified(mavenHelper.isModified(null)); - component.setBomRef(packageUrl); + resultComponent.setModified(mavenHelper.isModified(null)); + resultComponent.setBomRef(packageUrl); } logger.debug(MESSAGE_CALCULATING_HASHES); if (artifactFile != null) { - component.setHashes(calculateHashes(artifactFile)); + resultComponent.setHashes(calculateHashes(artifactFile)); } - return component; + return resultComponent; } - private List buildProperties(GraphNode node) { - return node.getInScopeConfigurations().stream() + private List buildProperties(SerializableComponent component) { + return component.getInScopeConfigurations().stream() .map(v -> { Property property = new Property(); property.setName("inScopeConfiguration"); diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java index b368a84c..7d2d5d84 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java @@ -22,14 +22,17 @@ import java.io.File; import java.util.ArrayDeque; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Objects; import java.util.Queue; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; -import org.cyclonedx.gradle.model.ArtifactInfo; -import org.cyclonedx.gradle.model.GraphNode; -import org.cyclonedx.model.*; +import java.util.stream.Collectors; +import org.cyclonedx.gradle.model.ConfigurationScope; +import org.cyclonedx.gradle.model.SerializableComponent; +import org.cyclonedx.gradle.model.SerializableComponents; import org.gradle.api.GradleException; import org.gradle.api.artifacts.ModuleVersionIdentifier; import org.gradle.api.artifacts.component.ComponentIdentifier; @@ -37,6 +40,7 @@ import org.gradle.api.artifacts.result.ResolvedComponentResult; import org.gradle.api.artifacts.result.ResolvedDependencyResult; import org.gradle.api.logging.Logger; +import org.jetbrains.annotations.NotNull; public class CycloneDxDependencyTraverser { @@ -53,8 +57,8 @@ public CycloneDxDependencyTraverser(final Logger logger, final CycloneDxBomBuild this.resultGraph = new HashMap<>(); } - public void registerArtifact(final ArtifactInfo artifact) { - resolvedArtifacts.put(artifact.getComponentId(), artifact.getArtifactFile()); + public void registerArtifact(final ComponentIdentifier componentId, final File artifactFile) { + resolvedArtifacts.put(componentId, artifactFile); } public void traverseParentGraph( @@ -123,8 +127,35 @@ private void mergeIntoResultGraph( .forEach(v -> v.inScopeConfiguration(projectName, configName)); } - public Bom toBom() { - return builder.buildBom(this.resultGraph, this.parentNode, this.resolvedArtifacts); + public SerializableComponents serializableComponents() { + + Map> result = new HashMap<>(); + this.resultGraph.forEach((k, v) -> { + result.put( + serializableComponent(k), + v.stream().map(w -> serializableComponent(w)).collect(Collectors.toSet())); + }); + + return new SerializableComponents(result, serializableComponent(this.parentNode)); + } + + private SerializableComponent serializableComponent(final GraphNode node) { + + ResolvedComponentResult resolvedComponent = node.getResult(); + if (this.resolvedArtifacts.containsKey(resolvedComponent.getId())) { + return new SerializableComponent( + resolvedComponent.getModuleVersion().getGroup(), + resolvedComponent.getModuleVersion().getName(), + resolvedComponent.getModuleVersion().getVersion(), + node.getInScopeConfigurations(), + this.resolvedArtifacts.get(resolvedComponent.getId())); + } else { + return new SerializableComponent( + resolvedComponent.getModuleVersion().getGroup(), + resolvedComponent.getModuleVersion().getName(), + resolvedComponent.getModuleVersion().getVersion(), + node.getInScopeConfigurations()); + } } private String getRef(final ModuleVersionIdentifier identifier) { @@ -140,4 +171,51 @@ private String getRef(final ModuleVersionIdentifier identifier) { return String.format("%s:%s:%s", identifier.getGroup(), identifier.getName(), identifier.getVersion()); } + + private static class GraphNode implements Comparable { + + private final String ref; + private final ResolvedComponentResult result; + private final Set inScopeConfigurations; + + private GraphNode(final String ref, final ResolvedComponentResult result) { + this.ref = ref; + this.result = result; + this.inScopeConfigurations = new HashSet<>(); + } + + private String getRef() { + return ref; + } + + private ResolvedComponentResult getResult() { + return result; + } + + private void inScopeConfiguration(final String projectName, final String configName) { + inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); + } + + private Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + @Override + public int compareTo(@NotNull GraphNode o) { + return this.ref.compareTo(o.ref); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GraphNode graphNode = (GraphNode) o; + return Objects.equals(ref, graphNode.ref); + } + + @Override + public int hashCode() { + return Objects.hashCode(ref); + } + } } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index a9dfc54d..f066da2d 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -19,65 +19,24 @@ package org.cyclonedx.gradle; import java.io.File; -import java.util.*; -import java.util.stream.Collectors; -import org.cyclonedx.gradle.model.*; +import org.cyclonedx.gradle.model.SerializableComponents; import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; -import org.gradle.api.artifacts.result.ResolvedArtifactResult; import org.gradle.api.provider.Provider; public class CycloneDxPlugin implements Plugin { public void apply(Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { - final ResolvedBuild resolvedBuild = getResolvedBuild(project); - final Provider> artifacts = getArtifacts(project); + final Provider components = + project.getProviders().provider(new ComponentProvider(project)); final File destination = project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); - task.getResolvedBuild().set(resolvedBuild); + task.getComponents().set(components); task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); - task.getArtifacts().set(artifacts); }); } - - private ResolvedBuild getResolvedBuild(final Project project) { - - final ResolvedBuild resolvedBuild = new ResolvedBuild(project.getName()); - project.getConfigurations().stream() - .filter(Configuration::isCanBeResolved) - .forEach(v -> resolvedBuild.addProjectConfiguration(resolvedConfiguration(v))); - - project.getChildProjects().forEach((k, v) -> v.getConfigurations().stream() - .filter(Configuration::isCanBeResolved) - .forEach(w -> resolvedBuild.addSubProjectConfiguration(k, resolvedConfiguration(w)))); - - return resolvedBuild; - } - - private Provider> getArtifacts(final Project project) { - - final List configurations = project.getAllprojects().stream() - .flatMap(v -> v.getConfigurations().stream()) - .collect(Collectors.toList()); - - return project.getProviders().provider(() -> configurations.stream() - .filter(Configuration::isCanBeResolved) - .flatMap(config -> config.getIncoming().getArtifacts().getArtifacts().stream() - .map(artifact -> toArtifactInfo(artifact))) - .collect(Collectors.toSet())); - } - - private ArtifactInfo toArtifactInfo(final ResolvedArtifactResult result) { - return new ArtifactInfo(result.getId().getComponentIdentifier(), result.getFile()); - } - - private ResolvedConfiguration resolvedConfiguration(final Configuration config) { - return new ResolvedConfiguration( - config.getName(), config.getIncoming().getResolutionResult().getRootComponent()); - } } diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index 9335289b..5aa7c5f8 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -19,59 +19,33 @@ package org.cyclonedx.gradle; import java.io.File; -import java.util.Map; -import java.util.Set; -import org.cyclonedx.gradle.model.ArtifactInfo; -import org.cyclonedx.gradle.model.ResolvedBuild; -import org.cyclonedx.gradle.model.ResolvedConfiguration; +import org.cyclonedx.gradle.model.SerializableComponents; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.gradle.api.DefaultTask; import org.gradle.api.provider.Property; -import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.TaskAction; public abstract class CycloneDxTask extends DefaultTask { - private final CycloneDxDependencyTraverser traverser; + private final CycloneDxBomBuilder builder; public CycloneDxTask() { - this.traverser = new CycloneDxDependencyTraverser(getLogger(), new CycloneDxBomBuilder(getLogger())); + this.builder = new CycloneDxBomBuilder(getLogger()); } @Input - public abstract Property getResolvedBuild(); + public abstract Property getComponents(); @Input public abstract Property getDestination(); - @Input - public abstract SetProperty getArtifacts(); - @TaskAction public void createBom() { - final ResolvedBuild resolvedBuild = getResolvedBuild().get(); - - registerArtifacts(); - buildParentDependencies(resolvedBuild.getProjectName(), resolvedBuild.getProjectConfigurations()); - buildChildDependencies(resolvedBuild.getSubProjectsConfigurations()); - File destination = new File(getDestination().get(), "bom.json"); - CycloneDxUtils.writeBom(traverser.toBom(), destination); - } - - private void buildParentDependencies(final String projectName, Set configurations) { - configurations.forEach(config -> traverser.traverseParentGraph( - config.getDependencyGraph().get(), projectName, config.getConfigurationName())); - } - - private void buildChildDependencies(final Map> configurations) { - configurations.forEach((key, value) -> value.forEach(config -> - traverser.traverseChildGraph(config.getDependencyGraph().get(), key, config.getConfigurationName()))); - } - - private void registerArtifacts() { - getArtifacts().get().forEach(traverser::registerArtifact); + SerializableComponents components = getComponents().get(); + CycloneDxUtils.writeBom( + builder.buildBom(components.getSerializableComponents(), components.getRootComponent()), destination); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java b/src/main/java/org/cyclonedx/gradle/model/ConfigurationScope.java similarity index 60% rename from src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java rename to src/main/java/org/cyclonedx/gradle/model/ConfigurationScope.java index 8a9c40fa..9dca898e 100644 --- a/src/main/java/org/cyclonedx/gradle/model/ArtifactInfo.java +++ b/src/main/java/org/cyclonedx/gradle/model/ConfigurationScope.java @@ -18,24 +18,23 @@ */ package org.cyclonedx.gradle.model; -import java.io.File; -import org.gradle.api.artifacts.component.ComponentIdentifier; +import java.io.Serializable; -public class ArtifactInfo { +public class ConfigurationScope implements Serializable { - private final ComponentIdentifier componentId; - private final File artifactFile; + private final String projectName; + private final String configName; - public ArtifactInfo(final ComponentIdentifier componentId, final File artifactFile) { - this.componentId = componentId; - this.artifactFile = artifactFile; + public ConfigurationScope(final String projectName, final String configName) { + this.projectName = projectName; + this.configName = configName; } - public ComponentIdentifier getComponentId() { - return componentId; + public String getProjectName() { + return projectName; } - public File getArtifactFile() { - return artifactFile; + public String getConfigName() { + return configName; } } diff --git a/src/main/java/org/cyclonedx/gradle/model/GraphNode.java b/src/main/java/org/cyclonedx/gradle/model/GraphNode.java deleted file mode 100644 index a43539fc..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/GraphNode.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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.model; - -import java.util.HashSet; -import java.util.Objects; -import java.util.Set; -import org.gradle.api.artifacts.result.ResolvedComponentResult; -import org.jetbrains.annotations.NotNull; - -public class GraphNode implements Comparable { - - private final String ref; - private final ResolvedComponentResult result; - private final Set inScopeConfigurations; - - public GraphNode(final String ref, final ResolvedComponentResult result) { - this.ref = ref; - this.result = result; - this.inScopeConfigurations = new HashSet<>(); - } - - public String getRef() { - return ref; - } - - public ResolvedComponentResult getResult() { - return result; - } - - public void inScopeConfiguration(final String projectName, final String configName) { - inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); - } - - public Set getInScopeConfigurations() { - return inScopeConfigurations; - } - - @Override - public int compareTo(@NotNull GraphNode o) { - return this.ref.compareTo(o.ref); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - GraphNode graphNode = (GraphNode) o; - return Objects.equals(ref, graphNode.ref); - } - - @Override - public int hashCode() { - return Objects.hashCode(ref); - } - - public static class ConfigurationScope { - private final String projectName; - private final String configName; - - private ConfigurationScope(final String projectName, final String configName) { - this.projectName = projectName; - this.configName = configName; - } - - public String getProjectName() { - return projectName; - } - - public String getConfigName() { - return configName; - } - } -} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java deleted file mode 100644 index 22509fc4..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/ResolvedBuild.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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.model; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -public class ResolvedBuild { - private final String projectName; - private final Set projectConfigurations; - private final Map> subProjectsConfigurations; - - public ResolvedBuild(final String projectName) { - this.projectName = projectName; - this.projectConfigurations = new HashSet<>(); - this.subProjectsConfigurations = new HashMap<>(); - } - - public String getProjectName() { - return projectName; - } - - public void addProjectConfiguration(final ResolvedConfiguration configuration) { - projectConfigurations.add(configuration); - } - - public Set getProjectConfigurations() { - return projectConfigurations; - } - - public void addSubProjectConfiguration(final String projectName, final ResolvedConfiguration configuration) { - if (subProjectsConfigurations.containsKey(projectName)) { - subProjectsConfigurations.get(projectName).add(configuration); - } else { - final Set subProjectConfigurations = new HashSet<>(); - subProjectConfigurations.add(configuration); - subProjectsConfigurations.put(projectName, subProjectConfigurations); - } - } - - public Map> getSubProjectsConfigurations() { - return subProjectsConfigurations; - } -} diff --git a/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java b/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java deleted file mode 100644 index 385bbadf..00000000 --- a/src/main/java/org/cyclonedx/gradle/model/ResolvedConfiguration.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.model; - -import org.gradle.api.artifacts.result.ResolvedComponentResult; -import org.gradle.api.provider.Provider; - -public class ResolvedConfiguration { - - private final String configurationName; - private final Provider dependencyGraph; - - public ResolvedConfiguration( - final String configurationName, final Provider dependencyGraph) { - this.configurationName = configurationName; - this.dependencyGraph = dependencyGraph; - } - - public String getConfigurationName() { - return configurationName; - } - - public Provider getDependencyGraph() { - return dependencyGraph; - } -} diff --git a/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java b/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java new file mode 100644 index 00000000..3c65ebcb --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java @@ -0,0 +1,91 @@ +/* + * 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.model; + +import java.io.File; +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class SerializableComponent implements Serializable { + + private final String group; + private final String name; + private final String version; + private final File artifactFile; + private final Set inScopeConfigurations; + + public SerializableComponent( + final String group, + final String name, + final String version, + final Set inScopeConfigurations) { + this(group, name, version, inScopeConfigurations, null); + } + + public SerializableComponent( + final String group, + final String name, + final String version, + final Set inScopeConfigurations, + final File artifactFile) { + this.group = group; + this.name = name; + this.version = version; + this.artifactFile = artifactFile; + this.inScopeConfigurations = inScopeConfigurations; + } + + public String getGroup() { + return group; + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + + public Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + public Optional getArtifactFile() { + return Optional.ofNullable(artifactFile); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SerializableComponent that = (SerializableComponent) o; + return Objects.equals(group, that.group) + && Objects.equals(name, that.name) + && Objects.equals(version, that.version) + && Objects.equals(artifactFile, that.artifactFile); + } + + @Override + public int hashCode() { + return Objects.hash(group, name, version, artifactFile); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java b/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java new file mode 100644 index 00000000..77089ae6 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java @@ -0,0 +1,44 @@ +/* + * 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.model; + +import java.io.Serializable; +import java.util.Map; +import java.util.Set; + +public class SerializableComponents implements Serializable { + + private final Map> serializableComponents; + private final SerializableComponent rootComponent; + + public SerializableComponents( + Map> serializableComponents, + SerializableComponent rootComponent) { + this.serializableComponents = serializableComponents; + this.rootComponent = rootComponent; + } + + public Map> getSerializableComponents() { + return serializableComponents; + } + + public SerializableComponent getRootComponent() { + return rootComponent; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java index 016b6185..68e353ce 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -21,18 +21,18 @@ import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; import java.util.TreeMap; -import org.gradle.api.artifacts.ModuleVersionIdentifier; +import org.cyclonedx.gradle.model.SerializableComponent; public class DependencyUtils { public static String generatePackageUrl( - final ModuleVersionIdentifier version, final TreeMap qualifiers) + final SerializableComponent component, final TreeMap qualifiers) throws MalformedPackageURLException { return new PackageURL( PackageURL.StandardTypes.MAVEN, - version.getGroup(), - version.getName(), - version.getVersion(), + component.getGroup(), + component.getName(), + component.getVersion(), qualifiers, null) .canonicalize(); diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index 2ccd86b6..cef7b0db 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -159,6 +159,7 @@ class PluginConfigurationSpec extends Specification { cyclonedxBom { // No componentName override -> Use rootProject.name } + 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' From 6f0cf7bba78c4525064d495619cbff19317e42e7 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 4 Nov 2024 08:39:02 +0000 Subject: [PATCH 18/26] feat: implement custom configuration Signed-off-by: Gordon --- .../cyclonedx/gradle/ComponentProvider.java | 69 ----- .../gradle/CycloneDxDependencyTraverser.java | 221 -------------- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 13 +- .../org/cyclonedx/gradle/CycloneDxTask.java | 287 +++++++++++++++++- .../gradle/DependencyGraphTraverser.java | 197 ++++++++++++ .../org/cyclonedx/gradle/MavenHelper.java | 192 ++++++------ .../cyclonedx/gradle/MavenProjectLookup.java | 102 +++++++ ...loneDxBomBuilder.java => SbomBuilder.java} | 123 +++++--- .../cyclonedx/gradle/SbomGraphProvider.java | 138 +++++++++ .../gradle/model/ComponentComparator.java | 2 +- .../gradle/model/DependencyComparator.java | 2 +- .../cyclonedx/gradle/model/SbomComponent.java | 121 ++++++++ ...bleComponent.java => SbomComponentId.java} | 44 +-- ...alizableComponents.java => SbomGraph.java} | 19 +- .../cyclonedx/gradle/model/SbomMetaData.java | 89 ++++++ .../gradle/utils/CycloneDxUtils.java | 39 ++- .../gradle/utils/DependencyUtils.java | 101 +++++- .../org/cyclonedx/gradle/CycloneDxSpec.groovy | 19 -- .../gradle/DependencyResolutionSpec.groovy | 214 ++++++++++++- .../gradle/PluginConfigurationSpec.groovy | 268 ++++------------ .../componentc/1.0.1/componentc-1.0.1.pom | 18 ++ .../componentc/1.0.1/componentc-1.0.1.tgz | 1 + .../componentd/1.0.0/componentd-1.0.0.pom | 18 ++ .../componentd/1.0.0/componentd-1.0.0.tgz | 1 + 24 files changed, 1552 insertions(+), 746 deletions(-) delete mode 100644 src/main/java/org/cyclonedx/gradle/ComponentProvider.java delete mode 100644 src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java create mode 100644 src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java create mode 100644 src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java rename src/main/java/org/cyclonedx/gradle/{CycloneDxBomBuilder.java => SbomBuilder.java} (55%) create mode 100644 src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java create mode 100644 src/main/java/org/cyclonedx/gradle/model/SbomComponent.java rename src/main/java/org/cyclonedx/gradle/model/{SerializableComponent.java => SbomComponentId.java} (55%) rename src/main/java/org/cyclonedx/gradle/model/{SerializableComponents.java => SbomGraph.java} (57%) create mode 100644 src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.pom create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.tgz create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.pom create mode 100644 src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.tgz diff --git a/src/main/java/org/cyclonedx/gradle/ComponentProvider.java b/src/main/java/org/cyclonedx/gradle/ComponentProvider.java deleted file mode 100644 index 73ce5eb9..00000000 --- a/src/main/java/org/cyclonedx/gradle/ComponentProvider.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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; - -import java.util.concurrent.Callable; -import org.cyclonedx.gradle.model.SerializableComponents; -import org.gradle.api.Project; -import org.gradle.api.artifacts.Configuration; - -public class ComponentProvider implements Callable { - - private final Project project; - - public ComponentProvider(final Project project) { - this.project = project; - } - - @Override - public SerializableComponents call() throws Exception { - - final CycloneDxDependencyTraverser traverser = - new CycloneDxDependencyTraverser(project.getLogger(), new CycloneDxBomBuilder(project.getLogger())); - - traverseParentProject(traverser); - traverseChildProjects(traverser); - registerArtifacts(traverser); - - return traverser.serializableComponents(); - } - - private void traverseParentProject(final CycloneDxDependencyTraverser traverser) { - project.getConfigurations().stream() - .filter(Configuration::isCanBeResolved) - .forEach(config -> traverser.traverseParentGraph( - config.getIncoming().getResolutionResult().getRoot(), project.getName(), config.getName())); - } - - private void traverseChildProjects(final CycloneDxDependencyTraverser traverser) { - project.getChildProjects().forEach((k, v) -> v.getConfigurations().stream() - .filter(Configuration::isCanBeResolved) - .forEach(config -> traverser.traverseChildGraph( - config.getIncoming().getResolutionResult().getRoot(), k, config.getName()))); - } - - private void registerArtifacts(final CycloneDxDependencyTraverser traverser) { - project.getAllprojects().stream() - .flatMap(project -> project.getConfigurations().stream()) - .filter(Configuration::isCanBeResolved) - .forEach(config -> config.getIncoming().getArtifacts().getArtifacts().stream() - .forEach(artifact -> traverser.registerArtifact( - artifact.getId().getComponentIdentifier(), artifact.getFile()))); - } -} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java b/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java deleted file mode 100644 index 7d2d5d84..00000000 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxDependencyTraverser.java +++ /dev/null @@ -1,221 +0,0 @@ -/* - * 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; - -import com.networknt.schema.utils.StringUtils; -import java.io.File; -import java.util.ArrayDeque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Queue; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.stream.Collectors; -import org.cyclonedx.gradle.model.ConfigurationScope; -import org.cyclonedx.gradle.model.SerializableComponent; -import org.cyclonedx.gradle.model.SerializableComponents; -import org.gradle.api.GradleException; -import org.gradle.api.artifacts.ModuleVersionIdentifier; -import org.gradle.api.artifacts.component.ComponentIdentifier; -import org.gradle.api.artifacts.result.DependencyResult; -import org.gradle.api.artifacts.result.ResolvedComponentResult; -import org.gradle.api.artifacts.result.ResolvedDependencyResult; -import org.gradle.api.logging.Logger; -import org.jetbrains.annotations.NotNull; - -public class CycloneDxDependencyTraverser { - - private final Map> resultGraph; - private final Logger logger; - private final Map resolvedArtifacts; - private final CycloneDxBomBuilder builder; - private GraphNode parentNode; - - public CycloneDxDependencyTraverser(final Logger logger, final CycloneDxBomBuilder builder) { - this.builder = builder; - this.logger = logger; - this.resolvedArtifacts = new HashMap<>(); - this.resultGraph = new HashMap<>(); - } - - public void registerArtifact(final ComponentIdentifier componentId, final File artifactFile) { - resolvedArtifacts.put(componentId, artifactFile); - } - - public void traverseParentGraph( - final ResolvedComponentResult rootNode, final String projectName, final String configName) { - final String parentRef = getRef(rootNode.getModuleVersion()); - this.parentNode = new GraphNode(parentRef, rootNode); - traverseGraph(rootNode, projectName, configName); - } - - public void traverseChildGraph( - final ResolvedComponentResult rootNode, final String projectName, final String configName) { - - if (this.parentNode == null) { - throw new GradleException("Parent graphs has to be traversed first"); - } - - final String childRef = getRef(rootNode.getModuleVersion()); - final GraphNode childNode = new GraphNode(childRef, rootNode); - this.resultGraph.get(this.parentNode).add(childNode); - traverseGraph(rootNode, projectName, configName); - } - - public void traverseGraph( - final ResolvedComponentResult rootNode, final String projectName, final String configName) { - - final Map> graph = new TreeMap<>(); - final Queue queue = new ArrayDeque<>(); - - final String rootRef = getRef(rootNode.getModuleVersion()); - final GraphNode rootGraphNode = new GraphNode(rootRef, rootNode); - queue.add(rootGraphNode); - - while (!queue.isEmpty()) { - final GraphNode graphNode = queue.poll(); - if (!graph.containsKey(graphNode)) { - graph.put(graphNode, new TreeSet<>()); - for (DependencyResult dep : graphNode.getResult().getDependencies()) { - if (dep instanceof ResolvedDependencyResult) { - final ResolvedComponentResult dependencyComponent = - ((ResolvedDependencyResult) dep).getSelected(); - String ref = getRef(dependencyComponent.getModuleVersion()); - GraphNode dependencyNode = new GraphNode(ref, dependencyComponent); - graph.get(graphNode).add(dependencyNode); - queue.add(dependencyNode); - } - } - } - } - - mergeIntoResultGraph(graph, projectName, configName); - } - - private void mergeIntoResultGraph( - final Map> graph, final String projectName, final String configName) { - - graph.keySet().forEach(node -> { - if (resultGraph.containsKey(node)) { - resultGraph.get(node).addAll(graph.get(node)); - } else { - resultGraph.put(node, graph.get(node)); - } - }); - - resultGraph.keySet().stream() - .filter(graph::containsKey) - .forEach(v -> v.inScopeConfiguration(projectName, configName)); - } - - public SerializableComponents serializableComponents() { - - Map> result = new HashMap<>(); - this.resultGraph.forEach((k, v) -> { - result.put( - serializableComponent(k), - v.stream().map(w -> serializableComponent(w)).collect(Collectors.toSet())); - }); - - return new SerializableComponents(result, serializableComponent(this.parentNode)); - } - - private SerializableComponent serializableComponent(final GraphNode node) { - - ResolvedComponentResult resolvedComponent = node.getResult(); - if (this.resolvedArtifacts.containsKey(resolvedComponent.getId())) { - return new SerializableComponent( - resolvedComponent.getModuleVersion().getGroup(), - resolvedComponent.getModuleVersion().getName(), - resolvedComponent.getModuleVersion().getVersion(), - node.getInScopeConfigurations(), - this.resolvedArtifacts.get(resolvedComponent.getId())); - } else { - return new SerializableComponent( - resolvedComponent.getModuleVersion().getGroup(), - resolvedComponent.getModuleVersion().getName(), - resolvedComponent.getModuleVersion().getVersion(), - node.getInScopeConfigurations()); - } - } - - private String getRef(final ModuleVersionIdentifier identifier) { - - // The cause for this failure is mainly if the group/name/project of the build isn't set - if (StringUtils.isBlank(identifier.getGroup()) - || StringUtils.isBlank(identifier.getName()) - || StringUtils.isBlank(identifier.getVersion())) { - throw new GradleException(String.format( - "Invalid module identifier provided. Group: %s, Name: %s, Version: %s", - identifier.getGroup(), identifier.getName(), identifier.getVersion())); - } - - return String.format("%s:%s:%s", identifier.getGroup(), identifier.getName(), identifier.getVersion()); - } - - private static class GraphNode implements Comparable { - - private final String ref; - private final ResolvedComponentResult result; - private final Set inScopeConfigurations; - - private GraphNode(final String ref, final ResolvedComponentResult result) { - this.ref = ref; - this.result = result; - this.inScopeConfigurations = new HashSet<>(); - } - - private String getRef() { - return ref; - } - - private ResolvedComponentResult getResult() { - return result; - } - - private void inScopeConfiguration(final String projectName, final String configName) { - inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); - } - - private Set getInScopeConfigurations() { - return inScopeConfigurations; - } - - @Override - public int compareTo(@NotNull GraphNode o) { - return this.ref.compareTo(o.ref); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - GraphNode graphNode = (GraphNode) o; - return Objects.equals(ref, graphNode.ref); - } - - @Override - public int hashCode() { - return Objects.hashCode(ref); - } - } -} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index f066da2d..e2df81b0 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -18,23 +18,20 @@ */ package org.cyclonedx.gradle; -import java.io.File; -import org.cyclonedx.gradle.model.SerializableComponents; +import org.cyclonedx.gradle.model.SbomGraph; import org.gradle.api.Plugin; import org.gradle.api.Project; import org.gradle.api.provider.Provider; public class CycloneDxPlugin implements Plugin { - public void apply(Project project) { + public void apply(final Project project) { + project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { - final Provider components = - project.getProviders().provider(new ComponentProvider(project)); - final File destination = - project.getLayout().getBuildDirectory().dir("reports").get().getAsFile(); + final Provider components = + project.getProviders().provider(new SbomGraphProvider(project, task)); task.getComponents().set(components); - task.getDestination().set(destination); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); }); diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index 5aa7c5f8..ebf4bdbc 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -19,33 +19,304 @@ package org.cyclonedx.gradle; import java.io.File; -import org.cyclonedx.gradle.model.SerializableComponents; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import org.cyclonedx.gradle.model.SbomGraph; import org.cyclonedx.gradle.utils.CycloneDxUtils; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.LicenseChoice; +import org.cyclonedx.model.OrganizationalEntity; import org.gradle.api.DefaultTask; +import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.OutputDirectory; import org.gradle.api.tasks.TaskAction; public abstract class CycloneDxTask extends DefaultTask { - private final CycloneDxBomBuilder builder; + private static final String DEFAULT_PROJECT_TYPE = "library"; + + private final Property outputName; + private final Property schemaVersion; + private final Property componentName; + private final Property componentVersion; + private final Property outputFormat; + private final Property includeBomSerialNumber; + private final ListProperty skipConfigs; + private final ListProperty includeConfigs; + private final Property includeMetadataResolution; + private final Property includeLicenseText; + private final Property projectType; + private final ListProperty skipProjects; + private final Property destination; + private OrganizationalEntity organizationalEntity; + private LicenseChoice licenseChoice; public CycloneDxTask() { - this.builder = new CycloneDxBomBuilder(getLogger()); + + outputName = getProject().getObjects().property(String.class); + outputName.convention("bom"); + + schemaVersion = getProject().getObjects().property(String.class); + schemaVersion.convention(CycloneDxUtils.DEFAULT_SCHEMA_VERSION.getVersionString()); + + componentName = getProject().getObjects().property(String.class); + componentName.convention(getProject().getName()); + + componentVersion = getProject().getObjects().property(String.class); + componentVersion.convention(getProject().getVersion().toString()); + + outputFormat = getProject().getObjects().property(String.class); + outputFormat.convention("all"); + + includeBomSerialNumber = getProject().getObjects().property(Boolean.class); + includeBomSerialNumber.convention(true); + + skipConfigs = getProject().getObjects().listProperty(String.class); + includeConfigs = getProject().getObjects().listProperty(String.class); + + includeMetadataResolution = getProject().getObjects().property(Boolean.class); + includeMetadataResolution.convention(true); + + includeLicenseText = getProject().getObjects().property(Boolean.class); + includeLicenseText.convention(true); + + projectType = getProject().getObjects().property(String.class); + projectType.convention(DEFAULT_PROJECT_TYPE); + + skipProjects = getProject().getObjects().listProperty(String.class); + + organizationalEntity = new OrganizationalEntity(); + licenseChoice = new LicenseChoice(); + + destination = getProject().getObjects().property(File.class); + destination.convention(getProject() + .getLayout() + .getBuildDirectory() + .dir("reports") + .get() + .getAsFile()); + } + + @Input + public Property getOutputName() { + return outputName; + } + + public void setOutputName(final String output) { + this.outputName.set(output); + } + + @Input + public Property getSchemaVersion() { + return schemaVersion; + } + + public void setSchemaVersion(final String schemaVersion) { + this.schemaVersion.set(schemaVersion); + } + + @Input + public Property getComponentName() { + return componentName; + } + + public void setComponentName(final String componentName) { + this.componentName.set(componentName); + } + + @Input + public Property getComponentVersion() { + return componentVersion; + } + + public void setComponentVersion(final String componentVersion) { + this.componentVersion.set(componentVersion); + } + + @Input + public Property getOutputFormat() { + return outputFormat; + } + + public void setOutputFormat(final String format) { + this.outputFormat.set(format); + } + + @Input + public Property getIncludeBomSerialNumber() { + return includeBomSerialNumber; + } + + public void setIncludeBomSerialNumber(final boolean includeBomSerialNumber) { + this.includeBomSerialNumber.set(includeBomSerialNumber); + } + + @Input + public ListProperty getSkipConfigs() { + return skipConfigs; + } + + public void setSkipConfigs(final Collection skipConfigs) { + this.skipConfigs.addAll(skipConfigs); + } + + @Input + public ListProperty getIncludeConfigs() { + return includeConfigs; + } + + public void setIncludeConfigs(final Collection includeConfigs) { + this.includeConfigs.addAll(includeConfigs); } @Input - public abstract Property getComponents(); + public Property getIncludeMetadataResolution() { + return includeMetadataResolution; + } + + public void setIncludeMetadataResolution(final boolean includeMetadataResolution) { + this.includeMetadataResolution.set(includeMetadataResolution); + } @Input - public abstract Property getDestination(); + public Property getIncludeLicenseText() { + return includeLicenseText; + } + + public void setIncludeLicenseText(final boolean includeLicenseText) { + this.includeLicenseText.set(includeLicenseText); + } + + @Input + public Property getProjectType() { + return projectType; + } + + public void setProjectType(final String projectType) { + this.projectType.set(projectType); + } + + @Input + public ListProperty getSkipProjects() { + return skipProjects; + } + + public void setSkipProjects(final Collection skipProjects) { + this.skipProjects.addAll(skipProjects); + } + + @Internal + OrganizationalEntity getOrganizationalEntity() { + return organizationalEntity; + } + + @Internal + LicenseChoice getLicenseChoice() { + return licenseChoice; + } + + @Input + public abstract Property getComponents(); + + @OutputDirectory + public Property getDestination() { + return destination; + } + + public void setDestination(final File destination) { + this.destination.set(destination); + } @TaskAction public void createBom() { - File destination = new File(getDestination().get(), "bom.json"); - SerializableComponents components = getComponents().get(); + final SbomBuilder builder = new SbomBuilder(getLogger(), this); + final SbomGraph components = getComponents().get(); + final Bom bom = builder.buildBom(components.getGraph(), components.getRootComponent()); + CycloneDxUtils.writeBom( - builder.buildBom(components.getSerializableComponents(), components.getRootComponent()), destination); + bom, + getDestination().get(), + getOutputName().get(), + CycloneDxUtils.schemaVersion(getSchemaVersion().get()), + getOutputFormat().get()); + } + + public void setOrganizationalEntity(final Consumer customizer) { + final OrganizationalEntity origin = new OrganizationalEntity(); + customizer.accept(origin); + this.organizationalEntity = origin; + + final Map organizationalEntity = new HashMap<>(); + + organizationalEntity.put("name", this.organizationalEntity.getName()); + if (this.organizationalEntity.getUrls() != null) { + for (int i = 0; i < this.organizationalEntity.getUrls().size(); i++) { + organizationalEntity.put( + "url" + i, this.organizationalEntity.getUrls().get(i)); + } + } + if (this.organizationalEntity.getContacts() != null) { + for (int i = 0; i < this.organizationalEntity.getContacts().size(); i++) { + organizationalEntity.put( + "contact_name" + i, + this.organizationalEntity.getContacts().get(i).getName()); + organizationalEntity.put( + "contact_email" + i, + this.organizationalEntity.getContacts().get(i).getEmail()); + organizationalEntity.put( + "contact_phone" + i, + this.organizationalEntity.getContacts().get(i).getPhone()); + } + } + // Definition of gradle Input via Hashmap because Hashmap is serializable (OrganizationalEntity isn't + // serializable) + getInputs().property("OrganizationalEntity", organizationalEntity); + } + + public void setLicenseChoice(final Consumer customizer) { + final LicenseChoice origin = new LicenseChoice(); + customizer.accept(origin); + this.licenseChoice = origin; + + final Map licenseChoice = new HashMap<>(); + + if (this.licenseChoice.getLicenses() != null) { + for (int i = 0; i < this.licenseChoice.getLicenses().size(); i++) { + if (this.licenseChoice.getLicenses().get(i).getName() != null) { + licenseChoice.put( + "licenseChoice" + i + "name", + this.licenseChoice.getLicenses().get(i).getName()); + } + if (this.licenseChoice.getLicenses().get(i).getId() != null) { + licenseChoice.put( + "licenseChoice" + i + "id", + this.licenseChoice.getLicenses().get(i).getId()); + } + licenseChoice.put( + "licenseChoice" + i + "text", + this.licenseChoice + .getLicenses() + .get(i) + .getAttachmentText() + .getText()); + licenseChoice.put( + "licenseChoice" + i + "url", + this.licenseChoice.getLicenses().get(i).getUrl()); + } + } + + if (this.licenseChoice.getExpression() != null) { + licenseChoice.put( + "licenseChoice_Expression", + this.licenseChoice.getExpression().getValue()); + } + // Definition of gradle Input via Hashmap because Hashmap is serializable (LicenseChoice isn't serializable) + getInputs().property("LicenseChoice", licenseChoice); } } diff --git a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java new file mode 100644 index 00000000..421c7b40 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java @@ -0,0 +1,197 @@ +/* + * 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; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.maven.model.License; +import org.apache.maven.project.MavenProject; +import org.cyclonedx.gradle.model.ConfigurationScope; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; +import org.cyclonedx.gradle.model.SbomMetaData; +import org.cyclonedx.gradle.utils.DependencyUtils; +import org.cyclonedx.model.Component; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.result.DependencyResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.api.artifacts.result.ResolvedDependencyResult; +import org.gradle.api.logging.Logger; + +class DependencyGraphTraverser { + + private final Logger logger; + private final Map resolvedArtifacts; + private final MavenProjectLookup mavenLookup; + private final boolean includeMetaData; + private final MavenHelper mavenHelper; + + public DependencyGraphTraverser( + final Logger logger, + final Map resolvedArtifacts, + final MavenProjectLookup mavenLookup, + final CycloneDxTask task) { + this.logger = logger; + this.resolvedArtifacts = resolvedArtifacts; + this.mavenLookup = mavenLookup; + this.includeMetaData = task.getIncludeMetadataResolution().get(); + this.mavenHelper = new MavenHelper(logger, task.getIncludeLicenseText().get()); + } + + Map traverseGraph( + final ResolvedComponentResult rootNode, final String projectName, final String configName) { + + final Map> graph = new HashMap<>(); + final Queue queue = new ArrayDeque<>(); + + final GraphNode rootGraphNode = new GraphNode(rootNode); + rootGraphNode.inScopeConfiguration(projectName, configName); + queue.add(rootGraphNode); + + while (!queue.isEmpty()) { + final GraphNode graphNode = queue.poll(); + if (!graph.containsKey(graphNode)) { + graph.put(graphNode, new HashSet<>()); + for (DependencyResult dep : graphNode.getResult().getDependencies()) { + if (dep instanceof ResolvedDependencyResult) { + final ResolvedComponentResult dependencyComponent = + ((ResolvedDependencyResult) dep).getSelected(); + final GraphNode dependencyNode = new GraphNode(dependencyComponent); + dependencyNode.inScopeConfiguration(projectName, configName); + graph.get(graphNode).add(dependencyNode); + queue.add(dependencyNode); + } + } + } + } + + return toSbomComponents(graph); + } + + private Map toSbomComponents(final Map> graph) { + return graph.entrySet().stream() + .map(entry -> toSbomComponent(entry.getKey(), entry.getValue())) + .collect(Collectors.toMap(v -> v.getId(), v -> v)); + } + + private SbomComponent toSbomComponent(final GraphNode node, final Set dependencyNodes) { + + final File artifactFile = getArtifactFile(node); + final SbomComponentId id = DependencyUtils.toComponentId(node.getResult(), artifactFile); + + List licenses = null; + SbomMetaData metaData = null; + if (includeMetaData && node.id instanceof ModuleComponentIdentifier) { + final Component component = new Component(); + extractMetaDataFromArtifactPom(artifactFile, component, node.getResult()); + licenses = extractMetaDataFromRepository(component, node.getResult()); + metaData = SbomMetaData.fromComponent(component); + } + + return new SbomComponent.Builder() + .withId(id) + .withDependencyComponents(getSbomDependencies(dependencyNodes)) + .withInScopeConfigurations(node.getInScopeConfigurations()) + .withArtifactFile(artifactFile) + .withMetaData(metaData) + .withLicenses(licenses) + .build(); + } + + private void extractMetaDataFromArtifactPom( + final File artifactFile, final Component component, final ResolvedComponentResult result) { + + if (artifactFile == null) { + return; + } + + final MavenProject mavenProject = mavenHelper.extractPom(artifactFile, result.getModuleVersion()); + if (mavenProject != null) { + mavenHelper.getClosestMetadata(artifactFile, mavenProject, component, result.getModuleVersion()); + } + } + + private List extractMetaDataFromRepository( + final Component component, final ResolvedComponentResult result) { + final MavenProject mavenProject = mavenLookup.getResolvedMavenProject(result); + if (mavenProject != null) { + mavenHelper.extractMetadata(mavenProject, component); + return mavenProject.getLicenses(); + } + + return null; + } + + private Set getSbomDependencies(final Set dependencyNodes) { + return dependencyNodes.stream() + .map(dependency -> DependencyUtils.toComponentId(dependency.getResult(), getArtifactFile(dependency))) + .collect(Collectors.toSet()); + } + + private File getArtifactFile(final GraphNode node) { + return this.resolvedArtifacts.get(node.getResult().getId()); + } + + private static class GraphNode { + + private final ComponentIdentifier id; + private final ResolvedComponentResult result; + private final Set inScopeConfigurations; + + private GraphNode(final ResolvedComponentResult result) { + this.id = result.getId(); + this.result = result; + this.inScopeConfigurations = new HashSet<>(); + } + + private ResolvedComponentResult getResult() { + return result; + } + + private void inScopeConfiguration(final String projectName, final String configName) { + inScopeConfigurations.add(new ConfigurationScope(projectName, configName)); + } + + private Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final GraphNode graphNode = (GraphNode) o; + return Objects.equals(id, graphNode.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + } +} diff --git a/src/main/java/org/cyclonedx/gradle/MavenHelper.java b/src/main/java/org/cyclonedx/gradle/MavenHelper.java index 3db45800..9a736e60 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenHelper.java +++ b/src/main/java/org/cyclonedx/gradle/MavenHelper.java @@ -39,7 +39,6 @@ import org.apache.maven.project.MavenProject; import org.codehaus.plexus.util.ReaderFactory; import org.codehaus.plexus.util.xml.pull.XmlPullParserException; -import org.cyclonedx.Version; import org.cyclonedx.model.Component; import org.cyclonedx.model.ExternalReference; import org.cyclonedx.model.LicenseChoice; @@ -54,13 +53,11 @@ */ class MavenHelper { - private Logger logger; - private Version schemaVersion; - private Boolean includeLicenseText; + private final Logger logger; + private final Boolean includeLicenseText; - public MavenHelper(Logger logger, Version schemaVersion, Boolean includeLicenseText) { + public MavenHelper(final Logger logger, final Boolean includeLicenseText) { this.logger = logger; - this.schemaVersion = schemaVersion; this.includeLicenseText = includeLicenseText; } @@ -77,14 +74,18 @@ public MavenHelper(Logger logger, Version schemaVersion, Boolean includeLicenseT * @param component * the component to populate data for */ - void getClosestMetadata(ResolvedArtifact artifact, MavenProject project, Component component) { + void getClosestMetadata( + final File artifact, + final MavenProject project, + final Component component, + final ModuleVersionIdentifier mid) { extractMetadata(project, component); if (project.getParent() != null) { - getClosestMetadata(artifact, project.getParent(), component); + getClosestMetadata(artifact, project.getParent(), component, mid); } else if (project.getModel().getParent() != null) { - @Nullable final MavenProject parentProject = retrieveParentProject(artifact, project); + @Nullable final MavenProject parentProject = retrieveParentProject(artifact, project, mid); if (parentProject != null) { - getClosestMetadata(artifact, parentProject, component); + getClosestMetadata(artifact, parentProject, component, mid); } } } @@ -97,7 +98,7 @@ void getClosestMetadata(ResolvedArtifact artifact, MavenProject project, Compone * @param component * the component to add data to */ - public void extractMetadata(MavenProject project, Component component) { + public void extractMetadata(final MavenProject project, final Component component) { if (component.getPublisher() == null) { // If we don't already have publisher information, retrieve it. if (project.getOrganization() != null) { @@ -108,76 +109,65 @@ public void extractMetadata(MavenProject project, Component component) { // If we don't already have description information, retrieve it. component.setDescription(project.getDescription()); } - if (component.getLicenseChoice() == null - || component.getLicenseChoice().getLicenses() == null - || component.getLicenseChoice().getLicenses().isEmpty()) { - // If we don't already have license information, retrieve it. - if (project.getLicenses() != null) { - component.setLicenseChoice(resolveMavenLicenses(project.getLicenses())); + if (project.getOrganization() != null && project.getOrganization().getUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.WEBSITE)) { + addExternalReference( + ExternalReference.Type.WEBSITE, + project.getOrganization().getUrl(), + component); } } - if (Version.VERSION_10 != schemaVersion) { - if (project.getOrganization() != null && project.getOrganization().getUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.WEBSITE)) { - addExternalReference( - ExternalReference.Type.WEBSITE, - project.getOrganization().getUrl(), - component); - } - } - if (project.getCiManagement() != null && project.getCiManagement().getUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.BUILD_SYSTEM)) { - addExternalReference( - ExternalReference.Type.BUILD_SYSTEM, - project.getCiManagement().getUrl(), - component); - } + if (project.getCiManagement() != null && project.getCiManagement().getUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.BUILD_SYSTEM)) { + addExternalReference( + ExternalReference.Type.BUILD_SYSTEM, + project.getCiManagement().getUrl(), + component); } - if (project.getDistributionManagement() != null - && project.getDistributionManagement().getDownloadUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.DISTRIBUTION)) { - addExternalReference( - ExternalReference.Type.DISTRIBUTION, - project.getDistributionManagement().getDownloadUrl(), - component); - } + } + if (project.getDistributionManagement() != null + && project.getDistributionManagement().getDownloadUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.DISTRIBUTION)) { + addExternalReference( + ExternalReference.Type.DISTRIBUTION, + project.getDistributionManagement().getDownloadUrl(), + component); } - if (project.getDistributionManagement() != null - && project.getDistributionManagement().getRepository() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.DISTRIBUTION)) { - addExternalReference( - ExternalReference.Type.DISTRIBUTION, - project.getDistributionManagement().getRepository().getUrl(), - component); - } + } + if (project.getDistributionManagement() != null + && project.getDistributionManagement().getRepository() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.DISTRIBUTION)) { + addExternalReference( + ExternalReference.Type.DISTRIBUTION, + project.getDistributionManagement().getRepository().getUrl(), + component); } - if (project.getIssueManagement() != null - && project.getIssueManagement().getUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.ISSUE_TRACKER)) { - addExternalReference( - ExternalReference.Type.ISSUE_TRACKER, - project.getIssueManagement().getUrl(), - component); - } + } + if (project.getIssueManagement() != null && project.getIssueManagement().getUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.ISSUE_TRACKER)) { + addExternalReference( + ExternalReference.Type.ISSUE_TRACKER, + project.getIssueManagement().getUrl(), + component); } - if (project.getMailingLists() != null && project.getMailingLists().size() > 0) { - for (MailingList list : project.getMailingLists()) { - if (list.getArchive() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.MAILING_LIST)) { - addExternalReference(ExternalReference.Type.MAILING_LIST, list.getArchive(), component); - } - } else if (list.getSubscribe() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.MAILING_LIST)) { - addExternalReference(ExternalReference.Type.MAILING_LIST, list.getSubscribe(), component); - } + } + if (project.getMailingLists() != null && project.getMailingLists().size() > 0) { + for (MailingList list : project.getMailingLists()) { + if (list.getArchive() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.MAILING_LIST)) { + addExternalReference(ExternalReference.Type.MAILING_LIST, list.getArchive(), component); + } + } else if (list.getSubscribe() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.MAILING_LIST)) { + addExternalReference(ExternalReference.Type.MAILING_LIST, list.getSubscribe(), component); } } } - if (project.getScm() != null && project.getScm().getUrl() != null) { - if (!doesComponentHaveExternalReference(component, ExternalReference.Type.VCS)) { - addExternalReference( - ExternalReference.Type.VCS, project.getScm().getUrl(), component); - } + } + if (project.getScm() != null && project.getScm().getUrl() != null) { + if (!doesComponentHaveExternalReference(component, ExternalReference.Type.VCS)) { + addExternalReference( + ExternalReference.Type.VCS, project.getScm().getUrl(), component); } } } @@ -213,13 +203,13 @@ private boolean doesComponentHaveExternalReference(final Component component, fi boolean resolved = false; if (artifactLicense.getName() != null) { final LicenseChoice resolvedByName = - LicenseResolver.resolve(artifactLicense.getName(), this.includeLicenseText); + LicenseResolver.resolve(artifactLicense.getName(), includeLicenseText); if (resolvedByName != null) { if (resolvedByName.getLicenses() != null && !resolvedByName.getLicenses().isEmpty()) { resolved = true; licenseChoice.addLicense(resolvedByName.getLicenses().get(0)); - } else if (resolvedByName.getExpression() != null && Version.VERSION_10 != schemaVersion) { + } else if (resolvedByName.getExpression() != null) { resolved = true; licenseChoice.setExpression(resolvedByName.getExpression()); } @@ -233,7 +223,7 @@ private boolean doesComponentHaveExternalReference(final Component component, fi && !resolvedByUrl.getLicenses().isEmpty()) { resolved = true; licenseChoice.addLicense(resolvedByUrl.getLicenses().get(0)); - } else if (resolvedByUrl.getExpression() != null && Version.VERSION_10 != schemaVersion) { + } else if (resolvedByUrl.getExpression() != null) { resolved = true; licenseChoice.setExpression(resolvedByUrl.getExpression()); } @@ -269,10 +259,9 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * @param project * the maven project the artifact is part of */ - @Nullable private MavenProject retrieveParentProject(ResolvedArtifact artifact, MavenProject project) { - if (artifact.getFile() == null - || artifact.getFile().getParentFile() == null - || !isDescribedArtifact(artifact)) { + @Nullable private MavenProject retrieveParentProject( + final File artifact, MavenProject project, final ModuleVersionIdentifier mid) { + if (artifact == null || artifact.getParentFile() == null) { return null; } final Model model = project.getModel(); @@ -281,14 +270,13 @@ private boolean doesComponentHaveExternalReference(final Component component, fi // Navigate out of version, artifactId, and first (possibly only) level of // groupId final StringBuilder getout = new StringBuilder("../../../"); - final ModuleVersionIdentifier mid = artifact.getModuleVersion().getId(); final int periods = mid.getGroup().length() - mid.getGroup().replace(".", "").length(); for (int i = 0; i < periods; i++) { getout.append("../"); } final File parentFile = new File( - artifact.getFile().getParentFile(), + artifact.getParentFile(), getout + parent.getGroupId().replace(".", "/") + "/" + parent.getArtifactId() + "/" + parent.getVersion() + "/" + parent.getArtifactId() + "-" + parent.getVersion() + ".pom"); if (parentFile.exists() && parentFile.isFile()) { @@ -309,14 +297,10 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * the artifact to extract the pom from * @return a Maven project */ - @Nullable MavenProject extractPom(ResolvedArtifact artifact) { - if (!isDescribedArtifact(artifact)) { - return null; - } - if (artifact.getFile() != null && artifact.getFile().exists()) { + @Nullable MavenProject extractPom(final File artifact, final ModuleVersionIdentifier mid) { + if (artifact != null && artifact.exists()) { try { - final JarFile jarFile = new JarFile(artifact.getFile()); - final ModuleVersionIdentifier mid = artifact.getModuleVersion().getId(); + final JarFile jarFile = new JarFile(artifact); final JarEntry entry = jarFile.getJarEntry("META-INF/maven/" + mid.getGroup() + "/" + mid.getName() + "/pom.xml"); if (entry != null) { @@ -340,7 +324,12 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * @throws IOException * oops */ - @Nullable MavenProject readPom(File file) { + @Nullable static MavenProject readPom(final File file) { + + if (file == null) { + return null; + } + try { final MavenXpp3Reader mavenreader = new MavenXpp3Reader(); try (final Reader reader = ReaderFactory.newXmlReader(file)) { @@ -348,9 +337,8 @@ private boolean doesComponentHaveExternalReference(final Component component, fi return new MavenProject(model); } } catch (XmlPullParserException | IOException e) { - logger.error("An error occurred attempting to read POM", e); + throw new IllegalStateException("An error occurred attempting to read POM", e); } - return null; } /** @@ -360,7 +348,7 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * the inputstream to read from * @return a MavenProject */ - @Nullable MavenProject readPom(InputStream in) { + @Nullable static MavenProject readPom(final InputStream in) { try { final MavenXpp3Reader mavenreader = new MavenXpp3Reader(); try (final Reader reader = ReaderFactory.newXmlReader(in)) { @@ -368,7 +356,7 @@ private boolean doesComponentHaveExternalReference(final Component component, fi return new MavenProject(model); } } catch (XmlPullParserException | IOException e) { - logger.error("An error occurred attempting to read POM", e); + // logger.error("An error occurred attempting to read POM", e); } return null; } @@ -383,23 +371,23 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * the current gradle project which gets used as the base resolver * @return model for effective pom */ - Model resolveEffectivePom(File pomFile, Project gradleProject) { + static Model resolveEffectivePom(final File pomFile, final Project gradleProject) { // force the parent POMs and BOMs to be resolved - ModelResolver modelResolver = new GradleAssistedMavenModelResolverImpl(gradleProject); - ModelBuildingRequest req = new DefaultModelBuildingRequest(); + final ModelResolver modelResolver = new GradleAssistedMavenModelResolverImpl(gradleProject); + final ModelBuildingRequest req = new DefaultModelBuildingRequest(); req.setModelResolver(modelResolver); req.setPomFile(pomFile); req.getSystemProperties().putAll(System.getProperties()); req.setValidationLevel(ModelBuildingRequest.VALIDATION_LEVEL_MINIMAL); // execute the model building request - DefaultModelBuilderFactory factory = new DefaultModelBuilderFactory(); - DefaultModelBuilder builder = factory.newInstance(); + final DefaultModelBuilderFactory factory = new DefaultModelBuilderFactory(); + final DefaultModelBuilder builder = factory.newInstance(); Model effectiveModel = null; try { effectiveModel = builder.build(req).getEffectiveModel(); } catch (ModelBuildingException e) { - logger.error("An error occurred attempting to resolve effective POM", e); + throw new IllegalStateException("An error occurred attempting to resolve effective POM", e); } return effectiveModel; } @@ -412,7 +400,7 @@ Model resolveEffectivePom(File pomFile, Project gradleProject) { * the artifact * @return true if artifact will have a POM, false if not */ - boolean isDescribedArtifact(Artifact artifact) { + boolean isDescribedArtifact(final Artifact artifact) { return artifact.getType().equalsIgnoreCase("jar"); } @@ -424,11 +412,11 @@ boolean isDescribedArtifact(Artifact artifact) { * the artifact * @return true if artifact will have a POM, false if not */ - boolean isDescribedArtifact(ResolvedArtifact artifact) { + static boolean isDescribedArtifact(final ResolvedArtifact artifact) { return artifact.getType().equalsIgnoreCase("jar"); } - boolean isModified(ResolvedArtifact artifact) { + boolean isModified(final ResolvedArtifact artifact) { // todo: compare hashes + GAV with what the artifact says against Maven Central // to determine if component has been modified. return false; diff --git a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java new file mode 100644 index 00000000..80f9db6a --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java @@ -0,0 +1,102 @@ +/* + * 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; + +import java.io.File; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import org.apache.maven.model.Model; +import org.apache.maven.project.MavenProject; +import org.gradle.api.Project; +import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.result.ArtifactResolutionResult; +import org.gradle.api.artifacts.result.ArtifactResult; +import org.gradle.api.artifacts.result.ComponentArtifactsResult; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +import org.gradle.api.artifacts.result.ResolvedComponentResult; +import org.gradle.maven.MavenModule; +import org.gradle.maven.MavenPomArtifact; + +class MavenProjectLookup { + + private final Project project; + private final Map cache; + + MavenProjectLookup(final Project project) { + this.project = project; + this.cache = new HashMap<>(); + } + + MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { + + if (result == null) { + return null; + } + + if (cache.containsKey(result.getId())) { + return cache.get(result.getId()); + } + + try { + final File pomFile = buildMavenProject(result.getId()); + final MavenProject mavenProject = MavenHelper.readPom(pomFile); + if (mavenProject != null) { + final Model model = MavenHelper.resolveEffectivePom(pomFile, project); + if (model != null) { + mavenProject.setLicenses(model.getLicenses()); + } + + return mavenProject; + } + } catch (Exception err) { + project.getLogger().error("Unable to resolve POM for {}: {}", result.getId(), err); + } + return null; + } + + private File buildMavenProject(final ComponentIdentifier id) { + + final ArtifactResolutionResult result = project.getDependencies() + .createArtifactResolutionQuery() + .forComponents(id) + .withArtifacts(MavenModule.class, MavenPomArtifact.class) + .execute(); + + final Iterator componentIt = + result.getResolvedComponents().iterator(); + if (!componentIt.hasNext()) { + return null; + } + + final Iterator artifactIt = + componentIt.next().getArtifacts(MavenPomArtifact.class).iterator(); + if (!artifactIt.hasNext()) { + return null; + } + + final ArtifactResult artifact = artifactIt.next(); + if (artifact instanceof ResolvedArtifactResult) { + final ResolvedArtifactResult resolvedArtifact = (ResolvedArtifactResult) artifact; + return resolvedArtifact.getFile(); + } + + return null; + } +} diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java similarity index 55% rename from src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java rename to src/main/java/org/cyclonedx/gradle/SbomBuilder.java index 791bb463..f59d2b7c 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxBomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java @@ -24,6 +24,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -32,23 +33,25 @@ import java.util.TreeSet; import java.util.UUID; import java.util.stream.Collectors; -import org.apache.commons.io.FilenameUtils; import org.cyclonedx.Version; import org.cyclonedx.gradle.model.ComponentComparator; import org.cyclonedx.gradle.model.DependencyComparator; -import org.cyclonedx.gradle.model.SerializableComponent; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.cyclonedx.gradle.utils.DependencyUtils; import org.cyclonedx.model.Bom; import org.cyclonedx.model.Component; import org.cyclonedx.model.Dependency; +import org.cyclonedx.model.ExternalReference; import org.cyclonedx.model.Hash; +import org.cyclonedx.model.LicenseChoice; import org.cyclonedx.model.Metadata; import org.cyclonedx.model.Property; import org.cyclonedx.util.BomUtils; import org.gradle.api.logging.Logger; -public class CycloneDxBomBuilder { +class SbomBuilder { private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; private static final TreeMap EMPTY_TYPE = new TreeMap<>(); @@ -57,57 +60,61 @@ public class CycloneDxBomBuilder { private final Map> artifactHashes; private final MavenHelper mavenHelper; private final Version version; + private final CycloneDxTask task; - public CycloneDxBomBuilder(final Logger logger) { + SbomBuilder(final Logger logger, final CycloneDxTask task) { this.logger = logger; this.version = CycloneDxUtils.DEFAULT_SCHEMA_VERSION; this.artifactHashes = new HashMap<>(); - this.mavenHelper = new MavenHelper(logger, version, false); + this.mavenHelper = new MavenHelper(logger, task.getIncludeLicenseText().get()); + this.task = task; } - public Bom buildBom( - final Map> resultGraph, - final SerializableComponent parentComponent) { + Bom buildBom(final Map resultGraph, final SbomComponent rootComponent) { final Set dependencies = new TreeSet<>(new DependencyComparator()); final Set components = new TreeSet<>(new ComponentComparator()); - resultGraph.keySet().forEach(component -> { - addDependency(dependencies, resultGraph.get(component), component); - addComponent(components, component, parentComponent); + resultGraph.keySet().forEach(componentId -> { + addDependency(dependencies, resultGraph.get(componentId)); + addComponent(components, resultGraph.get(componentId), rootComponent); }); final Bom bom = new Bom(); - bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); - bom.setMetadata(buildMetadata(parentComponent)); + if (task.getIncludeBomSerialNumber().get()) bom.setSerialNumber("urn:uuid:" + UUID.randomUUID()); + bom.setMetadata(buildMetadata(rootComponent)); bom.setComponents(new ArrayList<>(components)); bom.setDependencies(new ArrayList<>(dependencies)); return bom; } - private Metadata buildMetadata(final SerializableComponent parentComponent) { + private Metadata buildMetadata(final SbomComponent parentComponent) { final Metadata metadata = new Metadata(); try { - metadata.setComponent(toComponent(parentComponent, null)); + final Component component = toComponent(parentComponent, null, resolveProjectType()); + component.setProperties(null); + component.setName(task.getComponentName().get()); + component.setVersion(task.getComponentVersion().get()); + metadata.setComponent(component); } catch (MalformedPackageURLException e) { logger.warn("Error constructing packageUrl for parent component. Skipping...", e); } + metadata.setLicenseChoice(task.getLicenseChoice()); + metadata.setManufacture(task.getOrganizationalEntity()); + return metadata; } - private void addDependency( - final Set dependencies, - final Set dependencyComponents, - final SerializableComponent component) { + private void addDependency(final Set dependencies, final SbomComponent component) { final Dependency dependency; try { - dependency = toDependency(component); + dependency = toDependency(component.getId()); } catch (MalformedPackageURLException e) { logger.warn("Error constructing packageUrl for component. Skipping...", e); return; } - dependencyComponents.forEach(dependencyComponent -> { + component.getDependencyComponents().forEach(dependencyComponent -> { try { dependency.addDependency(toDependency(dependencyComponent)); } catch (MalformedPackageURLException e) { @@ -117,43 +124,55 @@ private void addDependency( dependencies.add(dependency); } - private Dependency toDependency(final SerializableComponent component) throws MalformedPackageURLException { + private Dependency toDependency(final SbomComponentId componentId) throws MalformedPackageURLException { - final String ref = DependencyUtils.generatePackageUrl( - component, getType(component.getArtifactFile().orElse(null))); + final String ref = DependencyUtils.generatePackageUrl(componentId, getQualifiers(componentId.getType())); return new Dependency(ref); } private void addComponent( - final Set components, - final SerializableComponent component, - final SerializableComponent parentComponent) { + final Set components, final SbomComponent component, final SbomComponent parentComponent) { if (!component.equals(parentComponent)) { final File artifactFile = component.getArtifactFile().orElse(null); try { - components.add(toComponent(component, artifactFile)); + components.add(toComponent(component, artifactFile, Component.Type.LIBRARY)); } catch (MalformedPackageURLException e) { logger.warn("Error constructing packageUrl for component. Skipping...", e); } } } - private Component toComponent(final SerializableComponent component, final File artifactFile) + private Component toComponent(final SbomComponent component, final File artifactFile, final Component.Type type) throws MalformedPackageURLException { - final String packageUrl = DependencyUtils.generatePackageUrl(component, getType(artifactFile)); + final String packageUrl = DependencyUtils.generatePackageUrl( + component.getId(), getQualifiers(component.getId().getType())); final Component resultComponent = new Component(); - resultComponent.setGroup(component.getGroup()); - resultComponent.setName(component.getName()); - resultComponent.setVersion(component.getVersion()); - resultComponent.setType(Component.Type.LIBRARY); + resultComponent.setGroup(component.getId().getGroup()); + resultComponent.setName(component.getId().getName()); + resultComponent.setVersion(component.getId().getVersion()); + resultComponent.setType(type); resultComponent.setPurl(packageUrl); resultComponent.setProperties(buildProperties(component)); - if (version.getVersion() >= 1.1) { - resultComponent.setModified(mavenHelper.isModified(null)); - resultComponent.setBomRef(packageUrl); - } + resultComponent.setModified(mavenHelper.isModified(null)); + resultComponent.setBomRef(packageUrl); + + component.getSbomMetaData().ifPresent(metaData -> { + resultComponent.setDescription(metaData.getDescription()); + resultComponent.setPublisher(metaData.getPublisher()); + metaData.getExternalReferences().forEach(reference -> { + final ExternalReference ref = new ExternalReference(); + ref.setType(ExternalReference.Type.valueOf(reference.getType())); + ref.setUrl(reference.getUrl()); + resultComponent.addExternalReference(ref); + }); + }); + + component.getLicenses().ifPresent(licenses -> { + LicenseChoice licenseChoice = mavenHelper.resolveMavenLicenses(licenses); + resultComponent.setLicenses(licenseChoice); + }); logger.debug(MESSAGE_CALCULATING_HASHES); if (artifactFile != null) { @@ -163,7 +182,7 @@ private Component toComponent(final SerializableComponent component, final File return resultComponent; } - private List buildProperties(SerializableComponent component) { + private List buildProperties(final SbomComponent component) { return component.getInScopeConfigurations().stream() .map(v -> { Property property = new Property(); @@ -171,6 +190,7 @@ private List buildProperties(SerializableComponent component) { property.setValue(String.format("%s:%s", v.getProjectName(), v.getConfigName())); return property; }) + .sorted(Comparator.comparing(Property::getValue)) .collect(Collectors.toList()); } @@ -185,18 +205,27 @@ private List calculateHashes(final File artifactFile) { }); } - private TreeMap getType(final File file) { - if (file == null) { - return EMPTY_TYPE; + private Component.Type resolveProjectType() { + for (Component.Type type : Component.Type.values()) { + if (type.getTypeName().equalsIgnoreCase(task.getProjectType().get())) { + return type; + } } + logger.warn("Invalid project type. Defaulting to 'library'"); + logger.warn("Valid types are:"); + for (Component.Type type : Component.Type.values()) { + logger.warn(" " + type.getTypeName()); + } + return Component.Type.LIBRARY; + } - String fileExtension = FilenameUtils.getExtension(file.getName()); - if (StringUtils.isBlank(fileExtension)) { + private TreeMap getQualifiers(final String type) { + if (StringUtils.isBlank(type)) { return EMPTY_TYPE; } - final TreeMap type = new TreeMap<>(); - type.put("type", fileExtension); - return type; + final TreeMap qualifiers = new TreeMap<>(); + qualifiers.put("type", type); + return qualifiers; } } diff --git a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java new file mode 100644 index 00000000..cbb592da --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -0,0 +1,138 @@ +/* + * 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; + +import java.io.File; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.lang3.StringUtils; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; +import org.cyclonedx.gradle.model.SbomGraph; +import org.cyclonedx.gradle.utils.DependencyUtils; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +import org.gradle.api.artifacts.component.ComponentIdentifier; + +public class SbomGraphProvider implements Callable { + + private final Project project; + private final CycloneDxTask task; + + public SbomGraphProvider(final Project project, final CycloneDxTask task) { + this.project = project; + this.task = task; + } + + @Override + public SbomGraph call() throws Exception { + + if (StringUtils.isBlank((String) project.getGroup()) || StringUtils.isBlank((String) project.getVersion())) { + throw new IllegalStateException("Project group and version are required for the CycloneDx task"); + } + + final DependencyGraphTraverser traverser = new DependencyGraphTraverser( + project.getLogger(), getArtifacts(), new MavenProjectLookup(project), task); + + final Map graph = Stream.concat( + traverseParentProject(traverser), traverseChildProjects(traverser)) + .reduce(new HashMap<>(), DependencyUtils::mergeGraphs); + + return buildSbomGraph(graph); + } + + private SbomGraph buildSbomGraph(final Map graph) { + + final Optional rootProject = DependencyUtils.findRootComponent(project, graph); + if (rootProject.isPresent()) { + DependencyUtils.connectRootWithSubProjects( + project, rootProject.get().getId(), graph); + return new SbomGraph(graph, rootProject.get()); + } else { + final SbomComponentId rootProjectId = new SbomComponentId( + (String) project.getGroup(), project.getName(), (String) project.getVersion(), ""); + final SbomComponent sbomComponent = new SbomComponent.Builder() + .withId(rootProjectId) + .withDependencyComponents(new HashSet<>()) + .withInScopeConfigurations(new HashSet<>()) + .build(); + + return new SbomGraph(graph, sbomComponent); + } + } + + private Stream> traverseParentProject( + final DependencyGraphTraverser traverser) { + + if (shouldSkipProject(project)) { + return Stream.empty(); + } + return project.getConfigurations().stream() + .filter(configuration -> shouldIncludeConfiguration(configuration) + && !shouldSkipConfiguration(configuration) + && configuration.isCanBeResolved()) + .map(config -> traverser.traverseGraph( + config.getIncoming().getResolutionResult().getRoot(), project.getName(), config.getName())); + } + + private Stream> traverseChildProjects( + final DependencyGraphTraverser traverser) { + return project.getChildProjects().entrySet().stream() + .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() + .filter(project -> !shouldSkipProject(project)) + .flatMap(project -> project.getConfigurations().stream()) + .filter(configuration -> shouldIncludeConfiguration(configuration) + && !shouldSkipConfiguration(configuration) + && configuration.isCanBeResolved()) + .flatMap(config -> config.getIncoming().getArtifacts().getArtifacts().stream()) + .collect(Collectors.toMap( + artifact -> artifact.getId().getComponentIdentifier(), + artifact -> artifact.getFile(), + (v1, v2) -> v1)); + } + + private boolean shouldSkipConfiguration(final Configuration configuration) { + return task.getSkipConfigs().get().stream().anyMatch(configuration.getName()::matches); + } + + private boolean shouldIncludeConfiguration(final Configuration configuration) { + return task.getIncludeConfigs().get().isEmpty() + || task.getIncludeConfigs().get().stream().anyMatch(configuration.getName()::matches); + } + + private boolean shouldSkipProject(final Project project) { + return task.getSkipProjects().get().contains(project.getName()); + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java b/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java index 07b8e7b4..19c06269 100644 --- a/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java +++ b/src/main/java/org/cyclonedx/gradle/model/ComponentComparator.java @@ -24,7 +24,7 @@ public class ComponentComparator implements Comparator { @Override - public int compare(Component o1, Component o2) { + public int compare(final Component o1, final Component o2) { return o1.getBomRef().compareTo(o2.getBomRef()); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java b/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java index c64ba7b2..3460372b 100644 --- a/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java +++ b/src/main/java/org/cyclonedx/gradle/model/DependencyComparator.java @@ -23,7 +23,7 @@ public class DependencyComparator implements Comparator { @Override - public int compare(Dependency o1, Dependency o2) { + public int compare(final Dependency o1, final Dependency o2) { return o1.getRef().compareTo(o2.getRef()); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java new file mode 100644 index 00000000..0eea5539 --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java @@ -0,0 +1,121 @@ +/* + * 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.model; + +import java.io.File; +import java.io.Serializable; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.apache.maven.model.License; + +public final class SbomComponent implements Serializable { + + private final SbomComponentId id; + private final Set inScopeConfigurations; + private final Set dependencyComponents; + private final File artifactFile; + private final SbomMetaData metaData; + private final List licenses; + + public SbomComponent( + final SbomComponentId id, + final Set inScopeConfigurations, + final Set dependencyComponents, + final File artifactFile, + final SbomMetaData metaData, + final List licenses) { + this.id = id; + this.inScopeConfigurations = inScopeConfigurations; + this.dependencyComponents = dependencyComponents; + this.artifactFile = artifactFile; + this.metaData = metaData; + this.licenses = licenses; + } + + public SbomComponentId getId() { + return id; + } + + public Set getInScopeConfigurations() { + return inScopeConfigurations; + } + + public Set getDependencyComponents() { + return dependencyComponents; + } + + public Optional getArtifactFile() { + return Optional.ofNullable(artifactFile); + } + + public Optional getSbomMetaData() { + return Optional.ofNullable(metaData); + } + + public Optional> getLicenses() { + return Optional.ofNullable(licenses); + } + + public static class Builder { + + private SbomComponentId id; + private Set inScopeConfigurations; + private Set dependencyComponents; + private File artifactFile; + private SbomMetaData metaData; + private List licenses; + + public Builder() {} + + public Builder withId(final SbomComponentId id) { + this.id = id; + return this; + } + + public Builder withInScopeConfigurations(final Set inScopeConfigurations) { + this.inScopeConfigurations = inScopeConfigurations; + return this; + } + + public Builder withDependencyComponents(final Set dependencyComponents) { + this.dependencyComponents = dependencyComponents; + return this; + } + + public Builder withArtifactFile(final File artifactFile) { + this.artifactFile = artifactFile; + return this; + } + + public Builder withMetaData(final SbomMetaData metaData) { + this.metaData = metaData; + return this; + } + + public Builder withLicenses(final List licenses) { + this.licenses = licenses; + return this; + } + + public SbomComponent build() { + return new SbomComponent(id, inScopeConfigurations, dependencyComponents, artifactFile, metaData, licenses); + } + } +} diff --git a/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java b/src/main/java/org/cyclonedx/gradle/model/SbomComponentId.java similarity index 55% rename from src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java rename to src/main/java/org/cyclonedx/gradle/model/SbomComponentId.java index 3c65ebcb..ee47ea9f 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SerializableComponent.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomComponentId.java @@ -18,43 +18,21 @@ */ package org.cyclonedx.gradle.model; -import java.io.File; import java.io.Serializable; import java.util.Objects; -import java.util.Optional; -import java.util.Set; -public final class SerializableComponent implements Serializable { +public class SbomComponentId implements Serializable { private final String group; private final String name; private final String version; - private final File artifactFile; - private final Set inScopeConfigurations; + private final String type; - public SerializableComponent( - final String group, - final String name, - final String version, - final Set inScopeConfigurations) { - this(group, name, version, inScopeConfigurations, null); - } - - public SerializableComponent( - final String group, - final String name, - final String version, - final Set inScopeConfigurations, - final File artifactFile) { + public SbomComponentId(final String group, final String name, final String version, final String type) { this.group = group; this.name = name; this.version = version; - this.artifactFile = artifactFile; - this.inScopeConfigurations = inScopeConfigurations; - } - - public String getGroup() { - return group; + this.type = type; } public String getName() { @@ -65,27 +43,27 @@ public String getVersion() { return version; } - public Set getInScopeConfigurations() { - return inScopeConfigurations; + public String getGroup() { + return group; } - public Optional getArtifactFile() { - return Optional.ofNullable(artifactFile); + public String getType() { + return type; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - SerializableComponent that = (SerializableComponent) o; + final SbomComponentId that = (SbomComponentId) o; return Objects.equals(group, that.group) && Objects.equals(name, that.name) && Objects.equals(version, that.version) - && Objects.equals(artifactFile, that.artifactFile); + && Objects.equals(type, that.type); } @Override public int hashCode() { - return Objects.hash(group, name, version, artifactFile); + return Objects.hash(group, name, version, type); } } diff --git a/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java b/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java similarity index 57% rename from src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java rename to src/main/java/org/cyclonedx/gradle/model/SbomGraph.java index 77089ae6..a490a3d7 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SerializableComponents.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java @@ -20,25 +20,22 @@ import java.io.Serializable; import java.util.Map; -import java.util.Set; -public class SerializableComponents implements Serializable { +public class SbomGraph implements Serializable { - private final Map> serializableComponents; - private final SerializableComponent rootComponent; + private final Map graph; + private final SbomComponent rootComponent; - public SerializableComponents( - Map> serializableComponents, - SerializableComponent rootComponent) { - this.serializableComponents = serializableComponents; + public SbomGraph(final Map graph, final SbomComponent rootComponent) { + this.graph = graph; this.rootComponent = rootComponent; } - public Map> getSerializableComponents() { - return serializableComponents; + public Map getGraph() { + return graph; } - public SerializableComponent getRootComponent() { + public SbomComponent getRootComponent() { return rootComponent; } } diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java b/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java new file mode 100644 index 00000000..bce3c8fa --- /dev/null +++ b/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java @@ -0,0 +1,89 @@ +/* + * 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.model; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import org.cyclonedx.model.Component; + +public class SbomMetaData implements Serializable { + + private String publisher; + private String description; + private List externalReferences = new ArrayList<>(); + + private SbomMetaData() {} + + public String getPublisher() { + return publisher; + } + + public void setPublisher(final String publisher) { + this.publisher = publisher; + } + + public String getDescription() { + return description; + } + + public void setDescription(final String description) { + this.description = description; + } + + public void addExternalReference(final String type, final String url) { + externalReferences.add(new ExternalReference(type, url)); + } + + public List getExternalReferences() { + return externalReferences; + } + + public static SbomMetaData fromComponent(final Component component) { + + final SbomMetaData metaData = new SbomMetaData(); + metaData.setDescription(component.getDescription()); + metaData.setPublisher(component.getPublisher()); + if (component.getExternalReferences() != null) { + component.getExternalReferences().forEach(reference -> { + metaData.addExternalReference(reference.getType().toString(), reference.getUrl()); + }); + } + return metaData; + } + + public static class ExternalReference implements Serializable { + + private final String type; + private final String url; + + private ExternalReference(final String type, final String url) { + this.type = type; + this.url = url; + } + + public String getType() { + return type; + } + + public String getUrl() { + return url; + } + } +} diff --git a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java index 507323f6..661561a0 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/CycloneDxUtils.java @@ -19,12 +19,12 @@ package org.cyclonedx.gradle.utils; import java.io.File; -import java.io.IOException; import java.nio.charset.StandardCharsets; import org.apache.commons.io.FileUtils; import org.cyclonedx.Version; import org.cyclonedx.generators.BomGeneratorFactory; import org.cyclonedx.generators.json.BomJsonGenerator; +import org.cyclonedx.generators.xml.BomXmlGenerator; import org.cyclonedx.model.Bom; import org.gradle.api.GradleException; @@ -58,23 +58,42 @@ public static Version schemaVersion(String version) { } } - public static void writeBom(final Bom bom, final File destination) { - try { - writeJSONBom(DEFAULT_SCHEMA_VERSION, bom, destination); - } catch (IOException e) { - throw new GradleException("An error occurred writing BOM", e); + public static void writeBom( + final Bom bom, + final File destination, + final String outputName, + final Version version, + final String formats) { + + if (formats.equals("all") || formats.equals("json")) { + final File jsonFile = new File(destination, String.format("%s.json", outputName)); + writeJSONBom(version, bom, jsonFile); } - } - private static void writeJSONBom(final Version schemaVersion, final Bom bom, final File destination) - throws IOException { + if (formats.equals("all") || formats.equals("xml")) { + final File xmlFile = new File(destination, String.format("%s.xml", outputName)); + writeXmlBom(version, bom, xmlFile); + } + } + private static void writeJSONBom(final Version schemaVersion, final Bom bom, final File destination) { final BomJsonGenerator bomGenerator = BomGeneratorFactory.createJson(schemaVersion, bom); try { final String bomString = bomGenerator.toJsonString(); FileUtils.write(destination, bomString, StandardCharsets.UTF_8, false); } catch (Exception e) { - throw new GradleException("Valid message", e); + throw new GradleException("Error writing json bom file", e); + } + } + + private static void writeXmlBom(final Version schemaVersion, final Bom bom, final File destination) { + + final BomXmlGenerator bomGenerator = BomGeneratorFactory.createXml(schemaVersion, bom); + try { + final String bomString = bomGenerator.toXmlString(); + FileUtils.write(destination, bomString, StandardCharsets.UTF_8, false); + } catch (Exception e) { + throw new GradleException("Error writing xml bom file", e); } } } diff --git a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java index 68e353ce..d69fc005 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -20,19 +20,108 @@ import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.TreeMap; -import org.cyclonedx.gradle.model.SerializableComponent; +import java.util.stream.Collectors; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.cyclonedx.gradle.model.SbomComponent; +import org.cyclonedx.gradle.model.SbomComponentId; +import org.gradle.api.Project; +import org.gradle.api.artifacts.component.ModuleComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedComponentResult; public class DependencyUtils { - public static String generatePackageUrl( - final SerializableComponent component, final TreeMap qualifiers) + public static Map mergeGraphs( + final Map firstGraph, + final Map secondGraph) { + + final Map mergedGraph = new HashMap<>(firstGraph); + secondGraph.keySet().stream().forEach(id -> { + if (firstGraph.containsKey(id)) { + SbomComponent resultComponent = mergedGraph.get(id); + SbomComponent targetComponent = secondGraph.get(id); + resultComponent.getDependencyComponents().addAll(targetComponent.getDependencyComponents()); + resultComponent.getInScopeConfigurations().addAll(targetComponent.getInScopeConfigurations()); + } else { + mergedGraph.put(id, secondGraph.get(id)); + } + }); + + return mergedGraph; + } + + public static void connectRootWithSubProjects( + final Project project, + final SbomComponentId rootProjectId, + final Map graph) { + + if (project.getSubprojects().isEmpty()) { + return; + } + + final Set dependencyComponentIds = project.getSubprojects().stream() + .map(subProject -> new SbomComponentId( + (String) subProject.getGroup(), subProject.getName(), (String) subProject.getVersion(), "")) + .filter(componentId -> graph.containsKey(componentId)) + .collect(Collectors.toSet()); + + graph.get(rootProjectId).getDependencyComponents().addAll(dependencyComponentIds); + } + + public static Optional findRootComponent( + final Project project, final Map graph) { + + final SbomComponentId rootProjectId = + new SbomComponentId((String) project.getGroup(), project.getName(), (String) project.getVersion(), ""); + + if (!graph.containsKey(rootProjectId)) { + return Optional.empty(); + } else { + return Optional.of(graph.get(rootProjectId)); + } + } + + public static SbomComponentId toComponentId(final ResolvedComponentResult node, final File file) { + + String type = ""; + if (node.getId() instanceof ModuleComponentIdentifier) { + if (file != null) { + type = getType(file); + } else { + type = "pom"; + } + } + + return new SbomComponentId( + node.getModuleVersion().getGroup(), + node.getModuleVersion().getName(), + node.getModuleVersion().getVersion(), + type); + } + + private static String getType(final File file) { + + final String fileExtension = FilenameUtils.getExtension(file.getName()); + if (StringUtils.isBlank(fileExtension)) { + return "pom"; + } + + return fileExtension; + } + + public static String generatePackageUrl(final SbomComponentId componentId, final TreeMap qualifiers) throws MalformedPackageURLException { return new PackageURL( PackageURL.StandardTypes.MAVEN, - component.getGroup(), - component.getName(), - component.getVersion(), + componentId.getGroup(), + componentId.getName(), + componentId.getVersion(), qualifiers, null) .canonicalize(); diff --git a/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy index e888ba5c..1cbe4469 100644 --- a/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/CycloneDxSpec.groovy @@ -53,23 +53,4 @@ class CycloneDxSpec extends Specification { expect: leafProject.tasks.findByName('cyclonedxBom') } - - @Ignore - def "cyclonedxBom metadata creation uses project specific values"() { - expect: - Metadata root = rootProject.tasks.findByName('cyclonedxBom').createMetadata() - root.component.group == 'group' - root.component.name == 'root' - root.component.version == '1.3' - - Metadata parent = parentProject.tasks.findByName('cyclonedxBom').createMetadata() - parent.component.group == 'group' - parent.component.name == 'parent' - parent.component.version == '1.3' - - Metadata leaf = leafProject.tasks.findByName('cyclonedxBom').createMetadata() - leaf.component.group == 'group' - leaf.component.name == 'leaf' - leaf.component.version == '1.3.1' - } } diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index beed3a4a..c172be03 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -1,6 +1,8 @@ package org.cyclonedx.gradle import com.fasterxml.jackson.databind.ObjectMapper +import groovy.json.JsonSlurper +import org.cyclonedx.gradle.utils.CycloneDxUtils import org.cyclonedx.model.Bom import org.cyclonedx.model.Component import org.cyclonedx.model.Dependency @@ -32,7 +34,7 @@ class DependencyResolutionSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -65,7 +67,7 @@ class DependencyResolutionSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -158,4 +160,212 @@ class DependencyResolutionSpec extends Specification { Component componentc = bom.getComponents().find(c -> c.bomRef == 'pkg:maven/com.test/componentc@1.0.0?type=tgz') assert componentc != null } + + def "should build bom successfully for native kotlin project"() { + given: + File testDir = TestUtils.duplicate("native-kotlin-project") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "build/reports") + + assert reportDir.exists() + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + } + + def "loops between jar dependencies in the dependency graph should be processed"() { + given: + File testDir = TestUtils.duplicate("dependency-graph-loop") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "build/reports") + + assert reportDir.exists() + } + + def "loops between non-jar dependencies in the dependency graph should be processed"() { + given: + String localRepoUri = TestUtils.duplicateRepo("local") + + File testDir = TestUtils.createFromString(""" + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + repositories { + maven{ + url '$localRepoUri' + } + } + group = 'com.example' + version = '1.0.0' + + dependencies { + implementation("com.test:componentc:1.0.1") + }""", "rootProject.name = 'simple-project'") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--configuration-cache") + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File jsonBom = new File(testDir, "build/reports/bom.json") + Bom bom = new ObjectMapper().readValue(jsonBom, Bom.class) + Component componentc = bom.getComponents().find(c -> c.bomRef == 'pkg:maven/com.test/componentc@1.0.1?type=tgz') + assert componentc != null + Component componentd = bom.getComponents().find(c -> c.bomRef == 'pkg:maven/com.test/componentd@1.0.0?type=tgz') + assert componentd != null + } + + def "multi-module with plugin at root should output boms in build/reports with default version including sub-projects as components"() { + given: + File testDir = TestUtils.duplicate("multi-module") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") + .withPluginClasspath() + .build() + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "build/reports") + + assert reportDir.exists() + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + + def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) + assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString + + def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") + assert appAComponent.hasComponentDefined() + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") + + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") + assert appBComponent.hasComponentDefined() + assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0") + } + + def "multi-module with plugin in subproject should output boms in build/reports with for sub-project app-a"() { + given: + File testDir = TestUtils.duplicate("multi-module-subproject") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments(":app-a:cyclonedxBom", "--info", "-S", "--configuration-cache") + .withPluginClasspath() + .build() + then: + result.task(":app-a:cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "app-a/build/reports") + + assert reportDir.exists() + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + + def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) + assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString + + assert jsonBom.metadata.component.type == "library" + assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-a@1.0.0" + assert jsonBom.metadata.component.group == "com.example" + assert jsonBom.metadata.component.name == "app-a" + assert jsonBom.metadata.component.version == "1.0.0" + assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-a@1.0.0" + + def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") + assert !appAComponent.hasComponentDefined() + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") + + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0?type=jar") + assert !appBComponent.hasComponentDefined() + assert appBComponent.dependencies == null + } + + def "multi-module with plugin in subproject should output boms in build/reports with for sub-project app-b"() { + given: + File testDir = TestUtils.duplicate("multi-module-subproject") + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments(":app-a:assemble", ":app-b:cyclonedxBom", "--info", "-S") + .withPluginClasspath() + .build() + then: + result.task(":app-b:cyclonedxBom").outcome == TaskOutcome.SUCCESS + File reportDir = new File(testDir, "app-b/build/reports") + + assert reportDir.exists() + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + + def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) + assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString + + assert jsonBom.metadata.component.type == "library" + assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-b@1.0.0" + assert jsonBom.metadata.component.group == "com.example" + assert jsonBom.metadata.component.name == "app-b" + assert jsonBom.metadata.component.version == "1.0.0" + assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-b@1.0.0" + + def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") + assert appAComponent.hasComponentDefined() + assert appAComponent.component.hashes != null + assert !appAComponent.component.hashes.empty + assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") + + def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") + assert !appBComponent.hasComponentDefined() + assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0") + } + + private static def loadJsonBom(File file) { + return new JsonSlurper().parse(file) + } + + private static class JsonBomComponent { + + def component + def dependencies + + boolean hasComponentDefined() { + return component != null + && ["library", "application"].contains(component.type) + && !component.group.empty + && !component.name.empty + && !component.version.empty + && !component.purl.empty + } + + boolean dependsOn(String ref) { + return dependencies != null && dependencies.dependsOn.contains(ref) + } + + static JsonBomComponent of(jsonBom, String ref) { + return new JsonBomComponent( + component: jsonBom.components.find { it."bom-ref".equals(ref) }, + dependencies: jsonBom.dependencies.find { it.ref.equals(ref) } + ) + } + } } diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index cef7b0db..2e3198e7 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -1,34 +1,15 @@ package org.cyclonedx.gradle import com.fasterxml.jackson.databind.ObjectMapper -import groovy.json.JsonSlurper import org.cyclonedx.gradle.utils.CycloneDxUtils import org.cyclonedx.model.Bom import org.cyclonedx.model.Component import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import spock.lang.Specification -import spock.lang.Ignore class PluginConfigurationSpec extends Specification { - def "loops in the dependency graph should be processed"() { - given: - File testDir = TestUtils.duplicate("dependency-graph-loop") - - when: - def result = GradleRunner.create() - .withProjectDir(testDir) - .withArguments("cyclonedxBom") - .withPluginClasspath() - .build() - - then: - result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS - File reportDir = new File(testDir, "build/reports") - - assert reportDir.exists() - } def "simple-project should output boms in build/reports with default schema version"() { given: @@ -37,7 +18,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() then: @@ -45,7 +26,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"${CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString}\"") } @@ -57,7 +38,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() then: @@ -65,10 +46,9 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "output-dir") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 } - @Ignore def "custom-output project should write boms under my-bom"() { given: File testDir = TestUtils.duplicate("custom-outputname") @@ -76,7 +56,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() then: @@ -93,7 +73,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -101,12 +81,11 @@ class PluginConfigurationSpec extends Specification { result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 assert !result.output.contains("An error occurred attempting to read POM") } - @Ignore def "should use configured schemaVersion"() { given: File testDir = TestUtils.createFromString(""" @@ -130,7 +109,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -139,7 +118,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"1.3\"") } @@ -178,19 +157,35 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"name\" : \"hello-world\"") } - def "should build bom successfully for native kotlin project"() { + def "should use configured componentName"() { given: - File testDir = TestUtils.duplicate("native-kotlin-project") + File testDir = TestUtils.createFromString(""" + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + repositories { + mavenCentral() + } + group = 'com.example' + version = '1.0.0' + cyclonedxBom { + componentName = 'customized-component-name' + } + 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' + }""", "rootProject.name = 'hello-world'") when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -199,11 +194,12 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 + File jsonBom = new File(reportDir, "bom.json") + assert jsonBom.text.contains("\"name\" : \"customized-component-name\"") } - @Ignore - def "should use configured componentName"() { + def "should use configured componentVersion"() { given: File testDir = TestUtils.createFromString(""" plugins { @@ -216,7 +212,7 @@ class PluginConfigurationSpec extends Specification { group = 'com.example' version = '1.0.0' cyclonedxBom { - componentName = 'customized-component-name' + componentVersion = '999-SNAPSHOT' } dependencies { implementation group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version:'2.8.11' @@ -226,7 +222,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -235,13 +231,12 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") - assert jsonBom.text.contains("\"name\" : \"customized-component-name\"") + assert jsonBom.text.contains("\"version\" : \"999-SNAPSHOT\"") } - @Ignore - def "should use configured componentVersion"() { + def "should use configured projectType"() { given: File testDir = TestUtils.createFromString(""" plugins { @@ -254,17 +249,16 @@ class PluginConfigurationSpec extends Specification { group = 'com.example' version = '1.0.0' cyclonedxBom { - componentVersion = '999-SNAPSHOT' + projectType = 'framework' } 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' }""", "rootProject.name = 'hello-world'") when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -273,12 +267,11 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") - assert jsonBom.text.contains("\"version\" : \"999-SNAPSHOT\"") + assert jsonBom.text.contains("\"type\" : \"framework\"") } - @Ignore def "should use configured outputFormat to limit generated file"() { given: File testDir = TestUtils.createFromString(""" @@ -302,7 +295,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -311,12 +304,11 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 1 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.exists() } - @Ignore def "includes component bom-ref when schema version greater than 1.0"() { given: File testDir = TestUtils.createFromString(""" @@ -339,7 +331,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -352,110 +344,6 @@ class PluginConfigurationSpec extends Specification { assert log4jCore.getBomRef() == 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0?type=jar' } - def "multi-module with plugin at root should output boms in build/reports with default version including sub-projects as components"() { - given: - File testDir = TestUtils.duplicate("multi-module") - - when: - def result = GradleRunner.create() - .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") - .withPluginClasspath() - .build() - then: - result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS - File reportDir = new File(testDir, "build/reports") - - assert reportDir.exists() - reportDir.listFiles().length == 1 - - def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) - assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString - - def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") - assert appAComponent.hasComponentDefined() - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") - - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") - assert appBComponent.hasComponentDefined() - assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") - } - - def "multi-module with plugin in subproject should output boms in build/reports with for sub-project app-a"() { - given: - File testDir = TestUtils.duplicate("multi-module-subproject") - - when: - def result = GradleRunner.create() - .withProjectDir(testDir) - .withArguments(":app-a:cyclonedxBom", "--info", "-S") - .withPluginClasspath() - .build() - then: - result.task(":app-a:cyclonedxBom").outcome == TaskOutcome.SUCCESS - File reportDir = new File(testDir, "app-a/build/reports") - - assert reportDir.exists() - reportDir.listFiles().length == 1 - - def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) - assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString - - assert jsonBom.metadata.component.type == "library" - assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-a@1.0.0" - assert jsonBom.metadata.component.group == "com.example" - assert jsonBom.metadata.component.name == "app-a" - assert jsonBom.metadata.component.version == "1.0.0" - assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-a@1.0.0" - - def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0") - assert !appAComponent.hasComponentDefined() - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0?type=jar") - - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0?type=jar") - assert !appBComponent.hasComponentDefined() - assert appBComponent.dependencies == null - } - - def "multi-module with plugin in subproject should output boms in build/reports with for sub-project app-b"() { - given: - File testDir = TestUtils.duplicate("multi-module-subproject") - - when: - def result = GradleRunner.create() - .withProjectDir(testDir) - .withArguments(":app-a:assemble", ":app-b:cyclonedxBom", "--info", "-S") - .withPluginClasspath() - .build() - then: - result.task(":app-b:cyclonedxBom").outcome == TaskOutcome.SUCCESS - File reportDir = new File(testDir, "app-b/build/reports") - - assert reportDir.exists() - reportDir.listFiles().length == 1 - - def jsonBom = loadJsonBom(new File(reportDir, "bom.json")) - assert jsonBom.specVersion == CycloneDxUtils.DEFAULT_SCHEMA_VERSION.versionString - - assert jsonBom.metadata.component.type == "library" - assert jsonBom.metadata.component."bom-ref" == "pkg:maven/com.example/app-b@1.0.0" - assert jsonBom.metadata.component.group == "com.example" - assert jsonBom.metadata.component.name == "app-b" - assert jsonBom.metadata.component.version == "1.0.0" - assert jsonBom.metadata.component.purl == "pkg:maven/com.example/app-b@1.0.0" - - def appAComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-a@1.0.0?type=jar") - assert appAComponent.hasComponentDefined() - assert appAComponent.component.hashes != null - assert !appAComponent.component.hashes.empty - assert !appAComponent.dependsOn("pkg:maven/com.example/app-b@1.0.0") - - def appBComponent = JsonBomComponent.of(jsonBom, "pkg:maven/com.example/app-b@1.0.0") - assert !appBComponent.hasComponentDefined() - assert appBComponent.dependsOn("pkg:maven/com.example/app-a@1.0.0?type=jar") - } - - @Ignore def "kotlin-dsl-project should allow configuring all properties"() { given: File testDir = TestUtils.duplicate("kotlin-project") @@ -463,21 +351,20 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S") + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") .withPluginClasspath() .build() then: - // result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert !jsonBom.text.contains("serialNumber") } - @Ignore def "kotlin-dsl-project-manufacture-licenses should allow definition of manufacture-data and licenses-data"() { given: File testDir = TestUtils.duplicate("kotlin-project-manufacture-licenses") @@ -485,7 +372,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S") + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") .withPluginClasspath() .build() @@ -494,7 +381,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") //check Manufacture Data assert jsonBom.text.contains("\"name\" : \"Test\"") @@ -510,7 +397,6 @@ class PluginConfigurationSpec extends Specification { } - @Ignore def "groovy-project-manufacture-licenses should allow definition of manufacture-data and licenses-data"() { given: File testDir = TestUtils.duplicate("groovy-project-manufacture-licenses") @@ -518,7 +404,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--info", "-S") + .withArguments("cyclonedxBom", "--info", "-S", "--configuration-cache") .withPluginClasspath() .build() @@ -527,7 +413,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") //check Manufacture Data assert jsonBom.text.contains("\"name\" : \"Test\"") @@ -543,7 +429,6 @@ class PluginConfigurationSpec extends Specification { } - @Ignore def "should skip configurations with regex"() { given: File testDir = TestUtils.createFromString(""" @@ -567,7 +452,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--stacktrace", "--configuration-cache") .withPluginClasspath() .build() @@ -580,7 +465,6 @@ class PluginConfigurationSpec extends Specification { assert log4jCore == null } - @Ignore def "should include configurations with regex"() { given: File testDir = TestUtils.createFromString(""" @@ -604,7 +488,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -634,7 +518,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -643,7 +527,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 1 + reportDir.listFiles().length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"1.6\"") } @@ -665,16 +549,15 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--stacktrace") .withPluginClasspath() .run() then: result.task(":cyclonedxBom").outcome == TaskOutcome.FAILED - assert result.output.contains("Invalid module identifier provided.") + assert result.output.contains("Project group and version are required for the CycloneDx task") } - @Ignore def "should include metadata by default"() { given: File testDir = TestUtils.createFromString(""" @@ -694,7 +577,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--stacktrace", "--configuration-cache") .withPluginClasspath() .build() @@ -704,7 +587,6 @@ class PluginConfigurationSpec extends Specification { assert jsonBom.text.contains("\"id\" : \"Apache-2.0\"") } - @Ignore def "should not include metadata when includeMetadataResolution is false"() { given: File testDir = TestUtils.createFromString(""" @@ -727,7 +609,7 @@ class PluginConfigurationSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom") + .withArguments("cyclonedxBom", "--configuration-cache") .withPluginClasspath() .build() @@ -736,34 +618,4 @@ class PluginConfigurationSpec extends Specification { File jsonBom = new File(testDir, "build/reports/bom.json") assert !jsonBom.text.contains("\"id\" : \"Apache-2.0\"") } - - private static def loadJsonBom(File file) { - return new JsonSlurper().parse(file) - } - - private static class JsonBomComponent { - - def component - def dependencies - - boolean hasComponentDefined() { - return component != null - && ["library", "application"].contains(component.type) - && !component.group.empty - && !component.name.empty - && !component.version.empty - && !component.purl.empty - } - - boolean dependsOn(String ref) { - return dependencies != null && dependencies.dependsOn.contains(ref) - } - - static JsonBomComponent of(jsonBom, String ref) { - return new JsonBomComponent( - component: jsonBom.components.find { it."bom-ref".equals(ref) }, - dependencies: jsonBom.dependencies.find { it.ref.equals(ref) } - ) - } - } } diff --git a/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.pom b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.pom new file mode 100644 index 00000000..afec76c8 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.pom @@ -0,0 +1,18 @@ + + + 4.0.0 + com.test + componentc + 1.0.1 + tgz + + + + com.test + componentd + 1.0.0 + + + + diff --git a/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.tgz b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.tgz new file mode 100644 index 00000000..6971eb4f --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentc/1.0.1/componentc-1.0.1.tgz @@ -0,0 +1 @@ +component c version 1.0.1 diff --git a/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.pom b/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.pom new file mode 100644 index 00000000..e14bbdd0 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.pom @@ -0,0 +1,18 @@ + + + 4.0.0 + com.test + componentd + 1.0.0 + tgz + + + + com.test + componentc + 1.0.1 + + + + diff --git a/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.tgz b/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.tgz new file mode 100644 index 00000000..c8c2e020 --- /dev/null +++ b/src/test/resources/test-repos/local/repository/com/test/componentd/1.0.0/componentd-1.0.0.tgz @@ -0,0 +1 @@ +component d version 1.0.0 From babadc837132959df1c68259909e82bfe87bf7d1 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 4 Nov 2024 08:44:21 +0000 Subject: [PATCH 19/26] fix: add entry into cache Signed-off-by: Gordon --- src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java index 80f9db6a..16f15499 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java +++ b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java @@ -63,10 +63,11 @@ MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { mavenProject.setLicenses(model.getLicenses()); } + cache.put(result.getId(), mavenProject); return mavenProject; } } catch (Exception err) { - project.getLogger().error("Unable to resolve POM for {}: {}", result.getId(), err); + project.getLogger().error("Unable to resolve POM for {}", result.getId(), err); } return null; } From 686bf452604171fb372b17af7f9f174efa5522d7 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 4 Nov 2024 10:05:27 +0000 Subject: [PATCH 20/26] fix: getGroup and getVersion do not return String objects Signed-off-by: Gordon --- .../java/org/cyclonedx/gradle/SbomGraphProvider.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java index cbb592da..76fb5db5 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -26,7 +26,6 @@ import java.util.concurrent.Callable; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.apache.commons.lang3.StringUtils; import org.cyclonedx.gradle.model.SbomComponent; import org.cyclonedx.gradle.model.SbomComponentId; import org.cyclonedx.gradle.model.SbomGraph; @@ -48,7 +47,9 @@ public SbomGraphProvider(final Project project, final CycloneDxTask task) { @Override public SbomGraph call() throws Exception { - if (StringUtils.isBlank((String) project.getGroup()) || StringUtils.isBlank((String) project.getVersion())) { + if (project.getGroup().equals("") + || project.getName().isEmpty() + || project.getVersion().equals("")) { throw new IllegalStateException("Project group and version are required for the CycloneDx task"); } @@ -71,7 +72,10 @@ private SbomGraph buildSbomGraph(final Map graph return new SbomGraph(graph, rootProject.get()); } else { final SbomComponentId rootProjectId = new SbomComponentId( - (String) project.getGroup(), project.getName(), (String) project.getVersion(), ""); + project.getGroup().toString(), + project.getName(), + project.getVersion().toString(), + ""); final SbomComponent sbomComponent = new SbomComponent.Builder() .withId(rootProjectId) .withDependencyComponents(new HashSet<>()) From 8fb80ab8736141ceb04e2eacbd7b525cc5952b37 Mon Sep 17 00:00:00 2001 From: Gordon Date: Tue, 5 Nov 2024 09:44:09 +0000 Subject: [PATCH 21/26] feat: add small fixes, javadoc, logging and nullables. Signed-off-by: Gordon --- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 3 ++ .../org/cyclonedx/gradle/CycloneDxTask.java | 22 +++++++++-- .../gradle/DependencyGraphTraverser.java | 29 ++++++++++++-- .../cyclonedx/gradle/MavenProjectLookup.java | 18 ++++++++- .../org/cyclonedx/gradle/SbomBuilder.java | 38 ++++++++++++++----- .../cyclonedx/gradle/SbomGraphProvider.java | 26 +++++++++++-- .../cyclonedx/gradle/model/SbomComponent.java | 22 ++++++----- .../org/cyclonedx/gradle/model/SbomGraph.java | 4 ++ .../cyclonedx/gradle/model/SbomMetaData.java | 17 +++++---- .../gradle/utils/DependencyUtils.java | 28 +++++++++----- 10 files changed, 160 insertions(+), 47 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index e2df81b0..115d0f58 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -23,6 +23,9 @@ import org.gradle.api.Project; import org.gradle.api.provider.Provider; +/** + * Entrypoint of the plugin which simply configures one task + */ public class CycloneDxPlugin implements Plugin { public void apply(final Project project) { diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index ebf4bdbc..43259058 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.Map; import java.util.function.Consumer; +import javax.annotation.Nullable; import org.cyclonedx.gradle.model.SbomGraph; import org.cyclonedx.gradle.utils.CycloneDxUtils; import org.cyclonedx.model.Bom; @@ -36,8 +37,13 @@ import org.gradle.api.tasks.OutputDirectory; import org.gradle.api.tasks.TaskAction; +/** + * This task mainly acts a container for the user configurations (includeConfigs, projectType, schemaVersion, ...) + * and orchestrating the calls between the core objects (SbomGraphProvider and SbomBuilder) + */ public abstract class CycloneDxTask extends DefaultTask { + private static final String MESSAGE_WRITING_BOM_OUTPUT = "CycloneDX: Writing BOM output"; private static final String DEFAULT_PROJECT_TYPE = "library"; private final Property outputName; @@ -53,8 +59,10 @@ public abstract class CycloneDxTask extends DefaultTask { private final Property projectType; private final ListProperty skipProjects; private final Property destination; - private OrganizationalEntity organizationalEntity; - private LicenseChoice licenseChoice; + + @Nullable private OrganizationalEntity organizationalEntity; + + @Nullable private LicenseChoice licenseChoice; public CycloneDxTask() { @@ -211,12 +219,12 @@ public void setSkipProjects(final Collection skipProjects) { } @Internal - OrganizationalEntity getOrganizationalEntity() { + @Nullable OrganizationalEntity getOrganizationalEntity() { return organizationalEntity; } @Internal - LicenseChoice getLicenseChoice() { + @Nullable LicenseChoice getLicenseChoice() { return licenseChoice; } @@ -232,6 +240,10 @@ public void setDestination(final File destination) { this.destination.set(destination); } + /** + * Executes the main logic of the plugin by loading the dependency graph (SbomGraphProvider.get()) + * and providing the result to SbomBuilder + */ @TaskAction public void createBom() { @@ -239,6 +251,8 @@ public void createBom() { 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(), diff --git a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java index 421c7b40..585cf26f 100644 --- a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java +++ b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java @@ -20,6 +20,7 @@ import java.io.File; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -43,6 +44,10 @@ import org.gradle.api.artifacts.result.ResolvedDependencyResult; import org.gradle.api.logging.Logger; +/** + * Traverses the dependency graph of a configuration which returns a data model that 1) contains all the information + * required to generate the CycloneDX Bom and 2) is fully serializable to support the build cache + */ class DependencyGraphTraverser { private final Logger logger; @@ -63,6 +68,16 @@ public DependencyGraphTraverser( this.mavenHelper = new MavenHelper(logger, task.getIncludeLicenseText().get()); } + /** + * Traverses the dependency graph of a configuration belonging to the specified project + * + * @param rootNode entry point into the graph which is typically represents a project + * @param projectName project to which the configuration belongs to + * @param configName name of the configuration + * + * @return a graph represented as map which is fully serializable. The graph nodes are instances of + * SbomComponent which contain the necessary information to generate the Bom + */ Map traverseGraph( final ResolvedComponentResult rootNode, final String projectName, final String configName) { @@ -73,14 +88,20 @@ Map traverseGraph( rootGraphNode.inScopeConfiguration(projectName, configName); queue.add(rootGraphNode); + logger.debug("CycloneDX: Traversal of graph for configuration {} of project {}", configName, projectName); while (!queue.isEmpty()) { final GraphNode graphNode = queue.poll(); if (!graph.containsKey(graphNode)) { graph.put(graphNode, new HashSet<>()); - for (DependencyResult dep : graphNode.getResult().getDependencies()) { + logger.debug("CycloneDX: Traversing node with ID {}", graphNode.id); + for (final DependencyResult dep : graphNode.getResult().getDependencies()) { if (dep instanceof ResolvedDependencyResult) { final ResolvedComponentResult dependencyComponent = ((ResolvedDependencyResult) dep).getSelected(); + logger.debug( + "CycloneDX: Node with ID {} has dependency with ID {}", + graphNode.id, + dependencyComponent); final GraphNode dependencyNode = new GraphNode(dependencyComponent); dependencyNode.inScopeConfiguration(projectName, configName); graph.get(graphNode).add(dependencyNode); @@ -107,6 +128,7 @@ private SbomComponent toSbomComponent(final GraphNode node, final Set List licenses = null; SbomMetaData metaData = null; if (includeMetaData && node.id instanceof ModuleComponentIdentifier) { + logger.debug("CycloneDX: Including meta data for node {}", node.id); final Component component = new Component(); extractMetaDataFromArtifactPom(artifactFile, component, node.getResult()); licenses = extractMetaDataFromRepository(component, node.getResult()); @@ -126,12 +148,13 @@ private SbomComponent toSbomComponent(final GraphNode node, final Set private void extractMetaDataFromArtifactPom( final File artifactFile, final Component component, final ResolvedComponentResult result) { - if (artifactFile == null) { + if (artifactFile == null || result.getModuleVersion() == null) { return; } 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()); } } @@ -144,7 +167,7 @@ private List extractMetaDataFromRepository( return mavenProject.getLicenses(); } - return null; + return new ArrayList<>(); } private Set getSbomDependencies(final Set dependencyNodes) { diff --git a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java index 16f15499..c3252210 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java +++ b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java @@ -22,6 +22,7 @@ import java.util.HashMap; import java.util.Iterator; import java.util.Map; +import javax.annotation.Nullable; import org.apache.maven.model.Model; import org.apache.maven.project.MavenProject; import org.gradle.api.Project; @@ -34,6 +35,9 @@ import org.gradle.maven.MavenModule; import org.gradle.maven.MavenPomArtifact; +/** + * Finds the pom.xml of a maven project in the gradle repositories and, if exists, instantiates a MavenProject object + */ class MavenProjectLookup { private final Project project; @@ -44,7 +48,15 @@ class MavenProjectLookup { this.cache = new HashMap<>(); } - MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { + /** + * Retrieve the MavenProject instance for the provided component + * + * @param result the resolved component for which to find the maven project, + * or null if the pom.xml is not found + * + * @return a MavenProject instance for this component + */ + @Nullable MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { if (result == null) { return null; @@ -58,6 +70,7 @@ MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { final File pomFile = buildMavenProject(result.getId()); final MavenProject mavenProject = MavenHelper.readPom(pomFile); if (mavenProject != null) { + project.getLogger().debug("CycloneDX: parse queried pom file for component {}", result.getId()); final Model model = MavenHelper.resolveEffectivePom(pomFile, project); if (model != null) { mavenProject.setLicenses(model.getLicenses()); @@ -72,7 +85,7 @@ MavenProject getResolvedMavenProject(final ResolvedComponentResult result) { return null; } - private File buildMavenProject(final ComponentIdentifier id) { + @Nullable File buildMavenProject(final ComponentIdentifier id) { final ArtifactResolutionResult result = project.getDependencies() .createArtifactResolutionQuery() @@ -94,6 +107,7 @@ private File buildMavenProject(final ComponentIdentifier id) { final ArtifactResult artifact = artifactIt.next(); if (artifact instanceof ResolvedArtifactResult) { + project.getLogger().debug("CycloneDX: found pom file for component {}", id); final ResolvedArtifactResult resolvedArtifact = (ResolvedArtifactResult) artifact; return resolvedArtifact.getFile(); } diff --git a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java index f59d2b7c..e94e0645 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; @@ -51,9 +51,15 @@ import org.cyclonedx.util.BomUtils; import org.gradle.api.logging.Logger; +/** + * Generates the CycloneDX Bom from the aggregated dependency graph taking into account the provided + * user configuration (componentName, includeBomSerialNumber,...) + */ class SbomBuilder { private static final String MESSAGE_CALCULATING_HASHES = "CycloneDX: Calculating Hashes"; + private static final String MESSAGE_CREATING_BOM = "CycloneDX: Creating BOM"; + private static final TreeMap EMPTY_TYPE = new TreeMap<>(); private final Logger logger; @@ -70,8 +76,18 @@ class SbomBuilder { this.task = task; } + /** + * Builds the CycloneDX Bom from the aggregated dependency graph + * + * @param resultGraph the aggregated dependency graph across all the configurations + * @param rootComponent the root component of the graph which is the parent project + * + * @return the CycloneDX Bom + */ Bom buildBom(final Map resultGraph, final SbomComponent rootComponent) { + task.getLogger().info(MESSAGE_CREATING_BOM); + final Set dependencies = new TreeSet<>(new DependencyComparator()); final Set components = new TreeSet<>(new ComponentComparator()); @@ -183,15 +199,17 @@ private Component toComponent(final SbomComponent component, final File artifact } private List buildProperties(final SbomComponent component) { - return component.getInScopeConfigurations().stream() - .map(v -> { - Property property = new Property(); - property.setName("inScopeConfiguration"); - 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(",")); + + final Property property = new Property(); + property.setName("inScopeConfiguration"); + property.setValue(value); + + return Collections.singletonList(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 76fb5db5..151cd582 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -33,9 +33,16 @@ import org.gradle.api.Project; import org.gradle.api.artifacts.Configuration; import org.gradle.api.artifacts.component.ComponentIdentifier; +import org.gradle.api.artifacts.result.ResolvedArtifactResult; +/** + * Provider that lazily calculates the aggregated dependency graph. The usage of a provider is essential to support + * configuration cache and also to ensure that all dependencies have been resolved when the CycloneDxTask is executed. + */ public class SbomGraphProvider implements Callable { + private static final String MESSAGE_RESOLVING_DEPS = "CycloneDX: Resolving Dependencies"; + private final Project project; private final CycloneDxTask task; @@ -44,6 +51,15 @@ public SbomGraphProvider(final Project project, final CycloneDxTask task) { this.task = task; } + /** + * Calculates the aggregated dependency graph across all the configurations of both the parent project and + * child projects. The steps are as follows: + * 1) generate dependency graphs for the parent 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) + * + * @return the aggregated dependency graph + */ @Override public SbomGraph call() throws Exception { @@ -53,11 +69,13 @@ public SbomGraph call() throws Exception { throw new IllegalStateException("Project group and version are required for the CycloneDx task"); } + project.getLogger().info(MESSAGE_RESOLVING_DEPS); + final DependencyGraphTraverser traverser = new DependencyGraphTraverser( project.getLogger(), getArtifacts(), new MavenProjectLookup(project), task); final Map graph = Stream.concat( - traverseParentProject(traverser), traverseChildProjects(traverser)) + traverseCurrentProject(traverser), traverseChildProjects(traverser)) .reduce(new HashMap<>(), DependencyUtils::mergeGraphs); return buildSbomGraph(graph); @@ -71,6 +89,7 @@ private SbomGraph buildSbomGraph(final Map graph project, rootProject.get().getId(), graph); return new SbomGraph(graph, rootProject.get()); } else { + project.getLogger().debug("CycloneDX: root project not found. Constructing it."); final SbomComponentId rootProjectId = new SbomComponentId( project.getGroup().toString(), project.getName(), @@ -86,7 +105,7 @@ private SbomGraph buildSbomGraph(final Map graph } } - private Stream> traverseParentProject( + private Stream> traverseCurrentProject( final DependencyGraphTraverser traverser) { if (shouldSkipProject(project)) { @@ -103,6 +122,7 @@ private Stream> traverseParentProject( 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) @@ -123,7 +143,7 @@ private Map getArtifacts() { .flatMap(config -> config.getIncoming().getArtifacts().getArtifacts().stream()) .collect(Collectors.toMap( artifact -> artifact.getId().getComponentIdentifier(), - artifact -> artifact.getFile(), + ResolvedArtifactResult::getFile, (v1, v2) -> v1)); } diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java index 0eea5539..5e992bb1 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomComponent.java @@ -23,6 +23,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import javax.annotation.Nullable; import org.apache.maven.model.License; public final class SbomComponent implements Serializable { @@ -30,17 +31,20 @@ public final class SbomComponent implements Serializable { private final SbomComponentId id; private final Set inScopeConfigurations; private final Set dependencyComponents; - private final File artifactFile; - private final SbomMetaData metaData; - private final List licenses; + + @Nullable private final File artifactFile; + + @Nullable private final SbomMetaData metaData; + + @Nullable private final List licenses; public SbomComponent( final SbomComponentId id, final Set inScopeConfigurations, final Set dependencyComponents, - final File artifactFile, - final SbomMetaData metaData, - final List licenses) { + @Nullable final File artifactFile, + @Nullable final SbomMetaData metaData, + @Nullable final List licenses) { this.id = id; this.inScopeConfigurations = inScopeConfigurations; this.dependencyComponents = dependencyComponents; @@ -99,17 +103,17 @@ public Builder withDependencyComponents(final Set dependencyCom return this; } - public Builder withArtifactFile(final File artifactFile) { + public Builder withArtifactFile(@Nullable final File artifactFile) { this.artifactFile = artifactFile; return this; } - public Builder withMetaData(final SbomMetaData metaData) { + public Builder withMetaData(@Nullable final SbomMetaData metaData) { this.metaData = metaData; return this; } - public Builder withLicenses(final List licenses) { + public Builder withLicenses(@Nullable final List licenses) { this.licenses = licenses; return this; } diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java b/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java index a490a3d7..838f5b52 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomGraph.java @@ -21,6 +21,10 @@ import java.io.Serializable; import java.util.Map; +/** + * Represents the aggregated dependency graph across all the configurations of the projects in scope. It is fully + * serializable to support the build cache. + */ public class SbomGraph implements Serializable { private final Map graph; diff --git a/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java b/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java index bce3c8fa..918f3625 100644 --- a/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java +++ b/src/main/java/org/cyclonedx/gradle/model/SbomMetaData.java @@ -21,29 +21,32 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; import org.cyclonedx.model.Component; public class SbomMetaData implements Serializable { - private String publisher; - private String description; - private List externalReferences = new ArrayList<>(); + @Nullable private String publisher; + + @Nullable private String description; + + private final List externalReferences = new ArrayList<>(); private SbomMetaData() {} - public String getPublisher() { + @Nullable public String getPublisher() { return publisher; } - public void setPublisher(final String publisher) { + public void setPublisher(@Nullable final String publisher) { this.publisher = publisher; } - public String getDescription() { + @Nullable public String getDescription() { return description; } - public void setDescription(final String description) { + public void setDescription(@Nullable final String description) { this.description = description; } diff --git a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java index d69fc005..6a90fe9d 100644 --- a/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java +++ b/src/main/java/org/cyclonedx/gradle/utils/DependencyUtils.java @@ -67,8 +67,11 @@ public static void connectRootWithSubProjects( final Set dependencyComponentIds = project.getSubprojects().stream() .map(subProject -> new SbomComponentId( - (String) subProject.getGroup(), subProject.getName(), (String) subProject.getVersion(), "")) - .filter(componentId -> graph.containsKey(componentId)) + subProject.getGroup().toString(), + subProject.getName(), + subProject.getVersion().toString(), + "")) + .filter(graph::containsKey) .collect(Collectors.toSet()); graph.get(rootProjectId).getDependencyComponents().addAll(dependencyComponentIds); @@ -77,8 +80,11 @@ public static void connectRootWithSubProjects( public static Optional findRootComponent( final Project project, final Map graph) { - final SbomComponentId rootProjectId = - new SbomComponentId((String) project.getGroup(), project.getName(), (String) project.getVersion(), ""); + final SbomComponentId rootProjectId = new SbomComponentId( + project.getGroup().toString(), + project.getName(), + project.getVersion().toString(), + ""); if (!graph.containsKey(rootProjectId)) { return Optional.empty(); @@ -98,11 +104,15 @@ public static SbomComponentId toComponentId(final ResolvedComponentResult node, } } - return new SbomComponentId( - node.getModuleVersion().getGroup(), - node.getModuleVersion().getName(), - node.getModuleVersion().getVersion(), - type); + if (node.getModuleVersion() != null) { + return new SbomComponentId( + node.getModuleVersion().getGroup(), + node.getModuleVersion().getName(), + node.getModuleVersion().getVersion(), + type); + } else { + return new SbomComponentId("N/A", node.getId().getDisplayName(), "N/A", type); + } } private static String getType(final File file) { From 15564d55585309678207d03a0f3f2c0ec3b1514b Mon Sep 17 00:00:00 2001 From: Gordon Date: Thu, 7 Nov 2024 18:31:06 +0000 Subject: [PATCH 22/26] feat: log configuration parameters, use empty list for licenses, validate sbom, missing nullables and remove duplicated code 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 6d756d59..d29e8f6e 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; + } +} From 8f204a8a4adc1f4a08340227091d7fbd013159a5 Mon Sep 17 00:00:00 2001 From: Gordon Date: Mon, 11 Nov 2024 20:08:05 +0000 Subject: [PATCH 23/26] feat: add additional logging for purl failures and remove cdx:maven:package:projectsAndScopes properties (adding back in later PR) Signed-off-by: Gordon --- .../org/cyclonedx/gradle/SbomBuilder.java | 27 ++++++------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java index 443d96b4..57472f8d 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java @@ -24,7 +24,6 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -32,7 +31,6 @@ import java.util.TreeMap; 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; @@ -114,7 +112,10 @@ private Metadata buildMetadata(final SbomComponent parentComponent) { component.setVersion(task.getComponentVersion().get()); metadata.setComponent(component); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for parent component. Skipping...", e); + logger.warn( + "Error constructing packageUrl for parent component {}. Skipping...", + parentComponent.getId().getName(), + e); } metadata.setLicenseChoice(task.getLicenseChoice()); metadata.setManufacture(task.getOrganizationalEntity()); @@ -128,7 +129,10 @@ private void addDependency(final Set dependencies, final SbomCompone try { dependency = toDependency(component.getId()); } catch (MalformedPackageURLException e) { - logger.warn("Error constructing packageUrl for component. Skipping...", e); + logger.warn( + "Error constructing packageUrl for component {}. Skipping...", + component.getId().getName(), + e); return; } component.getDependencyComponents().forEach(dependencyComponent -> { @@ -206,28 +210,13 @@ 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()); - } - private Property buildIsTestProperty(final SbomComponent component) { boolean isTestComponent = component.getInScopeConfigurations().stream() From 7a81820053f6989b41e01107d58d1d1f768fbbbd Mon Sep 17 00:00:00 2001 From: Gordon Date: Wed, 13 Nov 2024 15:10:56 +0000 Subject: [PATCH 24/26] feat: parse plugin.properties for tools metadata and make SbomGraphProvider package private Signed-off-by: Gordon --- .../org/cyclonedx/gradle/CycloneDxPlugin.java | 6 ----- .../org/cyclonedx/gradle/CycloneDxTask.java | 9 ++++--- .../gradle/DependencyGraphTraverser.java | 2 +- .../org/cyclonedx/gradle/SbomBuilder.java | 27 +++++++++++++++++++ .../cyclonedx/gradle/SbomGraphProvider.java | 4 +-- .../gradle/DependencyResolutionSpec.groovy | 3 ++- 6 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java index 115d0f58..1e91d863 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxPlugin.java @@ -18,10 +18,8 @@ */ package org.cyclonedx.gradle; -import org.cyclonedx.gradle.model.SbomGraph; import org.gradle.api.Plugin; import org.gradle.api.Project; -import org.gradle.api.provider.Provider; /** * Entrypoint of the plugin which simply configures one task @@ -31,10 +29,6 @@ public class CycloneDxPlugin implements Plugin { public void apply(final Project project) { project.getTasks().register("cyclonedxBom", CycloneDxTask.class, (task) -> { - final Provider components = - project.getProviders().provider(new SbomGraphProvider(project, task)); - - task.getComponents().set(components); task.setGroup("Reporting"); task.setDescription("Generates a CycloneDX compliant Software Bill of Materials (SBOM)"); }); diff --git a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java index e744828b..c0f25c0c 100644 --- a/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java +++ b/src/main/java/org/cyclonedx/gradle/CycloneDxTask.java @@ -32,6 +32,7 @@ import org.gradle.api.DefaultTask; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; +import org.gradle.api.provider.Provider; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.OutputDirectory; @@ -59,6 +60,7 @@ public abstract class CycloneDxTask extends DefaultTask { private final Property projectType; private final ListProperty skipProjects; private final Property destination; + private final Provider componentsProvider; @Nullable private OrganizationalEntity organizationalEntity; @@ -66,6 +68,8 @@ public abstract class CycloneDxTask extends DefaultTask { public CycloneDxTask() { + componentsProvider = getProject().getProviders().provider(new SbomGraphProvider(getProject(), this)); + outputName = getProject().getObjects().property(String.class); outputName.convention("bom"); @@ -228,9 +232,6 @@ public void setSkipProjects(final Collection skipProjects) { return licenseChoice; } - @Input - public abstract Property getComponents(); - @OutputDirectory public Property getDestination() { return destination; @@ -250,7 +251,7 @@ public void createBom() { logParameters(); final SbomBuilder builder = new SbomBuilder(getLogger(), this); - final SbomGraph components = getComponents().get(); + final SbomGraph components = componentsProvider.get(); final Bom bom = builder.buildBom(components.getGraph(), components.getRootComponent()); getLogger().info(MESSAGE_WRITING_BOM_OUTPUT); diff --git a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java index 4ea6dc17..b0225c34 100644 --- a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java +++ b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java @@ -57,7 +57,7 @@ class DependencyGraphTraverser { private final boolean includeMetaData; private final MavenHelper mavenHelper; - public DependencyGraphTraverser( + DependencyGraphTraverser( final Logger logger, final Map resolvedArtifacts, final MavenProjectLookup mavenLookup, diff --git a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java index 57472f8d..fa3f5681 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomBuilder.java +++ b/src/main/java/org/cyclonedx/gradle/SbomBuilder.java @@ -22,11 +22,13 @@ import com.networknt.schema.utils.StringUtils; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Properties; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; @@ -47,6 +49,7 @@ import org.cyclonedx.model.LicenseChoice; import org.cyclonedx.model.Metadata; import org.cyclonedx.model.Property; +import org.cyclonedx.model.Tool; import org.cyclonedx.util.BomUtils; import org.gradle.api.logging.Logger; @@ -120,6 +123,15 @@ private Metadata buildMetadata(final SbomComponent parentComponent) { metadata.setLicenseChoice(task.getLicenseChoice()); metadata.setManufacture(task.getOrganizationalEntity()); + final Properties pluginProperties = readPluginProperties(); + if (!pluginProperties.isEmpty()) { + final Tool tool = new Tool(); + tool.setVendor(pluginProperties.getProperty("vendor")); + tool.setName(pluginProperties.getProperty("name")); + tool.setVersion(pluginProperties.getProperty("version")); + metadata.addTool(tool); + } + return metadata; } @@ -262,4 +274,19 @@ private TreeMap getQualifiers(final String type) { qualifiers.put("type", type); return qualifiers; } + + private Properties readPluginProperties() { + + final Properties props = new Properties(); + try (final InputStream inputStream = this.getClass().getResourceAsStream("plugin.properties")) { + if (inputStream == null) { + logger.info("plugin.properties is not found on the classpath"); + } else { + props.load(inputStream); + } + } catch (Exception e) { + logger.warn("Error whilst loading plugin.properties", e); + } + return props; + } } diff --git a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java index be309349..e53d5e34 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -40,7 +40,7 @@ * Provider that lazily calculates the aggregated dependency graph. The usage of a provider is essential to support * configuration cache and also to ensure that all dependencies have been resolved when the CycloneDxTask is executed. */ -public class SbomGraphProvider implements Callable { +class SbomGraphProvider implements Callable { private static final String MESSAGE_RESOLVING_DEPS = "CycloneDX: Resolving Dependencies"; @@ -48,7 +48,7 @@ public class SbomGraphProvider implements Callable { private final CycloneDxTask task; private final MavenProjectLookup mavenLookup; - public SbomGraphProvider(final Project project, final CycloneDxTask task) { + SbomGraphProvider(final Project project, final CycloneDxTask task) { this.project = project; this.task = task; this.mavenLookup = new MavenProjectLookup(project); diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index c172be03..ca3f624a 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -187,11 +187,12 @@ class DependencyResolutionSpec extends Specification { when: def result = GradleRunner.create() .withProjectDir(testDir) - .withArguments("cyclonedxBom", "--configuration-cache") + .withArguments("cyclonedxBom", "--configuration-cache", "--info", "--stacktrace") .withPluginClasspath() .build() then: + println(result.output) result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS File reportDir = new File(testDir, "build/reports") From 2b8aebe1e3c3cb00f1ac22d30c6e1e723afd65d5 Mon Sep 17 00:00:00 2001 From: Gordon Date: Wed, 13 Nov 2024 16:11:05 +0000 Subject: [PATCH 25/26] fix: ignore directories when checking for generated bom files Signed-off-by: Gordon --- .../groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy index 0dba7df4..14174000 100644 --- a/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/PluginConfigurationSpec.groovy @@ -527,7 +527,7 @@ class PluginConfigurationSpec extends Specification { File reportDir = new File(testDir, "build/reports") assert reportDir.exists() - reportDir.listFiles().length == 2 + reportDir.listFiles({File file -> file.isFile()} as FileFilter).length == 2 File jsonBom = new File(reportDir, "bom.json") assert jsonBom.text.contains("\"specVersion\" : \"1.6\"") } From be924d6cb7faa15ae2c85ee0c324d6b70bb1f5f9 Mon Sep 17 00:00:00 2001 From: Gordon Date: Fri, 22 Nov 2024 16:21:45 +0000 Subject: [PATCH 26/26] feat: info logging for unresolved dependencies Signed-off-by: Gordon --- .../org/cyclonedx/gradle/DependencyGraphTraverser.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java index b0225c34..ef09fb7f 100644 --- a/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java +++ b/src/main/java/org/cyclonedx/gradle/DependencyGraphTraverser.java @@ -43,6 +43,7 @@ import org.gradle.api.artifacts.result.DependencyResult; import org.gradle.api.artifacts.result.ResolvedComponentResult; import org.gradle.api.artifacts.result.ResolvedDependencyResult; +import org.gradle.api.artifacts.result.UnresolvedDependencyResult; import org.gradle.api.logging.Logger; /** @@ -107,6 +108,12 @@ Map traverseGraph( dependencyNode.inScopeConfiguration(projectName, configName); graph.get(graphNode).add(dependencyNode); queue.add(dependencyNode); + } else if (dep instanceof UnresolvedDependencyResult) { + UnresolvedDependencyResult unresolved = (UnresolvedDependencyResult) dep; + logger.info( + "CycloneDX: Unable to resolve artifact {} because {}", + unresolved.getAttempted().getDisplayName(), + unresolved.getFailure().toString()); } } }