From 1074cbcd20d5e31d7e096675ff04b5f693802905 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Mon, 30 Mar 2026 16:47:37 +0200 Subject: [PATCH 01/14] Add `build-attestation` target This PR was moved from apache/commons-build-plugin#417 It adds a goal to generate 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 --- checkstyle.xml | 2 +- fb-excludes.xml | 5 + pom.xml | 65 +++ .../plugin/internal/ArtifactUtils.java | 118 +++++ .../plugin/internal/BuildToolDescriptors.java | 89 ++++ .../release/plugin/internal/GitUtils.java | 117 +++++ .../release/plugin/internal/package-info.java | 23 + .../plugin/mojos/BuildAttestationMojo.java | 454 ++++++++++++++++++ .../plugin/slsa/v1_2/BuildDefinition.java | 173 +++++++ .../plugin/slsa/v1_2/BuildMetadata.java | 140 ++++++ .../release/plugin/slsa/v1_2/Builder.java | 125 +++++ .../release/plugin/slsa/v1_2/Provenance.java | 120 +++++ .../plugin/slsa/v1_2/ResourceDescriptor.java | 227 +++++++++ .../release/plugin/slsa/v1_2/RunDetails.java | 137 ++++++ .../release/plugin/slsa/v1_2/Statement.java | 122 +++++ .../plugin/slsa/v1_2/package-info.java | 34 ++ .../release/plugin/internal/MojoUtils.java | 71 +++ .../mojos/BuildAttestationMojoTest.java | 129 +++++ src/test/resources/artifacts/artifact-jar.txt | 2 + src/test/resources/artifacts/artifact-pom.txt | 2 + 20 files changed, 2154 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/package-info.java create mode 100644 src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/package-info.java create mode 100644 src/test/java/org/apache/commons/release/plugin/internal/MojoUtils.java create mode 100644 src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java create mode 100644 src/test/resources/artifacts/artifact-jar.txt create mode 100644 src/test/resources/artifacts/artifact-pom.txt diff --git a/checkstyle.xml b/checkstyle.xml index 8f329d35b..0f5a18551 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -185,7 +185,7 @@ - + diff --git a/fb-excludes.xml b/fb-excludes.xml index 2cba28121..0a0a38438 100644 --- a/fb-excludes.xml +++ b/fb-excludes.xml @@ -18,6 +18,11 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubusercontent.com/spotbugs/spotbugs/3.1.0/spotbugs/etc/findbugsfilter.xsd"> + + + + + diff --git a/pom.xml b/pom.xml index faa5b1ae0..e0c6767e7 100644 --- a/pom.xml +++ b/pom.xml @@ -113,7 +113,22 @@ true true + + 2.21.1 + 2.21 + 2.0.17 + + + + org.slf4j + slf4j-bom + ${commons.slf4j.version} + pom + import + + + org.apache.commons @@ -151,6 +166,18 @@ maven-scm-api ${maven-scm.version} + + org.apache.maven.scm + maven-scm-manager-plexus + ${maven-scm.version} + compile + + + org.apache.maven.scm + maven-scm-provider-gitexe + ${maven-scm.version} + runtime + org.apache.maven.scm maven-scm-provider-svnexe @@ -171,6 +198,22 @@ commons-compress 1.28.0 + + com.fasterxml.jackson.core + jackson-databind + ${commons.jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${commons.jackson.annotations.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${commons.jackson.version} + runtime + org.apache.maven.plugin-testing maven-plugin-testing-harness @@ -188,11 +231,28 @@ junit-jupiter test + + net.javacrumbs.json-unit + json-unit-assertj + 2.40.1 + test + + + org.junit.jupiter + junit-jupiter-api + test + org.junit.vintage junit-vintage-engine test + + org.mockito + mockito-core + 4.11.0 + test + org.apache.maven @@ -223,6 +283,11 @@ + + org.slf4j + slf4j-simple + test + clean verify apache-rat:check checkstyle:check spotbugs:check javadoc:javadoc site diff --git a/src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java new file mode 100644 index 000000000..7f07244e2 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java @@ -0,0 +1,118 @@ +/* + * 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.release.plugin.internal; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.plugin.MojoExecutionException; + +/** + * Utilities to convert {@link Artifact} from and to other types. + */ +public final class ArtifactUtils { + + /** No instances. */ + private ArtifactUtils() { + // prevent instantiation + } + + /** + * Returns the conventional filename for the given artifact. + * + * @param artifact A Maven artifact. + * @return A filename. + */ + public static String getFileName(Artifact artifact) { + return getFileName(artifact, artifact.getArtifactHandler().getExtension()); + } + + /** + * Returns the filename for the given artifact with a changed extension. + * + * @param artifact A Maven artifact. + * @param extension The file name extension. + * @return A filename. + */ + 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(); + } + + /** + * Returns the Package URL corresponding to this artifact. + * + * @param artifact A maven artifact. + * @return A PURL for the given artifact. + */ + 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(); + } + + /** + * Returns a map of checksum algorithm names to hex-encoded digest values for the given artifact file. + * + * @param artifact A Maven artifact. + * @return A map of checksum algorithm names to hex-encoded digest values. + * @throws IOException If an I/O error occurs reading the artifact file. + */ + private static Map getChecksums(Artifact artifact) throws IOException { + Map checksums = new HashMap<>(); + DigestUtils digest = new DigestUtils(DigestUtils.getSha256Digest()); + String sha256sum = digest.digestAsHex(artifact.getFile()); + checksums.put("sha256", sha256sum); + return checksums; + } + + /** + * Converts a Maven artifact to a SLSA {@link ResourceDescriptor}. + * + * @param artifact A Maven artifact. + * @return A SLSA resource descriptor. + * @throws MojoExecutionException If an I/O error occurs retrieving the artifact. + */ + 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/release/plugin/internal/BuildToolDescriptors.java b/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java new file mode 100644 index 000000000..15be8d73e --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java @@ -0,0 +1,89 @@ +/* + * 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.release.plugin.internal; + +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; + +import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor; + +/** + * Factory methods for {@link ResourceDescriptor} instances representing build-tool dependencies. + */ +public final class BuildToolDescriptors { + + /** No instances. */ + 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 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 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 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 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/release/plugin/internal/GitUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java new file mode 100644 index 000000000..246027c49 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java @@ -0,0 +1,117 @@ +/* + * 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.release.plugin.internal; + +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; + +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.codec.digest.DigestUtils; + +/** + * Utilities for Git operations. + */ +public final class GitUtils { + + /** The SCM URI prefix for Git repositories. */ + private static final String SCM_GIT_PREFIX = "scm:git:"; + + /** + * Returns the Git tree hash for the given directory. + * + * @param path A directory path. + * @return A hex-encoded SHA-1 tree hash. + * @throws IOException If the path is not a directory or an I/O error occurs. + */ + 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)); + } + + /** + * Converts an SCM URI to a download URI suffixed with the current branch name. + * + * @param scmUri A Maven SCM URI starting with {@code scm:git}. + * @param repositoryPath A path inside the Git repository. + * @return A download URI of the form {@code git+@}. + * @throws IOException If the current branch cannot be determined. + */ + public static String scmToDownloadUri(String scmUri, Path repositoryPath) throws IOException { + if (!scmUri.startsWith(SCM_GIT_PREFIX)) { + throw new IllegalArgumentException("Invalid scmUri: " + scmUri); + } + String currentBranch = getCurrentBranch(repositoryPath); + return "git+" + scmUri.substring(SCM_GIT_PREFIX.length()) + "@" + currentBranch; + } + + /** + * Returns the current branch name for the given repository path. + * + *

Returns the commit SHA if the repository is in a detached HEAD state. + * + * @param repositoryPath A path inside the Git repository. + * @return The current branch name, or the commit SHA for a detached HEAD. + * @throws IOException If the {@code .git} directory cannot be found or read. + */ + 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; + } + + /** + * Walks up the directory tree from {@code path} to find the {@code .git} directory. + * + * @param path A path inside the Git repository. + * @return The path to the {@code .git} directory (or file for worktrees). + * @throws IOException If no {@code .git} directory is found. + */ + 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); + } + + /** No instances. */ + private GitUtils() { + // no instantiation + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/internal/package-info.java b/src/main/java/org/apache/commons/release/plugin/internal/package-info.java new file mode 100644 index 000000000..9218ebff4 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Internal utilities for the commons-release-plugin. + * + *

Should not be referenced by external artifacts.

+ */ +package org.apache.commons.release.plugin.internal; diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java new file mode 100644 index 000000000..0260e22e1 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -0,0 +1,454 @@ +/* + * 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.release.plugin.mojos; + +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.release.plugin.internal.ArtifactUtils; +import org.apache.commons.release.plugin.internal.BuildToolDescriptors; +import org.apache.commons.release.plugin.internal.GitUtils; +import org.apache.commons.release.plugin.slsa.v1_2.BuildDefinition; +import org.apache.commons.release.plugin.slsa.v1_2.BuildMetadata; +import org.apache.commons.release.plugin.slsa.v1_2.Builder; +import org.apache.commons.release.plugin.slsa.v1_2.Provenance; +import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor; +import org.apache.commons.release.plugin.slsa.v1_2.RunDetails; +import org.apache.commons.release.plugin.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 { + + /** The file extension for in-toto attestation files. */ + private static final String ATTESTATION_EXTENSION = "intoto.json"; + + /** Shared Jackson object mapper for serializing attestation statements. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + static { + OBJECT_MAPPER.findAndRegisterModules(); + OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + /** The SCM connection URL for the current project. */ + @Parameter(defaultValue = "${project.scm.connection}", readonly = true) + private String scmConnectionUrl; + + /** The Maven home directory. */ + @Parameter(defaultValue = "${maven.home}", readonly = true) + private File mavenHome; + + /** + * Issue SCM actions at this local directory. + */ + @Parameter(property = "commons.release.scmDirectory", defaultValue = "${basedir}") + private File scmDirectory; + + /** The output directory for the attestation file. */ + @Parameter(property = "commons.release.outputDirectory", defaultValue = "${project.build.directory}") + private File outputDirectory; + + /** Whether to skip attaching the attestation artifact to the project. */ + @Parameter(property = "commons.release.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; + + /** + * Creates a new instance with the given dependencies. + * + * @param project A Maven project. + * @param scmManager A SCM manager. + * @param runtimeInformation Maven runtime information. + * @param session A Maven session. + * @param mavenProjectHelper A helper to attach artifacts to the project. + */ + @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; + } + + /** + * Sets the output directory for the attestation file. + * + * @param outputDirectory The output directory. + */ + void setOutputDirectory(final File outputDirectory) { + this.outputDirectory = outputDirectory; + } + + /** + * Returns the SCM directory. + * + * @return The SCM directory. + */ + public File getScmDirectory() { + return scmDirectory; + } + + /** + * Sets the SCM directory. + * + * @param scmDirectory The SCM directory. + */ + public void setScmDirectory(File scmDirectory) { + this.scmDirectory = scmDirectory; + } + + /** + * Sets the public SCM connection URL. + * + * @param scmConnectionUrl The SCM connection URL. + */ + void setScmConnectionUrl(final String scmConnectionUrl) { + this.scmConnectionUrl = scmConnectionUrl; + } + + /** + * Sets the Maven home directory. + * + * @param mavenHome The Maven home directory. + */ + void setMavenHome(final File mavenHome) { + this.mavenHome = mavenHome; + } + + @Override + public void execute() throws MojoFailureException, MojoExecutionException { + // 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); + } + + /** + * Serializes the attestation statement to a file and optionally attaches it to the project. + * + * @param statement The attestation statement to write. + * @throws MojoExecutionException If the output directory cannot be created or the file cannot be written. + */ + private void writeStatement(final 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. + * @throws MojoExecutionException If artifact hashing fails. + */ + private List getSubjects() throws MojoExecutionException { + List subjects = new ArrayList<>(); + subjects.add(ArtifactUtils.toResourceDescriptor(project.getArtifact())); + for (Artifact artifact : project.getAttachedArtifacts()) { + subjects.add(ArtifactUtils.toResourceDescriptor(artifact)); + } + return subjects; + } + + /** + * Gets map of external build parameters captured from the current JVM and Maven session. + * + * @return A map of parameter names to values. + */ + private Map getExternalParameters() { + Map 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 env = new HashMap<>(); + params.put("env", env); + for (Map.Entry 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; + } + + /** + * Reconstructs the Maven command line string from the given execution request. + * + * @param request The Maven execution request. + * @return A string representation of the Maven command line. + */ + private String getCommandLine(final MavenExecutionRequest request) { + StringBuilder sb = new StringBuilder(); + for (String goal : request.getGoals()) { + sb.append(goal); + sb.append(" "); + } + List 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(); + } + + /** + * Removes the last character from the given {@link StringBuilder} if it is non-empty. + * + * @param sb The string builder to trim. + */ + private static void removeLast(final StringBuilder sb) { + if (sb.length() > 0) { + sb.setLength(sb.length() - 1); + } + } + + /** + * Returns resource descriptors for the JVM, Maven installation, SCM source, and project dependencies. + * + * @return A list of resolved build dependencies. + * @throws MojoExecutionException If any dependency cannot be resolved or hashed. + */ + private List getBuildDependencies() throws MojoExecutionException { + List 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; + } + + /** + * Returns resource descriptors for all resolved project dependencies. + * + * @return A list of resource descriptors for the project's resolved artifacts. + * @throws MojoExecutionException If a dependency artifact cannot be described. + */ + private List getProjectDependencies() throws MojoExecutionException { + List dependencies = new ArrayList<>(); + for (Artifact artifact : project.getArtifacts()) { + dependencies.add(ArtifactUtils.toResourceDescriptor(artifact)); + } + return dependencies; + } + + /** + * Returns a resource descriptor for the current SCM source, including the URI and Git commit digest. + * + * @return A resource descriptor for the SCM source. + * @throws IOException If the current branch cannot be determined. + * @throws MojoExecutionException If the SCM revision cannot be retrieved. + */ + private ResourceDescriptor getScmDescriptor() throws IOException, MojoExecutionException { + ResourceDescriptor scmDescriptor = new ResourceDescriptor(); + String scmUri = GitUtils.scmToDownloadUri(scmConnectionUrl, scmDirectory.toPath()); + scmDescriptor.setUri(scmUri); + // Compute the revision + Map digest = new HashMap<>(); + digest.put("gitCommit", getScmRevision()); + scmDescriptor.setDigest(digest); + return scmDescriptor; + } + + /** + * Creates and returns an SCM repository from the configured connection URL. + * + * @return The SCM repository. + * @throws MojoExecutionException If the SCM repository cannot be created. + */ + private ScmRepository getScmRepository() throws MojoExecutionException { + try { + return scmManager.makeScmRepository(scmConnectionUrl); + } catch (ScmException e) { + throw new MojoExecutionException("Failed to create SCM repository", e); + } + } + + /** + * Returns the current SCM revision (commit hash) for the configured SCM directory. + * + * @return The current SCM revision string. + * @throws MojoExecutionException If the revision cannot be retrieved from SCM. + */ + 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); + } + } + + /** + * Extracts the revision string from an SCM info result. + * + * @param result The SCM info result. + * @return The revision string. + * @throws MojoExecutionException If the result is unsuccessful or contains no revision. + */ + private String getScmRevision(final 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; + } + + /** + * Returns build metadata derived from the current Maven session, including start and finish timestamps. + * + * @return The build metadata. + */ + 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/release/plugin/slsa/v1_2/BuildDefinition.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java new file mode 100644 index 000000000..843bc0e17 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java @@ -0,0 +1,173 @@ +/* + * 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.release.plugin.slsa.v1_2; + +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. + * + *

Specifies everything that influenced the build output. Together with {@link RunDetails}, it forms the complete + * {@link Provenance} record.

+ * + * @see SLSA v1.2 Specification + */ +public class BuildDefinition { + + /** URI indicating what type of build was performed. */ + @JsonProperty("buildType") + private String buildType = "https://commons.apache.org/builds/0.1.0"; + + /** Inputs passed to the build. */ + @JsonProperty("externalParameters") + private Map externalParameters = new HashMap<>(); + + /** Parameters set by the build platform. */ + @JsonProperty("internalParameters") + private Map internalParameters = new HashMap<>(); + + /** Artifacts the build depends on, specified by URI and digest. */ + @JsonProperty("resolvedDependencies") + private List resolvedDependencies; + + /** Creates a new BuildDefinition instance with the default build type. */ + public BuildDefinition() { + } + + /** + * Creates a new BuildDefinition with the given build type and external parameters. + * + * @param buildType URI indicating what type of build was performed + * @param externalParameters inputs passed to the build + */ + public BuildDefinition(String buildType, Map externalParameters) { + this.buildType = buildType; + this.externalParameters = externalParameters; + } + + /** + * Returns the URI indicating what type of build was performed. + * + *

Determines the meaning of {@code externalParameters} and {@code internalParameters}.

+ * + * @return the build type URI + */ + public String getBuildType() { + return buildType; + } + + /** + * Sets the URI indicating what type of build was performed. + * + * @param buildType the build type URI + */ + public void setBuildType(String buildType) { + this.buildType = buildType; + } + + /** + * Returns the inputs passed to the build, such as command-line arguments or environment variables. + * + * @return the external parameters map, or {@code null} if not set + */ + public Map getExternalParameters() { + return externalParameters; + } + + /** + * Sets the inputs passed to the build. + * + * @param externalParameters the external parameters map + */ + public void setExternalParameters(Map externalParameters) { + this.externalParameters = externalParameters; + } + + /** + * Returns the artifacts the build depends on, such as sources, dependencies, build tools, and base images, + * specified by URI and digest. + * + * @return the internal parameters map, or {@code null} if not set + */ + public Map getInternalParameters() { + return internalParameters; + } + + /** + * Sets the artifacts the build depends on. + * + * @param internalParameters the internal parameters map + */ + public void setInternalParameters(Map internalParameters) { + this.internalParameters = internalParameters; + } + + /** + * Returns the materials that influenced the build. + * + *

Considered incomplete unless resolved materials are present.

+ * + * @return the list of resolved dependencies, or {@code null} if not set + */ + public List getResolvedDependencies() { + return resolvedDependencies; + } + + /** + * Sets the materials that influenced the build. + * + * @param resolvedDependencies the list of resolved dependencies + */ + public void setResolvedDependencies(List resolvedDependencies) { + this.resolvedDependencies = resolvedDependencies; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BuildDefinition that = (BuildDefinition) o; + return Objects.equals(buildType, that.buildType) + && Objects.equals(externalParameters, that.externalParameters) + && Objects.equals(internalParameters, that.internalParameters) + && Objects.equals(resolvedDependencies, that.resolvedDependencies); + } + + @Override + public int hashCode() { + return Objects.hash(buildType, externalParameters, internalParameters, resolvedDependencies); + } + + @Override + public String toString() { + return "BuildDefinition{" + + "buildType='" + buildType + '\'' + + ", externalParameters=" + externalParameters + + ", internalParameters=" + internalParameters + + ", resolvedDependencies=" + resolvedDependencies + + '}'; + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java new file mode 100644 index 000000000..345eb91ee --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java @@ -0,0 +1,140 @@ +/* + * 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.release.plugin.slsa.v1_2; + +import java.time.OffsetDateTime; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Metadata about a build invocation: its identifier and start and finish timestamps. + * + * @see SLSA v1.2 Specification + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class BuildMetadata { + + /** Identifier for this build invocation. */ + @JsonProperty("invocationId") + private String invocationId; + + /** Timestamp when the build started. */ + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + @JsonProperty("startedOn") + private OffsetDateTime startedOn; + + /** Timestamp when the build completed. */ + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + @JsonProperty("finishedOn") + private OffsetDateTime finishedOn; + + /** Creates a new BuildMetadata instance. */ + public BuildMetadata() { + } + + /** + * Creates a new BuildMetadata instance with all fields set. + * + * @param invocationId identifier for this build invocation + * @param startedOn timestamp when the build started + * @param finishedOn timestamp when the build completed + */ + public BuildMetadata(String invocationId, OffsetDateTime startedOn, OffsetDateTime finishedOn) { + this.invocationId = invocationId; + this.startedOn = startedOn; + this.finishedOn = finishedOn; + } + + /** + * Returns the identifier for this build invocation. + * + *

Useful for finding associated logs or other ad-hoc analysis. The exact meaning and format is defined by the + * builder and is treated as opaque and case-sensitive. The value SHOULD be globally unique.

+ * + * @return the invocation identifier, or {@code null} if not set + */ + public String getInvocationId() { + return invocationId; + } + + /** + * Sets the identifier for this build invocation. + * + * @param invocationId the invocation identifier + */ + public void setInvocationId(String invocationId) { + this.invocationId = invocationId; + } + + /** + * Returns the timestamp of when the build started, serialized as RFC 3339 in UTC ({@code "Z"} suffix). + * + * @return the start timestamp, or {@code null} if not set + */ + public OffsetDateTime getStartedOn() { + return startedOn; + } + + /** + * Sets the timestamp of when the build started. + * + * @param startedOn the start timestamp + */ + public void setStartedOn(OffsetDateTime startedOn) { + this.startedOn = startedOn; + } + + /** + * Returns the timestamp of when the build completed, serialized as RFC 3339 in UTC ({@code "Z"} suffix). + * + * @return the completion timestamp, or {@code null} if not set + */ + public OffsetDateTime getFinishedOn() { + return finishedOn; + } + + /** + * Sets the timestamp of when the build completed. + * + * @param finishedOn the completion timestamp + */ + public void setFinishedOn(OffsetDateTime finishedOn) { + this.finishedOn = finishedOn; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof BuildMetadata)) { + return false; + } + BuildMetadata that = (BuildMetadata) o; + return Objects.equals(invocationId, that.invocationId) && Objects.equals(startedOn, that.startedOn) && Objects.equals(finishedOn, that.finishedOn); + } + + @Override + public int hashCode() { + return Objects.hash(invocationId, startedOn, finishedOn); + } + + @Override + public String toString() { + return "BuildMetadata{invocationId='" + invocationId + "', startedOn=" + startedOn + ", finishedOn=" + finishedOn + '}'; + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java new file mode 100644 index 000000000..36e0f1a89 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java @@ -0,0 +1,125 @@ +/* + * 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.release.plugin.slsa.v1_2; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Entity that executed the build and is trusted to have correctly performed the operation and populated the provenance. + * + * @see SLSA v1.2 Specification + */ +public class Builder { + + /** Identifier URI of the builder. */ + @JsonProperty("id") + private String id = "https://commons.apache.org/builds/0.1.0"; + + /** Orchestrator dependencies that may affect provenance generation. */ + @JsonProperty("builderDependencies") + private List builderDependencies = new ArrayList<>(); + + /** Map of build platform component names to their versions. */ + @JsonProperty("version") + private Map version = new HashMap<>(); + + /** Creates a new Builder instance. */ + public Builder() { + } + + /** + * Returns the identifier of the builder. + * + * @return the builder identifier URI + */ + public String getId() { + return id; + } + + /** + * Sets the identifier of the builder. + * + * @param id the builder identifier URI + */ + public void setId(String id) { + this.id = id; + } + + /** + * Returns orchestrator dependencies that do not run within the build workload and do not affect the build output, + * but may affect provenance generation or security guarantees. + * + * @return the list of builder dependencies, or {@code null} if not set + */ + public List getBuilderDependencies() { + return builderDependencies; + } + + /** + * Sets the orchestrator dependencies that may affect provenance generation or security guarantees. + * + * @param builderDependencies the list of builder dependencies + */ + public void setBuilderDependencies(List builderDependencies) { + this.builderDependencies = builderDependencies; + } + + /** + * Returns a map of build platform component names to their versions. + * + * @return the version map, or {@code null} if not set + */ + public Map getVersion() { + return version; + } + + /** + * Sets the map of build platform component names to their versions. + * + * @param version the version map + */ + public void setVersion(Map version) { + this.version = version; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Builder)) { + return false; + } + Builder that = (Builder) o; + return Objects.equals(id, that.id) + && Objects.equals(builderDependencies, that.builderDependencies) + && Objects.equals(version, that.version); + } + + @Override + public int hashCode() { + return Objects.hash(id, builderDependencies, version); + } + + @Override + public String toString() { + return "Builder{id='" + id + "', builderDependencies=" + builderDependencies + ", version=" + version + '}'; + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java new file mode 100644 index 000000000..884246006 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java @@ -0,0 +1,120 @@ +/* + * 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.release.plugin.slsa.v1_2; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Root predicate of an SLSA v1.2 provenance attestation, describing what was built and how. + * + *

Combines a {@link BuildDefinition} (the inputs) with {@link RunDetails} (the execution context). Intended to be + * used as the {@code predicate} field of an in-toto {@link Statement}.

+ * + * @see SLSA v1.2 Specification + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Provenance { + + /** Predicate type URI used in the in-toto {@link Statement} wrapping this provenance. */ + public static final String PREDICATE_TYPE = "https://slsa.dev/provenance/v1"; + + /** Inputs that defined the build. */ + @JsonProperty("buildDefinition") + private BuildDefinition buildDefinition; + + /** Details about the build invocation. */ + @JsonProperty("runDetails") + private RunDetails runDetails; + + /** Creates a new Provenance instance. */ + public Provenance() { + } + + /** + * Creates a new Provenance with the given build definition and run details. + * + * @param buildDefinition inputs that defined the build + * @param runDetails details about the build invocation + */ + public Provenance(BuildDefinition buildDefinition, RunDetails runDetails) { + this.buildDefinition = buildDefinition; + this.runDetails = runDetails; + } + + /** + * Returns the build definition describing all inputs that produced the build output. + * + *

Includes source code, dependencies, build tools, base images, and other materials.

+ * + * @return the build definition, or {@code null} if not set + */ + public BuildDefinition getBuildDefinition() { + return buildDefinition; + } + + /** + * Sets the build definition describing all inputs that produced the build output. + * + * @param buildDefinition the build definition + */ + public void setBuildDefinition(BuildDefinition buildDefinition) { + this.buildDefinition = buildDefinition; + } + + /** + * Returns the details about the invocation of the build tool and the environment in which it was run. + * + * @return the run details, or {@code null} if not set + */ + public RunDetails getRunDetails() { + return runDetails; + } + + /** + * Sets the details about the invocation of the build tool and the environment in which it was run. + * + * @param runDetails the run details + */ + public void setRunDetails(RunDetails runDetails) { + this.runDetails = runDetails; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Provenance that = (Provenance) o; + return Objects.equals(buildDefinition, that.buildDefinition) && Objects.equals(runDetails, that.runDetails); + } + + @Override + public int hashCode() { + return Objects.hash(buildDefinition, runDetails); + } + + @Override + public String toString() { + return "Provenance{buildDefinition=" + buildDefinition + ", runDetails=" + runDetails + '}'; + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java new file mode 100644 index 000000000..55333f220 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java @@ -0,0 +1,227 @@ +/* + * 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.release.plugin.slsa.v1_2; + +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Description of an artifact or resource referenced in the build, identified by URI and cryptographic digest. + * + *

Used to represent inputs to, outputs from, or byproducts of the build process.

+ * + * @see SLSA v1.2 Specification + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ResourceDescriptor { + + /** Human-readable name of the resource. */ + @JsonProperty("name") + private String name; + + /** URI identifying the resource. */ + @JsonProperty("uri") + private String uri; + + /** Map of digest algorithm names to hex-encoded values. */ + @JsonProperty("digest") + private Map digest; + + /** Raw contents of the resource, base64-encoded in JSON. */ + @JsonProperty("content") + private byte[] content; + + /** Download URI for the resource, if different from {@link #uri}. */ + @JsonProperty("downloadLocation") + private String downloadLocation; + + /** Media type of the resource. */ + @JsonProperty("mediaType") + private String mediaType; + + /** Additional key-value metadata about the resource. */ + @JsonProperty("annotations") + private Map annotations; + + /** Creates a new ResourceDescriptor instance. */ + public ResourceDescriptor() { + } + + /** + * Creates a new ResourceDescriptor with the given URI and digest. + * + * @param uri URI identifying the resource + * @param digest map of digest algorithm names to their hex-encoded values + */ + public ResourceDescriptor(String uri, Map digest) { + this.uri = uri; + this.digest = digest; + } + + /** + * Returns the name of the resource. + * + * @return the resource name, or {@code null} if not set + */ + public String getName() { + return name; + } + + /** + * Sets the name of the resource. + * + * @param name the resource name + */ + public void setName(String name) { + this.name = name; + } + + /** + * Returns the URI identifying the resource. + * + * @return the resource URI, or {@code null} if not set + */ + public String getUri() { + return uri; + } + + /** + * Sets the URI identifying the resource. + * + * @param uri the resource URI + */ + public void setUri(String uri) { + this.uri = uri; + } + + /** + * Returns the map of cryptographic digest algorithms to their corresponding hex-encoded values for this resource. + * + *

Common keys include {@code "sha256"} and {@code "sha512"}.

+ * + * @return the digest map, or {@code null} if not set + */ + public Map getDigest() { + return digest; + } + + /** + * Sets the map of cryptographic digest algorithms to their hex-encoded values. + * + * @param digest the digest map + */ + public void setDigest(Map digest) { + this.digest = digest; + } + + /** + * Returns the raw contents of the resource, base64-encoded when serialized to JSON. + * + * @return the resource content, or {@code null} if not set + */ + public byte[] getContent() { + return content; + } + + /** + * Sets the raw contents of the resource. + * + * @param content the resource content + */ + public void setContent(byte[] content) { + this.content = content; + } + + /** + * Returns the download URI for the resource, if different from {@link #getUri()}. + * + * @return the download location URI, or {@code null} if not set + */ + public String getDownloadLocation() { + return downloadLocation; + } + + /** + * Sets the download URI for the resource. + * + * @param downloadLocation the download location URI + */ + public void setDownloadLocation(String downloadLocation) { + this.downloadLocation = downloadLocation; + } + + /** + * Returns the media type of the resource (e.g., {@code "application/octet-stream"}). + * + * @return the media type, or {@code null} if not set + */ + public String getMediaType() { + return mediaType; + } + + /** + * Sets the media type of the resource. + * + * @param mediaType the media type + */ + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + /** + * Returns additional key-value metadata about the resource, such as filename, size, or builder-specific attributes. + * + * @return the annotations map, or {@code null} if not set + */ + public Map getAnnotations() { + return annotations; + } + + /** + * Sets additional key-value metadata about the resource. + * + * @param annotations the annotations map + */ + public void setAnnotations(Map annotations) { + this.annotations = annotations; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ResourceDescriptor that = (ResourceDescriptor) o; + return Objects.equals(uri, that.uri) && Objects.equals(digest, that.digest); + } + + @Override + public int hashCode() { + return Objects.hash(uri, digest); + } + + @Override + public String toString() { + return "ResourceDescriptor{uri='" + uri + '\'' + ", digest=" + digest + '}'; + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java new file mode 100644 index 000000000..ffb118677 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java @@ -0,0 +1,137 @@ +/* + * 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.release.plugin.slsa.v1_2; + +import java.util.List; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Details about the build invocation: the builder identity, execution metadata, and any byproduct artifacts. + * + * @see SLSA v1.2 Specification + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RunDetails { + + /** Entity that executed the build. */ + @JsonProperty("builder") + private Builder builder; + + /** Metadata about the build invocation. */ + @JsonProperty("metadata") + private BuildMetadata metadata; + + /** Artifacts produced as a side effect of the build. */ + @JsonProperty("byproducts") + private List byproducts; + + /** Creates a new RunDetails instance. */ + public RunDetails() { + } + + /** + * Creates a new RunDetails with the given builder and metadata. + * + * @param builder entity that executed the build + * @param metadata metadata about the build invocation + */ + public RunDetails(Builder builder, BuildMetadata metadata) { + this.builder = builder; + this.metadata = metadata; + } + + /** + * Returns the builder that executed the invocation. + * + *

Trusted to have correctly performed the operation and populated this provenance.

+ * + * @return the builder, or {@code null} if not set + */ + public Builder getBuilder() { + return builder; + } + + /** + * Sets the builder that executed the invocation. + * + * @param builder the builder + */ + public void setBuilder(Builder builder) { + this.builder = builder; + } + + /** + * Returns the metadata about the build invocation, including its identifier and timing. + * + * @return the build metadata, or {@code null} if not set + */ + public BuildMetadata getMetadata() { + return metadata; + } + + /** + * Sets the metadata about the build invocation. + * + * @param metadata the build metadata + */ + public void setMetadata(BuildMetadata metadata) { + this.metadata = metadata; + } + + /** + * Returns artifacts produced as a side effect of the build that are not the primary output. + * + * @return the list of byproduct artifacts, or {@code null} if not set + */ + public List getByproducts() { + return byproducts; + } + + /** + * Sets the artifacts produced as a side effect of the build that are not the primary output. + * + * @param byproducts the list of byproduct artifacts + */ + public void setByproducts(List byproducts) { + this.byproducts = byproducts; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RunDetails that = (RunDetails) o; + return Objects.equals(builder, that.builder) && Objects.equals(metadata, that.metadata) && Objects.equals(byproducts, that.byproducts); + } + + @Override + public int hashCode() { + return Objects.hash(builder, metadata, byproducts); + } + + @Override + public String toString() { + return "RunDetails{builder=" + builder + ", metadata=" + metadata + ", byproducts=" + byproducts + '}'; + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java new file mode 100644 index 000000000..88aeb8ae8 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java @@ -0,0 +1,122 @@ +/* + * 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.release.plugin.slsa.v1_2; + +import java.util.List; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * In-toto v1 attestation envelope that binds a set of subject artifacts to an SLSA provenance predicate. + * + * @see in-toto Statement v1 + */ +public class Statement { + + /** The in-toto statement schema URI. */ + @JsonProperty("_type") + public static final String TYPE = "https://in-toto.io/Statement/v1"; + + /** Software artifacts that the attestation applies to. */ + @JsonProperty("subject") + private List subject; + + /** URI identifying the type of the predicate. */ + @JsonProperty("predicateType") + private String predicateType; + + /** The provenance predicate. */ + @JsonProperty("predicate") + private Provenance predicate; + + /** Creates a new Statement instance. */ + public Statement() { + } + + /** + * Returns the set of software artifacts that the attestation applies to. + * + *

Each element represents a single artifact. Artifacts are matched purely by digest, regardless of content + * type.

+ * + * @return the list of subject artifacts, or {@code null} if not set + */ + public List getSubject() { + return subject; + } + + /** + * Sets the set of software artifacts that the attestation applies to. + * + * @param subject the list of subject artifacts + */ + public void setSubject(List subject) { + this.subject = subject; + } + + /** + * Returns the URI identifying the type of the predicate. + * + * @return the predicate type URI, or {@code null} if no predicate has been set + */ + public String getPredicateType() { + return predicateType; + } + + /** + * Returns the provenance predicate. + * + *

Unset is treated the same as set-but-empty. May be omitted if {@code predicateType} fully describes the + * predicate.

+ * + * @return the provenance predicate, or {@code null} if not set + */ + public Provenance getPredicate() { + return predicate; + } + + /** + * Sets the provenance predicate and automatically assigns {@code predicateType} to the SLSA provenance v1 URI. + * + * @param predicate the provenance predicate + */ + public void setPredicate(Provenance predicate) { + this.predicate = predicate; + this.predicateType = Provenance.PREDICATE_TYPE; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Statement)) { + return false; + } + Statement statement = (Statement) o; + return Objects.equals(subject, statement.subject) && Objects.equals(predicateType, statement.predicateType) && Objects.equals(predicate, + statement.predicate); + } + + @Override + public int hashCode() { + return Objects.hash(subject, predicateType, predicate); + } + + @Override + public String toString() { + return "Statement{_type='" + TYPE + "', subject=" + subject + ", predicateType='" + predicateType + "', predicate=" + predicate + '}'; + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/package-info.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/package-info.java new file mode 100644 index 000000000..69a5ce287 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/package-info.java @@ -0,0 +1,34 @@ +/* + * 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. + */ + +/** + * SLSA 1.2 Build Attestation Models. + * + *

This package provides Jackson-annotated model classes that implement the Supply-chain Levels for Software Artifacts + * (SLSA) v1.2 specification.

+ * + *

Overview

+ * + *

SLSA is a framework for evaluating and improving the security posture of build systems. SLSA v1.2 defines a standard for recording build provenance: + * information about how software artifacts were produced.

+ * + * @see SLSA v1.2 Specification + * @see In-toto Attestation Framework + * @see Jackson JSON processor + */ +package org.apache.commons.release.plugin.slsa.v1_2; + diff --git a/src/test/java/org/apache/commons/release/plugin/internal/MojoUtils.java b/src/test/java/org/apache/commons/release/plugin/internal/MojoUtils.java new file mode 100644 index 000000000..6a6c3f5bf --- /dev/null +++ b/src/test/java/org/apache/commons/release/plugin/internal/MojoUtils.java @@ -0,0 +1,71 @@ +/* + * 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.release.plugin.internal; + +import java.nio.file.Path; + +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; + +/** + * 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/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java new file mode 100644 index 000000000..5f7cb6a3f --- /dev/null +++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java @@ -0,0 +1,129 @@ +/* + * 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.release.plugin.mojos; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Date; + +import org.apache.commons.release.plugin.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.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/resources/artifacts/artifact-jar.txt b/src/test/resources/artifacts/artifact-jar.txt new file mode 100644 index 000000000..103a21e64 --- /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 000000000..48d1bbc32 --- /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 From 54e34b72f3cde12acadccbb94ac7973dfe65a79e Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Mon, 30 Mar 2026 20:35:09 +0200 Subject: [PATCH 02/14] Add documentation of `buildType` --- .../plugin/internal/BuildToolDescriptors.java | 24 +++- .../plugin/mojos/BuildAttestationMojo.java | 3 +- src/site/markdown/slsa/v0.1.0.md | 131 ++++++++++++++++++ 3 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 src/site/markdown/slsa/v0.1.0.md diff --git a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java b/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java index 15be8d73e..5dac9a192 100644 --- a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java +++ b/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java @@ -48,8 +48,15 @@ public static ResourceDescriptor jvm(Path javaHome) throws IOException { Map 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"}; + String[] propertyNames = { + "java.version", "java.version.date", + "java.vendor", "java.vendor.url", "java.vendor.version", + "java.home", + "java.vm.specification.version", "java.vm.specification.vendor", "java.vm.specification.name", + "java.vm.version", "java.vm.vendor", "java.vm.name", + "java.specification.version", "java.specification.maintenance.version", + "java.specification.vendor", "java.specification.name", + }; Map annotations = new HashMap<>(); for (String prop : propertyNames) { annotations.put(prop.substring("java.".length()), System.getProperty(prop)); @@ -61,12 +68,17 @@ public static ResourceDescriptor jvm(Path javaHome) throws IOException { /** * 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 + *

{@code build.properties} resides in a JAR inside {@code ${maven.home}/lib/}, which is loaded by Maven's Core Classloader. + * Plugin code runs in an isolated Plugin Classloader, which does see that resources. Therefore, we need to pass the classloader from a class from + * Maven Core, such as {@link org.apache.maven.rtinfo.RuntimeInformation}.

+ * + * @param version Maven version string + * @param mavenHome path to the Maven home directory + * @param coreClassLoader a classloader from Maven's Core Classloader realm, used to load core resources * @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 { + public static ResourceDescriptor maven(String version, Path mavenHome, ClassLoader coreClassLoader) throws IOException { ResourceDescriptor descriptor = new ResourceDescriptor(); descriptor.setName("Maven"); descriptor.setUri("pkg:maven/org.apache.maven/apache-maven@" + version); @@ -74,7 +86,7 @@ public static ResourceDescriptor maven(String version, Path mavenHome) throws IO 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")) { + try (InputStream in = coreClassLoader.getResourceAsStream("org/apache/maven/messages/build.properties")) { if (in != null) { buildProps.load(in); } diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index 0260e22e1..6841193db 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -342,7 +342,8 @@ private List getBuildDependencies() throws MojoExecutionExce List dependencies = new ArrayList<>(); try { dependencies.add(BuildToolDescriptors.jvm(Paths.get(System.getProperty("java.home")))); - dependencies.add(BuildToolDescriptors.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath())); + dependencies.add(BuildToolDescriptors.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath(), + runtimeInformation.getClass().getClassLoader())); dependencies.add(getScmDescriptor()); } catch (IOException e) { throw new MojoExecutionException(e); diff --git a/src/site/markdown/slsa/v0.1.0.md b/src/site/markdown/slsa/v0.1.0.md new file mode 100644 index 000000000..b9a569ef2 --- /dev/null +++ b/src/site/markdown/slsa/v0.1.0.md @@ -0,0 +1,131 @@ + + +# Build Type: Apache Commons Maven Release + +```jsonc +"buildType": "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0" +``` + +This is a [SLSA Build Provenance](https://slsa.dev/spec/v1.2/build-provenance) build type +that describes releases produced by Apache Commons PMC release managers running Maven on their own equipment. + +## Build definition + +Artifacts are generated by a single Maven execution, typically of the form: + +```shell +mvn -Prelease deploy +``` + +The provenance is recorded by the `build-attestation` goal of the +`commons-release-plugin`, which runs in the `verify` phase. + +### External parameters + +External parameters capture everything supplied by the release manager at invocation time. +All parameters are captured from the running Maven session. + +| Parameter | Type | Description | +|-------------------------|----------|-------------------------------------------------------------------------| +| `maven.goals` | string[] | The list of Maven goals passed on the command line (e.g. `["deploy"]`). | +| `maven.profiles` | string[] | The list of active profiles passed via `-P` (e.g. `["release"]`). | +| `maven.user.properties` | object | User-defined properties passed via `-D` flags. | +| `maven.cmdline` | string | The reconstructed Maven command line. | +| `jvm.args` | string[] | JVM input arguments. | +| `env` | object | A filtered subset of environment variables: `TZ` and locale variables. | + +### Internal parameters + +No internal parameters are recorded for this build type. + +### Resolved dependencies + +The `resolvedDependencies` list captures all inputs that contributed to the build output. +It always contains the following entries, in order: + +#### JDK + +Represents the Java Development Kit used to run Maven (`"name": "JDK"`). +To allow verification of the JDK's integrity, a `gitTree` digest is computed over the `java.home` directory. + +The following annotations are recorded from [ +`System.getProperties()`](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/System.html#getProperties()): + +| Annotation key | System property | Description | +|-------------------------------------|------------------------------------------|--------------------------------------------------------------------------| +| `version` | `java.version` | Java Runtime Environment version. | +| `version.date` | `java.version.date` | Java Runtime Environment version date, in ISO-8601 YYYY-MM-DD format. | +| `vendor` | `java.vendor` | Java Runtime Environment vendor. | +| `vendor.url` | `java.vendor.url` | Java vendor URL. | +| `vendor.version` | `java.vendor.version` | Java vendor version _(optional)_. | +| `home` | `java.home` | Java installation directory. | +| `vm.specification.version` | `java.vm.specification.version` | Java Virtual Machine specification version. | +| `vm.specification.vendor` | `java.vm.specification.vendor` | Java Virtual Machine specification vendor. | +| `vm.specification.name` | `java.vm.specification.name` | Java Virtual Machine specification name. | +| `vm.version` | `java.vm.version` | Java Virtual Machine implementation version. | +| `vm.vendor` | `java.vm.vendor` | Java Virtual Machine implementation vendor. | +| `vm.name` | `java.vm.name` | Java Virtual Machine implementation name. | +| `specification.version` | `java.specification.version` | Java Runtime Environment specification version. | +| `specification.maintenance.version` | `java.specification.maintenance.version` | Java Runtime Environment specification maintenance version _(optional)_. | +| `specification.vendor` | `java.specification.vendor` | Java Runtime Environment specification vendor. | +| `specification.name` | `java.specification.name` | Java Runtime Environment specification name. | + +#### Maven + +Represents the Maven installation used to run the build (`"name": "Maven"`). +To allow verification of the installation's integrity, a `gitTree` hash is computed over the `maven.home` directory. + +The `uri` key contains the Package URL of the Maven distribution, as published to Maven Central. + +The following annotations are sourced from Maven's `build.properties`, bundled inside the Maven distribution. +They are only present if the resource is accessible from Maven's Core Classloader at runtime. + +| Annotation key | Description | +|-------------------------|--------------------------------------------------------------| +| `distributionId` | The ID of the Maven distribution. | +| `distributionName` | The full name of the Maven distribution. | +| `distributionShortName` | The short name of the Mavendistribution. | +| `buildNumber` | The Git commit hash from which this Maven release was built. | +| `version` | The Maven version string. | + +#### Source repository + +Represents the source code being built. +The URI follows +the [SPDX Download Location](https://spdx.github.io/spdx-spec/v2.3/package-information/#77-package-download-location-field) +format. + +#### Project dependencies + +One entry per resolved Maven dependency (compile + runtime scope), as declared in the project's POM. +These are appended after the build tool entries above. + +| Field | Value | +|-----------------|-----------------------------------------------------| +| `name` | Artifact filename, e.g. `commons-lang3-3.14.0.jar`. | +| `uri` | Package URL. | +| `digest.sha256` | SHA-256 hex digest of the artifact file on disk. | + +## Run details + +### Builder + +The `builder.id` is always `https://commons.apache.org/builds/0.1.0`. +It represents the commons-release-plugin acting as the build platform. + +## Subjects + +The attestation covers all artifacts attached to the Maven project at the time the `verify` phase runs: +the primary artifact (e.g. the JAR) and any attached artifacts (e.g. sources JAR, javadoc JAR, POM). + +| Field | Value | +|-----------------|------------------------------------------| +| `name` | Artifact filename. | +| `uri` | Package URL. | +| `digest.sha256` | SHA-256 hex digest of the artifact file. | + +## Version history + +### v0.1.0 + +Initial version. \ No newline at end of file From f005ea1de32e9142adebff215490b87f601d655c Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 2 Apr 2026 15:07:49 +0200 Subject: [PATCH 03/14] Temporarily change version to publish snapshot --- pom.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e0c6767e7..d07c6218a 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,8 @@ commons-release-plugin maven-plugin - 1.9.3-SNAPSHOT + + 1.9.3.slsa-SNAPSHOT Apache Commons Release Plugin Apache Maven Mojo for Apache Commons Release tasks. From 5167d7397f88b5755a6c2d071ee2439456f408bb Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Fri, 3 Apr 2026 19:10:18 +0200 Subject: [PATCH 04/14] fix: output attestation in JSON Line format --- .../commons/release/plugin/mojos/BuildAttestationMojo.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index 6841193db..030f6587d 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -73,7 +73,7 @@ public class BuildAttestationMojo extends AbstractMojo { /** The file extension for in-toto attestation files. */ - private static final String ATTESTATION_EXTENSION = "intoto.json"; + private static final String ATTESTATION_EXTENSION = "intoto.jsonl"; /** Shared Jackson object mapper for serializing attestation statements. */ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -237,7 +237,8 @@ private void writeStatement(final Statement statement) throws MojoExecutionExcep 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); + OBJECT_MAPPER.writeValue(os, statement); + os.write('\n'); } catch (IOException e) { throw new MojoExecutionException("Could not write attestation statement to: " + artifactPath, e); } From 1ace65c7f76b447bd63f9c39d3244d86c940de82 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Fri, 3 Apr 2026 19:21:22 +0200 Subject: [PATCH 05/14] fix: disable Jackson auto-close option --- .../commons/release/plugin/mojos/BuildAttestationMojo.java | 2 ++ .../release/plugin/mojos/BuildAttestationMojoTest.java | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index 030f6587d..254779981 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -33,6 +33,7 @@ import javax.inject.Inject; +import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import org.apache.commons.release.plugin.internal.ArtifactUtils; @@ -81,6 +82,7 @@ public class BuildAttestationMojo extends AbstractMojo { static { OBJECT_MAPPER.findAndRegisterModules(); OBJECT_MAPPER.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + OBJECT_MAPPER.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); } /** The SCM connection URL for the current project. */ diff --git a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java index 5f7cb6a3f..dae20c43c 100644 --- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java +++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java @@ -103,9 +103,9 @@ void attestationTest() throws Exception { mojo.execute(); Artifact attestation = project.getAttachedArtifacts().stream() - .filter(a -> "intoto.json".equals(a.getType())) + .filter(a -> "intoto.jsonl".equals(a.getType())) .findFirst() - .orElseThrow(() -> new AssertionError("No intoto.json artifact attached to project")); + .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project")); String json = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8); String resolvedDeps = "predicate.buildDefinition.resolvedDependencies"; From 02ad5af3c39fa77be621f52275462a0f2936a080 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Sun, 12 Apr 2026 21:07:53 +0200 Subject: [PATCH 06/14] fix: adapt to Commons Code API change --- .../org/apache/commons/release/plugin/internal/GitUtils.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java index 246027c49..c4d4aecec 100644 --- a/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java +++ b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java @@ -25,6 +25,7 @@ import org.apache.commons.codec.binary.Hex; import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.codec.digest.GitIdentifiers; /** * Utilities for Git operations. @@ -46,7 +47,7 @@ public static String gitTree(Path path) throws IOException { throw new IOException("Path is not a directory: " + path); } MessageDigest digest = DigestUtils.getSha1Digest(); - return Hex.encodeHexString(DigestUtils.gitTree(digest, path)); + return Hex.encodeHexString(GitIdentifiers.treeId(digest, path)); } /** From 16f776f54baa725884d8af5b338dc42d5569bf39 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Wed, 15 Apr 2026 14:18:59 +0200 Subject: [PATCH 07/14] feat: add support for DSSE signing Artifacts are signed using the Maven GPG Plugin and the results are wrapped in the DSSE envelope. --- pom.xml | 5 + .../release/plugin/internal/DsseUtils.java | 190 ++++++++++++++++++ .../plugin/mojos/BuildAttestationMojo.java | 164 +++++++++++++-- .../plugin/slsa/v1_2/DsseEnvelope.java | 133 ++++++++++++ .../release/plugin/slsa/v1_2/Signature.java | 108 ++++++++++ 5 files changed, 579 insertions(+), 21 deletions(-) create mode 100644 src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java create mode 100644 src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java diff --git a/pom.xml b/pom.xml index d07c6218a..26a71467f 100644 --- a/pom.xml +++ b/pom.xml @@ -199,6 +199,11 @@ commons-compress 1.28.0 + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + com.fasterxml.jackson.core jackson-databind diff --git a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java new file mode 100644 index 000000000..e4e362f01 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java @@ -0,0 +1,190 @@ +/* + * 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.release.plugin.internal; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.codec.binary.Hex; +import org.apache.commons.io.IOUtils; +import org.apache.commons.release.plugin.slsa.v1_2.DsseEnvelope; +import org.apache.commons.release.plugin.slsa.v1_2.Statement; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.plugins.gpg.AbstractGpgSigner; +import org.apache.maven.plugins.gpg.GpgSigner; +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.bcpg.sig.IssuerFingerprint; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureList; +import org.bouncycastle.openpgp.PGPSignatureSubpacketVector; +import org.bouncycastle.openpgp.bc.BcPGPObjectFactory; + +/** + * Utility methods for creating DSSE (Dead Simple Signing Envelope) envelopes signed with a PGP key. + */ +public final class DsseUtils { + + /** + * Not instantiable. + */ + private DsseUtils() { + } + + /** + * Creates and prepares a {@link GpgSigner} from the given configuration. + * + *

The returned signer has {@link AbstractGpgSigner#prepare()} already called and is ready + * for use with {@link #signPaeFile(AbstractGpgSigner, Path)}.

+ * + * @param executable path to the GPG executable, or {@code null} to use {@code gpg} from {@code PATH} + * @param defaultKeyring whether to include the default GPG keyring + * @param lockMode GPG lock mode ({@code "once"}, {@code "multiple"}, or {@code "never"}), + * or {@code null} for no explicit lock flag + * @param keyname name or fingerprint of the signing key, or {@code null} for the default key + * @param useAgent whether to use gpg-agent for passphrase management + * @param log Maven logger to attach to the signer + * @return a prepared {@link AbstractGpgSigner} + * @throws MojoFailureException if {@link AbstractGpgSigner#prepare()} fails + */ + public static AbstractGpgSigner createGpgSigner(final String executable, final boolean defaultKeyring, final String lockMode, final String keyname, + final boolean useAgent, final Log log) throws MojoFailureException { + final GpgSigner signer = new GpgSigner(executable); + signer.setDefaultKeyring(defaultKeyring); + signer.setLockMode(lockMode); + signer.setKeyName(keyname); + signer.setUseAgent(useAgent); + signer.setLog(log); + signer.prepare(); + return signer; + } + + /** + * Serializes {@code statement} to JSON, computes the DSSE Pre-Authentication Encoding (PAE), and writes + * the result to {@code buildDirectory/statement.pae}. + * + *

The PAE format is: + * {@code "DSSEv1" SP LEN(payloadType) SP payloadType SP LEN(payload) SP payload}, + * where {@code LEN} is the ASCII decimal byte-length of the operand.

+ * + * @param statement the attestation statement to encode + * @param objectMapper the Jackson mapper used to serialize {@code statement} + * @param buildDirectory directory in which the PAE file is created + * @return path to the written PAE file + * @throws MojoExecutionException if serialization or I/O fails + */ + public static Path writePaeFile(final Statement statement, final ObjectMapper objectMapper, final Path buildDirectory) throws MojoExecutionException { + try { + return writePaeFile(objectMapper.writeValueAsBytes(statement), buildDirectory); + } catch (final JsonProcessingException e) { + throw new MojoExecutionException("Failed to serialize attestation statement", e); + } + } + + /** + * Computes the DSSE Pre-Authentication Encoding (PAE) for {@code statementBytes} and writes it to + * {@code buildDirectory/statement.pae}. + * + *

Use this overload when the statement has already been serialized to bytes, so the same byte array + * can be reused as the {@link DsseEnvelope#setPayload(byte[]) envelope payload} without a second + * serialization pass.

+ * + * @param statementBytes the already-serialized JSON statement bytes to encode + * @param buildDirectory directory in which the PAE file is created + * @return path to the written PAE file + * @throws MojoExecutionException if I/O fails + */ + public static Path writePaeFile(final byte[] statementBytes, final Path buildDirectory) throws MojoExecutionException { + try { + final byte[] payloadTypeBytes = DsseEnvelope.PAYLOAD_TYPE.getBytes(StandardCharsets.UTF_8); + + final ByteArrayOutputStream pae = new ByteArrayOutputStream(); + pae.write(("DSSEv1 " + payloadTypeBytes.length + " ").getBytes(StandardCharsets.UTF_8)); + pae.write(payloadTypeBytes); + pae.write((" " + statementBytes.length + " ").getBytes(StandardCharsets.UTF_8)); + pae.write(statementBytes); + + final Path paeFile = buildDirectory.resolve("statement.pae"); + Files.write(paeFile, pae.toByteArray()); + return paeFile; + } catch (final IOException e) { + throw new MojoExecutionException("Failed to write PAE file", e); + } + } + + /** + * Signs {@code paeFile} using {@link AbstractGpgSigner#generateSignatureForArtifact(File)}, + * then decodes the resulting ASCII-armored {@code .asc} file with BouncyCastle and returns the raw + * binary PGP signature bytes. + * + *

The signer must already have {@link AbstractGpgSigner#prepare()} called before this method is + * invoked. The {@code .asc} file produced by the signer is not deleted; callers may remove it once + * the raw bytes have been consumed.

+ * + * @param signer the configured, prepared signer + * @param paeFile path to the PAE-encoded file to sign + * @return raw binary PGP signature bytes (suitable for storing in {@link org.apache.commons.release.plugin.slsa.v1_2.Signature#setSig}) + * @throws MojoExecutionException if signing or signature decoding fails + */ + public static byte[] signPaeFile(final AbstractGpgSigner signer, final Path paeFile) throws MojoExecutionException { + final File signatureFile = signer.generateSignatureForArtifact(paeFile.toFile()); + try (InputStream in = Files.newInputStream(signatureFile.toPath()); ArmoredInputStream armoredIn = new ArmoredInputStream(in)) { + return IOUtils.toByteArray(armoredIn); + } catch (final IOException e) { + throw new MojoExecutionException("Failed to read signature file: " + signatureFile, e); + } + } + + /** + * Extracts the key identifier from a binary OpenPGP Signature Packet. + * + *

Inspects the hashed subpackets for an {@code IssuerFingerprint} subpacket (type 33), + * which carries the full public-key fingerprint and is present in all signatures produced by + * GPG 2.1+. Falls back to the 8-byte {@code IssuerKeyID} from the unhashed subpackets + * when no fingerprint subpacket is found.

+ * + * @param sigBytes raw binary OpenPGP Signature Packet bytes, as returned by + * {@link #signPaeFile(AbstractGpgSigner, Path)} + * @return uppercase hex-encoded fingerprint or key ID string + * @throws MojoExecutionException if {@code sigBytes} cannot be parsed as an OpenPGP signature + */ + public static String getKeyId(final byte[] sigBytes) throws MojoExecutionException { + try { + final PGPSignatureList sigList = (PGPSignatureList) new BcPGPObjectFactory(sigBytes).nextObject(); + final PGPSignature sig = sigList.get(0); + final PGPSignatureSubpacketVector hashed = sig.getHashedSubPackets(); + if (hashed != null) { + final IssuerFingerprint fp = hashed.getIssuerFingerprint(); + if (fp != null) { + return Hex.encodeHexString(fp.getFingerprint()); + } + } + return Long.toHexString(sig.getKeyID()).toUpperCase(Locale.ROOT); + } catch (final IOException e) { + throw new MojoExecutionException("Failed to extract key ID from signature", e); + } + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index 254779981..7bcabda8d 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -26,6 +26,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,17 +35,21 @@ import javax.inject.Inject; import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import org.apache.commons.release.plugin.internal.ArtifactUtils; import org.apache.commons.release.plugin.internal.BuildToolDescriptors; +import org.apache.commons.release.plugin.internal.DsseUtils; import org.apache.commons.release.plugin.internal.GitUtils; import org.apache.commons.release.plugin.slsa.v1_2.BuildDefinition; import org.apache.commons.release.plugin.slsa.v1_2.BuildMetadata; import org.apache.commons.release.plugin.slsa.v1_2.Builder; +import org.apache.commons.release.plugin.slsa.v1_2.DsseEnvelope; import org.apache.commons.release.plugin.slsa.v1_2.Provenance; import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor; import org.apache.commons.release.plugin.slsa.v1_2.RunDetails; +import org.apache.commons.release.plugin.slsa.v1_2.Signature; import org.apache.commons.release.plugin.slsa.v1_2.Statement; import org.apache.maven.artifact.Artifact; import org.apache.maven.execution.MavenExecutionRequest; @@ -56,6 +61,7 @@ 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.plugins.gpg.AbstractGpgSigner; import org.apache.maven.project.MavenProject; import org.apache.maven.project.MavenProjectHelper; import org.apache.maven.rtinfo.RuntimeInformation; @@ -73,10 +79,14 @@ @Mojo(name = "build-attestation", defaultPhase = LifecyclePhase.VERIFY, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME) public class BuildAttestationMojo extends AbstractMojo { - /** The file extension for in-toto attestation files. */ + /** + * The file extension for in-toto attestation files. + */ private static final String ATTESTATION_EXTENSION = "intoto.jsonl"; - /** Shared Jackson object mapper for serializing attestation statements. */ + /** + * Shared Jackson object mapper for serializing attestation statements. + */ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); static { @@ -85,11 +95,15 @@ public class BuildAttestationMojo extends AbstractMojo { OBJECT_MAPPER.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); } - /** The SCM connection URL for the current project. */ + /** + * The SCM connection URL for the current project. + */ @Parameter(defaultValue = "${project.scm.connection}", readonly = true) private String scmConnectionUrl; - /** The Maven home directory. */ + /** + * The Maven home directory. + */ @Parameter(defaultValue = "${maven.home}", readonly = true) private File mavenHome; @@ -99,14 +113,62 @@ public class BuildAttestationMojo extends AbstractMojo { @Parameter(property = "commons.release.scmDirectory", defaultValue = "${basedir}") private File scmDirectory; - /** The output directory for the attestation file. */ + /** + * The output directory for the attestation file. + */ @Parameter(property = "commons.release.outputDirectory", defaultValue = "${project.build.directory}") private File outputDirectory; - /** Whether to skip attaching the attestation artifact to the project. */ + /** + * Whether to skip attaching the attestation artifact to the project. + */ @Parameter(property = "commons.release.skipAttach") private boolean skipAttach; + /** + * Whether to sign the attestation envelope with GPG. + */ + @Parameter(property = "commons.release.signAttestation", defaultValue = "true") + private boolean signAttestation; + + /** + * Path to the GPG executable; if not set, {@code gpg} is resolved from {@code PATH}. + */ + @Parameter(property = "gpg.executable") + private String executable; + + /** + * Whether to include the default GPG keyring. + * + *

When {@code false}, passes {@code --no-default-keyring} to the GPG command.

+ */ + @Parameter(property = "gpg.defaultKeyring", defaultValue = "true") + private boolean defaultKeyring; + + /** + * GPG database lock mode passed via {@code --lock-once}, {@code --lock-multiple}, or + * {@code --lock-never}; no lock flag is added when not set. + */ + @Parameter(property = "gpg.lockMode") + private String lockMode; + + /** + * Name or fingerprint of the GPG key to use for signing. + * + *

Passed as {@code --local-user} to the GPG command; uses the default key when not set.

+ */ + @Parameter(property = "gpg.keyname") + private String keyname; + + /** + * Whether to use gpg-agent for passphrase management. + * + *

For GPG versions before 2.1, passes {@code --use-agent} or {@code --no-use-agent} + * accordingly; ignored for GPG 2.1 and later where the agent is always used.

+ */ + @Parameter(property = "gpg.useagent", defaultValue = "true") + private boolean useAgent; + /** * The current Maven project. */ @@ -135,10 +197,10 @@ public class BuildAttestationMojo extends AbstractMojo { /** * Creates a new instance with the given dependencies. * - * @param project A Maven project. - * @param scmManager A SCM manager. + * @param project A Maven project. + * @param scmManager A SCM manager. * @param runtimeInformation Maven runtime information. - * @param session A Maven session. + * @param session A Maven session. * @param mavenProjectHelper A helper to attach artifacts to the project. */ @Inject @@ -217,16 +279,22 @@ public void execute() throws MojoFailureException, MojoExecutionException { statement.setSubject(getSubjects()); statement.setPredicate(provenance); - writeStatement(statement); + final Path outputPath = ensureOutputDirectory(); + final Path artifactPath = outputPath.resolve(ArtifactUtils.getFileName(project.getArtifact(), ATTESTATION_EXTENSION)); + if (signAttestation) { + signAndWriteStatement(statement, outputPath, artifactPath); + } else { + writeStatement(statement, artifactPath); + } } /** - * Serializes the attestation statement to a file and optionally attaches it to the project. + * Creates the output directory if it does not already exist and returns its path. * - * @param statement The attestation statement to write. - * @throws MojoExecutionException If the output directory cannot be created or the file cannot be written. + * @return the output directory path + * @throws MojoExecutionException if the directory cannot be created */ - private void writeStatement(final Statement statement) throws MojoExecutionException { + private Path ensureOutputDirectory() throws MojoExecutionException { final Path outputPath = outputDirectory.toPath(); try { if (!Files.exists(outputPath)) { @@ -235,18 +303,72 @@ private void writeStatement(final Statement statement) throws MojoExecutionExcep } 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)); + return outputPath; + } + + /** + * Serializes the attestation statement as a bare JSON line and writes it to {@code artifactPath}. + * + * @param statement the attestation statement to write + * @param artifactPath the destination file path + * @throws MojoExecutionException if the file cannot be written + */ + private void writeStatement(final Statement statement, final Path artifactPath) throws MojoExecutionException { getLog().info("Writing attestation statement to: " + artifactPath); + writeAndAttach(statement, artifactPath); + } + + /** + * Signs the attestation statement with GPG, wraps it in a DSSE envelope, and writes it to + * {@code artifactPath}. + * + * @param statement the attestation statement to sign and write + * @param outputPath directory used for intermediate PAE and signature files + * @param artifactPath the destination file path for the envelope + * @throws MojoExecutionException if serialization, signing, or file I/O fails + * @throws MojoFailureException if the GPG signer cannot be prepared + */ + private void signAndWriteStatement(final Statement statement, final Path outputPath, + final Path artifactPath) throws MojoExecutionException, MojoFailureException { + final byte[] statementBytes; + try { + statementBytes = OBJECT_MAPPER.writeValueAsBytes(statement); + } catch (JsonProcessingException e) { + throw new MojoExecutionException("Failed to serialize attestation statement", e); + } + final AbstractGpgSigner signer = DsseUtils.createGpgSigner(executable, defaultKeyring, lockMode, keyname, useAgent, getLog()); + final Path paeFile = DsseUtils.writePaeFile(statementBytes, outputPath); + final byte[] sigBytes = DsseUtils.signPaeFile(signer, paeFile); + + final Signature sig = new Signature(); + sig.setKeyid(DsseUtils.getKeyId(sigBytes)); + sig.setSig(sigBytes); + + final DsseEnvelope envelope = new DsseEnvelope(); + envelope.setPayload(statementBytes); + envelope.setSignatures(Collections.singletonList(sig)); + + getLog().info("Writing signed attestation envelope to: " + artifactPath); + writeAndAttach(envelope, artifactPath); + } + + /** + * Writes {@code value} as a JSON line to {@code artifactPath} and optionally attaches it to the project. + * + * @param value the object to serialize + * @param artifactPath the destination file path + * @throws MojoExecutionException if the file cannot be written + */ + private void writeAndAttach(final Object value, final Path artifactPath) throws MojoExecutionException { try (OutputStream os = Files.newOutputStream(artifactPath)) { - OBJECT_MAPPER.writeValue(os, statement); + OBJECT_MAPPER.writeValue(os, value); os.write('\n'); } catch (IOException e) { - throw new MojoExecutionException("Could not write attestation statement to: " + artifactPath, e); + throw new MojoExecutionException("Could not write attestation to: " + artifactPath, e); } if (!skipAttach) { - getLog().info(String.format("Attaching attestation statement as %s-%s.%s", mainArtifact.getArtifactId(), mainArtifact.getVersion(), - ATTESTATION_EXTENSION)); + final Artifact mainArtifact = project.getArtifact(); + getLog().info(String.format("Attaching attestation as %s-%s.%s", mainArtifact.getArtifactId(), mainArtifact.getVersion(), ATTESTATION_EXTENSION)); mavenProjectHelper.attachArtifact(project, ATTESTATION_EXTENSION, null, artifactPath.toFile()); } } @@ -373,7 +495,7 @@ private List getProjectDependencies() throws MojoExecutionEx * Returns a resource descriptor for the current SCM source, including the URI and Git commit digest. * * @return A resource descriptor for the SCM source. - * @throws IOException If the current branch cannot be determined. + * @throws IOException If the current branch cannot be determined. * @throws MojoExecutionException If the SCM revision cannot be retrieved. */ private ResourceDescriptor getScmDescriptor() throws IOException, MojoExecutionException { diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java new file mode 100644 index 000000000..604286a28 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java @@ -0,0 +1,133 @@ +/* + * 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.release.plugin.slsa.v1_2; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * DSSE (Dead Simple Signing Envelope) that wraps a signed in-toto statement payload. + * + *

The {@code payload} field holds the serialized {@link Statement} bytes; Jackson serializes them as Base64. The + * {@code payloadType} identifies the content type of the payload. The {@code signatures} list contains one or more + * cryptographic signatures over the PAE-encoded payload.

+ * + *

All three fields are REQUIRED and MUST be set, even if empty.

+ * + * @see DSSE Envelope specification + */ +public class DsseEnvelope { + + /** The payload type URI for in-toto attestation statements. */ + public static final String PAYLOAD_TYPE = "application/vnd.in-toto+json"; + + /** Content type identifying the format of {@link #payload}. */ + @JsonProperty("payloadType") + private String payloadType = PAYLOAD_TYPE; + + /** Serialized statement bytes, Base64-encoded in JSON. */ + @JsonProperty("payload") + private byte[] payload; + + /** One or more signatures over the PAE-encoded payload. */ + @JsonProperty("signatures") + private List signatures; + + /** Creates a new DsseEnvelope instance with {@code payloadType} pre-set to {@link #PAYLOAD_TYPE}. */ + public DsseEnvelope() { + } + + /** + * Returns the payload type URI. + * + * @return the payload type, never {@code null} in a valid envelope + */ + public String getPayloadType() { + return payloadType; + } + + /** + * Sets the payload type URI. + * + * @param payloadType the payload type URI + */ + public void setPayloadType(String payloadType) { + this.payloadType = payloadType; + } + + /** + * Returns the serialized payload bytes. + * + *

When serialized to JSON the bytes are Base64-encoded.

+ * + * @return the payload bytes, or {@code null} if not set + */ + public byte[] getPayload() { + return payload; + } + + /** + * Sets the serialized payload bytes. + * + * @param payload the payload bytes + */ + public void setPayload(byte[] payload) { + this.payload = payload; + } + + /** + * Returns the list of signatures over the PAE-encoded payload. + * + * @return the signatures, or {@code null} if not set + */ + public List getSignatures() { + return signatures; + } + + /** + * Sets the list of signatures over the PAE-encoded payload. + * + * @param signatures the signatures + */ + public void setSignatures(List signatures) { + this.signatures = signatures; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof DsseEnvelope)) { + return false; + } + DsseEnvelope envelope = (DsseEnvelope) o; + return Objects.equals(payloadType, envelope.payloadType) && Arrays.equals(payload, envelope.payload) + && Objects.equals(signatures, envelope.signatures); + } + + @Override + public int hashCode() { + return Objects.hash(payloadType, Arrays.hashCode(payload), signatures); + } + + @Override + public String toString() { + return "DsseEnvelope{payloadType='" + payloadType + "', payload=<" + (payload != null ? payload.length : 0) + + " bytes>, signatures=" + signatures + '}'; + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java new file mode 100644 index 000000000..1a3d381bf --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java @@ -0,0 +1,108 @@ +/* + * 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.release.plugin.slsa.v1_2; + +import java.util.Arrays; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A single cryptographic signature within a DSSE envelope. + * + *

The {@code sig} field holds the raw signature bytes; Jackson serializes them as Base64. The optional + * {@code keyid} field identifies which key produced the signature.

+ * + * @see DSSE Envelope specification + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Signature { + + /** + * Hint for which key was used to sign; unset is treated as empty. + * + *

Consumers MUST NOT require this field to be set, and MUST NOT use it for security decisions.

+ */ + @JsonProperty("keyid") + private String keyid; + + /** Raw signature bytes over the PAE-encoded payload, Base64-encoded in JSON. */ + @JsonProperty("sig") + private byte[] sig; + + /** Creates a new Signature instance. */ + public Signature() { + } + + /** + * Returns the key identifier hint, or {@code null} if not set. + * + * @return the key identifier, or {@code null} + */ + public String getKeyid() { + return keyid; + } + + /** + * Sets the key identifier hint. + * + * @param keyid the key identifier, or {@code null} to leave unset + */ + public void setKeyid(String keyid) { + this.keyid = keyid; + } + + /** + * Returns the raw signature bytes. + * + *

When serialized to JSON the bytes are Base64-encoded.

+ * + * @return the signature bytes, or {@code null} if not set + */ + public byte[] getSig() { + return sig; + } + + /** + * Sets the raw signature bytes. + * + * @param sig the signature bytes + */ + public void setSig(byte[] sig) { + this.sig = sig; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Signature)) { + return false; + } + Signature signature = (Signature) o; + return Objects.equals(keyid, signature.keyid) && Arrays.equals(sig, signature.sig); + } + + @Override + public int hashCode() { + return Objects.hash(keyid, Arrays.hashCode(sig)); + } + + @Override + public String toString() { + return "Signature{keyid='" + keyid + "', sig=<" + (sig != null ? sig.length : 0) + " bytes>}"; + } +} From c1644fa32581b429d364ebe36eba7e93c25122f7 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Apr 2026 18:58:23 +0200 Subject: [PATCH 08/14] fix: Javadoc of the model --- .../commons/release/plugin/internal/DsseUtils.java | 6 ++---- .../release/plugin/slsa/v1_2/BuildDefinition.java | 8 ++++---- .../release/plugin/slsa/v1_2/BuildMetadata.java | 9 +++------ .../commons/release/plugin/slsa/v1_2/Builder.java | 6 +++--- .../release/plugin/slsa/v1_2/DsseEnvelope.java | 12 +++--------- .../release/plugin/slsa/v1_2/Provenance.java | 4 ++-- .../plugin/slsa/v1_2/ResourceDescriptor.java | 14 +++++++------- .../release/plugin/slsa/v1_2/RunDetails.java | 6 +++--- .../release/plugin/slsa/v1_2/Signature.java | 11 +++-------- .../release/plugin/slsa/v1_2/Statement.java | 9 ++++----- 10 files changed, 34 insertions(+), 51 deletions(-) diff --git a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java index e4e362f01..99283d80d 100644 --- a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java +++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java @@ -57,13 +57,11 @@ private DsseUtils() { /** * Creates and prepares a {@link GpgSigner} from the given configuration. * - *

The returned signer has {@link AbstractGpgSigner#prepare()} already called and is ready - * for use with {@link #signPaeFile(AbstractGpgSigner, Path)}.

+ *

The returned signer has {@link AbstractGpgSigner#prepare()} already called and is ready for use with {@link #signPaeFile(AbstractGpgSigner, Path)}.

* * @param executable path to the GPG executable, or {@code null} to use {@code gpg} from {@code PATH} * @param defaultKeyring whether to include the default GPG keyring - * @param lockMode GPG lock mode ({@code "once"}, {@code "multiple"}, or {@code "never"}), - * or {@code null} for no explicit lock flag + * @param lockMode GPG lock mode ({@code "once"}, {@code "multiple"}, or {@code "never"}), or {@code null} for no explicit lock flag * @param keyname name or fingerprint of the signing key, or {@code null} for the default key * @param useAgent whether to use gpg-agent for passphrase management * @param log Maven logger to attach to the signer diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java index 843bc0e17..661d0ccd9 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java @@ -65,7 +65,7 @@ public BuildDefinition(String buildType, Map externalParameters) } /** - * Returns the URI indicating what type of build was performed. + * Gets the URI indicating what type of build was performed. * *

Determines the meaning of {@code externalParameters} and {@code internalParameters}.

* @@ -85,7 +85,7 @@ public void setBuildType(String buildType) { } /** - * Returns the inputs passed to the build, such as command-line arguments or environment variables. + * Gets the inputs passed to the build, such as command-line arguments or environment variables. * * @return the external parameters map, or {@code null} if not set */ @@ -103,7 +103,7 @@ public void setExternalParameters(Map externalParameters) { } /** - * Returns the artifacts the build depends on, such as sources, dependencies, build tools, and base images, + * Gets the artifacts the build depends on, such as sources, dependencies, build tools, and base images, * specified by URI and digest. * * @return the internal parameters map, or {@code null} if not set @@ -122,7 +122,7 @@ public void setInternalParameters(Map internalParameters) { } /** - * Returns the materials that influenced the build. + * Gets the materials that influenced the build. * *

Considered incomplete unless resolved materials are present.

* diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java index 345eb91ee..35d04e412 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java @@ -63,10 +63,7 @@ public BuildMetadata(String invocationId, OffsetDateTime startedOn, OffsetDateTi } /** - * Returns the identifier for this build invocation. - * - *

Useful for finding associated logs or other ad-hoc analysis. The exact meaning and format is defined by the - * builder and is treated as opaque and case-sensitive. The value SHOULD be globally unique.

+ * Gets the identifier for this build invocation. * * @return the invocation identifier, or {@code null} if not set */ @@ -84,7 +81,7 @@ public void setInvocationId(String invocationId) { } /** - * Returns the timestamp of when the build started, serialized as RFC 3339 in UTC ({@code "Z"} suffix). + * Gets the timestamp of when the build started, serialized as RFC 3339 in UTC ({@code "Z"} suffix). * * @return the start timestamp, or {@code null} if not set */ @@ -102,7 +99,7 @@ public void setStartedOn(OffsetDateTime startedOn) { } /** - * Returns the timestamp of when the build completed, serialized as RFC 3339 in UTC ({@code "Z"} suffix). + * Gets the timestamp of when the build completed, serialized as RFC 3339 in UTC ({@code "Z"} suffix). * * @return the completion timestamp, or {@code null} if not set */ diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java index 36e0f1a89..635e75cfb 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.java @@ -48,7 +48,7 @@ public Builder() { } /** - * Returns the identifier of the builder. + * Gets the identifier of the builder. * * @return the builder identifier URI */ @@ -66,7 +66,7 @@ public void setId(String id) { } /** - * Returns orchestrator dependencies that do not run within the build workload and do not affect the build output, + * Gets orchestrator dependencies that do not run within the build workload and do not affect the build output, * but may affect provenance generation or security guarantees. * * @return the list of builder dependencies, or {@code null} if not set @@ -85,7 +85,7 @@ public void setBuilderDependencies(List builderDependencies) } /** - * Returns a map of build platform component names to their versions. + * Gets a map of build platform component names to their versions. * * @return the version map, or {@code null} if not set */ diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java index 604286a28..fdb2353f3 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java @@ -25,12 +25,6 @@ /** * DSSE (Dead Simple Signing Envelope) that wraps a signed in-toto statement payload. * - *

The {@code payload} field holds the serialized {@link Statement} bytes; Jackson serializes them as Base64. The - * {@code payloadType} identifies the content type of the payload. The {@code signatures} list contains one or more - * cryptographic signatures over the PAE-encoded payload.

- * - *

All three fields are REQUIRED and MUST be set, even if empty.

- * * @see DSSE Envelope specification */ public class DsseEnvelope { @@ -55,7 +49,7 @@ public DsseEnvelope() { } /** - * Returns the payload type URI. + * Gets the payload type URI. * * @return the payload type, never {@code null} in a valid envelope */ @@ -73,7 +67,7 @@ public void setPayloadType(String payloadType) { } /** - * Returns the serialized payload bytes. + * Gets the serialized payload bytes. * *

When serialized to JSON the bytes are Base64-encoded.

* @@ -93,7 +87,7 @@ public void setPayload(byte[] payload) { } /** - * Returns the list of signatures over the PAE-encoded payload. + * Gets the list of signatures over the PAE-encoded payload. * * @return the signatures, or {@code null} if not set */ diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java index 884246006..c9dfd2e28 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java @@ -59,7 +59,7 @@ public Provenance(BuildDefinition buildDefinition, RunDetails runDetails) { } /** - * Returns the build definition describing all inputs that produced the build output. + * Gets the build definition describing all inputs that produced the build output. * *

Includes source code, dependencies, build tools, base images, and other materials.

* @@ -79,7 +79,7 @@ public void setBuildDefinition(BuildDefinition buildDefinition) { } /** - * Returns the details about the invocation of the build tool and the environment in which it was run. + * Gets the details about the invocation of the build tool and the environment in which it was run. * * @return the run details, or {@code null} if not set */ diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java index 55333f220..2ce42ce25 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java @@ -76,7 +76,7 @@ public ResourceDescriptor(String uri, Map digest) { } /** - * Returns the name of the resource. + * Gets the name of the resource. * * @return the resource name, or {@code null} if not set */ @@ -94,7 +94,7 @@ public void setName(String name) { } /** - * Returns the URI identifying the resource. + * Gets the URI identifying the resource. * * @return the resource URI, or {@code null} if not set */ @@ -112,7 +112,7 @@ public void setUri(String uri) { } /** - * Returns the map of cryptographic digest algorithms to their corresponding hex-encoded values for this resource. + * Gets the map of cryptographic digest algorithms to their corresponding hex-encoded values for this resource. * *

Common keys include {@code "sha256"} and {@code "sha512"}.

* @@ -132,7 +132,7 @@ public void setDigest(Map digest) { } /** - * Returns the raw contents of the resource, base64-encoded when serialized to JSON. + * Gets the raw contents of the resource, base64-encoded when serialized to JSON. * * @return the resource content, or {@code null} if not set */ @@ -150,7 +150,7 @@ public void setContent(byte[] content) { } /** - * Returns the download URI for the resource, if different from {@link #getUri()}. + * Gets the download URI for the resource, if different from {@link #getUri()}. * * @return the download location URI, or {@code null} if not set */ @@ -168,7 +168,7 @@ public void setDownloadLocation(String downloadLocation) { } /** - * Returns the media type of the resource (e.g., {@code "application/octet-stream"}). + * Gets the media type of the resource (e.g., {@code "application/octet-stream"}). * * @return the media type, or {@code null} if not set */ @@ -186,7 +186,7 @@ public void setMediaType(String mediaType) { } /** - * Returns additional key-value metadata about the resource, such as filename, size, or builder-specific attributes. + * Gets additional key-value metadata about the resource, such as filename, size, or builder-specific attributes. * * @return the annotations map, or {@code null} if not set */ diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java index ffb118677..7b20b5a1d 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java @@ -58,7 +58,7 @@ public RunDetails(Builder builder, BuildMetadata metadata) { } /** - * Returns the builder that executed the invocation. + * Gets the builder that executed the invocation. * *

Trusted to have correctly performed the operation and populated this provenance.

* @@ -78,7 +78,7 @@ public void setBuilder(Builder builder) { } /** - * Returns the metadata about the build invocation, including its identifier and timing. + * Gets the metadata about the build invocation, including its identifier and timing. * * @return the build metadata, or {@code null} if not set */ @@ -96,7 +96,7 @@ public void setMetadata(BuildMetadata metadata) { } /** - * Returns artifacts produced as a side effect of the build that are not the primary output. + * Gets artifacts produced as a side effect of the build that are not the primary output. * * @return the list of byproduct artifacts, or {@code null} if not set */ diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java index 1a3d381bf..c2caf8000 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java @@ -25,9 +25,6 @@ /** * A single cryptographic signature within a DSSE envelope. * - *

The {@code sig} field holds the raw signature bytes; Jackson serializes them as Base64. The optional - * {@code keyid} field identifies which key produced the signature.

- * * @see DSSE Envelope specification */ @JsonInclude(JsonInclude.Include.NON_NULL) @@ -41,7 +38,7 @@ public class Signature { @JsonProperty("keyid") private String keyid; - /** Raw signature bytes over the PAE-encoded payload, Base64-encoded in JSON. */ + /** Raw signature bytes of the PAE-encoded payload. */ @JsonProperty("sig") private byte[] sig; @@ -50,7 +47,7 @@ public Signature() { } /** - * Returns the key identifier hint, or {@code null} if not set. + * Gets the key identifier hint, or {@code null} if not set. * * @return the key identifier, or {@code null} */ @@ -68,9 +65,7 @@ public void setKeyid(String keyid) { } /** - * Returns the raw signature bytes. - * - *

When serialized to JSON the bytes are Base64-encoded.

+ * Gets the raw signature bytes. * * @return the signature bytes, or {@code null} if not set */ diff --git a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java index 88aeb8ae8..4f3506e0b 100644 --- a/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java @@ -49,10 +49,9 @@ public Statement() { } /** - * Returns the set of software artifacts that the attestation applies to. + * Gets the set of software artifacts that the attestation applies to. * - *

Each element represents a single artifact. Artifacts are matched purely by digest, regardless of content - * type.

+ *

Each element represents a single artifact. Artifacts are matched purely by digest, regardless of content type.

* * @return the list of subject artifacts, or {@code null} if not set */ @@ -70,7 +69,7 @@ public void setSubject(List subject) { } /** - * Returns the URI identifying the type of the predicate. + * Gets the URI identifying the type of the predicate. * * @return the predicate type URI, or {@code null} if no predicate has been set */ @@ -79,7 +78,7 @@ public String getPredicateType() { } /** - * Returns the provenance predicate. + * Gets the provenance predicate. * *

Unset is treated the same as set-but-empty. May be omitted if {@code predicateType} fully describes the * predicate.

From 722edc03d3c552966c162a15007ffb33bfc45315 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Apr 2026 20:32:33 +0200 Subject: [PATCH 09/14] fix: Javadoc of `DsseUtils` --- .../release/plugin/internal/DsseUtils.java | 54 ++++++++----------- .../plugin/mojos/BuildAttestationMojo.java | 2 +- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java index 99283d80d..23cac277c 100644 --- a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java +++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java @@ -17,7 +17,6 @@ package org.apache.commons.release.plugin.internal; import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -57,7 +56,7 @@ private DsseUtils() { /** * Creates and prepares a {@link GpgSigner} from the given configuration. * - *

The returned signer has {@link AbstractGpgSigner#prepare()} already called and is ready for use with {@link #signPaeFile(AbstractGpgSigner, Path)}.

+ *

The returned signer has {@link AbstractGpgSigner#prepare()} already called and is ready for use with {@link #signFile(AbstractGpgSigner, Path)}.

* * @param executable path to the GPG executable, or {@code null} to use {@code gpg} from {@code PATH} * @param defaultKeyring whether to include the default GPG keyring @@ -81,12 +80,9 @@ public static AbstractGpgSigner createGpgSigner(final String executable, final b } /** - * Serializes {@code statement} to JSON, computes the DSSE Pre-Authentication Encoding (PAE), and writes - * the result to {@code buildDirectory/statement.pae}. + * Serializes {@code statement} to JSON using the DSSE Pre-Authentication Encoding (PAE) * - *

The PAE format is: - * {@code "DSSEv1" SP LEN(payloadType) SP payloadType SP LEN(payload) SP payload}, - * where {@code LEN} is the ASCII decimal byte-length of the operand.

+ *
PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
* * @param statement the attestation statement to encode * @param objectMapper the Jackson mapper used to serialize {@code statement} @@ -103,12 +99,9 @@ public static Path writePaeFile(final Statement statement, final ObjectMapper ob } /** - * Computes the DSSE Pre-Authentication Encoding (PAE) for {@code statementBytes} and writes it to - * {@code buildDirectory/statement.pae}. + * Writes serialized JSON to a file using the DSSE Pre-Authentication Encoding (PAE) * - *

Use this overload when the statement has already been serialized to bytes, so the same byte array - * can be reused as the {@link DsseEnvelope#setPayload(byte[]) envelope payload} without a second - * serialization pass.

+ *
PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
* * @param statementBytes the already-serialized JSON statement bytes to encode * @param buildDirectory directory in which the PAE file is created @@ -134,38 +127,35 @@ public static Path writePaeFile(final byte[] statementBytes, final Path buildDir } /** - * Signs {@code paeFile} using {@link AbstractGpgSigner#generateSignatureForArtifact(File)}, - * then decodes the resulting ASCII-armored {@code .asc} file with BouncyCastle and returns the raw - * binary PGP signature bytes. + * Signs {@code paeFile} and returns the raw OpenPGP signature bytes. * - *

The signer must already have {@link AbstractGpgSigner#prepare()} called before this method is - * invoked. The {@code .asc} file produced by the signer is not deleted; callers may remove it once - * the raw bytes have been consumed.

+ *

The signer must already have {@link AbstractGpgSigner#prepare()} called before this method is invoked.

* * @param signer the configured, prepared signer - * @param paeFile path to the PAE-encoded file to sign - * @return raw binary PGP signature bytes (suitable for storing in {@link org.apache.commons.release.plugin.slsa.v1_2.Signature#setSig}) + * @param path path to the file to sign + * @return raw binary PGP signature bytes * @throws MojoExecutionException if signing or signature decoding fails */ - public static byte[] signPaeFile(final AbstractGpgSigner signer, final Path paeFile) throws MojoExecutionException { - final File signatureFile = signer.generateSignatureForArtifact(paeFile.toFile()); - try (InputStream in = Files.newInputStream(signatureFile.toPath()); ArmoredInputStream armoredIn = new ArmoredInputStream(in)) { - return IOUtils.toByteArray(armoredIn); + public static byte[] signFile(final AbstractGpgSigner signer, final Path path) throws MojoExecutionException { + final Path signaturePath = signer.generateSignatureForArtifact(path.toFile()).toPath(); + final byte[] signatureBytes; + try (InputStream in = Files.newInputStream(signaturePath); ArmoredInputStream armoredIn = new ArmoredInputStream(in)) { + signatureBytes = IOUtils.toByteArray(armoredIn); } catch (final IOException e) { - throw new MojoExecutionException("Failed to read signature file: " + signatureFile, e); + throw new MojoExecutionException("Failed to read signature file: " + signaturePath, e); } + try { + Files.delete(signaturePath); + } catch (final IOException e) { + throw new MojoExecutionException("Failed to delete signature file: " + signaturePath, e); + } + return signatureBytes; } /** * Extracts the key identifier from a binary OpenPGP Signature Packet. * - *

Inspects the hashed subpackets for an {@code IssuerFingerprint} subpacket (type 33), - * which carries the full public-key fingerprint and is present in all signatures produced by - * GPG 2.1+. Falls back to the 8-byte {@code IssuerKeyID} from the unhashed subpackets - * when no fingerprint subpacket is found.

- * - * @param sigBytes raw binary OpenPGP Signature Packet bytes, as returned by - * {@link #signPaeFile(AbstractGpgSigner, Path)} + * @param sigBytes raw binary OpenPGP Signature Packet bytes * @return uppercase hex-encoded fingerprint or key ID string * @throws MojoExecutionException if {@code sigBytes} cannot be parsed as an OpenPGP signature */ diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index 7bcabda8d..a72853075 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -338,7 +338,7 @@ private void signAndWriteStatement(final Statement statement, final Path outputP } final AbstractGpgSigner signer = DsseUtils.createGpgSigner(executable, defaultKeyring, lockMode, keyname, useAgent, getLog()); final Path paeFile = DsseUtils.writePaeFile(statementBytes, outputPath); - final byte[] sigBytes = DsseUtils.signPaeFile(signer, paeFile); + final byte[] sigBytes = DsseUtils.signFile(signer, paeFile); final Signature sig = new Signature(); sig.setKeyid(DsseUtils.getKeyId(sigBytes)); From ec90db88aa6035d05df3890a72b5f2d5990cd73a Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Apr 2026 20:36:03 +0200 Subject: [PATCH 10/14] fix: Javadoc of `BuildAttestationMojo` --- .../commons/release/plugin/mojos/BuildAttestationMojo.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index a72853075..06326f0c1 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -319,8 +319,7 @@ private void writeStatement(final Statement statement, final Path artifactPath) } /** - * Signs the attestation statement with GPG, wraps it in a DSSE envelope, and writes it to - * {@code artifactPath}. + * Signs the attestation statement with GPG and writes it to {@code artifactPath}. * * @param statement the attestation statement to sign and write * @param outputPath directory used for intermediate PAE and signature files @@ -533,8 +532,8 @@ 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); + InfoScmResult result = scmManager.getProviderByRepository(scmRepository).info(scmRepository.getProviderRepository(), new ScmFileSet(scmDirectory) + , commandParameters); return getScmRevision(result); } catch (ScmException e) { From 2ad0751624a3e23fc2357c42760d413a5d14d373 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Apr 2026 21:10:22 +0200 Subject: [PATCH 11/14] fix: add sign test --- .../plugin/mojos/BuildAttestationMojo.java | 40 ++++++- .../mojos/BuildAttestationMojoTest.java | 107 ++++++++++++++---- .../commons-release-plugin-1.9.2.jar.asc | 7 ++ 3 files changed, 133 insertions(+), 21 deletions(-) create mode 100644 src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index 06326f0c1..75346ab82 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -169,6 +169,11 @@ public class BuildAttestationMojo extends AbstractMojo { @Parameter(property = "gpg.useagent", defaultValue = "true") private boolean useAgent; + /** + * GPG signer used for signing; lazily initialized from plugin parameters when {@code null}. + */ + private AbstractGpgSigner signer; + /** * The current Maven project. */ @@ -258,6 +263,37 @@ void setMavenHome(final File mavenHome) { this.mavenHome = mavenHome; } + /** + * Sets whether to sign the attestation envelope. + * + * @param signAttestation {@code true} to sign, {@code false} to skip signing + */ + void setSignAttestation(final boolean signAttestation) { + this.signAttestation = signAttestation; + } + + /** + * Overrides the GPG signer used for signing. Intended for testing. + * + * @param signer the signer to use + */ + void setSigner(final AbstractGpgSigner signer) { + this.signer = signer; + } + + /** + * Returns the GPG signer, creating and preparing it from plugin parameters if not already set. + * + * @return the prepared signer + * @throws MojoFailureException if signer preparation fails + */ + private AbstractGpgSigner getSigner() throws MojoFailureException { + if (signer == null) { + signer = DsseUtils.createGpgSigner(executable, defaultKeyring, lockMode, keyname, useAgent, getLog()); + } + return signer; + } + @Override public void execute() throws MojoFailureException, MojoExecutionException { // Build definition @@ -335,7 +371,7 @@ private void signAndWriteStatement(final Statement statement, final Path outputP } catch (JsonProcessingException e) { throw new MojoExecutionException("Failed to serialize attestation statement", e); } - final AbstractGpgSigner signer = DsseUtils.createGpgSigner(executable, defaultKeyring, lockMode, keyname, useAgent, getLog()); + final AbstractGpgSigner signer = getSigner(); final Path paeFile = DsseUtils.writePaeFile(statementBytes, outputPath); final byte[] sigBytes = DsseUtils.signFile(signer, paeFile); @@ -417,7 +453,7 @@ private Map getExternalParameters() { * @param request The Maven execution request. * @return A string representation of the Maven command line. */ - private String getCommandLine(final MavenExecutionRequest request) { + private static String getCommandLine(final MavenExecutionRequest request) { StringBuilder sb = new StringBuilder(); for (String goal : request.getGoals()) { sb.append(goal); diff --git a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java index dae20c43c..c52de3993 100644 --- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java +++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java @@ -19,12 +19,17 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import java.io.File; +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.nio.file.StandardCopyOption; import java.util.Date; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.release.plugin.internal.MojoUtils; +import org.apache.commons.release.plugin.slsa.v1_2.DsseEnvelope; import org.apache.maven.artifact.Artifact; import org.apache.maven.bridge.MavenRepositorySystem; import org.apache.maven.execution.DefaultMavenExecutionRequest; @@ -33,8 +38,10 @@ import org.apache.maven.execution.MavenExecutionResult; import org.apache.maven.execution.MavenSession; import org.apache.maven.model.Model; +import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.project.MavenProject; import org.apache.maven.project.MavenProjectHelper; +import org.apache.maven.plugins.gpg.AbstractGpgSigner; import org.apache.maven.rtinfo.RuntimeInformation; import org.apache.maven.scm.manager.ScmManager; import org.codehaus.plexus.PlexusContainer; @@ -46,6 +53,8 @@ public class BuildAttestationMojoTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @TempDir private static Path localRepositoryPath; @@ -89,6 +98,50 @@ private static MavenProject createMavenProject(MavenProjectHelper projectHelper, return project; } + private static AbstractGpgSigner createMockSigner() { + return new AbstractGpgSigner() { + @Override + public String signerName() { + return "mock"; + } + + @Override + public String getKeyInfo() { + return "mock-key"; + } + + @Override + protected void generateSignatureForFile(final File file, final File signature) throws MojoExecutionException { + try { + Files.copy(Paths.get("src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc"), + signature.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (final IOException e) { + throw new MojoExecutionException("Failed to copy mock signature", e); + } + } + }; + } + + private static void assertResolvedDependencies(final String statementJson) { + String resolvedDeps = "predicate.buildDefinition.resolvedDependencies"; + String javaVersion = System.getProperty("java.version"); + + assertThatJson(statementJson) + .node(resolvedDeps).isArray() + .anySatisfy(dep -> { + assertThatJson(dep).node("name").isEqualTo("JDK"); + assertThatJson(dep).node("annotations.version").isEqualTo(javaVersion); + }); + + assertThatJson(statementJson) + .node(resolvedDeps).isArray() + .anySatisfy(dep -> assertThatJson(dep).node("name").isEqualTo("Maven")); + + assertThatJson(statementJson) + .node(resolvedDeps).isArray() + .anySatisfy(dep -> assertThatJson(dep).node("uri").isString().startsWith("git+https://github.com/apache/commons-lang.git")); + } + @Test void attestationTest() throws Exception { MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class); @@ -102,28 +155,44 @@ void attestationTest() throws Exception { mojo.setMavenHome(new File(System.getProperty("maven.home", "."))); mojo.execute(); - Artifact attestation = project.getAttachedArtifacts().stream() - .filter(a -> "intoto.jsonl".equals(a.getType())) - .findFirst() - .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project")); + Artifact attestation = getAttestation(project); String json = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8); - String resolvedDeps = "predicate.buildDefinition.resolvedDependencies"; - String javaVersion = System.getProperty("java.version"); + assertResolvedDependencies(json); + } - assertThatJson(json) - .node(resolvedDeps).isArray() - .anySatisfy(dep -> { - assertThatJson(dep).node("name").isEqualTo("JDK"); - assertThatJson(dep).node("annotations.version").isEqualTo(javaVersion); - }); + @Test + void signingTest() throws Exception { + MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class); + MavenRepositorySystem repoSystem = container.lookup(MavenRepositorySystem.class); + MavenProject project = createMavenProject(projectHelper, repoSystem); - assertThatJson(json) - .node(resolvedDeps).isArray() - .anySatisfy(dep -> assertThatJson(dep).node("name").isEqualTo("Maven")); + 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.setMavenHome(new File(System.getProperty("maven.home", "."))); + mojo.setSignAttestation(true); + mojo.setSigner(createMockSigner()); + mojo.execute(); - assertThatJson(json) - .node(resolvedDeps).isArray() - .anySatisfy(dep -> assertThatJson(dep).node("uri").isString().startsWith("git+https://github.com/apache/commons-lang.git")); + Artifact attestation = getAttestation(project); + String envelopeJson = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8); + + assertThatJson(envelopeJson).node("payloadType").isEqualTo(DsseEnvelope.PAYLOAD_TYPE); + assertThatJson(envelopeJson).node("signatures").isArray().hasSize(1); + assertThatJson(envelopeJson).node("signatures[0].sig").isString().isNotEmpty(); + + DsseEnvelope envelope = OBJECT_MAPPER.readValue(envelopeJson.trim(), DsseEnvelope.class); + String statementJson = new String(envelope.getPayload(), StandardCharsets.UTF_8); + assertResolvedDependencies(statementJson); + } + + private static Artifact getAttestation(MavenProject project) { + return project.getAttachedArtifacts() + .stream() + .filter(a -> "intoto.jsonl".equals(a.getType())) + .findFirst() + .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project")); } -} +} \ No newline at end of file diff --git a/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc b/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc new file mode 100644 index 000000000..c8e60938a --- /dev/null +++ b/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc @@ -0,0 +1,7 @@ +-----BEGIN PGP SIGNATURE----- + +iHUEABYKAB0WIQT03VnJAUi9xSvrkKRTCqXyXCUBHwUCaXYyIwAKCRBTCqXyXCUB +H9E7AQDHPmR05PZJGDiGzz1qJnqTuu/Jo3mS3A9AoHWSJbT6HwD+InboQcE6tCGT +5MpDNFzs2aNqyb9klElvKE2c6o8cJw4= +=Nf0G +-----END PGP SIGNATURE----- From f8876c412112e22a5555e7d142766bb5e968d543 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Apr 2026 21:18:53 +0200 Subject: [PATCH 12/14] fix: build errors --- .../apache/commons/release/plugin/internal/DsseUtils.java | 4 ++-- .../commons/release/plugin/mojos/BuildAttestationMojo.java | 4 ++-- .../release/plugin/mojos/BuildAttestationMojoTest.java | 6 +++--- .../signatures/commons-release-plugin-1.9.2.jar.asc | 7 ------- 4 files changed, 7 insertions(+), 14 deletions(-) delete mode 100644 src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc diff --git a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java index 23cac277c..20267a497 100644 --- a/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java +++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java @@ -80,7 +80,7 @@ public static AbstractGpgSigner createGpgSigner(final String executable, final b } /** - * Serializes {@code statement} to JSON using the DSSE Pre-Authentication Encoding (PAE) + * Serializes {@code statement} to JSON using the DSSE Pre-Authentication Encoding (PAE). * *
PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
* @@ -99,7 +99,7 @@ public static Path writePaeFile(final Statement statement, final ObjectMapper ob } /** - * Writes serialized JSON to a file using the DSSE Pre-Authentication Encoding (PAE) + * Writes serialized JSON to a file using the DSSE Pre-Authentication Encoding (PAE). * *
PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
* diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index 75346ab82..e08cbe11e 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -568,8 +568,8 @@ 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); + InfoScmResult result = scmManager.getProviderByRepository(scmRepository).info(scmRepository.getProviderRepository(), + new ScmFileSet(scmDirectory), commandParameters); return getScmRevision(result); } catch (ScmException e) { diff --git a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java index c52de3993..ee90bd8d2 100644 --- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java +++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java @@ -39,9 +39,9 @@ import org.apache.maven.execution.MavenSession; import org.apache.maven.model.Model; import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.gpg.AbstractGpgSigner; import org.apache.maven.project.MavenProject; import org.apache.maven.project.MavenProjectHelper; -import org.apache.maven.plugins.gpg.AbstractGpgSigner; import org.apache.maven.rtinfo.RuntimeInformation; import org.apache.maven.scm.manager.ScmManager; import org.codehaus.plexus.PlexusContainer; @@ -113,7 +113,7 @@ public String getKeyInfo() { @Override protected void generateSignatureForFile(final File file, final File signature) throws MojoExecutionException { try { - Files.copy(Paths.get("src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc"), + Files.copy(Paths.get("src/test/resources/mojos/detach-distributions/target/commons-text-1.4.jar.asc"), signature.toPath(), StandardCopyOption.REPLACE_EXISTING); } catch (final IOException e) { throw new MojoExecutionException("Failed to copy mock signature", e); @@ -195,4 +195,4 @@ private static Artifact getAttestation(MavenProject project) { .findFirst() .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project")); } -} \ No newline at end of file +} diff --git a/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc b/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc deleted file mode 100644 index c8e60938a..000000000 --- a/src/test/resources/signatures/commons-release-plugin-1.9.2.jar.asc +++ /dev/null @@ -1,7 +0,0 @@ ------BEGIN PGP SIGNATURE----- - -iHUEABYKAB0WIQT03VnJAUi9xSvrkKRTCqXyXCUBHwUCaXYyIwAKCRBTCqXyXCUB -H9E7AQDHPmR05PZJGDiGzz1qJnqTuu/Jo3mS3A9AoHWSJbT6HwD+InboQcE6tCGT -5MpDNFzs2aNqyb9klElvKE2c6o8cJw4= -=Nf0G ------END PGP SIGNATURE----- From affb0a77124beeb896e30bf6175ae498c9ce668a Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Apr 2026 21:36:16 +0200 Subject: [PATCH 13/14] fix: base tests on already existing test resources --- .../mojos/BuildAttestationMojoTest.java | 97 +++++++++++++------ 1 file changed, 65 insertions(+), 32 deletions(-) diff --git a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java index ee90bd8d2..210b27f67 100644 --- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java +++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java @@ -53,6 +53,8 @@ public class BuildAttestationMojoTest { + private static final String ARTIFACTS_DIR = "src/test/resources/mojos/detach-distributions/target/"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @TempDir @@ -85,16 +87,23 @@ private static BuildAttestationMojo createBuildAttestationMojo(MavenProject proj createMavenSession(createMavenExecutionRequest(), new DefaultMavenExecutionResult()), projectHelper); } - private static MavenProject createMavenProject(MavenProjectHelper projectHelper, MavenRepositorySystem repoSystem) throws ComponentLookupException { + private static MavenProject createMavenProject(MavenProjectHelper projectHelper, MavenRepositorySystem repoSystem) { MavenProject project = new MavenProject(new Model()); - Artifact artifact = repoSystem.createArtifact("groupId", "artifactId", "1.2.3", null, "jar"); + Artifact artifact = repoSystem.createArtifact("org.apache.commons", "commons-text", "1.4", null, "jar"); + artifact.setFile(new File(ARTIFACTS_DIR + "commons-text-1.4.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")); + project.setGroupId("org.apache.commons"); + project.setArtifactId("commons-text"); + project.setVersion("1.4"); + projectHelper.attachArtifact(project, "pom", null, new File(ARTIFACTS_DIR + "commons-text-1.4.pom")); + projectHelper.attachArtifact(project, "jar", "sources", new File(ARTIFACTS_DIR + "commons-text-1.4-sources.jar")); + projectHelper.attachArtifact(project, "jar", "javadoc", new File(ARTIFACTS_DIR + "commons-text-1.4-javadoc.jar")); + projectHelper.attachArtifact(project, "jar", "tests", new File(ARTIFACTS_DIR + "commons-text-1.4-tests.jar")); + projectHelper.attachArtifact(project, "jar", "test-sources", new File(ARTIFACTS_DIR + "commons-text-1.4-test-sources.jar")); + projectHelper.attachArtifact(project, "tar.gz", "bin", new File(ARTIFACTS_DIR + "commons-text-1.4-bin.tar.gz")); + projectHelper.attachArtifact(project, "zip", "bin", new File(ARTIFACTS_DIR + "commons-text-1.4-bin.zip")); + projectHelper.attachArtifact(project, "tar.gz", "src", new File(ARTIFACTS_DIR + "commons-text-1.4-src.tar.gz")); + projectHelper.attachArtifact(project, "zip", "src", new File(ARTIFACTS_DIR + "commons-text-1.4-src.zip")); return project; } @@ -113,8 +122,7 @@ public String getKeyInfo() { @Override protected void generateSignatureForFile(final File file, final File signature) throws MojoExecutionException { try { - Files.copy(Paths.get("src/test/resources/mojos/detach-distributions/target/commons-text-1.4.jar.asc"), - signature.toPath(), StandardCopyOption.REPLACE_EXISTING); + Files.copy(Paths.get(ARTIFACTS_DIR + "commons-text-1.4.jar.asc"), signature.toPath(), StandardCopyOption.REPLACE_EXISTING); } catch (final IOException e) { throw new MojoExecutionException("Failed to copy mock signature", e); } @@ -122,7 +130,44 @@ protected void generateSignatureForFile(final File file, final File signature) t }; } - private static void assertResolvedDependencies(final String statementJson) { + private static Artifact getAttestation(final MavenProject project) { + return project.getAttachedArtifacts().stream() + .filter(a -> "intoto.jsonl".equals(a.getType())) + .findFirst() + .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project")); + } + + private static void assertSubject(final String statementJson, final String name, final String sha256) { + assertThatJson(statementJson) + .node("subject").isArray() + .anySatisfy(s -> { + assertThatJson(s).node("name").isEqualTo(name); + assertThatJson(s).node("digest.sha256").isEqualTo(sha256); + }); + } + + private static void assertStatementContent(final String statementJson) { + assertSubject(statementJson, "commons-text-1.4.jar", + "ad2d2eacf15ab740c115294afc1192603d8342004a6d7d0ad35446f7dda8a134"); + assertSubject(statementJson, "commons-text-1.4.pom", + "4d6277b1e0720bb054c640620679a9da120f753029342150e714095f48934d76"); + assertSubject(statementJson, "commons-text-1.4-sources.jar", + "58a95591fe7fc94db94a0a9e64b4a5bcc1c49edf17f2b24d7c0747357d855761"); + assertSubject(statementJson, "commons-text-1.4-javadoc.jar", + "42f5b341d0fbeaa30b06aed90612840bc513fb39792c3d39446510670216e8b1"); + assertSubject(statementJson, "commons-text-1.4-tests.jar", + "e4e365d08d601a4bda44be2a31f748b96762504d301742d4a0f7f5953d4c793a"); + assertSubject(statementJson, "commons-text-1.4-test-sources.jar", + "9200a2a41b35f2d6d30c1c698308591cf577547ec39514657dff0e2f7dff18ca"); + assertSubject(statementJson, "commons-text-1.4-bin.tar.gz", + "8b9393f7ddc2efb69d8c2b6f4d85d8711dddfe77009799cf21619fc9b8411897"); + assertSubject(statementJson, "commons-text-1.4-bin.zip", + "ad3732dcb38e510b1dbb1544115d0eb797fab61afe0008fdb187cd4ef1706cd7"); + assertSubject(statementJson, "commons-text-1.4-src.tar.gz", + "1cb8536c375c3cff66757fd40c2bf878998254ba0a247866a6536bd48ba2e88a"); + assertSubject(statementJson, "commons-text-1.4-src.zip", + "e4a6c992153faae4f7faff689b899073000364e376736b9746a5d0acb9d8b980"); + String resolvedDeps = "predicate.buildDefinition.resolvedDependencies"; String javaVersion = System.getProperty("java.version"); @@ -132,14 +177,13 @@ private static void assertResolvedDependencies(final String statementJson) { assertThatJson(dep).node("name").isEqualTo("JDK"); assertThatJson(dep).node("annotations.version").isEqualTo(javaVersion); }); - assertThatJson(statementJson) .node(resolvedDeps).isArray() .anySatisfy(dep -> assertThatJson(dep).node("name").isEqualTo("Maven")); - assertThatJson(statementJson) .node(resolvedDeps).isArray() - .anySatisfy(dep -> assertThatJson(dep).node("uri").isString().startsWith("git+https://github.com/apache/commons-lang.git")); + .anySatisfy(dep -> assertThatJson(dep).node("uri").isString() + .startsWith("git+https://github.com/apache/commons-text.git")); } @Test @@ -151,14 +195,12 @@ void attestationTest() throws Exception { 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.setScmConnectionUrl("scm:git:https://github.com/apache/commons-text.git"); mojo.setMavenHome(new File(System.getProperty("maven.home", "."))); mojo.execute(); - Artifact attestation = getAttestation(project); - String json = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8); - - assertResolvedDependencies(json); + String json = new String(Files.readAllBytes(getAttestation(project).getFile().toPath()), StandardCharsets.UTF_8); + assertStatementContent(json); } @Test @@ -170,14 +212,13 @@ void signingTest() throws Exception { 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.setScmConnectionUrl("scm:git:https://github.com/apache/commons-text.git"); mojo.setMavenHome(new File(System.getProperty("maven.home", "."))); mojo.setSignAttestation(true); mojo.setSigner(createMockSigner()); mojo.execute(); - Artifact attestation = getAttestation(project); - String envelopeJson = new String(Files.readAllBytes(attestation.getFile().toPath()), StandardCharsets.UTF_8); + String envelopeJson = new String(Files.readAllBytes(getAttestation(project).getFile().toPath()), StandardCharsets.UTF_8); assertThatJson(envelopeJson).node("payloadType").isEqualTo(DsseEnvelope.PAYLOAD_TYPE); assertThatJson(envelopeJson).node("signatures").isArray().hasSize(1); @@ -185,14 +226,6 @@ void signingTest() throws Exception { DsseEnvelope envelope = OBJECT_MAPPER.readValue(envelopeJson.trim(), DsseEnvelope.class); String statementJson = new String(envelope.getPayload(), StandardCharsets.UTF_8); - assertResolvedDependencies(statementJson); - } - - private static Artifact getAttestation(MavenProject project) { - return project.getAttachedArtifacts() - .stream() - .filter(a -> "intoto.jsonl".equals(a.getType())) - .findFirst() - .orElseThrow(() -> new AssertionError("No intoto.jsonl artifact attached to project")); + assertStatementContent(statementJson); } -} +} \ No newline at end of file From 3e4deda6991d7786f54c9330c6ea2a062c8d1253 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 16 Apr 2026 21:59:37 +0200 Subject: [PATCH 14/14] fix: refactor external parameters from BuildAttestationMojo --- ...Descriptors.java => BuildDefinitions.java} | 85 +++++++++++++++++-- .../plugin/mojos/BuildAttestationMojo.java | 80 +---------------- .../plugin/internal/BuildDefinitionsTest.java | 65 ++++++++++++++ .../mojos/BuildAttestationMojoTest.java | 2 +- 4 files changed, 147 insertions(+), 85 deletions(-) rename src/main/java/org/apache/commons/release/plugin/internal/{BuildToolDescriptors.java => BuildDefinitions.java} (56%) create mode 100644 src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java diff --git a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java b/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java similarity index 56% rename from src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java rename to src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java index 5dac9a192..dc51d0730 100644 --- a/src/main/java/org/apache/commons/release/plugin/internal/BuildToolDescriptors.java +++ b/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java @@ -18,21 +18,26 @@ import java.io.IOException; import java.io.InputStream; +import java.lang.management.ManagementFactory; import java.nio.file.Path; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Properties; import org.apache.commons.release.plugin.slsa.v1_2.ResourceDescriptor; +import org.apache.maven.execution.MavenExecutionRequest; +import org.apache.maven.execution.MavenSession; /** - * Factory methods for {@link ResourceDescriptor} instances representing build-tool dependencies. + * Factory methods for the SLSA {@code BuildDefinition} fields: JVM, Maven descriptors and external build parameters. */ -public final class BuildToolDescriptors { +public final class BuildDefinitions { - /** No instances. */ - private BuildToolDescriptors() { - // no instantiation + /** + * No instances. + */ + private BuildDefinitions() { } /** @@ -72,9 +77,9 @@ public static ResourceDescriptor jvm(Path javaHome) throws IOException { * Plugin code runs in an isolated Plugin Classloader, which does see that resources. Therefore, we need to pass the classloader from a class from * Maven Core, such as {@link org.apache.maven.rtinfo.RuntimeInformation}.

* - * @param version Maven version string - * @param mavenHome path to the Maven home directory - * @param coreClassLoader a classloader from Maven's Core Classloader realm, used to load core resources + * @param version Maven version string + * @param mavenHome path to the Maven home directory + * @param coreClassLoader a classloader from Maven's Core Classloader realm, used to load core resources * @return a descriptor for the Maven installation * @throws IOException if hashing the Maven home directory fails */ @@ -98,4 +103,68 @@ public static ResourceDescriptor maven(String version, Path mavenHome, ClassLoad } return descriptor; } + + /** + * Returns a map of external build parameters captured from the current JVM and Maven session. + * + * @param session the current Maven session + * @return a map of parameter names to values + */ + public static Map externalParameters(final MavenSession session) { + Map 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", commandLine(request)); + Map env = new HashMap<>(); + params.put("env", env); + for (Map.Entry 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; + } + + /** + * Reconstructs the Maven command line string from the given execution request. + * + * @param request the Maven execution request + * @return a string representation of the Maven command line + */ + static String commandLine(final MavenExecutionRequest request) { + StringBuilder sb = new StringBuilder(); + for (String goal : request.getGoals()) { + sb.append(goal).append(" "); + } + List activeProfiles = request.getActiveProfiles(); + if (activeProfiles != null && !activeProfiles.isEmpty()) { + sb.append("-P"); + for (String profile : activeProfiles) { + sb.append(profile).append(","); + } + removeLast(sb); + sb.append(" "); + } + Properties userProperties = request.getUserProperties(); + for (String propertyName : userProperties.stringPropertyNames()) { + sb.append("-D").append(propertyName).append("=").append(userProperties.get(propertyName)).append(" "); + } + removeLast(sb); + return sb.toString(); + } + + /** + * Removes last character from a {@link StringBuilder}. + * + * @param sb The {@link StringBuilder} to use + */ + private static void removeLast(final StringBuilder sb) { + if (sb.length() > 0) { + sb.setLength(sb.length() - 1); + } + } } diff --git a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java index e08cbe11e..32fe3b341 100644 --- a/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -19,7 +19,6 @@ 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; @@ -30,7 +29,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Properties; import javax.inject.Inject; @@ -39,7 +37,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import org.apache.commons.release.plugin.internal.ArtifactUtils; -import org.apache.commons.release.plugin.internal.BuildToolDescriptors; +import org.apache.commons.release.plugin.internal.BuildDefinitions; import org.apache.commons.release.plugin.internal.DsseUtils; import org.apache.commons.release.plugin.internal.GitUtils; import org.apache.commons.release.plugin.slsa.v1_2.BuildDefinition; @@ -52,7 +50,6 @@ import org.apache.commons.release.plugin.slsa.v1_2.Signature; import org.apache.commons.release.plugin.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; @@ -298,7 +295,7 @@ private AbstractGpgSigner getSigner() throws MojoFailureException { public void execute() throws MojoFailureException, MojoExecutionException { // Build definition BuildDefinition buildDefinition = new BuildDefinition(); - buildDefinition.setExternalParameters(getExternalParameters()); + buildDefinition.setExternalParameters(BuildDefinitions.externalParameters(session)); buildDefinition.setResolvedDependencies(getBuildDependencies()); // Builder Builder builder = new Builder(); @@ -423,75 +420,6 @@ private List getSubjects() throws MojoExecutionException { return subjects; } - /** - * Gets map of external build parameters captured from the current JVM and Maven session. - * - * @return A map of parameter names to values. - */ - private Map getExternalParameters() { - Map 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 env = new HashMap<>(); - params.put("env", env); - for (Map.Entry 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; - } - - /** - * Reconstructs the Maven command line string from the given execution request. - * - * @param request The Maven execution request. - * @return A string representation of the Maven command line. - */ - private static String getCommandLine(final MavenExecutionRequest request) { - StringBuilder sb = new StringBuilder(); - for (String goal : request.getGoals()) { - sb.append(goal); - sb.append(" "); - } - List 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(); - } - - /** - * Removes the last character from the given {@link StringBuilder} if it is non-empty. - * - * @param sb The string builder to trim. - */ - private static void removeLast(final StringBuilder sb) { - if (sb.length() > 0) { - sb.setLength(sb.length() - 1); - } - } - /** * Returns resource descriptors for the JVM, Maven installation, SCM source, and project dependencies. * @@ -501,8 +429,8 @@ private static void removeLast(final StringBuilder sb) { private List getBuildDependencies() throws MojoExecutionException { List dependencies = new ArrayList<>(); try { - dependencies.add(BuildToolDescriptors.jvm(Paths.get(System.getProperty("java.home")))); - dependencies.add(BuildToolDescriptors.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath(), + dependencies.add(BuildDefinitions.jvm(Paths.get(System.getProperty("java.home")))); + dependencies.add(BuildDefinitions.maven(runtimeInformation.getMavenVersion(), mavenHome.toPath(), runtimeInformation.getClass().getClassLoader())); dependencies.add(getScmDescriptor()); } catch (IOException e) { diff --git a/src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java b/src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java new file mode 100644 index 000000000..aa9081d71 --- /dev/null +++ b/src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java @@ -0,0 +1,65 @@ +/* + * 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.release.plugin.internal; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Properties; +import java.util.stream.Stream; + +import org.apache.maven.execution.DefaultMavenExecutionRequest; +import org.apache.maven.execution.MavenExecutionRequest; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class BuildDefinitionsTest { + + static Stream commandLineArguments() { + return Stream.of( + Arguments.of("empty", emptyList(), emptyList(), new Properties(), ""), + Arguments.of("single goal", singletonList("verify"), emptyList(), new Properties(), "verify"), + Arguments.of("multiple goals", asList("clean", "verify"), emptyList(), new Properties(), "clean verify"), + Arguments.of("single profile", singletonList("verify"), singletonList("release"), new Properties(), "verify -Prelease"), + Arguments.of("multiple profiles", singletonList("verify"), asList("release", "sign"), new Properties(), "verify -Prelease,sign"), + Arguments.of("user property", singletonList("verify"), emptyList(), props("foo", "bar"), "verify -Dfoo=bar"), + Arguments.of("goals, profile and property", singletonList("verify"), singletonList("release"), props("foo", "bar"), + "verify -Prelease -Dfoo=bar") + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("commandLineArguments") + void commandLineTest(final String description, final List goals, final List profiles, + final Properties userProperties, final String expected) { + MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(goals); + request.setActiveProfiles(profiles); + request.setUserProperties(userProperties); + assertThat(BuildDefinitions.commandLine(request)).isEqualTo(expected); + } + + private static Properties props(final String key, final String value) { + Properties p = new Properties(); + p.setProperty(key, value); + return p; + } +} diff --git a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java index 210b27f67..b5d527294 100644 --- a/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java +++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java @@ -228,4 +228,4 @@ void signingTest() throws Exception { String statementJson = new String(envelope.getPayload(), StandardCharsets.UTF_8); assertStatementContent(statementJson); } -} \ No newline at end of file +}