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 0f7428f07..404d3c42f 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. @@ -113,7 +114,29 @@ true true + + 2.21.2 + 2.0.17 + + + + org.slf4j + slf4j-bom + ${commons.slf4j.version} + pom + import + + + + com.fasterxml.jackson + jackson-bom + ${commons.jackson.version} + pom + import + + + org.apache.commons @@ -171,6 +194,29 @@ commons-compress 1.28.0 + + org.apache.commons + commons-lang3 + 3.20.0 + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + runtime + org.apache.maven.plugin-testing maven-plugin-testing-harness @@ -188,11 +234,34 @@ junit-jupiter test + + net.javacrumbs.json-unit + json-unit + 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.commons + commons-exec + 1.6.0 + test + org.apache.maven @@ -223,6 +292,11 @@ + + org.slf4j + slf4j-simple + test + clean verify apache-rat:check checkstyle:check spotbugs:check javadoc:javadoc site @@ -240,6 +314,25 @@ + + + + src/test/resources + true + + attestations/** + plugin.properties + + + + src/test/resources + false + + attestations/** + plugin.properties + + + @@ -532,7 +625,7 @@ com.github.spotbugs spotbugs-maven-plugin - + 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..20cee902d --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/ArtifactUtils.java @@ -0,0 +1,158 @@ +/* + * 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.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +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 { + + /** + * Maps standard JDK {@link java.security.MessageDigest} algorithm names to the in-toto digest names used in SLSA {@link ResourceDescriptor} digest sets. + * + *

JDK algorithms that have no in-toto equivalent (such as {@code MD2}) are omitted.

+ * + * @see + * JDK standard {@code MessageDigest} algorithm names + * @see + * in-toto digest set specification + */ + private static final Map IN_TOTO_DIGEST_NAMES; + + static { + final Map m = new HashMap<>(); + m.put("MD5", "md5"); + m.put("SHA-1", "sha1"); + m.put("SHA-224", "sha224"); + m.put("SHA-256", "sha256"); + m.put("SHA-384", "sha384"); + m.put("SHA-512", "sha512"); + m.put("SHA-512/224", "sha512_224"); + m.put("SHA-512/256", "sha512_256"); + m.put("SHA3-224", "sha3_224"); + m.put("SHA3-256", "sha3_256"); + m.put("SHA3-384", "sha3_384"); + m.put("SHA3-512", "sha3_512"); + IN_TOTO_DIGEST_NAMES = Collections.unmodifiableMap(m); + } + + /** + * Gets a map of checksum algorithm names to hex-encoded digest values for the given artifact file. + * + * @param artifact A Maven artifact. + * @param algorithms JSSE names of algorithms to use + * @return A map of checksum algorithm names to hex-encoded digest values. + * @throws IOException If an I/O error occurs reading the artifact file. + * @throws IllegalArgumentException If any of the algorithms is not supported. + */ + private static Map getChecksums(final Artifact artifact, final String... algorithms) throws IOException { + final Map checksums = new HashMap<>(); + for (final String algorithm : algorithms) { + final String key = IN_TOTO_DIGEST_NAMES.get(algorithm); + if (key == null) { + throw new IllegalArgumentException("Invalid algorithm name for in-toto attestation: " + algorithm); + } + final DigestUtils digest = new DigestUtils(DigestUtils.getDigest(algorithm)); + final String checksum = digest.digestAsHex(artifact.getFile()); + checksums.put(key, checksum); + } + return checksums; + } + + /** + * Gets the filename of an artifact in the default Maven repository layout, using the specified extension. + * + * @param artifact A Maven artifact. + * @param extension The file name extension. + * @return A filename. + */ + public static String getFileName(final Artifact artifact, final String extension) { + final 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(); + } + + /** + * Gets the filename of an artifact in the default Maven repository layout. + * + * @param artifact A Maven artifact. + * @return A filename. + */ + public static String getFileName(final Artifact artifact) { + return getFileName(artifact, artifact.getArtifactHandler().getExtension()); + } + + /** + * Gets the Package URL corresponding to this artifact. + * + * @param artifact A maven artifact. + * @return A PURL for the given artifact. + */ + public static String getPackageUrl(final Artifact artifact) { + final StringBuilder sb = new StringBuilder(); + sb.append("pkg:maven/").append(artifact.getGroupId()).append("/").append(artifact.getArtifactId()).append("@").append(artifact.getVersion()) + .append("?"); + final String classifier = artifact.getClassifier(); + if (classifier != null) { + sb.append("classifier=").append(classifier).append("&"); + } + sb.append("type=").append(artifact.getType()); + return sb.toString(); + } + + /** + * Converts a Maven artifact to a SLSA {@link ResourceDescriptor}. + * + * @param artifact A Maven artifact. + * @param algorithms A comma-separated list of checksum algorithms to use. + * @return A SLSA resource descriptor. + * @throws MojoExecutionException If an I/O error occurs retrieving the artifact. + */ + public static ResourceDescriptor toResourceDescriptor(final Artifact artifact, final String algorithms) throws MojoExecutionException { + final ResourceDescriptor descriptor = new ResourceDescriptor() + .setName(getFileName(artifact)) + .setUri(getPackageUrl(artifact)); + if (artifact.getFile() != null) { + try { + descriptor.setDigest(getChecksums(artifact, StringUtils.split(algorithms, ","))); + } catch (final IOException e) { + throw new MojoExecutionException("Unable to compute hash for artifact file: " + artifact.getFile(), e); + } + } + return descriptor; + } + + /** No instances. */ + private ArtifactUtils() { + // prevent instantiation + } +} diff --git a/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java b/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java new file mode 100644 index 000000000..5935492bb --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/BuildDefinitions.java @@ -0,0 +1,186 @@ +/* + * 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.lang.management.ManagementFactory; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; + +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 the SLSA {@code BuildDefinition} fields: JVM, Maven descriptors and external build parameters. + */ +public final class BuildDefinitions { + + /** + * User-property names containing any of these substrings (case-insensitive) are omitted from attestations. + * + *

The Maven GPG plugin discourages passing credentials on the command line, but a stray {@code -Dgpg.passphrase=...} must not be captured in the + * attestation if someone does it anyway.

+ */ + private static final List SENSITIVE_KEYWORDS = + Arrays.asList("secret", "password", "passphrase", "token", "credential"); + + /** + * Reconstructs the Maven command line string from the given execution request. + * User properties whose name matches {@link #SENSITIVE_KEYWORDS} are omitted. + * + * @param request the Maven execution request + * @return a string representation of the Maven command line + */ + static String commandLine(final MavenExecutionRequest request) { + final List args = new ArrayList<>(request.getGoals()); + final String profiles = String.join(",", request.getActiveProfiles()); + if (!profiles.isEmpty()) { + args.add("-P" + profiles); + } + request.getUserProperties().forEach((key, value) -> { + final String k = key.toString(); + if (isNotSensitive(k)) { + args.add("-D" + k + "=" + value); + } + }); + return String.join(" ", args); + } + + /** + * Checks if a property key is not sensitive. + * + * @param property A property key + * @return {@code true} if the property is not considered sensitive + */ + private static boolean isNotSensitive(final String property) { + final String lower = property.toLowerCase(Locale.ROOT); + return SENSITIVE_KEYWORDS.stream().noneMatch(lower::contains); + } + + /** + * 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) { + final Map params = new HashMap<>(); + params.put("jvm.args", ManagementFactory.getRuntimeMXBean().getInputArguments()); + final MavenExecutionRequest request = session.getRequest(); + params.put("maven.goals", request.getGoals()); + params.put("maven.profiles", request.getActiveProfiles()); + params.put("maven.user.properties", getUserProperties(request)); + params.put("maven.cmdline", commandLine(request)); + final Map env = new HashMap<>(); + params.put("env", env); + for (final Map.Entry entry : System.getenv().entrySet()) { + final String key = entry.getKey(); + if ("TZ".equals(key) || "LANG".equals(key) || key.startsWith("LC_")) { + env.put(key, entry.getValue()); + } + } + return params; + } + + /** + * Returns a filtered map of user properties. + * + * @param request A Maven request + * @return A map of user properties. + */ + private static TreeMap getUserProperties(final MavenExecutionRequest request) { + final TreeMap properties = new TreeMap<>(); + request.getUserProperties().forEach((k, value) -> { + final String key = k.toString(); + if (isNotSensitive(key)) { + properties.put(key, value.toString()); + } + }); + return properties; + } + + /** + * 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(final Path javaHome) throws IOException { + final String[] propertyNames = { + "java.home", "java.specification.maintenance.version", "java.specification.name", "java.specification.vendor", "java.specification.version", + "java.vendor", "java.vendor.url", "java.vendor.version", "java.version", "java.version.date", "java.vm.name", "java.vm.specification.name", + "java.vm.specification.vendor", "java.vm.specification.version", "java.vm.vendor", "java.vm.version" + }; + final Map annotations = new TreeMap<>(); + for (final String prop : propertyNames) { + annotations.put(prop.substring("java.".length()), System.getProperty(prop)); + } + return new ResourceDescriptor() + .setName("JDK") + .setDigest(Collections.singletonMap("gitTree", GitUtils.gitTree(javaHome))) + .setAnnotations(annotations); + } + + /** + * Creates a {@link ResourceDescriptor} for the Maven installation used during the build. + * + *

{@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 not see those 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(final String version, final Path mavenHome, final ClassLoader coreClassLoader) throws IOException { + final ResourceDescriptor descriptor = new ResourceDescriptor() + .setName("Maven") + .setUri("pkg:maven/org.apache.maven/apache-maven@" + version) + .setDigest(Collections.singletonMap("gitTree", GitUtils.gitTree(mavenHome))); + final Properties buildProps = new Properties(); + try (InputStream in = coreClassLoader.getResourceAsStream("org/apache/maven/messages/build.properties")) { + if (in != null) { + buildProps.load(in); + } + } + if (!buildProps.isEmpty()) { + final Map annotations = new HashMap<>(); + buildProps.forEach((key, value) -> annotations.put((String) key, value)); + descriptor.setAnnotations(annotations); + } + return descriptor; + } + + /** + * No instances. + */ + private BuildDefinitions() { + } +} 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..22e49249d --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/DsseUtils.java @@ -0,0 +1,167 @@ +/* + * 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 org.apache.commons.codec.binary.Hex; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.release.plugin.slsa.v1_2.DsseEnvelope; +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 { + + /** + * 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 #signStatement}.

+ * + * @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; + } + + /** + * 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()).toLowerCase(Locale.ROOT); + } catch (final IOException e) { + throw new MojoExecutionException("Failed to extract key ID from signature", e); + } + } + + /** + * Signs a serialized DSSE payload and returns the raw OpenPGP signature bytes. + * + *

Creates a unique temporary {@code .pae} file inside {@code workDir}, containing the payload + * wrapped in the DSSE Pre-Authentication Encoding:

+ * + *
PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body
+ * + *

then invokes {@code signer} on that file, reads back the un-armored PGP signature, and + * deletes both temporary files in a {@code finally} block. Cleanup is best-effort; a delete + * failure at the end of a successful sign only leaves a stray temporary file in {@code workDir} + * and does not fail the build.

+ * + *

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

+ * + * @param signer the configured, prepared signer + * @param statementBytes the already-serialized JSON statement bytes to sign + * @param workDir directory in which to create the intermediate PAE and signature files + * @return raw binary PGP signature bytes + * @throws MojoExecutionException if encoding, signing, or signature decoding fails + */ + public static byte[] signStatement(final AbstractGpgSigner signer, final byte[] statementBytes, final Path workDir) + throws MojoExecutionException { + File paeFile = null; + File ascFile = null; + try { + paeFile = File.createTempFile("statement-", ".pae", workDir.toFile()); + FileUtils.writeByteArrayToFile(paeFile, paeEncode(statementBytes)); + ascFile = signer.generateSignatureForArtifact(paeFile); + try (InputStream in = Files.newInputStream(ascFile.toPath()); + ArmoredInputStream armoredIn = new ArmoredInputStream(in)) { + return IOUtils.toByteArray(armoredIn); + } + } catch (final IOException e) { + throw new MojoExecutionException("Failed to sign attestation statement", e); + } finally { + FileUtils.deleteQuietly(paeFile); + FileUtils.deleteQuietly(ascFile); + } + } + + /** + * Encodes {@code statementBytes} using the DSSEv1 Pre-Authentication Encoding. + * + * @param statementBytes the already-serialized JSON statement bytes to encode + * @return the PAE-encoded bytes + */ + private static byte[] paeEncode(final byte[] statementBytes) { + final byte[] payloadTypeBytes = DsseEnvelope.PAYLOAD_TYPE.getBytes(StandardCharsets.UTF_8); + final ByteArrayOutputStream pae = new ByteArrayOutputStream(); + try { + pae.write(("DSSEv1 " + payloadTypeBytes.length + " ").getBytes(StandardCharsets.UTF_8)); + pae.write(payloadTypeBytes); + pae.write((" " + statementBytes.length + " ").getBytes(StandardCharsets.UTF_8)); + pae.write(statementBytes); + } catch (final IOException e) { + // ByteArrayOutputStream#write(byte[]) never throws; this branch is unreachable. + throw new IllegalStateException(e); + } + return pae.toByteArray(); + } + + /** + * Not instantiable. + */ + private DsseUtils() { + } +} 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..3b7c7be91 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/internal/GitUtils.java @@ -0,0 +1,228 @@ +/* + * 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.BufferedReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +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 { + + /** + * Prefix used in a {@code gitfile} to point to the Git directory. + * + *

See gitrepository-layout.

+ */ + private static final String GITDIR_PREFIX = "gitdir: "; + /** + * Maximum number of symbolic-ref hops before we give up (to avoid cycles). + */ + private static final int MAX_REF_DEPTH = 5; + /** + * Prefix used in {@code HEAD} and ref files to indicate a symbolic reference. + */ + private static final String REF_PREFIX = "ref: "; + /** + * The SCM URI prefix for Git repositories. + */ + private static final String SCM_GIT_PREFIX = "scm:git:"; + + /** + * 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(final Path path) throws IOException { + Path current = path.toAbsolutePath(); + while (current != null) { + final 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" + final String content = new String(Files.readAllBytes(candidate), StandardCharsets.UTF_8).trim(); + if (content.startsWith(GITDIR_PREFIX)) { + return current.resolve(content.substring(GITDIR_PREFIX.length())); + } + } + current = current.getParent(); + } + throw new IOException("No .git directory found above: " + path); + } + + /** + * Gets 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(final Path repositoryPath) throws IOException { + final Path gitDir = findGitDir(repositoryPath); + final String head = readHead(gitDir); + if (head.startsWith("ref: refs/heads/")) { + return head.substring("ref: refs/heads/".length()); + } + // Detached HEAD: the file contains the commit SHA. + return head; + } + + /** + * Gets the commit SHA pointed to by {@code HEAD}. + * + *

Handles loose refs under {@code /refs/...}, packed refs in {@code /packed-refs}, + * symbolic indirection (a ref file that itself contains {@code ref: ...}), and detached HEAD.

+ * + * @param repositoryPath A path inside the Git repository. + * @return The hex-encoded commit SHA. + * @throws IOException If the {@code .git} directory cannot be found, the ref cannot be resolved, + * or the symbolic chain is deeper than {@value #MAX_REF_DEPTH}. + */ + public static String getHeadCommit(final Path repositoryPath) throws IOException { + final Path gitDir = findGitDir(repositoryPath); + String value = readHead(gitDir); + for (int i = 0; i < MAX_REF_DEPTH; i++) { + if (!value.startsWith(REF_PREFIX)) { + return value; + } + value = resolveRef(gitDir, value.substring(REF_PREFIX.length())); + } + throw new IOException("Symbolic ref chain exceeds " + MAX_REF_DEPTH + " hops in: " + gitDir); + } + + /** + * 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(final Path path) throws IOException { + if (!Files.isDirectory(path)) { + throw new IOException("Path is not a directory: " + path); + } + final MessageDigest digest = DigestUtils.getSha1Digest(); + return Hex.encodeHexString(GitIdentifiers.treeId(digest, path)); + } + + /** + * Reads and trims the {@code HEAD} file of the given Git directory. + * + * @param gitDir The {@code .git} directory. + * @return The trimmed contents of {@code /HEAD}. + * @throws IOException If the file cannot be read. + */ + private static String readHead(final Path gitDir) throws IOException { + return new String(Files.readAllBytes(gitDir.resolve("HEAD")), StandardCharsets.UTF_8).trim(); + } + + /** + * Returns the directory that holds shared repository state (loose refs, {@code packed-refs}). + * In a linked worktree this is read from {@code /commondir}; otherwise it is + * {@code gitDir} itself. + * + * @param gitDir The {@code .git} directory. + * @return The shared-state directory. + * @throws IOException If {@code commondir} exists but cannot be read. + */ + private static Path resolveCommonDir(final Path gitDir) throws IOException { + final Path commonDir = gitDir.resolve("commondir"); + if (Files.isRegularFile(commonDir)) { + final String value = new String(Files.readAllBytes(commonDir), StandardCharsets.UTF_8).trim(); + return gitDir.resolve(value).normalize(); + } + return gitDir; + } + + /** + * Resolves a single ref (e.g. {@code refs/heads/foo}) to its stored value. + * + *

The return value is either a commit SHA or another {@code ref: ...} line, which the caller continues to resolve.

+ * + *

In a linked worktree, loose and packed refs are stored in the "common dir" (usually the + * main repository's {@code .git}), which is pointed to by {@code /commondir}.

+ * + * @param gitDir The {@code .git} directory. + * @param refPath The ref path relative to the common dir (e.g. {@code refs/heads/main}). + * @return Either a commit SHA or another {@code ref: ...} line to be resolved by the caller. + * @throws IOException If the ref is not found as a loose file or in {@code packed-refs}. + */ + private static String resolveRef(final Path gitDir, final String refPath) throws IOException { + final Path refsDir = resolveCommonDir(gitDir); + final Path refFile = refsDir.resolve(refPath); + if (Files.isRegularFile(refFile)) { + return new String(Files.readAllBytes(refFile), StandardCharsets.UTF_8).trim(); + } + final Path packed = refsDir.resolve("packed-refs"); + if (Files.isRegularFile(packed)) { + try (BufferedReader reader = Files.newBufferedReader(packed, StandardCharsets.UTF_8)) { + // packed-refs format: one ref per line as " ", with '#' header lines, + // blank lines, and "^" peeled-tag continuation lines that we skip. + // See https://git-scm.com/docs/gitrepository-layout + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty() || line.charAt(0) == '#' || line.charAt(0) == '^') { + continue; + } + final int space = line.indexOf(' '); + if (space > 0 && refPath.equals(line.substring(space + 1))) { + return line.substring(0, space); + } + } + } + } + throw new IOException("Cannot resolve ref: " + refPath); + } + + /** + * 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(final String scmUri, final Path repositoryPath) throws IOException { + if (!scmUri.startsWith(SCM_GIT_PREFIX)) { + throw new IllegalArgumentException("Invalid scmUri: " + scmUri); + } + final String currentBranch = getCurrentBranch(repositoryPath); + return "git+" + scmUri.substring(SCM_GIT_PREFIX.length()) + "@" + currentBranch; + } + + /** + * 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..44c988052 --- /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 + * + *

Should not be referenced by external artifacts. Their API can change at any moment

+ */ +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..39c398ff7 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojo.java @@ -0,0 +1,490 @@ +/* + * 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.List; + +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.plugin.descriptor.PluginDescriptor; +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; + +/** + * Generates a SLSA v1.2 in-toto attestation covering all artifacts attached to the project. + * + *

The goal binds to the {@code post-integration-test} phase so the following constraints are + * satisfied:

+ *
    + *
  • it runs after {@code package}, so every build artifact is already attached to the project;
  • + *
  • it runs before {@code maven-gpg-plugin}'s {@code sign} goal (bound to {@code verify}), so + * the attestation file itself receives the detached {@code .asc} signature required by + * Maven Central;
  • + *
  • it runs before {@code detach-distributions} (also bound to {@code verify}), so the + * distribution archives ({@code tar.gz}, {@code zip}) are covered by the attestation before + * they are removed from the list of artifacts to deploy.
  • + *
+ * + *

Binding to an earlier lifecycle phase than {@code verify} is needed because Maven 3 cannot + * order executions of different plugins within the same phase in a way that satisfies all three + * constraints at once.

+ */ +@Mojo(name = "build-attestation", defaultPhase = LifecyclePhase.POST_INTEGRATION_TEST, 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 used to serialize SLSA statements and DSSE envelopes to JSON. + * + *

Each attestation is written as a single JSON value followed by a line separator, matching + * the JSON Lines format used by {@code .intoto.jsonl} + * files. The mapper is configured not to auto-close the output stream so the caller can append + * the trailing newline, and to emit ISO-8601 timestamps rather than numeric ones.

+ */ + 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); + } + + /** + * Checksum algorithms used in the generated attestation. + * + *

The default list is:

+ *
    + *
  • {@code SHA-1} and {@code MD5} are easily available from Maven Central without downloading the artifact;
  • + *
  • {@code SHA-512} and {@code SHA-256} provide more security to the signed attestation, if the artifact is downloaded.
  • + *
+ */ + @Parameter(property = "commons.release.checksums.algorithms", defaultValue = "SHA-512,SHA-256,SHA-1,MD5") + private String algorithmNames; + /** + * 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; + /** + * Path to the GPG executable; if not set, {@code gpg} is resolved from {@code PATH}. + */ + @Parameter(property = "gpg.executable") + private String executable; + /** + * 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; + /** + * 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; + /** + * The Maven home directory. + */ + @Parameter(defaultValue = "${maven.home}", readonly = true) + private File mavenHome; + /** + * Helper to attach artifacts to the project. + */ + private final MavenProjectHelper mavenProjectHelper; + /** + * The output directory for the attestation file. + */ + @Parameter(property = "commons.release.outputDirectory", defaultValue = "${project.build.directory}") + private File outputDirectory; + /** + * The current Maven project. + */ + private final MavenProject project; + /** + * Runtime information. + */ + private final RuntimeInformation runtimeInformation; + /** + * The SCM connection URL for the current project. + */ + @Parameter(defaultValue = "${project.scm.connection}", readonly = true) + private String scmConnectionUrl; + /** + * Issue SCM actions at this local directory. + */ + @Parameter(property = "commons.release.scmDirectory", defaultValue = "${basedir}") + private File scmDirectory; + /** + * The current Maven session, used to resolve plugin dependencies. + */ + private final MavenSession session; + /** + * Whether to sign the attestation envelope with GPG. + */ + @Parameter(property = "commons.release.signAttestation", defaultValue = "true") + private boolean signAttestation; + /** + * Descriptor of this plugin; used to fill in {@code builder.id} with the plugin's own + * Package URL so that consumers can resolve the exact code that produced the provenance. + */ + @Parameter(defaultValue = "${plugin}", readonly = true) + private PluginDescriptor pluginDescriptor; + /** + * GPG signer used for signing; lazily initialized from plugin parameters when {@code null}. + */ + private AbstractGpgSigner signer; + /** + * Whether to skip attaching the attestation artifact to the project. + */ + @Parameter(property = "commons.release.skipAttach", defaultValue = "false") + private boolean skipAttach; + /** + * 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; + + /** + * Creates a new instance with the given dependencies. + * + * @param project A Maven project. + * @param runtimeInformation Maven runtime information. + * @param session A Maven session. + * @param mavenProjectHelper A helper to attach artifacts to the project. + */ + @Inject + public BuildAttestationMojo(final MavenProject project, final RuntimeInformation runtimeInformation, + final MavenSession session, final MavenProjectHelper mavenProjectHelper) { + this.project = project; + this.runtimeInformation = runtimeInformation; + this.session = session; + this.mavenProjectHelper = mavenProjectHelper; + } + + /** + * 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 (final IOException e) { + throw new MojoExecutionException("Could not create output directory.", e); + } + return outputPath; + } + + @Override + public void execute() throws MojoFailureException, MojoExecutionException { + final BuildDefinition buildDefinition = new BuildDefinition() + .setExternalParameters(BuildDefinitions.externalParameters(session)) + .setResolvedDependencies(getBuildDependencies()); + final String builderId = String.format("pkg:maven/%s/%s@%s", + pluginDescriptor.getGroupId(), pluginDescriptor.getArtifactId(), pluginDescriptor.getVersion()); + final RunDetails runDetails = new RunDetails() + .setBuilder(new Builder().setId(builderId)) + .setMetadata(getBuildMetadata()); + final Provenance provenance = new Provenance() + .setBuildDefinition(buildDefinition) + .setRunDetails(runDetails); + final Statement statement = new Statement() + .setSubject(getSubjects()) + .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); + } + } + + /** + * Gets 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 { + final List dependencies = new ArrayList<>(); + try { + 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 (final IOException e) { + throw new MojoExecutionException(e); + } + dependencies.addAll(getProjectDependencies()); + return dependencies; + } + + /** + * Gets build metadata derived from the current Maven session, including start and finish timestamps. + * + * @return The build metadata. + */ + private BuildMetadata getBuildMetadata() { + final OffsetDateTime startedOn = session.getStartTime().toInstant().atOffset(ZoneOffset.UTC); + final OffsetDateTime finishedOn = OffsetDateTime.now(ZoneOffset.UTC); + return new BuildMetadata(null, startedOn, finishedOn); + } + + /** + * Gets 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 { + final List dependencies = new ArrayList<>(); + for (final Artifact artifact : project.getArtifacts()) { + dependencies.add(ArtifactUtils.toResourceDescriptor(artifact, algorithmNames)); + } + return dependencies; + } + + /** + * Gets 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 or the HEAD commit cannot be determined. + */ + private ResourceDescriptor getScmDescriptor() throws IOException { + final Path scmPath = scmDirectory.toPath(); + return new ResourceDescriptor() + .setUri(GitUtils.scmToDownloadUri(scmConnectionUrl, scmPath)) + .setDigest(Collections.singletonMap("gitCommit", GitUtils.getHeadCommit(scmPath))); + } + + /** + * Gets 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; + } + + /** + * 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 { + final List subjects = new ArrayList<>(); + subjects.add(ArtifactUtils.toResourceDescriptor(project.getArtifact(), algorithmNames)); + for (final Artifact artifact : project.getAttachedArtifacts()) { + subjects.add(ArtifactUtils.toResourceDescriptor(artifact, algorithmNames)); + } + return subjects; + } + + /** + * Sets the list of checksum algorithms to use. + * + * @param algorithmNames A comma-separated list of {@link java.security.MessageDigest} algorithm names to use. + */ + void setAlgorithmNames(final String algorithmNames) { + this.algorithmNames = algorithmNames; + } + + /** + * Sets the Maven home directory. + * + * @param mavenHome The Maven home directory. + */ + void setMavenHome(final File mavenHome) { + this.mavenHome = mavenHome; + } + + /** + * Sets the output directory for the attestation file. + * + * @param outputDirectory The output directory. + */ + void setOutputDirectory(final File outputDirectory) { + this.outputDirectory = outputDirectory; + } + + /** + * Sets the public SCM connection URL. + * + * @param scmConnectionUrl The SCM connection URL. + */ + void setScmConnectionUrl(final String scmConnectionUrl) { + this.scmConnectionUrl = scmConnectionUrl; + } + + /** + * Sets the SCM directory. + * + * @param scmDirectory The SCM directory. + */ + void setScmDirectory(final File scmDirectory) { + this.scmDirectory = scmDirectory; + } + + /** + * 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; + } + + /** + * Sets the plugin descriptor. Intended for testing. + * + * @param pluginDescriptor the plugin descriptor + */ + void setPluginDescriptor(final PluginDescriptor pluginDescriptor) { + this.pluginDescriptor = pluginDescriptor; + } + + /** + * Sets the GPG signer used for signing. Intended for testing. + * + * @param signer the signer to use + */ + void setSigner(final AbstractGpgSigner signer) { + this.signer = signer; + } + + /** + * 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 (final JsonProcessingException e) { + throw new MojoExecutionException("Failed to serialize attestation statement", e); + } + final byte[] sigBytes = DsseUtils.signStatement(getSigner(), statementBytes, outputPath); + + final Signature sig = new Signature() + .setKeyid(DsseUtils.getKeyId(sigBytes)) + .setSig(sigBytes); + final DsseEnvelope envelope = new DsseEnvelope() + .setPayload(statementBytes) + .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 (final 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()); + } + } + + /** + * 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); + } +} 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..43bcb94d5 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildDefinition.java @@ -0,0 +1,187 @@ +/* + * 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/proper/commons-release-plugin/slsa/v0.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; + } + + @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); + } + + /** + * Gets 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; + } + + /** + * 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 Map getExternalParameters() { + return externalParameters; + } + + /** + * 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 + */ + public Map getInternalParameters() { + return internalParameters; + } + + /** + * Gets 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; + } + + @Override + public int hashCode() { + return Objects.hash(buildType, externalParameters, internalParameters, resolvedDependencies); + } + + /** + * Sets the URI indicating what type of build was performed. + * + * @param buildType the build type URI + * @return this for chaining + */ + public BuildDefinition setBuildType(String buildType) { + this.buildType = buildType; + return this; + } + + /** + * Sets the inputs passed to the build. + * + * @param externalParameters the external parameters map + * @return this for chaining + */ + public BuildDefinition setExternalParameters(Map externalParameters) { + this.externalParameters = externalParameters; + return this; + } + + /** + * Sets the artifacts the build depends on. + * + * @param internalParameters the internal parameters map + * @return this for chaining + */ + public BuildDefinition setInternalParameters(Map internalParameters) { + this.internalParameters = internalParameters; + return this; + } + + /** + * Sets the materials that influenced the build. + * + * @param resolvedDependencies the list of resolved dependencies + * @return this for chaining + */ + public BuildDefinition setResolvedDependencies(List resolvedDependencies) { + this.resolvedDependencies = resolvedDependencies; + return this; + } + + @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..595e0f714 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/BuildMetadata.java @@ -0,0 +1,141 @@ +/* + * 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 { + + /** Timestamp when the build completed. */ + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + @JsonProperty("finishedOn") + private OffsetDateTime finishedOn; + /** 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; + + /** 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; + } + + @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); + } + + /** + * 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 + */ + public OffsetDateTime getFinishedOn() { + return finishedOn; + } + + /** + * Gets the identifier for this build invocation. + * + * @return the invocation identifier, or {@code null} if not set + */ + public String getInvocationId() { + return invocationId; + } + + /** + * 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 + */ + public OffsetDateTime getStartedOn() { + return startedOn; + } + + @Override + public int hashCode() { + return Objects.hash(invocationId, startedOn, finishedOn); + } + + /** + * Sets the timestamp of when the build completed. + * + * @param finishedOn the completion timestamp + * @return this for chaining + */ + public BuildMetadata setFinishedOn(OffsetDateTime finishedOn) { + this.finishedOn = finishedOn; + return this; + } + + /** + * Sets the identifier for this build invocation. + * + * @param invocationId the invocation identifier + * @return this for chaining + */ + public BuildMetadata setInvocationId(String invocationId) { + this.invocationId = invocationId; + return this; + } + + /** + * Sets the timestamp of when the build started. + * + * @param startedOn the start timestamp + * @return this for chaining + */ + public BuildMetadata setStartedOn(OffsetDateTime startedOn) { + this.startedOn = startedOn; + return this; + } + + @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..508d622a8 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Builder.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.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 { + + /** Orchestrator dependencies that may affect provenance generation. */ + @JsonProperty("builderDependencies") + private List builderDependencies = new ArrayList<>(); + /** Identifier URI of the builder. */ + @JsonProperty("id") + private String id; + /** Map of build platform component names to their versions. */ + @JsonProperty("version") + private Map version = new HashMap<>(); + + /** Creates a new Builder instance. */ + public Builder() { + } + + @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); + } + + /** + * 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 + */ + public List getBuilderDependencies() { + return builderDependencies; + } + + /** + * Gets the identifier of the builder. + * + * @return the builder identifier URI + */ + public String getId() { + return id; + } + + /** + * Gets 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; + } + + @Override + public int hashCode() { + return Objects.hash(id, builderDependencies, version); + } + + /** + * Sets the orchestrator dependencies that may affect provenance generation or security guarantees. + * + * @param builderDependencies the list of builder dependencies + * @return this for chaining + */ + public Builder setBuilderDependencies(List builderDependencies) { + this.builderDependencies = builderDependencies; + return this; + } + + /** + * Sets the identifier of the builder. + * + * @param id the builder identifier URI + * @return this for chaining + */ + public Builder setId(String id) { + this.id = id; + return this; + } + + /** + * Sets the map of build platform component names to their versions. + * + * @param version the version map + * @return this for chaining + */ + public Builder setVersion(Map version) { + this.version = version; + return this; + } + + @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/DsseEnvelope.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java new file mode 100644 index 000000000..e68e5757c --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/DsseEnvelope.java @@ -0,0 +1,130 @@ +/* + * 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. + * + * @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"; + /** Serialized statement bytes, Base64-encoded in JSON. */ + @JsonProperty("payload") + private byte[] payload; + /** Content type identifying the format of {@link #payload}. */ + @JsonProperty("payloadType") + private String payloadType = PAYLOAD_TYPE; + /** 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() { + } + + @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); + } + + /** + * Gets 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; + } + + /** + * Gets the payload type URI. + * + * @return the payload type, never {@code null} in a valid envelope + */ + public String getPayloadType() { + return payloadType; + } + + /** + * Gets the list of signatures over the PAE-encoded payload. + * + * @return the signatures, or {@code null} if not set + */ + public List getSignatures() { + return signatures; + } + + @Override + public int hashCode() { + return Objects.hash(payloadType, Arrays.hashCode(payload), signatures); + } + + /** + * Sets the serialized payload bytes. + * + * @param payload the payload bytes + * @return this for chaining + */ + public DsseEnvelope setPayload(byte[] payload) { + this.payload = payload; + return this; + } + + /** + * Sets the payload type URI. + * + * @param payloadType the payload type URI + * @return this for chaining + */ + public DsseEnvelope setPayloadType(String payloadType) { + this.payloadType = payloadType; + return this; + } + + /** + * Sets the list of signatures over the PAE-encoded payload. + * + * @param signatures the signatures + * @return this for chaining + */ + public DsseEnvelope setSignatures(List signatures) { + this.signatures = signatures; + return this; + } + + @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/Provenance.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java new file mode 100644 index 000000000..6002dce7e --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Provenance.java @@ -0,0 +1,124 @@ +/* + * 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; + } + + @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); + } + + /** + * 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; + } + + /** + * 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; + } + + @Override + public int hashCode() { + return Objects.hash(buildDefinition, runDetails); + } + + /** + * Sets the build definition describing all inputs that produced the build output. + * + * @param buildDefinition the build definition + * @return this for chaining + */ + public Provenance setBuildDefinition(BuildDefinition buildDefinition) { + this.buildDefinition = buildDefinition; + return this; + } + + /** + * Sets the details about the invocation of the build tool and the environment in which it was run. + * + * @param runDetails the run details + * @return this for chaining + */ + public Provenance setRunDetails(RunDetails runDetails) { + this.runDetails = runDetails; + return this; + } + + @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..cb3510ed1 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/ResourceDescriptor.java @@ -0,0 +1,235 @@ +/* + * 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 { + + /** Additional key-value metadata about the resource. */ + @JsonProperty("annotations") + private Map annotations; + /** Raw contents of the resource, base64-encoded in JSON. */ + @JsonProperty("content") + private byte[] content; + /** Map of digest algorithm names to hex-encoded values. */ + @JsonProperty("digest") + private Map digest; + /** 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; + /** Human-readable name of the resource. */ + @JsonProperty("name") + private String name; + /** URI identifying the resource. */ + @JsonProperty("uri") + private String uri; + + /** 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; + } + + @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); + } + + /** + * 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 + */ + public Map getAnnotations() { + return annotations; + } + + /** + * Gets 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; + } + + /** + * Gets 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; + } + + /** + * Gets 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; + } + + /** + * Gets 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; + } + + /** + * Gets the name of the resource. + * + * @return the resource name, or {@code null} if not set + */ + public String getName() { + return name; + } + + /** + * Gets the URI identifying the resource. + * + * @return the resource URI, or {@code null} if not set + */ + public String getUri() { + return uri; + } + + @Override + public int hashCode() { + return Objects.hash(uri, digest); + } + + /** + * Sets additional key-value metadata about the resource. + * + * @param annotations the annotations map + * @return this for chaining + */ + public ResourceDescriptor setAnnotations(Map annotations) { + this.annotations = annotations; + return this; + } + + /** + * Sets the raw contents of the resource. + * + * @param content the resource content + * @return this for chaining + */ + public ResourceDescriptor setContent(byte[] content) { + this.content = content; + return this; + } + + /** + * Sets the map of cryptographic digest algorithms to their hex-encoded values. + * + * @param digest the digest map + * @return this for chaining + */ + public ResourceDescriptor setDigest(Map digest) { + this.digest = digest; + return this; + } + + /** + * Sets the download URI for the resource. + * + * @param downloadLocation the download location URI + * @return this for chaining + */ + public ResourceDescriptor setDownloadLocation(String downloadLocation) { + this.downloadLocation = downloadLocation; + return this; + } + + /** + * Sets the media type of the resource. + * + * @param mediaType the media type + * @return this for chaining + */ + public ResourceDescriptor setMediaType(String mediaType) { + this.mediaType = mediaType; + return this; + } + + /** + * Sets the name of the resource. + * + * @param name the resource name + * @return this for chaining + */ + public ResourceDescriptor setName(String name) { + this.name = name; + return this; + } + + /** + * Sets the URI identifying the resource. + * + * @param uri the resource URI + * @return this for chaining + */ + public ResourceDescriptor setUri(String uri) { + this.uri = uri; + return this; + } + + @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..da14aefc4 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/RunDetails.java @@ -0,0 +1,149 @@ +/* + * 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; + /** + * Artifacts produced as a side effect of the build. + */ + @JsonProperty("byproducts") + private List byproducts; + /** + * Metadata about the build invocation. + */ + @JsonProperty("metadata") + private BuildMetadata metadata; + + /** + * 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; + } + + @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); + } + + /** + * Gets 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; + } + + /** + * 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 List getByproducts() { + return byproducts; + } + + /** + * 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; + } + + @Override + public int hashCode() { + return Objects.hash(builder, metadata, byproducts); + } + + /** + * Sets the builder that executed the invocation. + * + * @param builder the builder + * @return this for chaining + */ + public RunDetails setBuilder(Builder builder) { + this.builder = builder; + return this; + } + + /** + * Sets the artifacts produced as a side effect of the build that are not the primary output. + * + * @param byproducts the list of byproduct artifacts + * @return this for chaining + */ + public RunDetails setByproducts(List byproducts) { + this.byproducts = byproducts; + return this; + } + + /** + * Sets the metadata about the build invocation. + * + * @param metadata the build metadata + * @return this for chaining + */ + public RunDetails setMetadata(BuildMetadata metadata) { + this.metadata = metadata; + return this; + } + + @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/Signature.java b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java new file mode 100644 index 000000000..77e769805 --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Signature.java @@ -0,0 +1,107 @@ +/* + * 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. + * + * @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 of the PAE-encoded payload. */ + @JsonProperty("sig") + private byte[] sig; + + /** Creates a new Signature instance. */ + public Signature() { + } + + @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); + } + + /** + * Gets the key identifier hint, or {@code null} if not set. + * + * @return the key identifier, or {@code null} + */ + public String getKeyid() { + return keyid; + } + + /** + * Gets the raw signature bytes. + * + * @return the signature bytes, or {@code null} if not set + */ + public byte[] getSig() { + return sig; + } + + @Override + public int hashCode() { + return Objects.hash(keyid, Arrays.hashCode(sig)); + } + + /** + * Sets the key identifier hint. + * + * @param keyid the key identifier, or {@code null} to leave unset + * @return this for chaining + */ + public Signature setKeyid(String keyid) { + this.keyid = keyid; + return this; + } + + /** + * Sets the raw signature bytes. + * + * @param sig the signature bytes + * @return this for chaining + */ + public Signature setSig(byte[] sig) { + this.sig = sig; + return this; + } + + @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..1d779c63d --- /dev/null +++ b/src/main/java/org/apache/commons/release/plugin/slsa/v1_2/Statement.java @@ -0,0 +1,131 @@ +/* + * 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. */ + public static final String TYPE = "https://in-toto.io/Statement/v1"; + /** The provenance predicate. */ + @JsonProperty("predicate") + private Provenance predicate; + /** URI identifying the type of the predicate. */ + @JsonProperty("predicateType") + private String predicateType; + /** Software artifacts that the attestation applies to. */ + @JsonProperty("subject") + private List subject; + + /** Creates a new Statement instance. */ + public Statement() { + } + + @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); + } + + /** + * Type of JSON object. + * + * @return Always {@value TYPE} + */ + @JsonProperty("_type") + public String getType() { + return TYPE; + } + + /** + * Gets 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; + } + + /** + * Gets 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; + } + + /** + * 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.

+ * + * @return the list of subject artifacts, or {@code null} if not set + */ + public List getSubject() { + return subject; + } + + @Override + public int hashCode() { + return Objects.hash(subject, predicateType, predicate); + } + + /** + * Sets the provenance predicate and automatically assigns {@code predicateType} to the SLSA provenance v1 URI. + * + * @param predicate the provenance predicate + * @return this for chaining + */ + public Statement setPredicate(Provenance predicate) { + this.predicate = predicate; + this.predicateType = Provenance.PREDICATE_TYPE; + return this; + } + + /** + * Sets the set of software artifacts that the attestation applies to. + * + * @param subject the list of subject artifacts + * @return this for chaining + */ + public Statement setSubject(List subject) { + this.subject = subject; + return this; + } + + @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/site/markdown/build-attestation.md b/src/site/markdown/build-attestation.md new file mode 100644 index 000000000..49b96a68c --- /dev/null +++ b/src/site/markdown/build-attestation.md @@ -0,0 +1,105 @@ + +# commons-release:build-attestation + +## Overview + +The `commons-release:build-attestation` goal produces a [SLSA](https://slsa.dev/) v1.2 provenance +statement in the [in-toto](https://in-toto.io/) format. The attestation lists every artifact +attached to the project as a subject, records the JDK, Maven installation, SCM source and +resolved dependencies used during the build, and writes the result to +`target/-.intoto.jsonl`. The envelope is signed with GPG by default and +attached to the project so that it is deployed alongside the other artifacts. + +The structure of the `predicate.buildDefinition.buildType` field is documented at +[SLSA build type v0.1.0](slsa/v0.1.0.html). + +## Phase ordering + +A Commons release relies on three goals running in a fixed order: + +1. `commons-release:build-attestation`, bound to `post-integration-test`. At this point every + build artifact, including the distribution archives, is already attached to the project. +2. `maven-gpg-plugin:sign`, bound to `verify`. It signs every attached artifact with a detached + `.asc`, including the `.intoto.jsonl` produced in step 1. Maven Central requires this for + every uploaded file. +3. `commons-release:detach-distributions`, bound to `verify`. It removes the `.tar.gz` and + `.zip` archives from the set of artifacts that will be uploaded to Nexus. + +Binding `build-attestation` to `post-integration-test` (rather than `verify`) puts it in an +earlier lifecycle phase than the other two goals, so Maven 3 is guaranteed to run it first, +regardless of the order in which plugins are declared in the POM. Within the `verify` phase, +`sign` must run before `detach-distributions`; this is controlled by declaring +`maven-gpg-plugin` before `commons-release-plugin` in the POM, since Maven executes plugins +within a single phase in the order they appear. + +If the distribution archives should not be covered by the attestation, override the default +phase binding and bind `build-attestation` to `verify` after `detach-distributions`. + +## Example configuration + +The snippet below wires the three goals in the recommended order. + +```xml + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + verify + + sign + + + + + + org.apache.commons + commons-release-plugin + + + + build-attestation + + build-attestation + + + + detach-distributions + verify + + detach-distributions + + + + + + +``` + +See the [goal parameters](build-attestation-mojo.html) for the full list of configurable +properties. 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..adb6f0668 --- /dev/null +++ b/src/site/markdown/slsa/v0.1.0.md @@ -0,0 +1,281 @@ + + +# Build Type: Apache Commons Maven Release + +```jsonc +"buildType": "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0" +``` + +This document defines a [SLSA v1.2 Build Provenance](https://slsa.dev/spec/v1.2/build-provenance) **build type** for +releases of Apache Commons components. + +Apache Commons releases are cut on a PMC release manager's workstation by invoking Maven against a checkout of the +project's Git repository. The `commons-release-plugin` captures the build inputs and emits the result as an in-toto +attestation covering every artifact attached to the project. + +Because the build runs on the release manager's own hardware rather than on a hosted build service, the provenance +corresponds to [SLSA Build Level 1](https://slsa.dev/spec/v1.2/levels): it is generated by the same process that +produces the artifacts and is signed with the release manager's OpenPGP key, but the build platform itself is not +separately attested. + +The OpenPGP keys used to sign past and present artifacts are available at: https://downloads.apache.org/commons/KEYS + +Attestations are serialized in the [JSON Lines](https://jsonlines.org/) format used across the +in-toto ecosystem, one JSON value per line, and published to Maven Central under the released +artifact's coordinates with an `intoto.jsonl` type: + +```xml + + + org.apache.commons + ${artifactId} + intoto.jsonl + ${version} + +``` + +## 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 (for example `["deploy"]`). | +| `maven.profiles` | string[] | The list of active profiles passed via `-P` (for example `["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 | +|-------------------------------------|------------------------------------------|--------------------------------------------------------------------------| +| `home` | `java.home` | Java installation directory. | +| `specification.maintenance.version` | `java.specification.maintenance.version` | Java Runtime Environment specification maintenance version _(optional)_. | +| `specification.name` | `java.specification.name` | Java Runtime Environment specification name. | +| `specification.vendor` | `java.specification.vendor` | Java Runtime Environment specification vendor. | +| `specification.version` | `java.specification.version` | Java Runtime Environment specification version. | +| `vendor` | `java.vendor` | Java Runtime Environment vendor. | +| `vendor.url` | `java.vendor.url` | Java vendor URL. | +| `vendor.version` | `java.vendor.version` | Java vendor version _(optional)_. | +| `version` | `java.version` | Java Runtime Environment version. | +| `version.date` | `java.version.date` | Java Runtime Environment version date, in ISO-8601 YYYY-MM-DD format. | +| `vm.name` | `java.vm.name` | Java Virtual Machine implementation name. | +| `vm.specification.name` | `java.vm.specification.name` | Java Virtual Machine specification name. | +| `vm.specification.vendor` | `java.vm.specification.vendor` | Java Virtual Machine specification vendor. | +| `vm.specification.version` | `java.vm.specification.version` | Java Virtual Machine specification version. | +| `vm.vendor` | `java.vm.vendor` | Java Virtual Machine implementation vendor. | +| `vm.version` | `java.vm.version` | Java Virtual Machine implementation version. | + +#### 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 Maven distribution. | +| `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, for example `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 the [Package URL](https://github.com/package-url/purl-spec) of the +`commons-release-plugin` release that produced the attestation, for example +`pkg:maven/org.apache.commons/commons-release-plugin@1.9.3`. It identifies the trust boundary of +the "build platform": the exact plugin code that emitted the provenance. Verifiers can resolve the +PURL to the signed artifact on Maven Central to inspect the builder. + +## Subjects + +The [`subject`](https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md#fields) array +lists every artifact produced by the build. It has the following properties + +| Field | Value | +|----------|-------------------------------------------------------------------------------------------------------------------------------------| +| `name` | Artifact filename in the default Maven repository layout, for example `commons-text-1.4-sources.jar`. | +| `uri` | [Package URL](https://github.com/package-url/purl-spec) identifying the artifact in the `maven` namespace. | +| `digest` | Map of [in-toto digest names](https://github.com/in-toto/attestation/blob/main/spec/v1/digest_set.md) to hex-encoded digest values. | + +By default, every subject carries `md5`, `sha1`, `sha256` and `sha512` digests. + +## Example + +The following is the bare attestation statement produced for the `commons-text` 1.4 release +(abridged: most subjects are elided, and the JDK annotations trimmed). The full fixture lives at +[ +`src/test/resources/attestations/commons-text-1.4.intoto.json`](https://github.com/apache/commons-release-plugin/blob/main/src/test/resources/attestations/commons-text-1.4.intoto.json) +in the plugin source tree. + +The statement shown below is wrapped in +a [DSSE envelope](https://github.com/secure-systems-lab/dsse/blob/master/envelope.md) +signed with the release manager's OpenPGP key, and the `.intoto.jsonl` file deployed to Maven Central +contains that envelope. + +```json5 +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "commons-text-1.4.jar", + "uri": "pkg:maven/commons-text/commons-text@1.4?type=jar", + "digest": { + "md5": "9cbe22bb0ce86c70779213dfb7f3eb5a", + "sha1": "c81f089b3542485d4d09b02aae822906e5d2f209", + "sha256": "ad2d2eacf15ab740c115294afc1192603d8342004a6d7d0ad35446f7dda8a134", + "sha512": "126302c5f6865733774eb41fecc10ba8d0bb5ba11d14b9562047429abeb13bf8cdcdbfdf5e7d7708e2a40f67f4265cbbce609164f57abcd676067a840aa48e6a" + } + }, + // … one entry per attached artifact (POM, sources, javadoc, tests, and distribution archives) … + { + "name": "commons-text-1.4-src.zip", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=src&type=zip", + "digest": { + "md5": "fd65603e930f2b0805c809aa2deb1498", + "sha1": "ca1cc6fbb4e46b44f8bb09b70c9e3a2ae3c5fce8", + "sha256": "e4a6c992153faae4f7faff689b899073000364e376736b9746a5d0acb9d8b980", + "sha512": "79ca61ff7b287407428bbb6ae13c6d372dcd0665114c55cd5bc57978a6fa760305e32feabef62cfeb0c4181220a59406239f6cccaa9a25c68773eef0250cb3a9" + } + } + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0", + "externalParameters": { + "maven.goals": [ + "deploy" + ], + "maven.profiles": [ + "release" + ], + "maven.user.properties": { + "gpg.keyname": "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9" + }, + "maven.cmdline": "deploy -Prelease -Dgpg.keyname=3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9", + "jvm.args": [ + "-Dfile.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8" + ], + "env": { + "LANG": "pl_PL.UTF-8", + "TZ": "UTC" + } + }, + "internalParameters": {}, + "resolvedDependencies": [ + // JDK that ran the build + { + "name": "JDK", + "digest": { + "gitTree": "bdb67e47c1b7df9c35ae045f29a348bb5bd32dc3" + }, + "annotations": { + "home": "/usr/lib/jvm/temurin-25-jdk-amd64", + "specification.maintenance.version": null, + "specification.name": "Java Platform API Specification", + "specification.vendor": "Oracle Corporation", + "specification.version": "25", + // … remaining java.* system properties elided … + } + }, + // Maven installation + { + "name": "Maven", + "uri": "pkg:maven/org.apache.maven/apache-maven@3.9.12", + "digest": { + "gitTree": "3cdb4a67690dc18373f70ead98dc86567cc5ad67" + }, + "annotations": { + "distributionId": "apache-maven", + "distributionName": "Apache Maven", + "distributionShortName": "Maven", + "buildNumber": "848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1", + "version": "3.9.12" + } + }, + // Source revision (branch or tag at release time) + { + "uri": "git+https://github.com/apache/commons-text.git@rel/commons-text-1.4", + "digest": { + "gitCommit": "f519b3670795da3fb4f43b6af1f727eadf8e6800" + } + } + ] + }, + "runDetails": { + "builder": { + "id": "pkg:maven/org.apache.commons/commons-release-plugin@1.9.3", + "builderDependencies": [], + "version": {} + }, + "metadata": { + "startedOn": "2026-04-20T09:28:44Z", + "finishedOn": "2026-04-20T09:38:12Z" + } + } + } +} +``` + +## Version history + +### v0.1.0 + +Initial version. \ No newline at end of file diff --git a/src/site/xdoc/index.xml b/src/site/xdoc/index.xml index ed9ce8dbc..36792674f 100644 --- a/src/site/xdoc/index.xml +++ b/src/site/xdoc/index.xml @@ -52,6 +52,10 @@ code readability):

    +
  • + commons-release:build-attestation generates a signed + SLSA v1.2 in-toto attestation covering all build artifacts and attaches it to the project. +
  • commons-release:detach-distributions - Remove tar.gz, tar.gz.asc, zip, and zip.asc 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..dac28a51c --- /dev/null +++ b/src/test/java/org/apache/commons/release/plugin/internal/BuildDefinitionsTest.java @@ -0,0 +1,73 @@ +/* + * 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.junit.jupiter.api.Assertions.assertEquals; + +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(), toProperties("foo", "bar"), "verify -Dfoo=bar"), + Arguments.of("goals, profile and property", singletonList("verify"), singletonList("release"), toProperties("foo", "bar"), + "verify -Prelease -Dfoo=bar"), + Arguments.of("redacts gpg.passphrase", singletonList("verify"), emptyList(), toProperties("gpg.passphrase", "s3cr3t"), "verify"), + Arguments.of("redacts passphrase case-insensitively", singletonList("verify"), emptyList(), toProperties("GPG_PASSPHRASE", "s3cr3t"), "verify"), + Arguments.of("redacts any *password*", singletonList("verify"), emptyList(), toProperties("my.db.password", "hunter2"), "verify"), + Arguments.of("redacts *token*", singletonList("verify"), emptyList(), toProperties("github.token", "ghp_xxx"), "verify"), + Arguments.of("keeps safe property, drops sensitive one", singletonList("verify"), emptyList(), + toProperties("foo", "bar", "gpg.passphrase", "s3cr3t"), "verify -Dfoo=bar") + ); + } + + private static Properties toProperties(final String... keysAndValues) { + final Properties p = new Properties(); + for (int i = 0; i < keysAndValues.length; i += 2) { + p.setProperty(keysAndValues[i], keysAndValues[i + 1]); + } + return p; + } + + @ParameterizedTest(name = "{0}") + @MethodSource("commandLineArguments") + void commandLineTest(final String description, final List goals, final List profiles, + final Properties userProperties, final String expected) { + final MavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setGoals(goals); + request.setActiveProfiles(profiles); + request.setUserProperties(userProperties); + assertEquals(expected, BuildDefinitions.commandLine(request)); + } +} diff --git a/src/test/java/org/apache/commons/release/plugin/internal/GitFixture.java b/src/test/java/org/apache/commons/release/plugin/internal/GitFixture.java new file mode 100644 index 000000000..8e534a8b0 --- /dev/null +++ b/src/test/java/org/apache/commons/release/plugin/internal/GitFixture.java @@ -0,0 +1,99 @@ +/* + * 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.util.Map; + +import org.apache.commons.exec.CommandLine; +import org.apache.commons.exec.DefaultExecutor; +import org.apache.commons.exec.Executor; +import org.apache.commons.exec.PumpStreamHandler; +import org.apache.commons.exec.environment.EnvironmentUtils; +import org.apache.commons.io.output.NullOutputStream; + +/** + * Builds real Git fixtures on disk by invoking the {@code git} CLI via Commons Exec + */ +final class GitFixture { + + static final String REPO_BRANCH = "foo"; + static final String WORKTREE_BRANCH = "bar"; + static final String SUBDIR = "subdir"; + /** + * SHA-1 of the single commit produced by {@link #createRepoAndWorktree}; deterministic thanks to {@link #ENV}. + */ + static final String INITIAL_COMMIT_SHA = "a2782b3461d2ed2a81193da1139f65bf9d2befc2"; + + /** + * Process environment with fixed author/committer dates so commit SHAs are stable across runs. + */ + private static final Map ENV; + + static { + try { + final Map env = EnvironmentUtils.getProcEnvironment(); + env.put("GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z"); + env.put("GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z"); + ENV = env; + } catch (final IOException e) { + throw new ExceptionInInitializerError(e); + } + } + + /** + * Creates a Git repo for testing. + * + * @param repo Path to the repository to create + * @param worktree Path to a separate worktree to create + */ + static void createRepoAndWorktree(final Path repo, final Path worktree) throws IOException { + final Path subdir = repo.resolve(SUBDIR); + Files.createDirectories(subdir); + git(repo, "init", "-q", "."); + // Put HEAD on 'foo' before the first commit (portable to older git without --initial-branch). + git(repo, "symbolic-ref", "HEAD", "refs/heads/" + REPO_BRANCH); + git(repo, "config", "user.email", "test@example.invalid"); + git(repo, "config", "user.name", "Test"); + git(repo, "config", "commit.gpgsign", "false"); + final Path readme = subdir.resolve("README"); + Files.write(readme, "hi\n".getBytes(StandardCharsets.UTF_8)); + git(repo, "add", repo.relativize(readme).toString()); + git(repo, "commit", "-q", "-m", "init"); + git(repo, "branch", WORKTREE_BRANCH); + git(repo, "worktree", "add", "-q", repo.relativize(worktree).toString(), "bar"); + } + + /** + * Runs {@code git} with the given args; stdout is discarded, stderr is forwarded to {@link System#err}. + */ + static void git(final Path workingDir, final String... args) throws IOException { + final CommandLine cmd = new CommandLine("git"); + for (final String a : args) { + cmd.addArgument(a, false); + } + final Executor exec = DefaultExecutor.builder().setWorkingDirectory(workingDir.toFile()).get(); + exec.setStreamHandler(new PumpStreamHandler(NullOutputStream.INSTANCE, System.err)); + exec.execute(cmd, ENV); + } + + private GitFixture() { + } +} diff --git a/src/test/java/org/apache/commons/release/plugin/internal/GitUtilsTest.java b/src/test/java/org/apache/commons/release/plugin/internal/GitUtilsTest.java new file mode 100644 index 000000000..b6e35ae9f --- /dev/null +++ b/src/test/java/org/apache/commons/release/plugin/internal/GitUtilsTest.java @@ -0,0 +1,139 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class GitUtilsTest { + + private static Path repo; + @TempDir + static Path tempDir; + private static Path worktree; + + @BeforeAll + static void setUp() throws IOException { + repo = tempDir.resolve("repo"); + worktree = tempDir.resolve("worktree"); + GitFixture.createRepoAndWorktree(repo, worktree); + } + + static Stream testGetCurrentBranch() { + return Stream.of(Arguments.of(repo, GitFixture.REPO_BRANCH), Arguments.of(repo.resolve(GitFixture.SUBDIR), GitFixture.REPO_BRANCH), + Arguments.of(worktree, GitFixture.WORKTREE_BRANCH), Arguments.of(worktree.resolve(GitFixture.SUBDIR), GitFixture.WORKTREE_BRANCH)); + } + + static Stream testGetHeadCommit() { + return Stream.of( + Arguments.of(repo, GitFixture.INITIAL_COMMIT_SHA), + Arguments.of(repo.resolve(GitFixture.SUBDIR), GitFixture.INITIAL_COMMIT_SHA), + Arguments.of(worktree, GitFixture.INITIAL_COMMIT_SHA), + Arguments.of(worktree.resolve(GitFixture.SUBDIR), GitFixture.INITIAL_COMMIT_SHA)); + } + + static Stream testScmToDownloadUri() { + return Stream.of( + Arguments.of("scm:git:https://gitbox.apache.org/repos/asf/commons-release-plugin.git", + repo, + "git+https://gitbox.apache.org/repos/asf/commons-release-plugin.git@" + GitFixture.REPO_BRANCH), + Arguments.of("scm:git:git@github.com:apache/commons-release-plugin.git", + repo, + "git+git@github.com:apache/commons-release-plugin.git@" + GitFixture.REPO_BRANCH), + Arguments.of("scm:git:ssh://git@github.com/apache/commons-release-plugin.git", + worktree, + "git+ssh://git@github.com/apache/commons-release-plugin.git@" + GitFixture.WORKTREE_BRANCH)); + } + + @ParameterizedTest + @MethodSource + void testGetCurrentBranch(final Path repo, final String expectedBranchName) throws Exception { + assertEquals(expectedBranchName, GitUtils.getCurrentBranch(repo)); + } + + @Test + void testGetCurrentBranchDetachedHead() throws IOException { + // Build a fresh repo so we don't mutate HEAD shared with the parameterized tests. + final Path detachedRepo = tempDir.resolve("detached-repo"); + final Path detachedWorktree = tempDir.resolve("detached-worktree"); + GitFixture.createRepoAndWorktree(detachedRepo, detachedWorktree); + GitFixture.git(detachedRepo, "checkout", "-q", "--detach", "HEAD"); + assertEquals(GitFixture.INITIAL_COMMIT_SHA, GitUtils.getCurrentBranch(detachedRepo)); + } + + @ParameterizedTest + @MethodSource + void testGetHeadCommit(final Path repositoryPath, final String expectedSha) throws IOException { + assertEquals(expectedSha, GitUtils.getHeadCommit(repositoryPath)); + } + + @Test + void testGetHeadCommitDetachedHead() throws IOException { + final Path detachedRepo = tempDir.resolve("detached-head-commit-repo"); + final Path detachedWorktree = tempDir.resolve("detached-head-commit-worktree"); + GitFixture.createRepoAndWorktree(detachedRepo, detachedWorktree); + GitFixture.git(detachedRepo, "checkout", "-q", "--detach", "HEAD"); + assertEquals(GitFixture.INITIAL_COMMIT_SHA, GitUtils.getHeadCommit(detachedRepo)); + } + + @Test + void testGetHeadCommitPackedRefs() throws IOException { + final Path packedRepo = tempDir.resolve("packed-repo"); + final Path packedWorktree = tempDir.resolve("packed-worktree"); + GitFixture.createRepoAndWorktree(packedRepo, packedWorktree); + // Move all loose refs (branches, tags) into .git/packed-refs and delete the loose files. + GitFixture.git(packedRepo, "pack-refs", "--all", "--prune"); + assertEquals(GitFixture.INITIAL_COMMIT_SHA, GitUtils.getHeadCommit(packedRepo)); + } + + @ParameterizedTest + @MethodSource + void testScmToDownloadUri(final String scmUri, final Path repositoryPath, final String expectedDownloadUri) throws IOException { + assertEquals(expectedDownloadUri, GitUtils.scmToDownloadUri(scmUri, repositoryPath)); + } + + @ParameterizedTest + @ValueSource(strings = { + "scm:svn:https://svn.apache.org/repos/asf/commons-release-plugin", + "scm:hg:https://example.com/repo", + "https://github.com/apache/commons-release-plugin.git", + "git:https://github.com/apache/commons-release-plugin.git", + "" + }) + void testScmToDownloadUriRejectsNonGit(final String scmUri) { + assertThrows(IllegalArgumentException.class, () -> GitUtils.scmToDownloadUri(scmUri, repo)); + } + + @Test + void throwsWhenNoGitDirectoryFound() throws IOException { + final Path plain = Files.createDirectories(tempDir.resolve("plain")); + assertThrows(IOException.class, () -> GitUtils.getCurrentBranch(plain)); + } +} 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..f751b114b --- /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 { + + public static RepositorySystemSession createRepositorySystemSession( + final PlexusContainer container, final Path localRepositoryPath) throws ComponentLookupException, RepositoryException { + final LocalRepositoryManagerFactory factory = container.lookup(LocalRepositoryManagerFactory.class, "simple"); + final DefaultRepositorySystemSession repoSession = new DefaultRepositorySystemSession(); + final 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; + } + + public static PlexusContainer setupContainer() throws PlexusContainerException { + return new DefaultPlexusContainer(setupContainerConfiguration()); + } + + private static ContainerConfiguration setupContainerConfiguration() { + final ClassWorld classWorld = + new ClassWorld("plexus.core", Thread.currentThread().getContextClassLoader()); + return new DefaultContainerConfiguration() + .setClassWorld(classWorld) + .setClassPathScanning(PlexusConstants.SCANNING_INDEX) + .setAutoWiring(true) + .setName("maven"); + } + + 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..fc507375a --- /dev/null +++ b/src/test/java/org/apache/commons/release/plugin/mojos/BuildAttestationMojoTest.java @@ -0,0 +1,258 @@ +/* + * 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.JsonAssert.assertJsonEquals; +import static net.javacrumbs.jsonunit.JsonAssert.assertJsonNodeAbsent; +import static net.javacrumbs.jsonunit.JsonAssert.assertJsonNodePresent; +import static net.javacrumbs.jsonunit.JsonAssert.assertJsonPartEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +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.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.Iterator; +import java.util.Properties; +import java.util.Set; +import java.util.TreeSet; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.javacrumbs.jsonunit.JsonAssert; +import net.javacrumbs.jsonunit.core.Option; +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; +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.model.io.xpp3.MavenXpp3Reader; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.descriptor.PluginDescriptor; +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.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 { + + private static final String ARTIFACTS_DIR = "src/test/resources/mojos/detach-distributions/target/"; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static PlexusContainer container; + private static JsonNode expectedStatement; + @TempDir + private static Path localRepositoryPath; + private static PluginDescriptor pluginDescriptor; + private static RepositorySystemSession repoSession; + + private static void assertStatementContent(final JsonNode statement) { + // Check all fields except `predicate` + assertJsonEquals(expectedStatement, statement, + JsonAssert.whenIgnoringPaths("predicate")); + + // Build definition except: + // - some external parameters we don't control + // - the resolved dependencies for which we check the structure, but ignore the values + assertJsonEquals(expectedStatement.at("/predicate/buildDefinition"), statement.at("/predicate/buildDefinition"), + JsonAssert.whenIgnoringPaths("externalParameters.jvm.args", "externalParameters.env", "resolvedDependencies", + "runDetails.metadata.finishedOn")); + + // `[0].annotations` holds JVM system properties; + // Not all properties are available on all JDKs, so they are either null or strings, which json-unit treats as a structural mismatch. + // We will check them below + assertJsonEquals(expectedStatement.at("/predicate/buildDefinition/resolvedDependencies"), + statement.at("/predicate/buildDefinition/resolvedDependencies"), + JsonAssert.when(Option.IGNORING_VALUES).whenIgnoringPaths("[0].annotations")); + + final Set expectedJdkFields = fieldNames( + expectedStatement.at("/predicate/buildDefinition/resolvedDependencies/0/annotations")); + final Set actualJdkFields = fieldNames( + statement.at("/predicate/buildDefinition/resolvedDependencies/0/annotations")); + assertEquals(expectedJdkFields, actualJdkFields); + } + + private static void configureBuildAttestationMojo(final BuildAttestationMojo mojo, final boolean signAttestation) { + mojo.setOutputDirectory(new File("target/attestations")); + mojo.setScmDirectory(new File(".")); + mojo.setScmConnectionUrl("scm:git:https://github.com/apache/commons-text.git"); + mojo.setMavenHome(new File(System.getProperty("maven.home", "."))); + mojo.setAlgorithmNames("SHA-512,SHA-256,SHA-1,MD5"); + mojo.setPluginDescriptor(pluginDescriptor); + mojo.setSignAttestation(signAttestation); + mojo.setSigner(createMockSigner()); + } + + private static BuildAttestationMojo createBuildAttestationMojo(final MavenProject project, final MavenProjectHelper projectHelper) + throws ComponentLookupException { + final RuntimeInformation runtimeInfo = container.lookup(RuntimeInformation.class); + return new BuildAttestationMojo(project, runtimeInfo, + createMavenSession(createMavenExecutionRequest(), new DefaultMavenExecutionResult()), projectHelper); + } + + private static MavenExecutionRequest createMavenExecutionRequest() { + final DefaultMavenExecutionRequest request = new DefaultMavenExecutionRequest(); + request.setStartTime(Date.from(Instant.parse("2026-04-20T09:28:44Z"))); + request.setActiveProfiles(Collections.singletonList("release")); + request.setGoals(Collections.singletonList("deploy")); + final Properties userProperties = new Properties(); + userProperties.setProperty("gpg.keyname", "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9"); + request.setUserProperties(userProperties); + return request; + } + + private static MavenProject createMavenProject(final MavenProjectHelper projectHelper, final MavenRepositorySystem repoSystem) throws Exception { + final File pomFile = new File(ARTIFACTS_DIR + "commons-text-1.4.pom"); + final Model model; + try (InputStream in = Files.newInputStream(pomFile.toPath())) { + model = new MavenXpp3Reader().read(in); + } + // Group id is inherited from the missing parent, so we override it + model.setGroupId("org.apache.commons"); + final MavenProject project = new MavenProject(model); + final Artifact artifact = repoSystem.createArtifact(model.getArtifactId(), model.getArtifactId(), model.getVersion(), null, "jar"); + artifact.setFile(new File(ARTIFACTS_DIR + "commons-text-1.4.jar")); + project.setArtifact(artifact); + projectHelper.attachArtifact(project, "pom", null, pomFile); + 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; + } + + @SuppressWarnings("deprecation") + private static MavenSession createMavenSession(final MavenExecutionRequest request, final MavenExecutionResult result) { + return new MavenSession(container, repoSession, request, result); + } + + private static AbstractGpgSigner createMockSigner() { + return new AbstractGpgSigner() { + @Override + protected void generateSignatureForFile(final File file, final File signature) throws MojoExecutionException { + try { + 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); + } + } + + @Override + public String getKeyInfo() { + return "mock-key"; + } + + @Override + public String signerName() { + return "mock"; + } + }; + } + + private static Set fieldNames(final JsonNode node) { + final Set names = new TreeSet<>(); + final Iterator it = node.fieldNames(); + while (it.hasNext()) { + names.add(it.next()); + } + return names; + } + + 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")); + } + + @BeforeAll + static void setup() throws Exception { + container = MojoUtils.setupContainer(); + repoSession = MojoUtils.createRepositorySystemSession(container, localRepositoryPath); + try (InputStream in = BuildAttestationMojoTest.class.getResourceAsStream("/attestations/commons-text-1.4.intoto.json")) { + expectedStatement = OBJECT_MAPPER.readTree(in); + } + final Properties pluginProps = new Properties(); + try (InputStream in = BuildAttestationMojoTest.class.getResourceAsStream("/plugin.properties")) { + pluginProps.load(in); + } + pluginDescriptor = new PluginDescriptor(); + pluginDescriptor.setGroupId(pluginProps.getProperty("plugin.groupId")); + pluginDescriptor.setArtifactId(pluginProps.getProperty("plugin.artifactId")); + pluginDescriptor.setVersion(pluginProps.getProperty("plugin.version")); + } + + @Test + void attestationTest() throws Exception { + final MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class); + final MavenRepositorySystem repoSystem = container.lookup(MavenRepositorySystem.class); + final MavenProject project = createMavenProject(projectHelper, repoSystem); + + final BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper); + configureBuildAttestationMojo(mojo, false); + mojo.execute(); + + final JsonNode statement = OBJECT_MAPPER.readTree(getAttestation(project).getFile()); + assertStatementContent(statement); + } + + @Test + void signingTest() throws Exception { + final MavenProjectHelper projectHelper = container.lookup(MavenProjectHelper.class); + final MavenRepositorySystem repoSystem = container.lookup(MavenRepositorySystem.class); + final MavenProject project = createMavenProject(projectHelper, repoSystem); + + final BuildAttestationMojo mojo = createBuildAttestationMojo(project, projectHelper); + configureBuildAttestationMojo(mojo, true); + mojo.execute(); + + final String envelopeJson = new String(Files.readAllBytes(getAttestation(project).getFile().toPath()), StandardCharsets.UTF_8); + + assertJsonPartEquals(DsseEnvelope.PAYLOAD_TYPE, envelopeJson, "payloadType"); + assertJsonNodePresent(envelopeJson, "signatures[0]"); + assertJsonNodeAbsent(envelopeJson, "signatures[1]"); + assertJsonPartEquals("${json-unit.regex}.+", envelopeJson, "signatures[0].sig"); + // Issuer fingerprint extracted from the canned commons-text-1.4.jar.asc. + assertJsonPartEquals("b6e73d84ea4fcc47166087253faad2cd5ecbb314", envelopeJson, "signatures[0].keyid"); + + final DsseEnvelope envelope = OBJECT_MAPPER.readValue(envelopeJson.trim(), DsseEnvelope.class); + final JsonNode statement = OBJECT_MAPPER.readTree(envelope.getPayload()); + assertStatementContent(statement); + } +} 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 diff --git a/src/test/resources/attestations/README.md b/src/test/resources/attestations/README.md new file mode 100644 index 000000000..abfedaff1 --- /dev/null +++ b/src/test/resources/attestations/README.md @@ -0,0 +1,23 @@ + +# Attestation examples + +Golden files used as expected values by the tests. + +Real build attestations are single-line JSON Lines (`.intoto.jsonl`). The files here are pretty-printed JSON (`.intoto.json`) for readability. + +The tests compare structurally via `json-unit`, ignoring environment-dependent fields (JVM arguments, timestamps, `resolvedDependencies` digests), so formatting differences are not significant. \ No newline at end of file diff --git a/src/test/resources/attestations/commons-text-1.4.intoto.json b/src/test/resources/attestations/commons-text-1.4.intoto.json new file mode 100644 index 000000000..1ddc4f2ed --- /dev/null +++ b/src/test/resources/attestations/commons-text-1.4.intoto.json @@ -0,0 +1,189 @@ +{ + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "commons-text-1.4.jar", + "uri": "pkg:maven/commons-text/commons-text@1.4?type=jar", + "digest": { + "md5": "9cbe22bb0ce86c70779213dfb7f3eb5a", + "sha1": "c81f089b3542485d4d09b02aae822906e5d2f209", + "sha256": "ad2d2eacf15ab740c115294afc1192603d8342004a6d7d0ad35446f7dda8a134", + "sha512": "126302c5f6865733774eb41fecc10ba8d0bb5ba11d14b9562047429abeb13bf8cdcdbfdf5e7d7708e2a40f67f4265cbbce609164f57abcd676067a840aa48e6a" + } + }, + { + "name": "commons-text-1.4.pom", + "uri": "pkg:maven/commons-text/commons-text@1.4?type=pom", + "digest": { + "md5": "00045f652e3dc8970442ce819806db34", + "sha1": "26fa30e496321e74c77ad66781ba53448e6e3a68", + "sha256": "4d6277b1e0720bb054c640620679a9da120f753029342150e714095f48934d76", + "sha512": "db8934f3062ea9c965ea27cfe4517a25513fb7cebe35ed02bedc1d8287b01c7ba64c93a8a261325fe12ab6957cbd80dbc8c06ec34c9a23c5a5c89ef6bace88fe" + } + }, + { + "name": "commons-text-1.4-sources.jar", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=sources&type=jar", + "digest": { + "md5": "21af10902cea10cf54bc9acf956863d4", + "sha1": "cadbe9d3980a21e6eaec3aad629bbcdb7714aa3f", + "sha256": "58a95591fe7fc94db94a0a9e64b4a5bcc1c49edf17f2b24d7c0747357d855761", + "sha512": "c91ed209fa97c5e69e21d3a29d1f2ea90f2f77451762b3c387a8cb94dea167b4d3f04ea1a8635232476d804f40935698b4f8884fd43520bcc79b3a0f9a757716" + } + }, + { + "name": "commons-text-1.4-javadoc.jar", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=javadoc&type=jar", + "digest": { + "md5": "2ad93e1d99c0b80cbf6872d1762d7297", + "sha1": "9f06cdf753e1bb512e7640ec5fcce83f5a19ba2c", + "sha256": "42f5b341d0fbeaa30b06aed90612840bc513fb39792c3d39446510670216e8b1", + "sha512": "052ab4e9facfc64f265a567607d40d37620b616908ce71405cae9cd30ad6ac9f2558663029933f90df67c1e748ac81451a32e28975dc773c2c7476c489146c30" + } + }, + { + "name": "commons-text-1.4-tests.jar", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=tests&type=jar", + "digest": { + "md5": "9820547734aff2c17c4a696bbd7d8d5e", + "sha1": "dbfb945a12375e9fe06558840b43f35374c391ff", + "sha256": "e4e365d08d601a4bda44be2a31f748b96762504d301742d4a0f7f5953d4c793a", + "sha512": "a8e373cf10a9dc2d3c1cfdd43b23910c9ca9d83dea4e566772dd1ebe5e28419777e0d83b002e8cf950f7bde3218b714e0cc3718464ee34ecda77f603e260ab20" + } + }, + { + "name": "commons-text-1.4-test-sources.jar", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=test-sources&type=jar", + "digest": { + "md5": "340231a697e2862c8d820de419a91d3e", + "sha1": "5e107fc877c67992948620a8a2d9bb94cab21aa0", + "sha256": "9200a2a41b35f2d6d30c1c698308591cf577547ec39514657dff0e2f7dff18ca", + "sha512": "02b405eb9aff57959a3e066030745be47b76c71af71f86e06fd5004602a399cdf86b1149554e92e9922d9da2fd2239dcf6e8c783462167320838dd7662405b30" + } + }, + { + "name": "commons-text-1.4-bin.tar.gz", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=bin&type=tar.gz", + "digest": { + "md5": "9fe25162590be6fa684a9d9cdc0b505d", + "sha1": "6f46fd82d5ccf9644a37bcec6f2158a52ebdbab8", + "sha256": "8b9393f7ddc2efb69d8c2b6f4d85d8711dddfe77009799cf21619fc9b8411897", + "sha512": "c76f4e5814c0533030fecf0ef7639ce68df54b76ebc1320d9c4e3b8dff0a90c95ce0a425b8df6a0d742f6e9fca8dce6f08632d576c6a99357fdd54b9435fed6c" + } + }, + { + "name": "commons-text-1.4-bin.zip", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=bin&type=zip", + "digest": { + "md5": "e29e5290b7420ba1908c418fc5bc7f8a", + "sha1": "447a051d6c8292c7e4d7641ca6586b335ef13bd6", + "sha256": "ad3732dcb38e510b1dbb1544115d0eb797fab61afe0008fdb187cd4ef1706cd7", + "sha512": "040634b27146e2008bf953f1bb63f8ccbb63cdaaaf24beb17acc6782d31452214e755539c2df737e46e7ede5d4e376cb3de5bbf72ceba5409b43f25612c2bf40" + } + }, + { + "name": "commons-text-1.4-src.tar.gz", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=src&type=tar.gz", + "digest": { + "md5": "be130c23d3b6630824e2a08530bd4581", + "sha1": "0dcee421c4e03d6bc098a61a5cdcc90656856611", + "sha256": "1cb8536c375c3cff66757fd40c2bf878998254ba0a247866a6536bd48ba2e88a", + "sha512": "8279eb7f45009f11658c256b07cfb06c5a45ef89949e78a67e5d64520d957707f90a9614c95e8aac350a07a54f9160f0802dbd1efc0769a5b1391e52ee4cd51b" + } + }, + { + "name": "commons-text-1.4-src.zip", + "uri": "pkg:maven/commons-text/commons-text@1.4?classifier=src&type=zip", + "digest": { + "md5": "fd65603e930f2b0805c809aa2deb1498", + "sha1": "ca1cc6fbb4e46b44f8bb09b70c9e3a2ae3c5fce8", + "sha256": "e4a6c992153faae4f7faff689b899073000364e376736b9746a5d0acb9d8b980", + "sha512": "79ca61ff7b287407428bbb6ae13c6d372dcd0665114c55cd5bc57978a6fa760305e32feabef62cfeb0c4181220a59406239f6cccaa9a25c68773eef0250cb3a9" + } + } + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": "https://commons.apache.org/proper/commons-release-plugin/slsa/v0.1.0", + "externalParameters": { + "maven.profiles": [ + "release" + ], + "maven.cmdline": "deploy -Prelease -Dgpg.keyname=3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9", + "jvm.args": [ + "-Dfile.encoding=UTF-8", + "-Dsun.stdout.encoding=UTF-8", + "-Dsun.stderr.encoding=UTF-8" + ], + "maven.user.properties": { + "gpg.keyname": "3C8D57E0A2B5C6D7E8F9A0B1C2D3E4F5A6B7C8D9" + }, + "maven.goals": [ + "deploy" + ], + "env": { + "LANG": "pl_PL.UTF-8" + } + }, + "internalParameters": {}, + "resolvedDependencies": [ + { + "name": "JDK", + "digest": { + "gitTree": "bdb67e47c1b7df9c35ae045f29a348bb5bd32dc3" + }, + "annotations": { + "home": "/usr/lib/jvm/temurin-25-jdk-amd64", + "specification.maintenance.version": null, + "specification.name": "Java Platform API Specification", + "specification.vendor": "Oracle Corporation", + "specification.version": "25", + "vendor": "Eclipse Adoptium", + "vendor.url": "https://adoptium.net/", + "vendor.version": "Temurin-25.0.2+10", + "version": "25.0.2", + "version.date": "2026-01-20", + "vm.name": "OpenJDK 64-Bit Server VM", + "vm.specification.name": "Java Virtual Machine Specification", + "vm.specification.vendor": "Oracle Corporation", + "vm.specification.version": "25", + "vm.vendor": "Eclipse Adoptium", + "vm.version": "25.0.2+10-LTS" + } + }, + { + "name": "Maven", + "uri": "pkg:maven/org.apache.maven/apache-maven@3.9.12", + "digest": { + "gitTree": "3cdb4a67690dc18373f70ead98dc86567cc5ad67" + }, + "annotations": { + "distributionId": "apache-maven", + "distributionName": "Apache Maven", + "distributionShortName": "Maven", + "buildNumber": "848fbb4bf2d427b72bdb2471c22fced7ebd9a7a1", + "version": "3.9.12" + } + }, + { + "uri": "git+https://github.com/apache/commons-text.git@feat/slsa", + "digest": { + "gitCommit": "f519b3670795da3fb4f43b6af1f727eadf8e6800" + } + } + ] + }, + "runDetails": { + "builder": { + "id": "pkg:maven/${project.groupId}/${project.artifactId}@${project.version}", + "builderDependencies": [], + "version": {} + }, + "metadata": { + "startedOn": "2026-04-20T09:28:44Z", + "finishedOn": "2026-04-20T09:38:12Z" + } + } + } +} diff --git a/src/test/resources/plugin.properties b/src/test/resources/plugin.properties new file mode 100644 index 000000000..7e61707a5 --- /dev/null +++ b/src/test/resources/plugin.properties @@ -0,0 +1,18 @@ +# 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. + +plugin.groupId=${project.groupId} +plugin.artifactId=${project.artifactId} +plugin.version=${project.version}