{@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, ClassLoader coreClassLoader) throws IOException { + ResourceDescriptor descriptor = new ResourceDescriptor(); + descriptor.setName("Maven"); + descriptor.setUri("pkg:maven/org.apache.maven/apache-maven@" + version); + MapThe 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 + * @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 using the DSSE Pre-Authentication Encoding (PAE). + * + *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} + * @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); + } + } + + /** + * 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+ * + * @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} and returns the raw OpenPGP signature bytes. + * + *
The signer must already have {@link AbstractGpgSigner#prepare()} called before this method is invoked.
+ * + * @param signer the configured, prepared signer + * @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[] 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: " + 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. + * + * @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 + */ + 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/internal/GitUtils.java b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java new file mode 100644 index 000000000..c4d4aecec --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.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.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; +import org.apache.commons.codec.digest.GitIdentifiers; + +/** + * 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(GitIdentifiers.treeId(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+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..32fe3b341 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -0,0 +1,543 @@ +/* + * 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.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.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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.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; +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.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.plugins.gpg.AbstractGpgSigner; +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.jsonl"; + + /** + * 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); + OBJECT_MAPPER.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + } + + /** + * 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; + + /** + * 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; + + /** + * GPG signer used for signing; lazily initialized from plugin parameters when {@code null}. + */ + private AbstractGpgSigner signer; + + /** + * 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; + } + + /** + * 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 + BuildDefinition buildDefinition = new BuildDefinition(); + buildDefinition.setExternalParameters(BuildDefinitions.externalParameters(session)); + 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); + + 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); + } + } + + /** + * Creates the output directory if it does not already exist and returns its path. + * + * @return the output directory path + * @throws MojoExecutionException if the directory cannot be created + */ + private Path ensureOutputDirectory() 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); + } + 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 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 = getSigner(); + final Path paeFile = DsseUtils.writePaeFile(statementBytes, outputPath); + final byte[] sigBytes = DsseUtils.signFile(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, value); + os.write('\n'); + } catch (IOException e) { + throw new MojoExecutionException("Could not write attestation to: " + artifactPath, e); + } + if (!skipAttach) { + 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()); + } + } + + /** + * Get the artifacts generated by the build. + * + * @return A list of resource descriptors for the build artifacts. + * @throws MojoExecutionException If artifact hashing fails. + */ + private ListSpecifies 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 MapDetermines 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; + } + + /** + * 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 + */ + public MapConsidered incomplete unless resolved materials are present.
+ * + * @return the list of resolved dependencies, or {@code null} if not set + */ + public ListWhen 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; + } + + /** + * Gets the list of signatures over the PAE-encoded payload. + * + * @return the signatures, or {@code null} if not set + */ + public ListCombines 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; + } + + /** + * Gets 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; + } + + /** + * 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 + */ + 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..2ce42ce25 --- /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 MapCommon keys include {@code "sha256"} and {@code "sha512"}.
+ * + * @return the digest map, or {@code null} if not set + */ + public MapTrusted 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; + } + + /** + * Gets 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; + } + + /** + * 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 + */ + public ListConsumers MUST NOT require this field to be set, and MUST NOT use it for security decisions.
+ */ + @JsonProperty("keyid") + private String keyid; + + /** Raw signature bytes of the PAE-encoded payload. */ + @JsonProperty("sig") + private byte[] sig; + + /** Creates a new Signature instance. */ + public Signature() { + } + + /** + * Gets 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; + } + + /** + * Gets the raw signature bytes. + * + * @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>}"; + } +} 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..4f3506e0b --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java @@ -0,0 +1,121 @@ +/* + * 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 ListEach 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 ListUnset 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.
+ * + *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/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 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