VYPR
High severityNVD Advisory· Published Feb 15, 2022· Updated Aug 3, 2024

CVE-2022-25174

CVE-2022-25174

Description

Jenkins Pipeline: Shared Groovy Libraries Plugin 552.vd9cc05b8a2e1 and earlier uses the same checkout directories for distinct SCMs for Pipeline libraries, allowing attackers with Item/Configure permission to invoke arbitrary OS commands on the controller through crafted SCM contents.

AI Insight

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

Jenkins Pipeline: Shared Groovy Libraries Plugin reuses checkout directories for distinct SCMs, enabling OS command execution via crafted SCM contents.

Vulnerability

Jenkins Pipeline: Shared Groovy Libraries Plugin versions 552.vd9cc05b8a2e1 and earlier use the same checkout directories for distinct SCMs when retrieving Pipeline libraries [1][2]. An attacker with Item/Configure permission can configure a Pipeline library with a crafted SCM, such as a malicious Git repository, that overwrites files in the shared directory upon checkout. This leads to arbitrary OS command execution on the Jenkins controller [1].

Exploitation

The attacker must have Item/Configure permission on a Pipeline job. They set up a Pipeline library with a crafted SCM that contains malicious files or scripts. When Jenkins loads the library, it performs a checkout into a directory shared across different SCMs, allowing the attacker's content to be placed in a location that can be executed by the controller [1][2].

Impact

Successful exploitation allows the attacker to execute arbitrary OS commands on the Jenkins controller with the privileges of the Jenkins process, leading to full compromise of the Jenkins instance [1].

Mitigation

Jenkins has released updated versions of the affected plugins that enforce distinct checkout directories per SCM [1][3]. Users should upgrade to the latest version of the Pipeline: Shared Groovy Libraries Plugin (e.g., versions after 552.vd9cc05b8a2e1). No workarounds are available [1].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
org.jenkins-ci.plugins.workflow:workflow-cps-global-libMaven
>= 544.vff04fa68714d, < 561.va561.va
org.jenkins-ci.plugins.workflow:workflow-cps-global-libMaven
>= 2.19, < 2.21.12.21.1
org.jenkins-ci.plugins.workflow:workflow-cps-global-libMaven
< 2.18.12.18.1

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

5

News mentions

1