/* * 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. * * Copyright (c) Steve Springett. All Rights Reserved. */ package org.cyclonedx.gradle; import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; import org.apache.commons.io.FileUtils; import org.apache.maven.project.MavenProject; import org.cyclonedx.BomGenerator; import org.cyclonedx.BomGeneratorFactory; import org.cyclonedx.BomParser; import org.cyclonedx.CycloneDxSchema; import org.cyclonedx.model.Bom; import org.cyclonedx.model.Component; 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.ModuleVersionIdentifier; import org.gradle.api.artifacts.ResolveException; import org.gradle.api.artifacts.ResolvedArtifact; import org.gradle.api.artifacts.ResolvedConfiguration; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.TaskAction; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.TransformerException; import java.io.File; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.TreeMap; import java.util.UUID; import java.util.stream.Collectors; public 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 = "CycloneDX: Writing BOM"; 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 as defined by the XSD"; private static final String MESSAGE_SKIPPING = "Skipping CycloneDX"; private File buildDir; private MavenHelper mavenHelper; private CycloneDxSchema.Version schemaVersion = CycloneDxSchema.Version.VERSION_11; private boolean includeBomSerialNumber; private boolean skip; private final List<String> skipConfigs = new ArrayList<>(); @Input public List<String> getSkipConfigs() { return skipConfigs; } public void setSkipConfigs(Collection<String> skipConfigs) { this.skipConfigs.clear(); this.skipConfigs.addAll(skipConfigs); } public void setBuildDir(File buildDir) { this.buildDir = buildDir; } private void initialize() { schemaVersion = schemaVersion(); mavenHelper = new MavenHelper(getLogger(), schemaVersion); if (schemaVersion == CycloneDxSchema.Version.VERSION_10) { includeBomSerialNumber = false; } else { includeBomSerialNumber = getBooleanParameter("cyclonedx.includeBomSerialNumber", true); } skip = getBooleanParameter("cyclonedx.skip", false); } @TaskAction @SuppressWarnings("unused") public void createBom() { initialize(); if (skip) { getLogger().info(MESSAGE_SKIPPING); return; } logParameters(); getLogger().info(MESSAGE_RESOLVING_DEPS); final Set<String> builtDependencies = getProject() .getRootProject() .getSubprojects() .stream() .map(p -> p.getGroup() + ":" + p.getName() + ":" + p.getVersion()) .collect(Collectors.toSet()); final Set<Component> components = new LinkedHashSet<>(); for (final Project p : getProject().getAllprojects()) { for (final Configuration configuration : p.getConfigurations()) { if (!shouldSkipConfiguration(configuration) && canBeResolved(configuration)) { final ResolvedConfiguration resolvedConfiguration = configuration.getResolvedConfiguration(); if (resolvedConfiguration != null) { List<String> depsFromConfig = new ArrayList<>(); for (final ResolvedArtifact artifact : resolvedConfiguration.getResolvedArtifacts()) { // Don't include other resources built from this Gradle project. final String dependencyName = getDependencyName(artifact); if(builtDependencies.stream().anyMatch(c -> c.equals(dependencyName))) { continue; } depsFromConfig.add(dependencyName); // Convert into a Component and augment with pom metadata if available. final Component component = convertArtifact(artifact); augmentComponentMetadata(component, dependencyName); components.add(component); } Collections.sort(depsFromConfig); getLogger().info("BOM inclusion for configuration {} : {}", configuration.getName(), depsFromConfig); } } } } writeBom(components); } private 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) { getLogger().warn("Failed to check resolvability of configuration {} -- assuming resolvability. Exception was: {}", configuration.getName(), e); return true; } } catch (NoSuchMethodException e) { // prior to Gradle 3.3 all configurations were resolvable return true; } } private String getDependencyName(ResolvedArtifact artifact) { final ModuleVersionIdentifier m = artifact.getModuleVersion().getId(); return m.getGroup() + ":" + m.getName() + ":" + m.getVersion(); } private void augmentComponentMetadata(Component component, String dependencyName) { final Dependency pomDep = getProject() .getDependencies() .create(dependencyName + "@pom"); final Configuration pomCfg = getProject() .getConfigurations() .detachedConfiguration(pomDep); MavenProject project = null; try { final File pomFile = pomCfg.resolve().stream().findFirst().orElse(null); project = mavenHelper.readPom(pomFile); } catch(IOException err) { getLogger().error("Unable to resolve POM for " + component.getPurl() + ": " + err); } catch(ResolveException err) { getLogger().error("Unable to resolve POM for " + component.getPurl() + ": " + err); } if(project != null) { if(project.getOrganization() != null) { component.setPublisher(project.getOrganization().getName()); } component.setDescription(project.getDescription()); component.setLicenseChoice(mavenHelper.resolveMavenLicenses(project.getLicenses())); } } 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); try { getLogger().debug(MESSAGE_CALCULATING_HASHES); component.setHashes(BomUtils.calculateHashes(artifact.getFile())); } catch(IOException e) { getLogger().error("Error encountered calculating hashes", e); } if (CycloneDxSchema.Version.VERSION_10 == schemaVersion()) { component.setModified(mavenHelper.isModified(artifact)); } component.setPurl(generatePackageUrl(artifact)); //if (CycloneDxSchema.Version.VERSION_10 != schemaVersion()) { // component.setBomRef(component.getPurl()); //} if (mavenHelper.isDescribedArtifact(artifact)) { final MavenProject project = mavenHelper.extractPom(artifact); if (project != null) { mavenHelper.getClosestMetadata(artifact, project, component); } } return component; } private boolean shouldSkipConfiguration(Configuration configuration) { return skipConfigs.contains(configuration.getName()); } private String generatePackageUrl(final ResolvedArtifact artifact) { try { TreeMap<String, String> qualifiers = null; if (artifact.getType() != null || artifact.getClassifier() != null) { qualifiers = new TreeMap<>(); if (artifact.getType() != null) { qualifiers.put("type", artifact.getType()); } if (artifact.getClassifier() != null) { qualifiers.put("classifier", artifact.getClassifier()); } } return new PackageURL(PackageURL.StandardTypes.MAVEN, artifact.getModuleVersion().getId().getGroup(), artifact.getModuleVersion().getId().getName(), artifact.getModuleVersion().getId().getVersion(), qualifiers, null).canonicalize(); } catch (MalformedPackageURLException e) { getLogger().warn("An unexpected issue occurred attempting to create a PackageURL for " + artifact.getModuleVersion().getId().getGroup() + ":" + artifact.getModuleVersion().getId().getName() + ":" + artifact.getModuleVersion().getId().getVersion(), e); } return null; } /** * Ported from Maven plugin. * @param components The CycloneDX components extracted from gradle dependencies */ protected void writeBom(Set<Component> components) throws GradleException{ try { getLogger().info(MESSAGE_CREATING_BOM); final Bom bom = new Bom(); if (CycloneDxSchema.Version.VERSION_10 != schemaVersion && includeBomSerialNumber) { bom.setSerialNumber("urn:uuid:" + UUID.randomUUID().toString()); } bom.setComponents(new ArrayList<>(components)); final BomGenerator bomGenerator = BomGeneratorFactory.create(schemaVersion, bom); bomGenerator.generate(); final String bomString = bomGenerator.toXmlString(); final File bomFile = new File(buildDir, "reports/bom.xml"); getLogger().info(MESSAGE_WRITING_BOM); FileUtils.write(bomFile, bomString, Charset.forName("UTF-8"), false); getLogger().info(MESSAGE_VALIDATING_BOM); final BomParser bomParser = new BomParser(); try { if (!bomParser.isValid(bomFile)) { throw new GradleException(MESSAGE_VALIDATION_FAILURE); } } 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); } } catch (ParserConfigurationException | TransformerException | IOException e) { throw new GradleException("An error occurred executing " + this.getClass().getName(), e); } } 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.valueOf((String)o); } } return defaultValue; } /** * Resolves the CycloneDX schema the mojo has been requested to use. * @return the CycloneDX schema to use */ private CycloneDxSchema.Version schemaVersion() { final Project project = super.getProject(); if (project.hasProperty("cyclonedx.schemaVersion")) { final String s = (String)project.getProperties().get("cyclonedx.schemaVersion"); if ("1.0".equals(s)) { return CycloneDxSchema.Version.VERSION_10; } } return CycloneDxSchema.Version.VERSION_11; } protected void logParameters() { if (getLogger().isInfoEnabled()) { getLogger().info("CycloneDX: Parameters"); getLogger().info("------------------------------------------------------------------------"); getLogger().info("schemaVersion : " + schemaVersion.name()); getLogger().info("includeBomSerialNumber : " + includeBomSerialNumber); getLogger().info("------------------------------------------------------------------------"); } } }