VYPR
High severityNVD Advisory· Published Feb 15, 2022· Updated Nov 19, 2024

CVE-2022-25182

CVE-2022-25182

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 on the Jenkins controller JVM using specially crafted library names if a global Pipeline library is already configured.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

A sandbox bypass in Jenkins Pipeline: Shared Groovy Libraries Plugin 552.vd9cc05b8a2e1 and earlier lets attackers with Item/Configure permission execute arbitrary code via crafted library names if a global library is configured.

Vulnerability

A sandbox bypass vulnerability exists in Jenkins Pipeline: Shared Groovy Libraries Plugin version 552.vd9cc05b8a2e1 and earlier. The flaw allows attackers with Item/Configure permission to execute arbitrary code on the Jenkins controller JVM by using specially crafted library names. This is possible only if a global Pipeline library is already configured on the Jenkins instance. The issue arises from the plugin's handling of SCM checkout directories for distinct SCMs, reusing the same workspace directory in some contexts [1], [2].

Exploitation

An attacker must have Item/Configure permission on a Jenkins project. No special network position is required beyond access to the Jenkins web UI or API. The attacker creates or modifies a Pipeline item to use a global library, supplying a crafted library name that triggers the sandbox bypass. The plugin then performs an SCM checkout into a reused workspace directory, allowing the attacker-controlled SCM contents to execute arbitrary OS commands or Java code on the controller JVM [1]. The attack does not require user interaction from other users.

Impact

Successful exploitation results in arbitrary code execution on the Jenkins controller JVM. The attacker can execute OS commands or Java code, leading to full compromise of the Jenkins controller, including access to all jobs, secrets, credentials, and the ability to modify system configuration. This is a high-severity vulnerability (CVSS 3.x High) [2].

Mitigation

Fixed versions have been released to address this vulnerability. Users should upgrade to Pipeline: Shared Groovy Libraries Plugin version 553.vd3376a_4a_6c4f or later. The fix includes changes to use distinct checkout directories per SCM for Pipeline libraries, preventing the sandbox bypass [1], [3]. No workaround is available. The affected versions prior to 552.vd9cc05b8a2e1 are also vulnerable, and users should upgrade to the latest version [1], [2].

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.

PackageAffected versionsPatched versions
org.jenkins-ci.plugins.workflow:workflow-cps-global-libMaven
< 561.va_ce0de3c2d69561.va_ce0de3c2d69

Affected products

2

Patches

1
ace0de3c2d69

[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

News mentions

1