CVE-2022-25181
Description
A sandbox bypass vulnerability in Jenkins Pipeline: Shared Groovy Libraries Plugin 552.vd9cc05b8a2e1 and earlier allows attackers with Item/Configure permission to execute arbitrary code in the context of the Jenkins controller JVM through crafted SCM contents, if a global Pipeline library already exists.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Jenkins Pipeline Shared Groovy Libraries Plugin ≤552.vd9cc05b8a2e1 allows attackers with Item/Configure permission to bypass the sandbox and execute arbitrary code on the controller via crafted SCM contents when a global Pipeline library exists.
Vulnerability
A sandbox bypass vulnerability in the Jenkins Pipeline: Shared Groovy Libraries Plugin versions 552.vd9cc05b8a2e1 and earlier [1][2] allows attackers with Item/Configure permission to execute arbitrary code in the context of the Jenkins controller JVM through crafted SCM contents, if a global Pipeline library already exists [2]. The plugin reused the same checkout directories for distinct SCMs when retrieving Pipeline libraries, enabling a malicious SCM to interfere with another SCM's checkout and inject untrusted code into the trusted library path [1].
Exploitation
An attacker must have Item/Configure permission on a Jenkins job [1][2]. The attack requires that a global Pipeline library has been configured [2]. By crafting SCM contents (e.g., in a Jenkinsfile or library source), the attacker can cause the plugin to use an existing workspace directory that contains files from a different SCM, thus bypassing the sandbox and executing arbitrary Groovy code during the library retrieval step [1].
Impact
Successful exploitation allows the attacker to execute arbitrary code on the Jenkins controller JVM with the same privileges as the Jenkins process [1][2]. This can lead to full compromise of the Jenkins controller, including disclosure of secrets, modification of configurations, and further lateral movement within the infrastructure [1].
Mitigation
The vulnerability is fixed in Pipeline: Shared Groovy Libraries Plugin version 553.vd9cc05b8a2e2 and later [1]. The fix ensures that distinct checkout directories are used for different SCMs [1]. Users should upgrade to the latest version available from the Jenkins update center. No workaround is mentioned in the references if upgrading is not immediately possible. Jenkins also recommends reviewing Item/Configure permissions and limiting global Pipeline library usage to trusted SCMs [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 | < 561.va_ce0de3c2d69 | 561.va_ce0de3c2d69 |
Affected products
2- ghsa-coordsRange: < 561.va_ce0de3c2d69
- 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
4- github.com/advisories/GHSA-7w2w-fwpf-9m4hghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-25181ghsaADVISORY
- 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