CVE-2022-25174
Description
Jenkins Pipeline: Shared Groovy Libraries Plugin 552.vd9cc05b8a2e1 and earlier uses the same checkout directories for distinct SCMs for Pipeline libraries, allowing attackers with Item/Configure permission to invoke arbitrary OS commands on the controller through crafted SCM contents.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Jenkins Pipeline: Shared Groovy Libraries Plugin reuses checkout directories for distinct SCMs, enabling OS command execution via crafted SCM contents.
Vulnerability
Jenkins Pipeline: Shared Groovy Libraries Plugin versions 552.vd9cc05b8a2e1 and earlier use the same checkout directories for distinct SCMs when retrieving Pipeline libraries [1][2]. An attacker with Item/Configure permission can configure a Pipeline library with a crafted SCM, such as a malicious Git repository, that overwrites files in the shared directory upon checkout. This leads to arbitrary OS command execution on the Jenkins controller [1].
Exploitation
The attacker must have Item/Configure permission on a Pipeline job. They set up a Pipeline library with a crafted SCM that contains malicious files or scripts. When Jenkins loads the library, it performs a checkout into a directory shared across different SCMs, allowing the attacker's content to be placed in a location that can be executed by the controller [1][2].
Impact
Successful exploitation allows the attacker to execute arbitrary OS commands on the Jenkins controller with the privileges of the Jenkins process, leading to full compromise of the Jenkins instance [1].
Mitigation
Jenkins has released updated versions of the affected plugins that enforce distinct checkout directories per SCM [1][3]. Users should upgrade to the latest version of the Pipeline: Shared Groovy Libraries Plugin (e.g., versions after 552.vd9cc05b8a2e1). No workarounds are available [1].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.jenkins-ci.plugins.workflow:workflow-cps-global-libMaven | >= 544.vff04fa68714d, < 561.va | 561.va |
org.jenkins-ci.plugins.workflow:workflow-cps-global-libMaven | >= 2.19, < 2.21.1 | 2.21.1 |
org.jenkins-ci.plugins.workflow:workflow-cps-global-libMaven | < 2.18.1 | 2.18.1 |
Affected products
2- ghsa-coordsRange: >= 544.vff04fa68714d, < 561.va
- Jenkins project/Jenkins Pipeline: Shared Groovy Libraries Pluginv5Range: unspecified
Patches
1ace0de3c2d69[SECURITY-2422][SECURITY-2441][SECURITY-2463][SECURITY-2476][SECURITY-2479][SECURITY-2586]
11 files changed · +388 −33
src/main/java/org/jenkinsci/plugins/workflow/libs/FolderLibraries.java+4 −1 modified@@ -77,7 +77,10 @@ private Collection<LibraryConfiguration> forGroup(@CheckForNull ItemGroup<?> gro if (!checkPermission || f.hasPermission(Item.CONFIGURE)) { FolderLibraries prop = f.getProperties().get(FolderLibraries.class); if (prop != null) { - libraries.addAll(prop.getLibraries()); + String source = FolderLibraries.ForJob.class.getName() + " " + f.getFullName(); + for (LibraryConfiguration library : prop.getLibraries()) { + libraries.add(new ResolvedLibraryConfiguration(library, source)); + } } } }
src/main/java/org/jenkinsci/plugins/workflow/libs/LibraryAdder.java+25 −13 modified@@ -89,7 +89,7 @@ if (action != null) { // Resuming a build, so just look up what we loaded before. for (LibraryRecord record : action.getLibraries()) { - FilePath libDir = new FilePath(execution.getOwner().getRootDir()).child("libs/" + record.name); + FilePath libDir = new FilePath(execution.getOwner().getRootDir()).child("libs/" + record.getDirectoryName()); for (String root : new String[] {"src", "vars"}) { FilePath dir = libDir.child(root); if (dir.isDirectory()) { @@ -120,7 +120,11 @@ } String version = cfg.defaultedVersion(libraryVersions.remove(name)); Boolean changelog = cfg.defaultedChangelogs(libraryChangelogs.remove(name)); - librariesAdded.put(name, new LibraryRecord(name, version, kindTrusted, changelog, cfg.getCachingConfiguration())); + String source = kind.getClass().getName(); + if (cfg instanceof LibraryResolver.ResolvedLibraryConfiguration) { + source = ((LibraryResolver.ResolvedLibraryConfiguration) cfg).getSource(); + } + librariesAdded.put(name, new LibraryRecord(name, version, kindTrusted, changelog, cfg.getCachingConfiguration(), source)); retrievers.put(name, cfg.getRetriever()); } } @@ -135,7 +139,7 @@ // Now actually try to retrieve the libraries. for (LibraryRecord record : librariesAdded.values()) { listener.getLogger().println("Loading library " + record.name + "@" + record.version); - for (URL u : retrieve(record.name, record.version, retrievers.get(record.name), record.trusted, record.changelog, record.cachingConfiguration, listener, build, execution, record.variables)) { + for (URL u : retrieve(record, retrievers.get(record.name), listener, build, execution)) { additions.add(new Addition(u, record.trusted)); } } @@ -152,11 +156,14 @@ } /** Retrieve library files. */ - static List<URL> retrieve(@NonNull String name, @NonNull String version, @NonNull LibraryRetriever retriever, boolean trusted, Boolean changelog, LibraryCachingConfiguration cachingConfiguration, @NonNull TaskListener listener, @NonNull Run<?,?> run, @NonNull CpsFlowExecution execution, @NonNull Set<String> variables) throws Exception { - FilePath libDir = new FilePath(execution.getOwner().getRootDir()).child("libs/" + name); + static List<URL> retrieve(@NonNull LibraryRecord record, @NonNull LibraryRetriever retriever, @NonNull TaskListener listener, @NonNull Run<?,?> run, @NonNull CpsFlowExecution execution) throws Exception { + String name = record.name; + String version = record.version; + boolean changelog = record.changelog; + LibraryCachingConfiguration cachingConfiguration = record.cachingConfiguration; + FilePath libDir = new FilePath(execution.getOwner().getRootDir()).child("libs/" + record.getDirectoryName()); Boolean shouldCache = cachingConfiguration != null; - final FilePath libraryCacheDir = new FilePath(LibraryCachingConfiguration.getGlobalLibrariesCacheDir(), name); - final FilePath versionCacheDir = new FilePath(libraryCacheDir, version); + final FilePath versionCacheDir = new FilePath(LibraryCachingConfiguration.getGlobalLibrariesCacheDir(), record.getDirectoryName()); final FilePath retrieveLockFile = new FilePath(versionCacheDir, LibraryCachingConfiguration.RETRIEVE_LOCK_FILE); final FilePath lastReadFile = new FilePath(versionCacheDir, LibraryCachingConfiguration.LAST_READ_FILE); @@ -195,9 +202,11 @@ static List<URL> retrieve(@NonNull String name, @NonNull String version, @NonNul } else { retriever.retrieve(name, version, changelog, libDir, run, listener); } + // Write the user-provided name to a file as a debugging aid. + libDir.withSuffix("-name.txt").write(name, "UTF-8"); // Replace any classes requested for replay: - if (!trusted) { + if (!record.trusted) { for (String clazz : ReplayAction.replacementsIn(execution)) { for (String root : new String[] {"src", "vars"}) { String rel = root + "/" + clazz.replace('.', '/') + ".groovy"; @@ -221,7 +230,7 @@ static List<URL> retrieve(@NonNull String name, @NonNull String version, @NonNul if (varsDir.isDirectory()) { urls.add(varsDir.toURI().toURL()); for (FilePath var : varsDir.list("*.groovy")) { - variables.add(var.getBaseName()); + record.variables.add(var.getBaseName()); } } if (urls.isEmpty()) { @@ -245,8 +254,11 @@ static List<URL> retrieve(@NonNull String name, @NonNull String version, @NonNul if (action != null) { FilePath libs = new FilePath(run.getRootDir()).child("libs"); for (LibraryRecord library : action.getLibraries()) { - FilePath f = libs.child(library.name + "/resources/" + name); - if (f.exists()) { + FilePath libResources = libs.child(library.getDirectoryName() + "/resources/"); + FilePath f = libResources.child(name); + if (!new File(f.getRemote()).getCanonicalFile().toPath().startsWith(libResources.absolutize().getRemote())) { + throw new AbortException(name + " references a file that is not contained within the library: " + library.name); + } else if (f.exists()) { resources.put(library.name, readResource(f, encoding)); } } @@ -278,7 +290,7 @@ private static String readResource(FilePath file, @CheckForNull String encoding) List<GlobalVariable> vars = new ArrayList<>(); for (LibraryRecord library : action.getLibraries()) { for (String variable : library.variables) { - vars.add(new UserDefinedGlobalVariable(variable, new File(run.getRootDir(), "libs/" + library.name + "/vars/" + variable + ".txt"))); + vars.add(new UserDefinedGlobalVariable(variable, new File(run.getRootDir(), "libs/" + library.getDirectoryName() + "/vars/" + variable + ".txt"))); } } return vars; @@ -304,7 +316,7 @@ private static String readResource(FilePath file, @CheckForNull String encoding) continue; // TODO JENKINS-41157 allow replay of trusted libraries if you have ADMINISTER } for (String rootName : new String[] {"src", "vars"}) { - FilePath root = libs.child(library.name + "/" + rootName); + FilePath root = libs.child(library.getDirectoryName() + "/" + rootName); if (!root.isDirectory()) { continue; }
src/main/java/org/jenkinsci/plugins/workflow/libs/LibraryRecord.java+43 −2 modified@@ -27,6 +27,7 @@ import java.util.Collections; import java.util.Set; import java.util.TreeSet; +import jenkins.security.HMACConfidentialKey; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; @@ -36,26 +37,49 @@ @ExportedBean public final class LibraryRecord { + private static final HMACConfidentialKey DIRECTORY_NAME_KEY = new HMACConfidentialKey(LibraryRecord.class, "directoryName", 32); + private static final String ASCII_UNIT_SEPARATOR = String.valueOf((char)31); + final String name; final String version; final Set<String> variables = new TreeSet<>(); final boolean trusted; final boolean changelog; final LibraryCachingConfiguration cachingConfiguration; + private String directoryName; - LibraryRecord(String name, String version, boolean trusted, boolean changelog, LibraryCachingConfiguration cachingConfiguration) { + /** + * @param name The name of the library, as entered by the user. Not validated or restricted in any way. + * @param version The version of the library, as entered by the user. Not validated or restricted in any way. + * @param trusted Whether the library is trusted. Typically determined by {@link LibraryResolver#isTrusted}, but see also {@link LibraryStep}. + * @param changelog Whether we should include any SCM changes in this library in the build's changelog. + * @param cachingConfiguration If non-null, contains cache-related configuration. + * @param source A string describing the source of the configuration of this library. Typically the class name of a {@link LibraryResolver}, sometimes with additional data, but see also {@link LibraryStep}. + */ + LibraryRecord(String name, String version, boolean trusted, boolean changelog, LibraryCachingConfiguration cachingConfiguration, String source) { this.name = name; this.version = version; this.trusted = trusted; this.changelog = changelog; this.cachingConfiguration = cachingConfiguration; + this.directoryName = directoryNameFor(name, version, String.valueOf(trusted), source); } @Exported public String getName() { return name; } + /** + * Returns a partially unique name that can be safely used as a directory name. + * + * Uniqueness is based on the library name, version, whether it is trusted, and the source of the library. + * {@link LibraryRetriever}-specific information such as the SCM is not used to produce this name. + */ + public String getDirectoryName() { + return directoryName; + } + @Exported public String getVersion() { return version; @@ -78,7 +102,24 @@ public boolean isChangelog() { @Override public String toString() { String cachingConfigurationStr = cachingConfiguration != null ? cachingConfiguration.toString() : "null"; - return "LibraryRecord{name=" + name + ", version=" + version + ", variables=" + variables + ", trusted=" + trusted + ", changelog=" + changelog + ", cachingConfiguration=" + cachingConfigurationStr + '}'; + return "LibraryRecord{name=" + name + ", version=" + version + ", variables=" + variables + ", trusted=" + trusted + ", changelog=" + changelog + ", cachingConfiguration=" + cachingConfigurationStr + ", directoryName=" + directoryName + '}'; + } + + private Object readResolve() { + if (directoryName == null) { + // Builds started before directoryName was added must continue to use the library name as the directory name. + directoryName = name; + } + return this; + } + + public static String directoryNameFor(String... data) { + for (String datum : data) { + if (datum.contains(ASCII_UNIT_SEPARATOR)) { // Very unlikely to appear in legitimate user-controlled text. + throw new IllegalStateException("Unable to create directory name due to control character in " + datum); + } + } + return DIRECTORY_NAME_KEY.mac(String.join(ASCII_UNIT_SEPARATOR, data)); } }
src/main/java/org/jenkinsci/plugins/workflow/libs/LibraryResolver.java+27 −0 modified@@ -85,4 +85,31 @@ public abstract class LibraryResolver implements ExtensionPoint { return Collections.emptySet(); } + /** + * Used by some implementations of {@link LibraryResolver} to prevent libraries with the same name that are + * configured in distinct trust domains from using the same cache directory. + * + * For example, {@link FolderLibraries.ForJob} may return libraries from different folders, and a user who is able + * to configure a library in one folder may not be able to configure a library in another folder. To prevent this + * from causing issues, {@link FolderLibraries.ForJob#forGroup} returns instances of this class where {@code source} + * includes the full name of the folder where the library is configured. + */ + static class ResolvedLibraryConfiguration extends LibraryConfiguration { + private final String source; + + ResolvedLibraryConfiguration(LibraryConfiguration config, @NonNull String source) { + super(config.getName(), config.getRetriever()); + setDefaultVersion(config.getDefaultVersion()); + setImplicit(config.isImplicit()); + setAllowVersionOverride(config.isAllowVersionOverride()); + setIncludeInChangesets(config.getIncludeInChangesets()); + setCachingConfiguration(config.getCachingConfiguration()); + this.source = source; + } + + @NonNull String getSource() { + return source; + } + } + }
src/main/java/org/jenkinsci/plugins/workflow/libs/LibraryStep.java+12 −7 modified@@ -166,6 +166,8 @@ public static class Execution extends AbstractSynchronousNonBlockingStepExecutio boolean trusted = false; Boolean changelog = step.getChangelog(); LibraryCachingConfiguration cachingConfiguration = null; + // Note that cachingConfiguration is only ever non-null if source is overwritten below, so the default value of source will never be used when caching is enabled. + String source = LibraryStep.class.getName() + " " + run.getExternalizableId(); LibraryRetriever retriever = step.getRetriever(); if (retriever == null) { for (LibraryResolver resolver : ExtensionList.lookup(LibraryResolver.class)) { @@ -176,6 +178,10 @@ public static class Execution extends AbstractSynchronousNonBlockingStepExecutio version = cfg.defaultedVersion(version); changelog = cfg.defaultedChangelogs(changelog); cachingConfiguration = cfg.getCachingConfiguration(); + source = resolver.getClass().getName(); + if (cfg instanceof LibraryResolver.ResolvedLibraryConfiguration) { + source = ((LibraryResolver.ResolvedLibraryConfiguration) cfg).getSource(); + } break; } } @@ -186,8 +192,7 @@ public static class Execution extends AbstractSynchronousNonBlockingStepExecutio } else if (version == null) { throw new AbortException("Must specify a version for library " + name); } - - LibraryRecord record = new LibraryRecord(name, version, trusted, changelog, cachingConfiguration); + LibraryRecord record = new LibraryRecord(name, version, trusted, changelog, cachingConfiguration, source); LibrariesAction action = run.getAction(LibrariesAction.class); if (action == null) { action = new LibrariesAction(Lists.newArrayList(record)); @@ -197,7 +202,7 @@ public static class Execution extends AbstractSynchronousNonBlockingStepExecutio for (LibraryRecord existing : libraries) { if (existing.name.equals(name)) { listener.getLogger().println("Only using first definition of library " + name); - return new LoadedClasses(name, trusted, changelog, run); + return new LoadedClasses(name, existing.getDirectoryName(), trusted, changelog, run); } } List<LibraryRecord> newLibraries = new ArrayList<>(libraries); @@ -207,11 +212,11 @@ public static class Execution extends AbstractSynchronousNonBlockingStepExecutio listener.getLogger().println("Loading library " + record.name + "@" + record.version); CpsFlowExecution exec = (CpsFlowExecution) getContext().get(FlowExecution.class); GroovyClassLoader loader = (trusted ? exec.getTrustedShell() : exec.getShell()).getClassLoader(); - for (URL u : LibraryAdder.retrieve(record.name, record.version, retriever, record.trusted, record.changelog, record.cachingConfiguration, listener, run, (CpsFlowExecution) getContext().get(FlowExecution.class), record.variables)) { + for (URL u : LibraryAdder.retrieve(record, retriever, listener, run, (CpsFlowExecution) getContext().get(FlowExecution.class))) { loader.addURL(u); } run.save(); // persist changes to LibrariesAction.libraries*.variables - return new LoadedClasses(name, trusted, changelog, run); + return new LoadedClasses(name, record.getDirectoryName(), trusted, changelog, run); } } @@ -228,8 +233,8 @@ public static final class LoadedClasses extends GroovyObjectSupport implements S /** {@code file:/…/libs/NAME/src/} */ private final @NonNull String srcUrl; - LoadedClasses(String library, boolean trusted, Boolean changelog, Run<?,?> run) { - this(library, trusted, changelog, "", null, /* cf. LibraryAdder.retrieve */ new File(run.getRootDir(), "libs/" + library + "/src").toURI().toString()); + LoadedClasses(String library, String libraryDirectoryName, boolean trusted, Boolean changelog, Run<?,?> run) { + this(library, trusted, changelog, "", null, /* cf. LibraryAdder.retrieve */ new File(run.getRootDir(), "libs/" + libraryDirectoryName + "/src").toURI().toString()); } LoadedClasses(String library, boolean trusted, Boolean changelog, String prefix, String clazz, String srcUrl) {
src/main/java/org/jenkinsci/plugins/workflow/libs/SCMSourceRetriever.java+4 −1 modified@@ -185,7 +185,8 @@ static void doRetrieve(String name, boolean changelog, @NonNull SCM scm, String if (baseWorkspace == null) { throw new IOException(node.getDisplayName() + " may be offline"); } - dir = baseWorkspace.withSuffix(getFilePathSuffix() + "libs").child(name); + String checkoutDirName = LibraryRecord.directoryNameFor(scm.getKey()); + dir = baseWorkspace.withSuffix(getFilePathSuffix() + "libs").child(checkoutDirName); } else { // should not happen, but just in case: throw new AbortException("Cannot check out in non-top-level build"); } @@ -194,6 +195,8 @@ static void doRetrieve(String name, boolean changelog, @NonNull SCM scm, String throw new IOException(node.getDisplayName() + " may be offline"); } try (WorkspaceList.Lease lease = computer.getWorkspaceList().allocate(dir)) { + // Write the SCM key to a file as a debugging aid. + lease.path.withSuffix("-scm-key.txt").write(scm.getKey(), "UTF-8"); retrySCMOperation(listener, () -> { delegate.checkout(run, lease.path, listener, node.createLauncher(listener)); return null;
src/test/java/org/jenkinsci/plugins/workflow/libs/LibraryAdderTest.java+102 −0 modified@@ -24,6 +24,7 @@ package org.jenkinsci.plugins.workflow.libs; +import com.cloudbees.hudson.plugins.folder.Folder; import hudson.FilePath; import hudson.model.Job; import hudson.model.Result; @@ -59,12 +60,16 @@ import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.TestExtension; +import org.jvnet.hudson.test.recipes.LocalData; + +import static org.hamcrest.Matchers.nullValue; public class LibraryAdderTest { @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher(); @Rule public JenkinsRule r = new JenkinsRule(); @Rule public GitSampleRepoRule sampleRepo = new GitSampleRepoRule(); + @Rule public GitSampleRepoRule sampleRepo2 = new GitSampleRepoRule(); @Rule public SubversionSampleRepoRule sampleSvnRepo = new SubversionSampleRepoRule(); @Test public void smokes() throws Exception { @@ -370,4 +375,101 @@ public class LibraryAdderTest { r.assertLogContains("expected to contain at least one of src or vars directories", b); } + @Issue("SECURITY-2422") + @Test public void libraryNamesAreNotUsedAsBuildDirectoryPaths() throws Exception { + sampleRepo.init(); + sampleRepo.write("vars/globalLibVar.groovy", "def call() { echo('global library') }"); + sampleRepo.git("add", "vars"); + sampleRepo.git("commit", "--message=init"); + sampleRepo2.init(); + LibraryConfiguration globalLib = new LibraryConfiguration("global", + new SCMSourceRetriever(new GitSCMSource(null, sampleRepo.toString(), "", "*", "", true))); + globalLib.setDefaultVersion("master"); + globalLib.setImplicit(true); + GlobalLibraries.get().setLibraries(Collections.singletonList(globalLib)); + // Create a folder library with distinct name, but which if used as a path will match the libs directory for the global library + sampleRepo2.write("vars/folderLibVar.groovy", "def call() { jenkins.model.Jenkins.get().setSystemMessage('folder library') }"); + sampleRepo2.git("add", "vars"); + sampleRepo2.git("commit", "--message=init"); + LibraryConfiguration folderLib = new LibraryConfiguration("folder/../global", + new SCMSourceRetriever(new GitSCMSource(null, sampleRepo2.toString(), "", "*", "", true))); + folderLib.setDefaultVersion("master"); + folderLib.setImplicit(true); + Folder f = r.jenkins.createProject(Folder.class, "folder1"); + f.getProperties().add(new FolderLibraries(Collections.singletonList(folderLib))); + // Create a build that uses both libraries. + WorkflowJob p = f.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("globalLibVar(); folderLibVar()", true)); + // The contents of the folder library should be untrusted and in a distinct libs directory. + WorkflowRun b = r.buildAndAssertStatus(Result.FAILURE, p); + r.assertLogContains("Scripts not permitted to use staticMethod jenkins.model.Jenkins get", b); + assertThat(r.jenkins.getSystemMessage(), nullValue()); + } + + @Issue("SECURITY-2586") + @Test public void libraryNamesAreNotUsedAsCacheDirectories() throws Exception { + sampleRepo.init(); + sampleRepo.write("vars/globalLibVar.groovy", "def call() { echo('global library') }"); + sampleRepo.git("add", "vars"); + sampleRepo.git("commit", "--message=init"); + sampleRepo2.init(); + LibraryConfiguration globalLib = new LibraryConfiguration("library", + new SCMSourceRetriever(new GitSCMSource(null, sampleRepo.toString(), "", "*", "", true))); + globalLib.setDefaultVersion("master"); + globalLib.setImplicit(true); + globalLib.setCachingConfiguration(new LibraryCachingConfiguration(60, "")); + GlobalLibraries.get().setLibraries(Collections.singletonList(globalLib)); + // Create a folder library with the same name and which is also set up to enable caching. + sampleRepo2.write("vars/folderLibVar.groovy", "def call() { jenkins.model.Jenkins.get().setSystemMessage('folder library') }"); + sampleRepo2.git("add", "vars"); + sampleRepo2.git("commit", "--message=init"); + LibraryConfiguration folderLib = new LibraryConfiguration("library", + new SCMSourceRetriever(new GitSCMSource(null, sampleRepo2.toString(), "", "*", "", true))); + folderLib.setDefaultVersion("master"); + folderLib.setImplicit(true); + folderLib.setCachingConfiguration(new LibraryCachingConfiguration(60, "")); + Folder f = r.jenkins.createProject(Folder.class, "folder1"); + f.getProperties().add(new FolderLibraries(Collections.singletonList(folderLib))); + // Create a job that uses the folder library, which will take precedence over the global library, since they have the same name. + WorkflowJob p = f.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("folderLibVar()", true)); + // First build fails as expected since it is not trusted. The folder library gets used and is cached. + WorkflowRun b1 = r.buildAndAssertStatus(Result.FAILURE, p); + r.assertLogContains("Only using first definition of library library", b1); + r.assertLogContains("Scripts not permitted to use staticMethod jenkins.model.Jenkins get", b1); + // Attacker deletes the folder library, then reruns the build. + // The global library should not use the cached version of the folder library. + f.getProperties().clear(); + WorkflowRun b2 = r.buildAndAssertStatus(Result.FAILURE, p); + r.assertLogContains("No such DSL method 'folderLibVar'", b2); + assertThat(r.jenkins.getSystemMessage(), nullValue()); + } + + @LocalData + @Test + public void correctLibraryDirectoryUsedWhenResumingOldBuild() throws Exception { + // LocalData was captured after saving the build in the following snippet: + /* + sampleRepo.init(); + sampleRepo.write("vars/foo.groovy", "def call() { echo('called Foo') }"); + sampleRepo.git("add", "vars"); + sampleRepo.git("commit", "--message=init"); + GlobalLibraries.get().setLibraries(Collections.singletonList( + new LibraryConfiguration("lib", + new SCMSourceRetriever(new GitSCMSource(null, sampleRepo.toString(), "", "*", "", true))))); + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition( + "@Library('lib@master') _\n" + + "sleep 100\n" + + "foo()", true)); + WorkflowRun b = p.scheduleBuild2(0).waitForStart(); + Thread.sleep(2000); + b.save(); + */ + WorkflowJob p = r.jenkins.getItemByFullName("p", WorkflowJob.class); + WorkflowRun b = p.getBuildByNumber(1); + r.assertBuildStatus(Result.SUCCESS, r.waitForCompletion(b)); + r.assertLogContains("called Foo", b); + } + }
src/test/java/org/jenkinsci/plugins/workflow/libs/LibraryStepTest.java+4 −2 modified@@ -99,13 +99,15 @@ public class LibraryStepTest { r.assertLogContains("ran library", b); LibrariesAction action = b.getAction(LibrariesAction.class); assertNotNull(action); - assertEquals("[LibraryRecord{name=stuff, version=master, variables=[x], trusted=true, changelog=true, cachingConfiguration=null}]", action.getLibraries().toString()); + String directoryName = LibraryRecord.directoryNameFor("stuff", "master", String.valueOf(true), GlobalLibraries.ForJob.class.getName()); + assertEquals("[LibraryRecord{name=stuff, version=master, variables=[x], trusted=true, changelog=true, cachingConfiguration=null, directoryName=" + directoryName + "}]", action.getLibraries().toString()); p.setDefinition(new CpsFlowDefinition("library identifier: 'otherstuff@master', retriever: modernSCM([$class: 'GitSCMSource', remote: $/" + sampleRepo + "/$, credentialsId: '']), changelog: false; x()", true)); b = r.buildAndAssertSuccess(p); r.assertLogContains("ran library", b); action = b.getAction(LibrariesAction.class); assertNotNull(action); - assertEquals("[LibraryRecord{name=otherstuff, version=master, variables=[x], trusted=false, changelog=false, cachingConfiguration=null}]", action.getLibraries().toString()); + directoryName = LibraryRecord.directoryNameFor("otherstuff", "master", String.valueOf(false), LibraryStep.class.getName() + " " + b.getExternalizableId()); + assertEquals("[LibraryRecord{name=otherstuff, version=master, variables=[x], trusted=false, changelog=false, cachingConfiguration=null, directoryName=" + directoryName + "}]", action.getLibraries().toString()); } @Test public void classes() throws Exception {
src/test/java/org/jenkinsci/plugins/workflow/libs/ResourceStepTest.java+70 −5 modified@@ -29,7 +29,6 @@ import hudson.model.Result; import hudson.model.Run; -import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -55,6 +54,7 @@ public class ResourceStepTest { @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher(); @Rule public JenkinsRule r = new JenkinsRule(); @Rule public GitSampleRepoRule sampleRepo = new GitSampleRepoRule(); + @Rule public GitSampleRepoRule sampleRepo2 = new GitSampleRepoRule(); @Test public void smokes() throws Exception { initFixedContentLibrary(); @@ -187,6 +187,71 @@ public class ResourceStepTest { r.assertLogContains(Base64.getEncoder().encodeToString(binaryData), run); } + @Issue("SECURITY-2479") + @Test public void symlinksInLibraryResourcesAreNotAllowedToEscapeWorkspaceContext() throws Exception { + sampleRepo.init(); + sampleRepo.write("src/Stuff.groovy", "class Stuff {static def contents(script) {script.libraryResource 'master.key'}}"); + Path resourcesDir = Paths.get(sampleRepo.getRoot().getPath(), "resources"); + Files.createDirectories(resourcesDir); + Path symlinkPath = Paths.get(resourcesDir.toString(), "master.key"); + Files.createSymbolicLink(symlinkPath, Paths.get("../../../../../../../secrets/master.key")); + + sampleRepo.git("add", "src", "resources"); + sampleRepo.git("commit", "--message=init"); + LibraryConfiguration libraryConfiguration = new LibraryConfiguration("symlink-stuff", new SCMSourceRetriever(new GitSCMSource(sampleRepo.toString()))); + GlobalLibraries.get().setLibraries(Collections.singletonList(libraryConfiguration)); + + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("@Library('symlink-stuff@master') import Stuff; echo(Stuff.contents(this))", true)); + r.assertLogContains("master.key references a file that is not contained within the library: symlink-stuff", r.buildAndAssertStatus(Result.FAILURE, p)); + } + + @Issue("SECURITY-2476") + @Test public void libraryResourceNotAllowedToEscapeWorkspaceContext() throws Exception { + sampleRepo.init(); + sampleRepo.write("src/Stuff.groovy", "class Stuff {static def contents(script) {script.libraryResource '../../../../../../../secrets/master.key'}}"); + Path resourcesDir = Paths.get(sampleRepo.getRoot().getPath(), "resources"); + Files.createDirectories(resourcesDir); + + sampleRepo.git("add", "src", "resources"); + sampleRepo.git("commit", "--message=init"); + LibraryConfiguration libraryConfiguration = new LibraryConfiguration("libres-stuff", new SCMSourceRetriever(new GitSCMSource(sampleRepo.toString()))); + GlobalLibraries.get().setLibraries(Collections.singletonList(libraryConfiguration)); + + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("@Library('libres-stuff@master') import Stuff; echo(Stuff.contents(this))", true)); + + r.assertLogContains("../../../../../../../secrets/master.key references a file that is not contained within the library: libres-stuff", r.buildAndAssertStatus(Result.FAILURE, p)); + } + + @Test public void findResourcesAttemptsToLoadFromAllIncludedLibraries() throws Exception { + sampleRepo.init(); + sampleRepo.write("src/Stuff.groovy", ""); + sampleRepo.write("resources/foo.txt", "Hello from foo!"); + sampleRepo.git("add", "src", "resources"); + sampleRepo.git("commit", "--message=init"); + + sampleRepo2.init(); + sampleRepo2.write("src/Thing.groovy", ""); + sampleRepo2.write("resources/bar.txt", "Hello from bar!"); + sampleRepo2.git("add", "src", "resources"); + sampleRepo2.git("commit", "--message=init"); + + LibraryConfiguration libraryConfiguration = new LibraryConfiguration("stuff", + new SCMSourceRetriever(new GitSCMSource(sampleRepo.toString()))); + LibraryConfiguration libraryConfiguration2 = new LibraryConfiguration("thing", + new SCMSourceRetriever(new GitSCMSource(sampleRepo2.toString()))); + GlobalLibraries.get().setLibraries(Arrays.asList(libraryConfiguration, libraryConfiguration2)); + + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition( + "@Library(['stuff@master', 'thing@master']) _; echo(libraryResource('foo.txt')); echo(libraryResource('bar.txt'))", true)); + + Run run = r.buildAndAssertStatus(Result.SUCCESS, p); + r.assertLogContains("Hello from bar!", run); + r.assertLogContains("Hello from foo!", run); + } + public void initFixedContentLibrary() throws Exception { sampleRepo.init(); sampleRepo.write("src/pkg/Stuff.groovy", "package pkg; class Stuff {static def contents(script) {script.libraryResource 'pkg/file'}}"); @@ -204,10 +269,10 @@ public void clearCache(String name) throws Exception { } public void modifyCacheTimestamp(String name, String version, long timestamp) throws Exception { - FilePath cacheDir = new FilePath(LibraryCachingConfiguration.getGlobalLibrariesCacheDir(), name); - FilePath versionCacheDir = new FilePath(cacheDir, version); - if (versionCacheDir.exists()) { - versionCacheDir.touch(timestamp); + String cacheDirName = LibraryRecord.directoryNameFor(name, version, String.valueOf(true), GlobalLibraries.ForJob.class.getName()); + FilePath cacheDir = new FilePath(LibraryCachingConfiguration.getGlobalLibrariesCacheDir(), cacheDirName); + if (cacheDir.exists()) { + cacheDir.touch(timestamp); } }
src/test/java/org/jenkinsci/plugins/workflow/libs/SCMSourceRetrieverTest.java+97 −2 modified@@ -24,17 +24,21 @@ package org.jenkinsci.plugins.workflow.libs; +import com.cloudbees.hudson.plugins.folder.Folder; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.AbortException; -import hudson.ExtensionList; import hudson.FilePath; +import hudson.Functions; import hudson.model.Item; import hudson.model.Result; import hudson.model.TaskListener; import hudson.scm.ChangeLogSet; import hudson.scm.SCM; import hudson.slaves.WorkspaceList; +import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -47,6 +51,9 @@ import jenkins.scm.api.SCMSource; import jenkins.scm.api.SCMSourceCriteria; import jenkins.scm.api.SCMSourceDescriptor; +import jenkins.scm.impl.subversion.SubversionSCMSource; +import jenkins.scm.impl.subversion.SubversionSampleRepoRule; +import org.apache.commons.io.FileUtils; import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; import org.jenkinsci.plugins.workflow.job.WorkflowJob; import org.jenkinsci.plugins.workflow.job.WorkflowRun; @@ -68,14 +75,18 @@ import org.jvnet.hudson.test.WithoutJenkins; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.matchesPattern; +import static org.hamcrest.Matchers.nullValue; import static org.jenkinsci.plugins.workflow.libs.SCMSourceRetriever.PROHIBITED_DOUBLE_DOT; +import static org.junit.Assume.assumeFalse; public class SCMSourceRetrieverTest { @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher(); @Rule public JenkinsRule r = new JenkinsRule(); @Rule public GitSampleRepoRule sampleRepo = new GitSampleRepoRule(); + @Rule public SubversionSampleRepoRule sampleRepoSvn = new SubversionSampleRepoRule(); @Issue("JENKINS-40408") @Test public void lease() throws Exception { @@ -88,13 +99,16 @@ public class SCMSourceRetrieverTest { new SCMSourceRetriever(new GitSCMSource(null, sampleRepo.toString(), "", "*", "", true))))); WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); p.setDefinition(new CpsFlowDefinition("@Library('echoing@master') import myecho; myecho()", true)); - FilePath base = r.jenkins.getWorkspaceFor(p).withSuffix("@libs").child("echoing"); + String checkoutDir = LibraryRecord.directoryNameFor("git " + sampleRepo.toString()); + FilePath base = r.jenkins.getWorkspaceFor(p).withSuffix("@libs").child(checkoutDir); try (WorkspaceList.Lease lease = r.jenkins.toComputer().getWorkspaceList().acquire(base)) { WorkflowRun b = r.buildAndAssertSuccess(p); r.assertLogContains("something special", b); r.assertLogNotContains("Retrying after 10 seconds", b); assertFalse(base.child("vars").exists()); + assertFalse(base.withSuffix("-scm-key.txt").exists()); assertTrue(base.withSuffix("@2").child("vars").exists()); + assertThat(base.withSuffix("@2-scm-key.txt").readToString(), equalTo("git " + sampleRepo.toString())); } } @@ -350,4 +364,85 @@ public static class BasicSCMSource extends SCMSource { p.delete(); assertFalse(ws.exists()); } + + @Issue("SECURITY-2441") + @Test public void libraryNamesAreNotUsedAsCheckoutDirectories() throws Exception { + sampleRepo.init(); + sampleRepo.write("vars/globalLibVar.groovy", "def call() { echo('global library') }"); + sampleRepo.git("add", "vars"); + sampleRepo.git("commit", "--message=init"); + LibraryConfiguration globalLib = new LibraryConfiguration("library", + new SCMSourceRetriever(new GitSCMSource(null, sampleRepo.toString(), "", "*", "", true))); + globalLib.setDefaultVersion("master"); + globalLib.setImplicit(true); + GlobalLibraries.get().setLibraries(Collections.singletonList(globalLib)); + // Create a folder library with the same name as the global library so it takes precedence. + sampleRepoSvn.init(); + sampleRepoSvn.write("vars/folderLibVar.groovy", "def call() { jenkins.model.Jenkins.get().setSystemMessage('folder library') }"); + // Copy .git folder from the Git repo for the global library into the SVN repo for the folder library as data. + FileUtils.copyDirectory(new File(sampleRepo.getRoot(), ".git"), new File(sampleRepoSvn.wc(), ".git")); + sampleRepoSvn.svnkit("add", sampleRepoSvn.wc() + "/vars"); + sampleRepoSvn.svnkit("add", sampleRepoSvn.wc() + "/.git"); + sampleRepoSvn.svnkit("commit", "--message=init", sampleRepoSvn.wc()); + LibraryConfiguration folderLib = new LibraryConfiguration("library", + new SCMSourceRetriever(new SubversionSCMSource(null, sampleRepoSvn.prjUrl()))); + folderLib.setDefaultVersion("trunk"); + folderLib.setImplicit(true); + Folder f = r.jenkins.createProject(Folder.class, "folder1"); + f.getProperties().add(new FolderLibraries(Collections.singletonList(folderLib))); + // Create a job that uses the folder library, which will take precedence over the global library, since they have the same name. + WorkflowJob p = f.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("folderLibVar()", true)); + // First build fails as expected since it is not trusted. The folder library is checked out. + WorkflowRun b1 = r.buildAndAssertStatus(Result.FAILURE, p); + r.assertLogContains("Only using first definition of library library", b1); + r.assertLogContains("Scripts not permitted to use staticMethod jenkins.model.Jenkins get", b1); + // Attacker deletes the folder library, then reruns the build. + // The existing checkout of the SVN repo should not be reused as the Git repo for the global library. + f.getProperties().clear(); + WorkflowRun b2 = r.buildAndAssertStatus(Result.FAILURE, p); + r.assertLogContains("No such DSL method 'folderLibVar'", b2); + assertThat(r.jenkins.getSystemMessage(), nullValue()); + } + + @Issue("SECURITY-2463") + @Test public void checkoutDirectoriesAreNotReusedByDifferentScms() throws Exception { + assumeFalse(Functions.isWindows()); // Checkout hook is not cross-platform. + sampleRepo.init(); + sampleRepo.write("vars/foo.groovy", "def call() { echo('using global lib') }"); + sampleRepo.git("add", "vars"); + sampleRepo.git("commit", "--message=init"); + LibraryConfiguration globalLib = new LibraryConfiguration("library", + new SCMSourceRetriever(new GitSCMSource(null, sampleRepo.toString(), "", "*", "", true))); + globalLib.setDefaultVersion("master"); + globalLib.setImplicit(true); + GlobalLibraries.get().setLibraries(Collections.singletonList(globalLib)); + // Create a folder library with the same name as the global library so it takes precedence. + sampleRepoSvn.init(); + sampleRepoSvn.write("vars/foo.groovy", "def call() { echo('using folder lib') }"); + // Copy .git folder from the Git repo for the global library into the SVN repo for the folder library as data. + File gitDirInSvnRepo = new File(sampleRepoSvn.wc(), ".git"); + FileUtils.copyDirectory(new File(sampleRepo.getRoot(), ".git"), gitDirInSvnRepo); + String jenkinsRootDir = r.jenkins.getRootDir().toString(); + // Add a Git post-checkout hook to the .git folder in the SVN repo. + Files.write(gitDirInSvnRepo.toPath().resolve("hooks/post-checkout"), ("#!/bin/sh\ntouch '" + jenkinsRootDir + "/hook-executed'\n").getBytes(StandardCharsets.UTF_8)); + sampleRepoSvn.svnkit("add", sampleRepoSvn.wc() + "/vars"); + sampleRepoSvn.svnkit("add", sampleRepoSvn.wc() + "/.git"); + sampleRepoSvn.svnkit("propset", "svn:executable", "ON", sampleRepoSvn.wc() + "/.git/hooks/post-checkout"); + sampleRepoSvn.svnkit("commit", "--message=init", sampleRepoSvn.wc()); + LibraryConfiguration folderLib = new LibraryConfiguration("library", + new SCMSourceRetriever(new SubversionSCMSource(null, sampleRepoSvn.prjUrl()))); + folderLib.setDefaultVersion("trunk"); + folderLib.setImplicit(true); + Folder f = r.jenkins.createProject(Folder.class, "folder1"); + f.getProperties().add(new FolderLibraries(Collections.singletonList(folderLib))); + // Run the build using the folder library (which uses the SVN repo). + WorkflowJob p = f.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("foo()", true)); + r.buildAndAssertSuccess(p); + // Delete the folder library, and rerun the build so the global library is used. + f.getProperties().clear(); + WorkflowRun b2 = r.buildAndAssertSuccess(p); + assertFalse("Git checkout should not execute hooks from SVN repo", new File(r.jenkins.getRootDir(), "hook-executed").exists()); + } }
src/test/resources/org/jenkinsci/plugins/workflow/libs/LibraryAdderTest/correctLibraryDirectoryUsedWhenResumingOldBuild.zip+0 −0 added
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-g9fx-6j5c-grmwghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-25174ghsaADVISORY
- github.com/CVEProject/cvelist/blob/e19344451ce1c4a4181b9f094b8fd38cd8d86c9f/2022/25xxx/CVE-2022-25174.jsonghsaWEB
- github.com/jenkinsci/workflow-cps-global-lib-plugin/commit/ace0de3c2d691662021ea10306eeb407da6b6365ghsaWEB
- www.jenkins.io/security/advisory/2022-02-15/ghsax_refsource_CONFIRMWEB
News mentions
1- Jenkins Security Advisory 2022-02-15Jenkins Security Advisories · Feb 15, 2022