From 90a31e7ad1a28cb2688023afe2dfb8e3c85058bc Mon Sep 17 00:00:00 2001 From: Sam Snyder Date: Tue, 6 Aug 2024 01:12:37 -0700 Subject: [PATCH] Add recipe which computes CycloneDx Software Bill of Materials (SBOM) documents for Maven and Gradle builds. --- build.gradle.kts | 1 + .../openrewrite/java/dependencies/Sbom.java | 294 ++++++++++++++++++ .../dependencies/SoftwareBillOfMaterials.java | 149 +++++++++ .../SoftwareBillOfMaterialsTest.java | 167 ++++++++++ 4 files changed, 611 insertions(+) create mode 100644 src/main/java/org/openrewrite/java/dependencies/Sbom.java create mode 100644 src/main/java/org/openrewrite/java/dependencies/SoftwareBillOfMaterials.java create mode 100644 src/test/java/org/openrewrite/java/dependencies/SoftwareBillOfMaterialsTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 658e00d..1935f69 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { runtimeOnly("org.openrewrite:rewrite-java-17") implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-csv") + implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") testImplementation("org.openrewrite.gradle.tooling:model:${rewriteVersion}") diff --git a/src/main/java/org/openrewrite/java/dependencies/Sbom.java b/src/main/java/org/openrewrite/java/dependencies/Sbom.java new file mode 100644 index 0000000..ca3ac34 --- /dev/null +++ b/src/main/java/org/openrewrite/java/dependencies/Sbom.java @@ -0,0 +1,294 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * 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 + *

+ * https://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. + */ +package org.openrewrite.java.dependencies; + +import com.fasterxml.jackson.annotation.*; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import lombok.Builder; +import lombok.Value; +import org.openrewrite.gradle.marker.GradleDependencyConfiguration; +import org.openrewrite.gradle.marker.GradleProject; +import org.openrewrite.internal.lang.Nullable; +import org.openrewrite.marker.Marker; +import org.openrewrite.maven.tree.MavenResolutionResult; +import org.openrewrite.maven.tree.ResolvedDependency; +import org.openrewrite.maven.tree.Scope; + +import java.util.*; +import java.util.stream.Collectors; + +import static java.util.Collections.singletonList; + +/** + * A CycloneDX 1.6 Software Bill of Materials (SBOM). + */ +@Value +public class Sbom { + + @Nullable + public static Sbom.Bom sbomFrom(Marker m) { + if(m instanceof MavenResolutionResult) { + return sbomFrom((MavenResolutionResult) m); + } else if(m instanceof GradleProject) { + return sbomFrom((GradleProject) m); + } + return null; + } + + + public static Sbom.Bom sbomFrom(MavenResolutionResult mrr) { + return Bom.builder() + .version(mrr.getPom().getVersion()) + .metadata(Metadata.builder() + .tools(singletonList(Tool.builder() + .vendor("OpenRewrite by Moderne") + .name("OpenRewrite CycloneDX") + .version("8.32.0") + .build())) + .component(componentFrom(mrr)) + .build()) + .components(componentsFrom(mrr)) + .dependencies(dependenciesFrom(mrr)) + .build(); + } + + public static Sbom.Bom sbomFrom(GradleProject gp) { + return Bom.builder() + .version(gp.getVersion()) + .metadata(Metadata.builder() + .tools(singletonList(Tool.builder() + .vendor("OpenRewrite by Moderne") + .name("OpenRewrite CycloneDX") + .version("8.32.0") + .build())) + .component(componentFrom(gp)) + .build()) + .components(componentsFrom(gp)) + .dependencies(dependenciesFrom(gp)) + .build(); + } + + private static Sbom.Component componentFrom(MavenResolutionResult mrr) { + String groupId = mrr.getPom().getGroupId(); + String artifactId = mrr.getPom().getArtifactId(); + String version = mrr.getPom().getVersion(); + String bomRef = bomRefFrom(groupId, artifactId, version); + return Component.builder() + .bomRef(bomRef) + .group(groupId) + .name(artifactId) + .version(version) + .purl(bomRef) + .build(); + } + private static Sbom.Component componentFrom(GradleProject gp) { + String groupId = gp.getGroup(); + String artifactId = gp.getName(); + String version = gp.getVersion(); + String bomRef = bomRefFrom(groupId, artifactId, version); + return Component.builder() + .bomRef(bomRef) + .group(groupId) + .name(artifactId) + .version(version) + .purl(bomRef) + .build(); + } + + private static String bomRefFrom(@Nullable String groupId, String artifactId, @Nullable String version) { + return String.format("pkg:maven/%s/%s@%s", + groupId == null ? "" : groupId, + artifactId, + version == null ? "" : version); + } + + private static List componentsFrom(MavenResolutionResult mrr) { + List compileDependencies = mrr.getDependencies().getOrDefault(Scope.Runtime, Collections.emptyList()); + List providedDependencies = mrr.getDependencies().getOrDefault(Scope.Provided, Collections.emptyList()); + return componentsFrom(compileDependencies, providedDependencies); + } + + private static List componentsFrom(GradleProject gp) { + List compileDependencies = Optional.ofNullable(gp.getConfiguration("runtimeClasspath")) + .map(GradleDependencyConfiguration::getDirectResolved) + .orElseGet(Collections::emptyList); + List providedDependencies = Optional.ofNullable(gp.getConfiguration("compileOnly")) + .map(GradleDependencyConfiguration::getDirectResolved) + .orElseGet(Collections::emptyList); + return componentsFrom(compileDependencies, providedDependencies); + } + + private static List componentsFrom(List compileDependencies, List providedDependencies) { + List components = new ArrayList<>(compileDependencies.size() + providedDependencies.size()); + Set seen = new HashSet<>(); + for (ResolvedDependency dep : compileDependencies) { + String bomRef = bomRefFrom(dep.getGroupId(), dep.getArtifactId(), dep.getVersion()); + seen.add(bomRef); + components.add(Component.builder() + .bomRef(bomRef) + .group(dep.getGroupId()) + .name(dep.getArtifactId()) + .version(dep.getVersion()) + .scope("required") + .licenses(dep.getLicenses().stream() + .map(l -> License.builder() + .name(l.getName()) + .build()) + .collect(Collectors.toList())) + .purl(bomRef) + .build()); + } + for (ResolvedDependency dep : providedDependencies) { + String bomRef = bomRefFrom(dep.getGroupId(), dep.getArtifactId(), dep.getVersion()); + // Provided is a superset of Compile + // Only add "optional" components for things not already recorded as "required" + if (seen.add(bomRef)) { + components.add(Component.builder() + .bomRef(bomRef) + .group(dep.getGroupId()) + .name(dep.getArtifactId()) + .version(dep.getVersion()) + .scope("optional") + .purl(bomRef) + .build()); + } + } + + return components; + } + + private static List dependenciesFrom(MavenResolutionResult mrr) { + List compileDependencies = mrr.getDependencies().getOrDefault(Scope.Runtime, Collections.emptyList()); + List providedDependencies = mrr.getDependencies().getOrDefault(Scope.Provided, Collections.emptyList()); + return dependenciesFrom(compileDependencies, providedDependencies); + } + + private static List dependenciesFrom(GradleProject gp) { + List compileDependencies = Optional.ofNullable(gp.getConfiguration("runtimeClasspath")) + .map(GradleDependencyConfiguration::getDirectResolved) + .orElseGet(Collections::emptyList); + List providedDependencies = Optional.ofNullable(gp.getConfiguration("compileOnly")) + .map(GradleDependencyConfiguration::getDirectResolved) + .orElseGet(Collections::emptyList); + return dependenciesFrom(compileDependencies, providedDependencies); + } + + private static List dependenciesFrom(List compileDependencies, List providedDependencies) { + List dependencies = new ArrayList<>(compileDependencies.size() + providedDependencies.size()); + + Set seen = new HashSet<>(); + for (ResolvedDependency dep : compileDependencies) { + Dependency dependency = dependencyFrom(dep); + if (seen.add(dependency)) { + dependencies.add(dependency); + } + } + for (ResolvedDependency dep : providedDependencies) { + Dependency dependency = dependencyFrom(dep); + if (seen.add(dependency)) { + dependencies.add(dependencyFrom(dep)); + } + } + return dependencies; + } + + private static Dependency dependencyFrom(ResolvedDependency dep) { + return Dependency.builder() + .ref(bomRefFrom(dep.getGroupId(), dep.getArtifactId(), dep.getVersion())) + .dependencies(dep.getDependencies().stream() + .map(Sbom::dependencyFrom) + .collect(Collectors.toList())) + .build(); + } + + @Builder + @Value + @JacksonXmlRootElement(localName = "bom") + @JsonPropertyOrder({"xmlns", "version"}) + public static class Bom { + @JacksonXmlProperty(isAttribute = true) + String xmlns = "http://cyclonedx.org/schema/bom/1.6"; + + @JacksonXmlProperty(isAttribute = true) + String version; + + Metadata metadata; + @JacksonXmlElementWrapper(localName = "components") + @JacksonXmlProperty(localName = "component") + List components; + + @JacksonXmlElementWrapper(localName = "dependencies") + @JacksonXmlProperty(localName = "dependency") + List dependencies; + } + + @Builder + @Value + public static class Metadata { + @JacksonXmlElementWrapper(localName = "tools") + @JacksonXmlProperty(localName = "tool") + List tools; + Component component; + } + + @Builder + @Value + public static class Tool { + String vendor; + String name; + String version; + } + + @Builder + @Value + @JsonPropertyOrder({"xmlns", "type", "group", "name", "version", "version"}) + public static class Component { + @JacksonXmlProperty(isAttribute = true, localName = "bom-ref") + String bomRef; + + @JacksonXmlProperty(isAttribute = true) + @Nullable + String type; + + String group; + String name; + String version; + @Nullable + String scope; + @JacksonXmlElementWrapper(localName = "licenses") + @JacksonXmlProperty(localName = "license") + List licenses; + String purl; + } + + @Builder + @Value + public static class License { + String id; + String name; + } + + @Builder + @Value + public static class Dependency { + @JacksonXmlProperty(isAttribute = true) + String ref; + @JacksonXmlElementWrapper(useWrapping = false) + List dependencies; + } +} diff --git a/src/main/java/org/openrewrite/java/dependencies/SoftwareBillOfMaterials.java b/src/main/java/org/openrewrite/java/dependencies/SoftwareBillOfMaterials.java new file mode 100644 index 0000000..26ac1b0 --- /dev/null +++ b/src/main/java/org/openrewrite/java/dependencies/SoftwareBillOfMaterials.java @@ -0,0 +1,149 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * 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 + *

+ * https://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. + */ +package org.openrewrite.java.dependencies; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.intellij.lang.annotations.Language; +import org.openrewrite.*; +import org.openrewrite.gradle.marker.GradleProject; +import org.openrewrite.marker.Marker; +import org.openrewrite.maven.tree.MavenResolutionResult; +import org.openrewrite.xml.SemanticallyEqual; +import org.openrewrite.xml.XmlParser; +import org.openrewrite.xml.XmlVisitor; +import org.openrewrite.xml.tree.Xml; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +@Value +@EqualsAndHashCode(callSuper = true) +public class SoftwareBillOfMaterials extends ScanningRecipe { + + @Override + public String getDisplayName() { + return "Software bill of materials"; + } + + @Override + public String getDescription() { + //language=markdown + return "Produces a software bill of materials (SBOM) for a project. An SBOM is a complete list of all dependencies " + + "used in a project, including transitive dependencies. The produced SBOM is in the [CycloneDX](https://cyclonedx.org/) XML format. " + + "Supports Gradle and Maven. " + + "Places a file named sbom.xml adjacent to the Gradle or Maven build file."; + } + + public static class Accumulator { + Set existingSboms = new LinkedHashSet<>(); + Set sbomPaths = new LinkedHashSet<>(); + Map sbomPathToDependencyMarker = new HashMap<>(); + } + + private static final XmlMapper xmlMapper = (XmlMapper) new XmlMapper() + .setSerializationInclusion(JsonInclude.Include.NON_EMPTY) + .setSerializationInclusion(JsonInclude.Include.NON_ABSENT) + .enable(SerializationFeature.INDENT_OUTPUT); + + @Override + public Accumulator getInitialValue(ExecutionContext ctx) { + return new Accumulator(); + } + + @Override + public TreeVisitor getScanner(Accumulator acc) { + //noinspection NullableProblems + return new TreeVisitor() { + @Override + public Tree visit(Tree tree, ExecutionContext executionContext) { + SourceFile s = (SourceFile) tree; + if (s.getSourcePath().toString().endsWith("sbom.xml")) { + acc.existingSboms.add(s.getSourcePath()); + return tree; + } + s.getMarkers().getMarkers() + .stream() + .filter(marker -> marker instanceof GradleProject || marker instanceof MavenResolutionResult) + .forEach(e -> { + String sbomPathString = PathUtils.separatorsToUnix(s.getSourcePath().toString()); + sbomPathString = sbomPathString.substring(0, sbomPathString.lastIndexOf("/") + 1) + "sbom.xml"; + Path sbomPath = Paths.get(sbomPathString); + acc.sbomPaths.add(sbomPath); + acc.sbomPathToDependencyMarker.put(sbomPath, e); + }); + return tree; + } + }; + } + + @Override + public Collection generate(Accumulator acc, Collection generatedInThisCycle, ExecutionContext ctx) { + Set newSbomPaths = new LinkedHashSet<>(acc.sbomPaths); + newSbomPaths.removeAll(acc.existingSboms); + List newSboms = new ArrayList<>(); + XmlParser xmlParser = XmlParser.builder().build(); + for (Path sbomPath : newSbomPaths) { + xmlParser.parse(ctx, "") + .map(it -> (Xml.Document) it.withSourcePath(sbomPath)) + .findAny() + .ifPresent(newSboms::add); + } + return newSboms; + } + + @Override + public TreeVisitor getVisitor(Accumulator acc) { + return new XmlVisitor() { + @Override + public Xml visitDocument(Xml.Document document, ExecutionContext ctx) { + if (!acc.sbomPaths.contains(document.getSourcePath())) { + return document; + } + Marker marker = acc.sbomPathToDependencyMarker.get(document.getSourcePath()); + if (marker != null) { + Sbom.Bom sbom = Sbom.sbomFrom(marker); + try { + @Language("xml") + String rawSbom = "\n" + + xmlMapper.writeValueAsString(sbom) + .replaceAll("\r", "") + "\n"; + XmlParser xmlParser = XmlParser.builder().build(); + //noinspection OptionalGetWithoutIsPresent + Xml.Document d = xmlParser.parse(rawSbom) + .map(it -> it.withSourcePath(document.getSourcePath()) + .withId(document.getId())) + .map(Xml.Document.class::cast) + .findAny() + .get(); + if (SemanticallyEqual.areEqual(document, d)) { + return document; + } + return d; + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + return document; + } + }; + } +} diff --git a/src/test/java/org/openrewrite/java/dependencies/SoftwareBillOfMaterialsTest.java b/src/test/java/org/openrewrite/java/dependencies/SoftwareBillOfMaterialsTest.java new file mode 100644 index 0000000..72a06e9 --- /dev/null +++ b/src/test/java/org/openrewrite/java/dependencies/SoftwareBillOfMaterialsTest.java @@ -0,0 +1,167 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * 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 + *

+ * https://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. + */ +package org.openrewrite.java.dependencies; + +import org.junit.jupiter.api.Test; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.gradle.Assertions.buildGradle; +import static org.openrewrite.gradle.Assertions.settingsGradle; +import static org.openrewrite.gradle.toolingapi.Assertions.withToolingApi; +import static org.openrewrite.java.Assertions.mavenProject; +import static org.openrewrite.maven.Assertions.pomXml; +import static org.openrewrite.xml.Assertions.xml; + +@SuppressWarnings("GroovyUnusedAssignment") +class SoftwareBillOfMaterialsTest implements RewriteTest { + + @Override + public void defaults(RecipeSpec spec) { + spec.recipe(new SoftwareBillOfMaterials()); + } + + @Test + void maven() { + rewriteRun( + //language=xml + pomXml(""" + + 4.0.0 + com.mycompany.app + my-app + 1 + + + org.yaml + snakeyaml + 1.27 + + + org.junit.jupiter + junit-jupiter + 5.7.0 + test + + + + """), + xml(null, + //language=xml + """ + + + + + + OpenRewrite by Moderne + OpenRewrite CycloneDX + 8.32.0 + + + + com.mycompany.app + my-app + 1 + pkg:maven/com.mycompany.app/my-app@1 + + + + + org.yaml + snakeyaml + 1.27 + required + + + Apache License, Version 2.0 + + + pkg:maven/org.yaml/snakeyaml@1.27 + + + + + + + """, + spec -> spec.path("sbom.xml")) + ); + } + + + @Test + void gradle() { + // GradlePlugin marker seems to be missing license information + rewriteRun( + spec -> spec.beforeRecipe(withToolingApi()), + mavenProject("root", + settingsGradle("include 'my-app'"), + mavenProject("my-app", + //language=groovy + buildGradle(""" + plugins { + id 'java' + } + repositories { + mavenCentral() + } + group = "com.mycompany.app" + version = "1" + dependencies { + implementation("org.yaml:snakeyaml:1.27") + } + """) + , + xml(null, + //language=xml + """ + + + + + + OpenRewrite by Moderne + OpenRewrite CycloneDX + 8.32.0 + + + + com.mycompany.app + my-app + 1 + pkg:maven/com.mycompany.app/my-app@1 + + + + + org.yaml + snakeyaml + 1.27 + required + + pkg:maven/org.yaml/snakeyaml@1.27 + + + + + + + """, + spec -> spec.path("sbom.xml")) + ))); + } +}