This is an automated email from the ASF dual-hosted git repository. pkarwasz pushed a commit to branch feat/slsa in repository https://gitbox.apache.org/repos/asf/commons-build-plugin.git
commit 841d9f50d9b2bde368c8d3e80dcdcc2e5c424467 Author: Piotr P. Karwasz <[email protected]> AuthorDate: Fri Mar 27 18:11:37 2026 +0100 Add `build-attestation` goal This goal generates a [SLSA](https://slsa.dev/) build attestation and attaches it to the build as a file with the `.intoto.json` extension. The attestation records the following information about the build environment: - The Java version used (vendor, version string) - The Maven version used - The `gitTree` hash of the unpacked Java distribution - The `gitTree` hash of the unpacked Maven distribution The `gitTree` hashes uniquely and verifiably identify the exact content of the Java and Maven distributions used during the build, independently of how or where they were obtained. This allows consumers of the attestation to verify that the build environment matches a known distribution. --- pom.xml | 53 +++ .../apache/commons/build/BuildAttestationMojo.java | 363 +++++++++++++++++++++ .../commons/build/internal/ArtifactUtils.java | 82 +++++ .../build/internal/BuildToolDescriptors.java | 88 +++++ .../apache/commons/build/internal/GitUtils.java | 87 +++++ .../build/models/slsa/v1_2/BuildDefinition.java | 10 +- .../commons/build/models/slsa/v1_2/Builder.java | 8 +- .../commons/build/BuildAttestationMojoTest.java | 134 ++++++++ .../apache/commons/build/internal/MojoUtils.java | 70 ++++ src/test/resources/artifacts/artifact-jar.txt | 2 + src/test/resources/artifacts/artifact-pom.txt | 2 + 11 files changed, 891 insertions(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index 408b7df..f1e7ef5 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,7 @@ <commons.jackson.version>2.21.1</commons.jackson.version> <commons.jackson.annotations.version>2.21</commons.jackson.annotations.version> <commons.maven.version>3.9.12</commons.maven.version> + <commons.maven.scm.version>2.2.1</commons.maven.scm.version> <!-- Define the following in ~/.m2/settings.xml in an active profile: (or provide them on the command line) @@ -127,6 +128,12 @@ <artifactId>jackson-annotations</artifactId> <version>${commons.jackson.annotations.version}</version> </dependency> + <dependency> + <groupId>com.fasterxml.jackson.datatype</groupId> + <artifactId>jackson-datatype-jsr310</artifactId> + <version>${commons.jackson.version}</version> + <scope>runtime</scope> + </dependency> <dependency> <!-- Try to deal with https://bz.apache.org/bugzilla/show_bug.cgi?id=66951 --> <groupId>org.apache.maven</groupId> @@ -134,6 +141,11 @@ <version>${commons.maven.version}</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.apache.maven.plugin-tools</groupId> + <artifactId>maven-plugin-annotations</artifactId> + <version>3.15.2</version> + </dependency> <dependency> <!-- Try to deal with https://bz.apache.org/bugzilla/show_bug.cgi?id=66951 --> <groupId>org.apache.maven</groupId> @@ -141,11 +153,52 @@ <version>${commons.maven.version}</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.apache.maven</groupId> + <artifactId>maven-embedder</artifactId> + <version>${commons.maven.version}</version> + <scope>provided</scope> + </dependency> <dependency> <groupId>org.apache.maven.plugin-tools</groupId> <artifactId>maven-script-ant</artifactId> <version>3.15.2</version> </dependency> + <dependency> + <groupId>org.apache.maven.scm</groupId> + <artifactId>maven-scm-manager-plexus</artifactId> + <version>${commons.maven.scm.version}</version> + <scope>compile</scope> + <exclusions> + <exclusion> + <groupId>org.codehaus.plexus</groupId> + <artifactId>plexus-container-default</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>org.apache.maven.scm</groupId> + <artifactId>maven-scm-provider-gitexe</artifactId> + <version>${commons.maven.scm.version}</version> + <scope>runtime</scope> + </dependency> + <dependency> + <groupId>net.javacrumbs.json-unit</groupId> + <artifactId>json-unit-assertj</artifactId> + <version>2.40.1</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter-api</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <version>5.18.0</version> + <scope>test</scope> + </dependency> </dependencies> <build> <!-- include the site goal to detect problems previously encountered with maven-plugin-plugin 3.9.0 --> diff --git a/src/main/java/org/apache/commons/build/BuildAttestationMojo.java b/src/main/java/org/apache/commons/build/BuildAttestationMojo.java new file mode 100644 index 0000000..d021d2d --- /dev/null +++ b/src/main/java/org/apache/commons/build/BuildAttestationMojo.java @@ -0,0 +1,363 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.build; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.management.ManagementFactory; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import javax.inject.Inject; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.apache.commons.build.internal.ArtifactUtils; +import org.apache.commons.build.internal.BuildToolDescriptors; +import org.apache.commons.build.internal.GitUtils; +import org.apache.commons.build.models.slsa.v1_2.BuildDefinition; +import org.apache.commons.build.models.slsa.v1_2.BuildMetadata; +import org.apache.commons.build.models.slsa.v1_2.Builder; +import org.apache.commons.build.models.slsa.v1_2.Provenance; +import org.apache.commons.build.models.slsa.v1_2.ResourceDescriptor; +import org.apache.commons.build.models.slsa.v1_2.RunDetails; +import org.apache.commons.build.models.slsa.v1_2.Statement; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; +import org.apache.maven.project.MavenProjectHelper; +import org.apache.maven.rtinfo.RuntimeInformation; +import org.apache.maven.scm.CommandParameters; +import org.apache.maven.scm.ScmException; +import org.apache.maven.scm.ScmFileSet; +import org.apache.maven.scm.command.info.InfoItem; +import org.apache.maven.scm.command.info.InfoScmResult; +import org.apache.maven.scm.manager.ScmManager; +import org.apache.maven.scm.repository.ScmRepository; + +/** + * This plugin generates an in-toto attestation for all the artifacts + */ +@Mojo(name = "build-attestation", defaultPhase = LifecyclePhase.VERIFY, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME) +public class BuildAttestationMojo extends AbstractMojo { + + private static final String ATTESTATION_EXTENSION = "intoto.json"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + static { + OBJECT_MAPPER.findAndRegisterModules(); + OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + @Parameter(defaultValue = "${project.scm.connection}", readonly = true) + private String scmConnectionUrl; + + @Parameter(defaultValue = "${project.scm.developerConnection}", readonly = true) + private String scmDeveloperConnectionUrl; + + @Parameter(defaultValue = "${project.scm.tag}", readonly = true) + private String scmTag; + + @Parameter(defaultValue = "${maven.home}", readonly = true) + private File mavenHome; + + /** + * Issue SCM actions at this local directory + */ + @Parameter(property = "commons.build.scmDirectory", defaultValue = "${basedir}") + private File scmDirectory; + + @Parameter(property = "commons.build.outputDirectory", defaultValue = "${project.build.directory}") + private File outputDirectory; + + @Parameter(property = "commons.build.skipAttach") + private boolean skipAttach; + + /** + * The current Maven project. + */ + private final MavenProject project; + + /** + * SCM manager to detect the Git revision. + */ + private final ScmManager scmManager; + + /** + * Runtime information + */ + private final RuntimeInformation runtimeInformation; + + /** + * The current Maven session, used to resolve plugin dependencies. + */ + private final MavenSession session; + + /** + * Helper to attach artifacts to the project. + */ + private final MavenProjectHelper mavenProjectHelper; + + @Inject + public BuildAttestationMojo(MavenProject project, ScmManager scmManager, RuntimeInformation runtimeInformation, MavenSession session, + MavenProjectHelper mavenProjectHelper) { + this.project = project; + this.scmManager = scmManager; + this.runtimeInformation = runtimeInformation; + this.session = session; + this.mavenProjectHelper = mavenProjectHelper; + } + + void setOutputDirectory(File outputDirectory) { + this.outputDirectory = outputDirectory; + } + + public File getScmDirectory() { + return scmDirectory; + } + + public void setScmDirectory(File scmDirectory) { + this.scmDirectory = scmDirectory; + } + + void setScmConnectionUrl(String scmConnectionUrl) { + this.scmConnectionUrl = scmConnectionUrl; + } + + void setScmDeveloperConnectionUrl(String scmDeveloperConnectionUrl) { + this.scmDeveloperConnectionUrl = scmDeveloperConnectionUrl; + } + + void setScmTag(String scmTag) { + this.scmTag = scmTag; + } + + void setMavenHome(File mavenHome) { + this.mavenHome = mavenHome; + } + + @Override + public void execute() throws MojoFailureException, MojoExecutionException { + getLog().info("This is a build attestation."); + // Build definition + BuildDefinition buildDefinition = new BuildDefinition(); + buildDefinition.setExternalParameters(getExternalParameters()); + buildDefinition.setResolvedDependencies(getBuildDependencies()); + // Builder + Builder builder = new Builder(); + // RunDetails + RunDetails runDetails = new RunDetails(); + runDetails.setBuilder(builder); + runDetails.setMetadata(getBuildMetadata()); + // Provenance + Provenance provenance = new Provenance(); + provenance.setBuildDefinition(buildDefinition); + provenance.setRunDetails(runDetails); + // Statement + Statement statement = new Statement(); + statement.setSubject(getSubjects()); + statement.setPredicate(provenance); + + writeStatement(statement); + } + + private void writeStatement(Statement statement) throws MojoExecutionException { + final Path outputPath = outputDirectory.toPath(); + try { + if (!Files.exists(outputPath)) { + Files.createDirectories(outputPath); + } + } catch (IOException e) { + throw new MojoExecutionException("Could not create output directory.", e); + } + final Artifact mainArtifact = project.getArtifact(); + final Path artifactPath = outputPath.resolve(ArtifactUtils.getFileName(mainArtifact, ATTESTATION_EXTENSION)); + getLog().info("Writing attestation statement to: " + artifactPath); + try (OutputStream os = Files.newOutputStream(artifactPath)) { + OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValue(os, statement); + } catch (IOException e) { + throw new MojoExecutionException("Could not write attestation statement to: " + artifactPath, e); + } + if (!skipAttach) { + getLog().info(String.format("Attaching attestation statement as %s-%s.%s", mainArtifact.getArtifactId(), mainArtifact.getVersion(), + ATTESTATION_EXTENSION)); + mavenProjectHelper.attachArtifact(project, ATTESTATION_EXTENSION, null, artifactPath.toFile()); + } + } + + /** + * Get the artifacts generated by the build. + * + * @return A list of resource descriptors for the build artifacts. + */ + private List<ResourceDescriptor> getSubjects() throws MojoExecutionException { + List<ResourceDescriptor> subjects = new ArrayList<>(); + subjects.add(ArtifactUtils.toResourceDescriptor(project.getArtifact())); + for (Artifact artifact : project.getAttachedArtifacts()) { + subjects.add(ArtifactUtils.toResourceDescriptor(artifact)); + } + return subjects; + } + + private Map<String, Object> getExternalParameters() { + Map<String, Object> params = new HashMap<>(); + params.put("jvm.args", ManagementFactory.getRuntimeMXBean().getInputArguments()); + MavenExecutionRequest request = session.getRequest(); + params.put("maven.goals", request.getGoals()); + params.put("maven.profiles", request.getActiveProfiles()); + params.put("maven.user.properties", request.getUserProperties()); + params.put("maven.cmdline", getCommandLine(request)); + Map<String, Object> env = new HashMap<>(); + params.put("env", env); + for (Map.Entry<String, String> entry : System.getenv().entrySet()) { + String key = entry.getKey(); + if ("TZ".equals(key) || "LANG".equals(key) || key.startsWith("LC_")) { + env.put(key, entry.getValue()); + } + } + return params; + } + + private String getCommandLine(MavenExecutionRequest request) { + StringBuilder sb = new StringBuilder(); + for (String goal : request.getGoals()) { + sb.append(goal); + sb.append(" "); + } + List<String> activeProfiles = request.getActiveProfiles(); + if (activeProfiles != null && !activeProfiles.isEmpty()) { + sb.append("-P"); + for (String profile : activeProfiles) { + sb.append(profile); + sb.append(","); + } + removeLast(sb); + sb.append(" "); + } + Properties userProperties = request.getUserProperties(); + for (String propertyName : userProperties.stringPropertyNames()) { + sb.append("-D"); + sb.append(propertyName); + sb.append("="); + sb.append(userProperties.get(propertyName)); + sb.append(" "); + } + removeLast(sb); + return sb.toString(); + } + + private static void removeLast(StringBuilder sb) { + if (sb.length() > 0) { + sb.setLength(sb.length() - 1); + } + } + + private List<ResourceDescriptor> getBuildDependencies() throws MojoExecutionException { + List<ResourceDescriptor> dependencies = new ArrayList<>(); + try { + dependencies.add(BuildToolDescriptors.jvm(Paths.get(System.getProperty("java.home")))); + dependencies.add(BuildToolDescriptors.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath())); + dependencies.add(getScmDescriptor()); + } catch (IOException e) { + throw new MojoExecutionException(e); + } + dependencies.addAll(getProjectDependencies()); + return dependencies; + } + + private List<ResourceDescriptor> getProjectDependencies() throws MojoExecutionException { + List<ResourceDescriptor> dependencies = new ArrayList<>(); + for (Artifact artifact : project.getArtifacts()) { + dependencies.add(ArtifactUtils.toResourceDescriptor(artifact)); + } + return dependencies; + } + + private ResourceDescriptor getScmDescriptor() throws IOException, MojoExecutionException { + ResourceDescriptor scmDescriptor = new ResourceDescriptor(); + String scmUri = GitUtils.scmToDownloadUri(scmConnectionUrl, scmDirectory.toPath()); + scmDescriptor.setUri(scmUri); + // Compute the revision + Map<String, String> digest = new HashMap<>(); + digest.put("gitCommit", getScmRevision()); + scmDescriptor.setDigest(digest); + return scmDescriptor; + } + + private ScmRepository getScmRepository() throws MojoExecutionException { + try { + return scmManager.makeScmRepository(scmConnectionUrl); + } catch (ScmException e) { + throw new MojoExecutionException("Failed to create SCM repository", e); + } + } + + private String getScmRevision() throws MojoExecutionException { + ScmRepository scmRepository = getScmRepository(); + CommandParameters commandParameters = new CommandParameters(); + try { + InfoScmResult result = scmManager.getProviderByRepository(scmRepository).info(scmRepository.getProviderRepository(), new ScmFileSet(scmDirectory) + , commandParameters); + + return getScmRevision(result); + } catch (ScmException e) { + throw new MojoExecutionException("Failed to retrieve SCM revision", e); + } + } + + private String getScmRevision(InfoScmResult result) throws MojoExecutionException { + if (!result.isSuccess()) { + throw new MojoExecutionException("Failed to retrieve SCM revision: " + result.getProviderMessage()); + } + + if (result.getInfoItems() == null || result.getInfoItems().isEmpty()) { + throw new MojoExecutionException("No SCM revision information found for " + scmDirectory); + } + + InfoItem item = result.getInfoItems().get(0); + + String revision = item.getRevision(); + if (revision == null) { + throw new MojoExecutionException("Empty SCM revision returned for " + scmDirectory); + } + return revision; + } + + private BuildMetadata getBuildMetadata() { + OffsetDateTime startedOn = session.getStartTime().toInstant().atOffset(ZoneOffset.UTC); + OffsetDateTime finishedOn = OffsetDateTime.now(ZoneOffset.UTC); + return new BuildMetadata(session.getRequest().getBuilderId(), startedOn, finishedOn); + } +} diff --git a/src/main/java/org/apache/commons/build/internal/ArtifactUtils.java b/src/main/java/org/apache/commons/build/internal/ArtifactUtils.java new file mode 100644 index 0000000..9e1f047 --- /dev/null +++ b/src/main/java/org/apache/commons/build/internal/ArtifactUtils.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.build.internal; + +import org.apache.commons.build.models.slsa.v1_2.ResourceDescriptor; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public final class ArtifactUtils { + + private ArtifactUtils() { + // prevent instantiation + } + + public static String getFileName(Artifact artifact) { + return getFileName(artifact, artifact.getArtifactHandler().getExtension()); + } + + public static String getFileName(Artifact artifact, String extension) { + StringBuilder fileName = new StringBuilder(); + fileName.append(artifact.getArtifactId()).append("-").append(artifact.getVersion()); + if (artifact.getClassifier() != null) { + fileName.append("-").append(artifact.getClassifier()); + } + fileName.append(".").append(extension); + return fileName.toString(); + } + + public static String getPackageUrl(Artifact artifact) { + StringBuilder sb = new StringBuilder(); + sb.append("pkg:maven/").append(artifact.getGroupId()).append("/").append(artifact.getArtifactId()).append("@").append(artifact.getVersion()) + .append("?"); + String classifier = artifact.getClassifier(); + if (classifier != null) { + sb.append("classifier=").append(classifier).append("&"); + } + sb.append("type=").append(artifact.getType()); + return sb.toString(); + } + + public static Map<String, String> getChecksums(Artifact artifact) throws IOException { + Map<String, String> checksums = new HashMap<>(); + DigestUtils digest = new DigestUtils(DigestUtils.getSha256Digest()); + String sha256sum = digest.digestAsHex(artifact.getFile()); + checksums.put("sha256", sha256sum); + return checksums; + } + + public static ResourceDescriptor toResourceDescriptor(Artifact artifact) throws MojoExecutionException { + ResourceDescriptor descriptor = new ResourceDescriptor(); + descriptor.setName(getFileName(artifact)); + descriptor.setUri(getPackageUrl(artifact)); + if (artifact.getFile() != null) { + try { + descriptor.setDigest(getChecksums(artifact)); + } catch (IOException e) { + throw new MojoExecutionException("Unable to compute hash for artifact file: " + artifact.getFile(), e); + } + } + return descriptor; + } +} diff --git a/src/main/java/org/apache/commons/build/internal/BuildToolDescriptors.java b/src/main/java/org/apache/commons/build/internal/BuildToolDescriptors.java new file mode 100644 index 0000000..0bdc333 --- /dev/null +++ b/src/main/java/org/apache/commons/build/internal/BuildToolDescriptors.java @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.build.internal; + +import org.apache.commons.build.models.slsa.v1_2.ResourceDescriptor; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * Factory methods for {@link ResourceDescriptor} instances representing build-tool dependencies. + */ +public final class BuildToolDescriptors { + + private BuildToolDescriptors() { + // no instantiation + } + + /** + * Creates a {@link ResourceDescriptor} for the JDK used during the build. + * + * @param javaHome path to the JDK home directory (value of the {@code java.home} system property) + * @return a descriptor with digest and annotations populated from system properties + * @throws IOException if hashing the JDK directory fails + */ + public static ResourceDescriptor jvm(Path javaHome) throws IOException { + ResourceDescriptor descriptor = new ResourceDescriptor(); + descriptor.setName("JDK"); + Map<String, String> digest = new HashMap<>(); + digest.put("gitTree", GitUtils.gitTree(javaHome)); + descriptor.setDigest(digest); + String[] propertyNames = {"java.version", "java.vendor", "java.vendor.version", "java.vm.name", "java.vm.version", "java.vm.vendor", + "java.runtime.name", "java.runtime.version", "java.specification.version"}; + Map<String, Object> annotations = new HashMap<>(); + for (String prop : propertyNames) { + annotations.put(prop.substring("java.".length()), System.getProperty(prop)); + } + descriptor.setAnnotations(annotations); + return descriptor; + } + + /** + * Creates a {@link ResourceDescriptor} for the Maven installation used during the build. + * + * @param version Maven version string + * @param mavenHome path to the Maven home directory + * @return a descriptor for the Maven installation + * @throws IOException if hashing the Maven home directory fails + */ + public static ResourceDescriptor maven(String version, Path mavenHome) throws IOException { + ResourceDescriptor descriptor = new ResourceDescriptor(); + descriptor.setName("Maven"); + descriptor.setUri("pkg:maven/org.apache.maven/apache-maven@" + version); + Map<String, String> digest = new HashMap<>(); + digest.put("gitTree", GitUtils.gitTree(mavenHome)); + descriptor.setDigest(digest); + Properties buildProps = new Properties(); + try (InputStream in = BuildToolDescriptors.class.getResourceAsStream("/org/apache/maven/messages/build.properties")) { + if (in != null) { + buildProps.load(in); + } + } + if (!buildProps.isEmpty()) { + Map<String, Object> annotations = new HashMap<>(); + buildProps.forEach((key, value) -> annotations.put((String) key, value)); + descriptor.setAnnotations(annotations); + } + return descriptor; + } +} diff --git a/src/main/java/org/apache/commons/build/internal/GitUtils.java b/src/main/java/org/apache/commons/build/internal/GitUtils.java new file mode 100644 index 0000000..01c987b --- /dev/null +++ b/src/main/java/org/apache/commons/build/internal/GitUtils.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.build.internal; + +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.codec.digest.DigestUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; + +public final class GitUtils { + + public static String gitTree(Path path) throws IOException { + if (!Files.isDirectory(path)) { + throw new IOException("Path is not a directory: " + path); + } + MessageDigest digest = DigestUtils.getSha1Digest(); + return Hex.encodeHexString(DigestUtils.gitTree(digest, path)); + } + + public static String gitBlob(Path path) throws IOException { + if (!Files.isRegularFile(path)) { + throw new IOException("Path is not a regular file: " + path); + } + MessageDigest digest = DigestUtils.getSha1Digest(); + return Hex.encodeHexString(DigestUtils.gitBlob(digest, path)); + } + + public static String scmToDownloadUri(String scmUri, Path repositoryPath) throws IOException { + if (!scmUri.startsWith("scm:git")) { + throw new IllegalArgumentException("Invalid scmUri: " + scmUri); + } + String currentBranch = getCurrentBranch(repositoryPath); + return "git+" + scmUri.substring(8) + "@" + currentBranch; + } + + public static String getCurrentBranch(Path repositoryPath) throws IOException { + Path gitDir = findGitDir(repositoryPath); + String head = new String(Files.readAllBytes(gitDir.resolve("HEAD")), StandardCharsets.UTF_8).trim(); + if (head.startsWith("ref: refs/heads/")) { + return head.substring("ref: refs/heads/".length()); + } + // detached HEAD — return the commit SHA + return head; + } + + private static Path findGitDir(Path path) throws IOException { + Path current = path.toAbsolutePath(); + while (current != null) { + Path candidate = current.resolve(".git"); + if (Files.isDirectory(candidate)) { + return candidate; + } + if (Files.isRegularFile(candidate)) { + // git worktree: .git is a file containing "gitdir: /path/to/real/.git" + String content = new String(Files.readAllBytes(candidate), StandardCharsets.UTF_8).trim(); + if (content.startsWith("gitdir: ")) { + return Paths.get(content.substring("gitdir: ".length())); + } + } + current = current.getParent(); + } + throw new IOException("No .git directory found above: " + path); + } + + private GitUtils() { + // no instantiation + } +} diff --git a/src/main/java/org/apache/commons/build/models/slsa/v1_2/BuildDefinition.java b/src/main/java/org/apache/commons/build/models/slsa/v1_2/BuildDefinition.java index 12bbab7..df0123e 100644 --- a/src/main/java/org/apache/commons/build/models/slsa/v1_2/BuildDefinition.java +++ b/src/main/java/org/apache/commons/build/models/slsa/v1_2/BuildDefinition.java @@ -16,12 +16,13 @@ */ package org.apache.commons.build.models.slsa.v1_2; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Inputs that define the build: the build type, external and internal parameters, and resolved dependencies. * @@ -30,17 +31,16 @@ import java.util.Objects; * * @see <a href="https://slsa.dev/spec/v1.2">SLSA v1.2 Specification</a> */ -@JsonInclude(JsonInclude.Include.NON_NULL) public class BuildDefinition { @JsonProperty("buildType") private String buildType = "https://commons.apache.org/builds/0.1.0"; @JsonProperty("externalParameters") - private Map<String, Object> externalParameters; + private Map<String, Object> externalParameters = new HashMap<>(); @JsonProperty("internalParameters") - private Map<String, Object> internalParameters; + private Map<String, Object> internalParameters = new HashMap<>(); @JsonProperty("resolvedDependencies") private List<ResourceDescriptor> resolvedDependencies; diff --git a/src/main/java/org/apache/commons/build/models/slsa/v1_2/Builder.java b/src/main/java/org/apache/commons/build/models/slsa/v1_2/Builder.java index d783592..7466a71 100644 --- a/src/main/java/org/apache/commons/build/models/slsa/v1_2/Builder.java +++ b/src/main/java/org/apache/commons/build/models/slsa/v1_2/Builder.java @@ -18,6 +18,8 @@ package org.apache.commons.build.models.slsa.v1_2; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -30,13 +32,13 @@ import java.util.Objects; public class Builder { @JsonProperty("id") - private String id; + private String id = "https://commons.apache.org/builds/0.1.0"; @JsonProperty("builderDependencies") - private List<ResourceDescriptor> builderDependencies; + private List<ResourceDescriptor> builderDependencies = new ArrayList<>(); @JsonProperty("version") - private Map<String, String> version; + private Map<String, String> version = new HashMap<>(); /** Creates a new Builder instance. */ public Builder() { diff --git a/src/test/java/org/apache/commons/build/BuildAttestationMojoTest.java b/src/test/java/org/apache/commons/build/BuildAttestationMojoTest.java new file mode 100644 index 0000000..46ed892 --- /dev/null +++ b/src/test/java/org/apache/commons/build/BuildAttestationMojoTest.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.build; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Date; +import java.util.Properties; + +import org.apache.commons.build.internal.MojoUtils; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.bridge.MavenRepositorySystem; +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.DefaultMavenExecutionResult; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionResult; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.model.Model; +import org.apache.maven.project.MavenProject; +import org.apache.maven.project.MavenProjectHelper; +import org.apache.maven.rtinfo.RuntimeInformation; +import org.apache.maven.scm.manager.ScmManager; +import org.codehaus.plexus.PlexusContainer; +import org.codehaus.plexus.component.repository.exception.ComponentLookupException; +import org.eclipse.aether.RepositorySystemSession; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class BuildAttestationMojoTest { + + @TempDir + private static Path localRepositoryPath; + + private static PlexusContainer container; + private static RepositorySystemSession repoSession; + + @BeforeAll + static void setup() throws Exception { + container = MojoUtils.setupContainer(); + repoSession = MojoUtils.createRepositorySystemSession(container, localRepositoryPath); + } + + private static MavenExecutionRequest createMavenExecutionRequest() { + DefaultMavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setStartTime(new Date()); + return request; + } + + @SuppressWarnings("deprecation") + private static MavenSession createMavenSession(MavenExecutionRequest request, MavenExecutionResult result) { + return new MavenSession(container, repoSession, request, result); + } + + private static BuildAttestationMojo createBuildAttestationMojo(MavenProject project, MavenProjectHelper projectHelper) throws ComponentLookupException { + ScmManager scmManager = container.lookup(ScmManager.class); + RuntimeInformation runtimeInfo = container.lookup(RuntimeInformation.class); + return new BuildAttestationMojo(project, scmManager, runtimeInfo, + createMavenSession(createMavenExecutionRequest(), new DefaultMavenExecutionResult()), projectHelper); + } + + private static MavenProject createMavenProject(MavenProjectHelper projectHelper, MavenRepositorySystem repoSystem) throws ComponentLookupException { + MavenProject project = new MavenProject(new Model()); + Artifact artifact = repoSystem.createArtifact("groupId", "artifactId", "1.2.3", null, "jar"); + project.setArtifact(artifact); + project.setGroupId("groupId"); + project.setArtifactId("artifactId"); + project.setVersion("1.2.3"); + // Attach a couple of artifacts + projectHelper.attachArtifact(project, "pom", null, new File("src/test/resources/artifacts/artifact-pom.txt")); + artifact.setFile(new File("src/test/resources/artifacts/artifact-jar.txt")); + return project; + } + + @Test + void attestationTest() throws Exception { + MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class); + MavenRepositorySystem repoSystem = container.lookup(MavenRepositorySystem.class); + MavenProject project = createMavenProject(projectHelper, repoSystem); + + BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper); + mojo.setOutputDirectory(new File("target/attestations")); + mojo.setScmDirectory(new File(".")); + mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-lang.git"); + mojo.setScmDeveloperConnectionUrl("scm:git:ssh://[email protected]/apache/commons-lang.git"); + mojo.setScmTag("tag"); + mojo.setMavenHome(new File(System.getProperty("maven.home", "."))); + mojo.execute(); + + Artifact attestation = project.getAttachedArtifacts().stream() + .filter(a -> "intoto.json".equals(a.getType())) + .findFirst() + .orElseThrow(() -> new AssertionError("No intoto.json artifact attached to project")); + String json = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8); + + String resolvedDeps = "predicate.buildDefinition.resolvedDependencies"; + String javaVersion = System.getProperty("java.version"); + + assertThatJson(json) + .node(resolvedDeps).isArray() + .anySatisfy(dep -> { + assertThatJson(dep).node("name").isEqualTo("JDK"); + assertThatJson(dep).node("annotations.version").isEqualTo(javaVersion); + }); + + assertThatJson(json) + .node(resolvedDeps).isArray() + .anySatisfy(dep -> assertThatJson(dep).node("name").isEqualTo("Maven")); + + assertThatJson(json) + .node(resolvedDeps).isArray() + .anySatisfy(dep -> assertThatJson(dep).node("uri").isString().startsWith("git+https://github.com/apache/commons-lang.git")); + } +} diff --git a/src/test/java/org/apache/commons/build/internal/MojoUtils.java b/src/test/java/org/apache/commons/build/internal/MojoUtils.java new file mode 100644 index 0000000..1ac3d25 --- /dev/null +++ b/src/test/java/org/apache/commons/build/internal/MojoUtils.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.commons.build.internal; + +import org.codehaus.plexus.ContainerConfiguration; +import org.codehaus.plexus.DefaultContainerConfiguration; +import org.codehaus.plexus.DefaultPlexusContainer; +import org.codehaus.plexus.PlexusConstants; +import org.codehaus.plexus.PlexusContainer; +import org.codehaus.plexus.PlexusContainerException; +import org.codehaus.plexus.classworlds.ClassWorld; +import org.codehaus.plexus.component.repository.exception.ComponentLookupException; +import org.eclipse.aether.DefaultRepositorySystemSession; +import org.eclipse.aether.RepositoryException; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.repository.LocalRepository; +import org.eclipse.aether.repository.LocalRepositoryManager; +import org.eclipse.aether.repository.RepositoryPolicy; +import org.eclipse.aether.spi.localrepo.LocalRepositoryManagerFactory; + +import java.nio.file.Path; + +/** + * Utilities to instantiate Mojos in a test environment. + */ +public final class MojoUtils { + + private static ContainerConfiguration setupContainerConfiguration() { + ClassWorld classWorld = + new ClassWorld("plexus.core", Thread.currentThread().getContextClassLoader()); + return new DefaultContainerConfiguration() + .setClassWorld(classWorld) + .setClassPathScanning(PlexusConstants.SCANNING_INDEX) + .setAutoWiring(true) + .setName("maven"); + } + + public static PlexusContainer setupContainer() throws PlexusContainerException { + return new DefaultPlexusContainer(setupContainerConfiguration()); + } + + public static RepositorySystemSession createRepositorySystemSession( + PlexusContainer container, Path localRepositoryPath) throws ComponentLookupException, RepositoryException { + LocalRepositoryManagerFactory factory = container.lookup(LocalRepositoryManagerFactory.class, "simple"); + DefaultRepositorySystemSession repoSession = new DefaultRepositorySystemSession(); + LocalRepositoryManager manager = + factory.newInstance(repoSession, new LocalRepository(localRepositoryPath.toFile())); + repoSession.setLocalRepositoryManager(manager); + // Default policies + repoSession.setUpdatePolicy(RepositoryPolicy.UPDATE_POLICY_DAILY); + repoSession.setChecksumPolicy(RepositoryPolicy.CHECKSUM_POLICY_WARN); + return repoSession; + } + + private MojoUtils() {} +} diff --git a/src/test/resources/artifacts/artifact-jar.txt b/src/test/resources/artifacts/artifact-jar.txt new file mode 100644 index 0000000..103a21e --- /dev/null +++ b/src/test/resources/artifacts/artifact-jar.txt @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: Apache-2.0 +A mock-up of a JAR file \ No newline at end of file diff --git a/src/test/resources/artifacts/artifact-pom.txt b/src/test/resources/artifacts/artifact-pom.txt new file mode 100644 index 0000000..48d1bbc --- /dev/null +++ b/src/test/resources/artifacts/artifact-pom.txt @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: Apache-2.0 +A mock-up of a POM file \ No newline at end of file
