Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e7dcd6a
fix: f001 (MEDIUM) — Keeps v1's structure: AbstractDeployMojo.validat…
gnodet Aug 31, 2026
2d4d30e
fix: f002 (MEDIUM) — Single choke point: AbstractDeployMojo.validateT…
gnodet Aug 31, 2026
0e15f77
fix: f003 (MEDIUM) — Both legs of the root cause closed with shared v…
gnodet Aug 31, 2026
21207aa
fix: f004 (MEDIUM) — Two-sided state fix, per the triage recommendati…
gnodet Aug 31, 2026
4155d95
fix: f005 (MEDIUM) — A single class-level monitor (DEPLOY_AT_END_LOCK…
gnodet Aug 31, 2026
a5e9653
fix: f006 (MEDIUM) — Verified-true doc fix plus visibility escalation…
gnodet Aug 31, 2026
6812306
fix: adapt deployAtEnd re-deploy test for cache optimization compatib…
gnodet Aug 31, 2026
779362f
fix: allow empty classifiers in deploy-file extras (IT regression)
gnodet Aug 31, 2026
fdeb595
fix: f007 (LOW) — Shared fail-closed parser AbstractDeployMojo.parseS…
gnodet Aug 31, 2026
baf3a02
fix: f008 (LOW) — The dm selection now distinguishes 'no snapshotRepo…
gnodet Aug 31, 2026
a710c2f
fix: f009 (LOW) — Warn-based client-side guard, honest about the stat…
gnodet Aug 31, 2026
8c7c7e1
fix: f010 (LOW) — New AbstractDeployMojo.redactUrlUserInfo() masks sc…
gnodet Aug 31, 2026
be183fe
fix: f011 (LOW) — Opt-in containment knob, per the triage recommendat…
gnodet Aug 31, 2026
b8e8bcb
fix: f012 (LOW) — Two targeted rejections rather than a pattern-order…
gnodet Aug 31, 2026
224ec5b
fix: update deployfile-release-skip IT for version-aware skip ordering
gnodet Sep 1, 2026
1802dce
fix: update deployfile-snapshot-skip IT for version-aware skip ordering
gnodet Sep 1, 2026
b3ad750
fix: resolve symlinks in isContainedIn for non-existent paths
gnodet Sep 1, 2026
ba5507a
Security audit: LOW/INFO docs and minor fixes — docs refresh, jar rob…
gnodet Sep 6, 2026
6433f9a
Merge branch 'master' into security/audit-low-robustness
slawekjaranowski Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/it/deployfile-release-skip/verify.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ assert !new File(basedir, "target/repo/org/apache/maven/plugins/deploy/its/deplo

File buildLog = new File(basedir, 'build.log')
assert buildLog.exists()
assert !buildLog.text.contains("[DEBUG] Using META-INF/maven/org.apache.maven.plugins.deploy.its/deployfile-release-skip/pom.xml as pomFile")
// The jar IS inspected (to resolve the version for the releases/snapshots classification),
// but the deployment is still skipped — that is the assertion that matters.
assert buildLog.text.contains("Skipping artifact deployment")
4 changes: 3 additions & 1 deletion src/it/deployfile-snapshot-skip/verify.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ assert !new File(basedir, "target/repo/org/apache/maven/plugins/deploy/its/deplo

File buildLog = new File(basedir, 'build.log')
assert buildLog.exists()
assert !buildLog.text.contains("[DEBUG] Using META-INF/maven/org.apache.maven.plugins.deploy.its/deployfile-snapshot-skip/pom.xml as pomFile")
// The jar IS inspected (to resolve the version for the releases/snapshots classification),
// but the deployment is still skipped — that is the assertion that matters.
assert buildLog.text.contains("Skipping artifact deployment")
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,40 @@ protected void warnIfAffectedPackagingAndMaven(String packaging) {
}
}

/**
* Recognized values of the {@code skip} tri-state shared by both mojos.
*/
enum SkipMode {
NONE,
ALL,
RELEASES,
SNAPSHOTS
}

/**
* Parses the {@code skip} tri-state <em>fail-closed</em>: {@code skip} is a publish-suppression
* control, so an unrecognized value (a typo such as {@code ture} or {@code release}) must fail
* the build instead of silently publishing. Values are matched case-insensitively.
*/
static SkipMode parseSkipMode(String value, String parameterName) throws MojoException {
if (value == null || value.isEmpty() || "false".equalsIgnoreCase(value)) {
return SkipMode.NONE;
}
if ("true".equalsIgnoreCase(value)) {
return SkipMode.ALL;
}
if ("releases".equalsIgnoreCase(value)) {
return SkipMode.RELEASES;
}
if ("snapshots".equalsIgnoreCase(value)) {
return SkipMode.SNAPSHOTS;
}
throw new MojoException("Unrecognized value '" + value + "' for " + parameterName
+ ": supported values are true, false, releases and snapshots."
+ " Refusing to deploy on an unrecognized value: a typo in a publish-suppression"
+ " control must not silently publish.");
}

/**
* Creates resolver {@link RemoteRepository} equipped with needed whistles and bells.
*/
Expand Down Expand Up @@ -420,6 +454,19 @@ private boolean isCredentialReuseAllowed() {
return userProperties != null && Boolean.parseBoolean(userProperties.get(ALLOW_CREDENTIAL_REUSE_PROPERTY));
}

/**
* Masks URL-embedded userinfo ({@code scheme://user:token@host/...}) before a repository
* string is logged: build logs are routinely archived and shared, and the deploy log line
* must be safe to keep while remaining useful as the audit signal for where artifacts and
* credentials were sent.
*/
static String redactUrlUserInfo(String value) {
if (value == null) {
return null;
}
return value.replaceAll("://[^/@\\s]+@", "://***@");
}

static String normalizeRepositoryUrl(String url) {
String normalized = url.trim();
while (normalized.endsWith("/")) {
Expand Down
134 changes: 123 additions & 11 deletions src/main/java/org/apache/maven/plugins/deploy/DeployFileMojo.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.apache.maven.api.Artifact;
import org.apache.maven.api.ProducedArtifact;
Expand Down Expand Up @@ -183,13 +184,38 @@ public class DeployFileMojo extends AbstractDeployMojo {
* <li><code>true</code>: will skip as usual</li>
* <li><code>releases</code>: will skip if current version of the project is a release</li>
* <li><code>snapshots</code>: will skip if current version of the project is a snapshot</li>
* <li>any other values will be considered as <code>false</code></li>
* <li>values are matched case-insensitively; any other value fails the build (fail-closed:
* a typo in a publish-suppression control must not silently publish)</li>
* </ul>
* The <code>releases</code>/<code>snapshots</code> variants are evaluated after the artifact
* coordinates are known, so a version supplied only via <code>pomFile</code> (or the jar's
* embedded POM) is classified correctly.
* @since 3.1.0
*/
@Parameter(property = "maven.deploy.file.skip", defaultValue = "false")
private String skip = Boolean.FALSE.toString();

/**
* Optional containment directory for all artifact-content paths ({@code file}, {@code files},
* {@code sources}, {@code javadoc}, {@code pomFile}): when set, each of those paths must resolve
* inside this directory (symlinks are resolved before the comparison) or the build fails.
* <p>
* Intended for CI/automation that fills deploy-file parameters from pipeline variables: without
* containment, any file readable by the build (for example {@code ~/.m2/settings.xml} passed as a
* side artifact) can be published to the deployment URL in one invocation. Disabled (no
* containment) by default; point it at the project or workspace directory in automated pipelines.
*
* @since 4.0.0
*/
@Parameter(property = "maven.deploy.file.containedIn")
private Path containedIn;

/**
* Whether {@link #pomFile} points at a temporary POM extracted from the artifact's jar (as
* opposed to an operator-supplied file): extracted POMs must be deleted after the deployment.
*/
private boolean pomFromJar;

void initProperties() throws MojoException {
Path deployedPom;
if (pomFile != null) {
Expand All @@ -199,6 +225,7 @@ void initProperties() throws MojoException {
deployedPom = readingPomFromJarFile();
if (deployedPom != null) {
pomFile = deployedPom;
pomFromJar = true;
}
}

Expand All @@ -211,10 +238,19 @@ private Path readingPomFromJarFile() {
Pattern pomEntry = Pattern.compile("META-INF/maven/.*/pom\\.xml");
try {
try (JarFile jarFile = new JarFile(file.toFile())) {
JarEntry entry = jarFile.stream()
List<JarEntry> entries = jarFile.stream()
.filter(e -> pomEntry.matcher(e.getName()).matches())
.findFirst()
.orElse(null);
.collect(Collectors.toList());
if (entries.size() > 1) {
// a shaded/multi-POM jar's author would otherwise choose which embedded POM
// fills in the missing coordinates (first match wins): require explicitness
getLog().warn("Found " + entries.size() + " POMs in " + file.getFileName() + " ("
+ entries.stream().map(JarEntry::getName).collect(Collectors.joining(", "))
+ "); none will be used to derive coordinates. Specify pomFile or explicit"
+ " groupId/artifactId/version/packaging.");
return null;
}
JarEntry entry = entries.isEmpty() ? null : entries.get(0);
if (entry != null) {
getLog().debug("Using " + entry.getName() + " as pomFile");

Expand All @@ -223,6 +259,10 @@ private Path readingPomFromJarFile() {
if (base.indexOf('.') > 0) {
base = base.substring(0, base.lastIndexOf('.'));
}
while (base.length() < 3) {
// File.createTempFile rejects prefixes shorter than 3 characters
base = base + "_";
}
Path pomFile = File.createTempFile(base, ".pom").toPath();

Files.copy(pomInputStream, pomFile, StandardCopyOption.REPLACE_EXISTING);
Expand All @@ -236,16 +276,17 @@ private Path readingPomFromJarFile() {
}
}
} catch (IOException e) {
// ignore, artifact not packaged by Maven
// a corrupt (or hostile) jar must not silently degrade coordinate derivation
getLog().warn("Could not read a POM from " + file.getFileName() + ": " + e.getMessage()
+ "; coordinates will not be derived from the artifact");
}
return null;
}

@SuppressWarnings("checkstyle:MethodLength")
public void execute() throws MojoException {
if (Boolean.parseBoolean(skip)
|| ("releases".equals(skip) && !session.isVersionSnapshot(version))
|| ("snapshots".equals(skip) && session.isVersionSnapshot(version))) {
SkipMode skipMode = parseSkipMode(skip, "maven.deploy.file.skip");
if (skipMode == SkipMode.ALL) {
getLog().info("Skipping artifact deployment");
return;
}
Expand All @@ -256,6 +297,13 @@ public void execute() throws MojoException {
throw new MojoException(message);
}

// containment applies to the operator-supplied paths, before any of them is read;
// the temporary POM later extracted from the (already contained) jar is exempt
checkContained(file, "file");
checkContained(pomFile, "pomFile");
checkContained(sources, "sources");
checkContained(javadoc, "javadoc");

initProperties();

validateCredentialBinding(repositoryId, url.replace(File.separator, "/"));
Expand All @@ -279,6 +327,15 @@ public void execute() throws MojoException {
+ "'version' and 'packaging' are required.");
}

// the releases/snapshots skip variants classify the version, so they are evaluated only
// after the version is known - including a version supplied via pomFile or the jar's
// embedded POM (previously they classified a possibly-null version)
if ((skipMode == SkipMode.RELEASES && !session.isVersionSnapshot(version))
|| (skipMode == SkipMode.SNAPSHOTS && session.isVersionSnapshot(version))) {
getLog().info("Skipping artifact deployment");
return;
}

if (!isValidId(groupId) || !isValidId(artifactId) || !isValidVersion(version)) {
throw new MojoException("The artifact information is not valid: uses invalid characters.");
}
Expand All @@ -300,7 +357,7 @@ public void execute() throws MojoException {
ProducedArtifact artifact = session.createProducedArtifact(
groupId, artifactId, version, classifier, isFilePom ? "pom" : getExtension(file), packaging);

if (file.equals(getLocalRepositoryFile(artifact))) {
if (isSameLocation(file, getLocalRepositoryFile(artifact))) {
throw new MojoException("Cannot deploy artifact from the local repository: " + file);
}

Expand Down Expand Up @@ -380,6 +437,7 @@ public void execute() throws MojoException {
file = Paths.get(files.substring(fi, nfi));
}
if (Files.isRegularFile(file)) {
checkContained(file, "files");
String extension = getExtension(file);
String type = types.substring(ti, nti).trim();
String classifierEntry = classifiers.substring(ci, nci).trim();
Expand Down Expand Up @@ -426,13 +484,15 @@ public void execute() throws MojoException {
.retryFailedDeploymentCount(Math.max(1, Math.min(10, getRetryFailedDeploymentCount())))
.build();

getLog().info("Deploying artifacts " + deployables + " to repository " + deploymentRepository);
getLog().info("Deploying artifacts " + deployables + " to repository "
+ deploymentRepository.getId() + " ("
+ redactUrlUserInfo(deploymentRepository.getUrl()) + ")");
ArtifactDeployer artifactDeployer = session.getService(ArtifactDeployer.class);
artifactDeployer.deploy(deployRequest);
} catch (ArtifactDeployerException e) {
throw new MojoException(e.getMessage(), e);
} finally {
if (pomFile == null && deployedPom != null) {
if ((pomFile == null || pomFromJar) && deployedPom != null) {
try {
Files.deleteIfExists(deployedPom);
} catch (IOException e) {
Expand All @@ -445,6 +505,48 @@ public void execute() throws MojoException {
}
}

/**
* Enforces the optional {@link #containedIn} containment directory for an artifact-content path.
*/
private void checkContained(Path path, String parameterName) throws MojoException {
if (containedIn == null || path == null) {
return;
}
if (!isContainedIn(path, containedIn)) {
throw new MojoException("Parameter '" + parameterName + "' resolves to "
+ path.toAbsolutePath().normalize() + ", which is outside the containment directory "
+ containedIn.toAbsolutePath().normalize() + " configured with maven.deploy.file.containedIn");
}
}

/**
* Returns {@code true} when {@code path} resolves inside {@code root}, resolving symlinks where
* the paths exist so a link pointing outside the containment directory does not pass.
*/
static boolean isContainedIn(Path path, Path root) {
return realOrNormalized(path).startsWith(realOrNormalized(root));
}

private static Path realOrNormalized(Path path) {
Path absolute = path.toAbsolutePath().normalize();
try {
return absolute.toRealPath();
} catch (IOException e) {
// Path does not exist — resolve the closest existing ancestor so that platform
// symlinks are honoured (e.g. macOS /var → /private/var), then re-append the
// non-existent suffix.
Path parent = absolute.getParent();
while (parent != null) {
try {
return parent.toRealPath().resolve(parent.relativize(absolute));
} catch (IOException ignored) {
parent = parent.getParent();
}
}
return absolute;
}
}

/**
* Gets the path of the specified artifact within the local repository. Note that the returned path need not exist
* (yet).
Expand All @@ -453,6 +555,16 @@ private Path getLocalRepositoryFile(Artifact artifact) {
return session.getPathForLocalArtifact(artifact);
}

/**
* Compares two paths as locations rather than spellings: a textual {@code Path.equals} lets a
* relative path, a symlink, or any non-canonical spelling of the same file slip past the
* local-repository self-deploy guard (an anti-footgun against local-repo metadata corruption,
* not a security boundary - but it should at least hold against trivial re-spellings).
*/
static boolean isSameLocation(Path a, Path b) {
return realOrNormalized(a).equals(realOrNormalized(b));
}

/**
* Process the supplied pomFile to get groupId, artifactId, version, and packaging
*
Expand Down
Loading
Loading