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

CVE-2022-45379

CVE-2022-45379

Description

Jenkins Script Security Plugin 1189.vb_a_b_7c8fd5fde and earlier stores whole-script approvals as the SHA-1 hash of the script, making it vulnerable to collision attacks.

AI Insight

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

Jenkins Script Security Plugin stores whole-script approvals as SHA-1 hashes, enabling attackers to generate collision-based script approvals and bypass security controls.

Root

Cause

The Jenkins Script Security Plugin, versions 1189.vb_a_b_7c8fd5fde and earlier, stores whole-script approvals using the SHA-1 hash of the approved script [1]. SHA-1 is a deprecated cryptographic hash function that is no longer considered collision-resistant. This design choice makes the approval mechanism vulnerable to collision attacks, where an attacker can craft two different scripts that produce the same SHA-1 hash [1][4].

Exploitation

An attacker with the ability to submit a script for approval (e.g., a user with Job/Configure permission) can exploit this weakness by generating a SHA-1 collision. The attacker creates a benign script that, when submitted, gets approved by an administrator using the SHA-1 hash. The attacker then replaces that benign script with a malicious one that has the same SHA-1 hash but performs harmful actions [1][2]. Because the approval is based solely on the hash, the malicious script bypasses the security review and can be executed within Jenkins [1][3].

Impact

Successful exploitation allows an attacker to execute arbitrary Groovy scripts within Jenkins, effectively bypassing the script approval security mechanism. This can lead to unauthorized access to sensitive data, configuration changes, and full compromise of the Jenkins server, depending on the privileges of the Jenkins process [2][4]. As noted in the advisory, administrators can revoke all SHA-1 based script approvals via the In-Process Script Approval page as a mitigation [1].

Mitigation

The vulnerability is fixed in Script Security Plugin version 1190.v65867a_a_47126, which uses SHA-512 for new approvals [1][3]. Previously approved scripts will be migrated to SHA-512 when they are next used; however, administrators should consider revoking existing SHA-1 approvals if collision attacks are a concern [1]. The plugin is part of the core Jenkins ecosystem, and users are strongly advised to upgrade immediately [1][4].

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:script-securityMaven
< 1190.v65867a_a_471261190.v65867a_a_47126

Affected products

2

Patches

1
65867aa47126

[SECURITY-2564]

5 files changed · +556 88
  • src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval.java+326 86 modified
    @@ -25,6 +25,7 @@
     package org.jenkinsci.plugins.scriptsecurity.scripts;
     
     import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
    +import hudson.model.BallColor;
     import jenkins.model.GlobalConfiguration;
     import jenkins.model.GlobalConfigurationCategory;
     import jenkins.util.SystemProperties;
    @@ -50,24 +51,28 @@
     import java.io.File;
     import java.io.IOException;
     import java.io.InputStream;
    -import java.io.UnsupportedEncodingException;
     import java.net.URL;
    +import java.nio.charset.StandardCharsets;
     import java.security.DigestInputStream;
     import java.security.MessageDigest;
     import java.security.NoSuchAlgorithmException;
     import java.util.ArrayList;
     import java.util.Collections;
     import java.util.Comparator;
    +import java.util.HashMap;
     import java.util.Iterator;
     import java.util.LinkedHashSet;
     import java.util.List;
    +import java.util.Map;
     import java.util.Set;
     import java.util.Stack;
     import java.util.TreeSet;
     import java.util.function.Consumer;
     import java.util.logging.Level;
     import java.util.logging.Logger;
     import java.util.regex.Pattern;
    +import java.util.stream.Collectors;
    +
     import edu.umd.cs.findbugs.annotations.CheckForNull;
     import edu.umd.cs.findbugs.annotations.NonNull;
     import jenkins.model.Jenkins;
    @@ -169,8 +174,82 @@ boolean isClassDirectory() {
                 return hash.compareTo(o.hash);
             }
         }
    -    
    -    /** All scripts which are already approved, via {@link #hash}. */
    +
    +    enum Hasher {
    +        SHA512 {
    +            final Pattern shaPattern = Pattern.compile("SHA512:[a-fA-F0-9]{128}");
    +            @Override
    +            String prefix() {
    +                return "SHA512:";
    +            }
    +
    +            @Override
    +            MessageDigest digest() throws NoSuchAlgorithmException {
    +                return MessageDigest.getInstance("SHA-512");
    +            }
    +
    +            @Override
    +            Pattern pattern() {
    +                return shaPattern;
    +            }
    +        },
    +        @Deprecated
    +        SHA1 {
    +            final Pattern shaPattern = Pattern.compile("[a-fA-F0-9]{40}");
    +            @Override
    +            String prefix() {
    +                return "";
    +            }
    +            @Override
    +            MessageDigest digest() throws NoSuchAlgorithmException {
    +                return MessageDigest.getInstance("SHA-1");
    +            }
    +
    +            @Override
    +            Pattern pattern() {
    +                return shaPattern;
    +            }
    +        };
    +        String hash(String script, String language) {
    +            try {
    +                MessageDigest digest = digest();
    +                digest.update(language.getBytes(StandardCharsets.UTF_8));
    +                digest.update((byte) ':');
    +                digest.update(script.getBytes(StandardCharsets.UTF_8));
    +                return prefix() + Util.toHexString(digest.digest());
    +            } catch (NoSuchAlgorithmException x) {
    +                throw new AssertionError(x);
    +            }
    +        }
    +
    +        /**
    +         * Creates digest of JAR contents.
    +         * Package visibility to be used in tests.
    +         */
    +        String hashClasspathEntry(URL entry) throws IOException {
    +            try {
    +                MessageDigest digest = digest();
    +                try (InputStream is = entry.openStream(); BufferedInputStream bis = new BufferedInputStream(is); DigestInputStream input = new DigestInputStream(bis, digest)) {
    +                    byte[] buffer = new byte[1024];
    +                    while (input.read(buffer) != -1) {
    +                        // discard
    +                    }
    +                    return prefix() + Util.toHexString(digest.digest());
    +                }
    +            } catch (NoSuchAlgorithmException x) {
    +                throw new AssertionError(x);
    +            }
    +        }
    +        abstract String prefix();
    +        abstract MessageDigest digest() throws NoSuchAlgorithmException;
    +        abstract Pattern pattern();
    +    }
    +
    +    static final Hasher DEFAULT_HASHER = Hasher.SHA512;
    +
    +    private static Thread convertDeprecatedApprovedClasspathEntriesThread = null;
    +
    +    /** All scripts which are already approved, via {@link Hasher#hash(String, String)}. */
         private final TreeSet<String> approvedScriptHashes = new TreeSet<>();
     
         /** All sandbox signatures which are already whitelisted, in {@link StaticWhitelist} format. */
    @@ -186,8 +265,74 @@ boolean isClassDirectory() {
             approvedClasspathEntries.add(acp);
         }
     
    -    public boolean isScriptApproved(String script, Language language) {
    -        return this.isScriptHashApproved(hash(script, language.getName()));
    +    public boolean isScriptApproved(@NonNull String script, @NonNull Language language) {
    +        for (Hasher hasher : Hasher.values()) { //Default Hasher should be first in the array
    +            String hash = hasher.hash(script, language.getName());
    +            if (this.isScriptHashApproved(hash)) {
    +                return true;
    +            }
    +        }
    +        return false;
    +    }
    +
    +    static class ConversionCheckResult {
    +        final String oldHash;
    +        final String newHash;
    +        final boolean approved;
    +        final boolean converted;
    +
    +        public ConversionCheckResult(final String oldHash, final String newHash, final boolean approved, final boolean converted) {
    +            this.oldHash = oldHash;
    +            this.newHash = newHash;
    +            this.approved = approved;
    +            this.converted = converted;
    +        }
    +    }
    +
    +    @Restricted(NoExternalUse.class) @NonNull
    +    private synchronized ConversionCheckResult checkAndConvertApprovedScript(@NonNull String script, @NonNull Language language) {
    +        String hash = DEFAULT_HASHER.hash(script, language.getName());
    +        if (approvedScriptHashes.contains(hash)) {
    +            return new ConversionCheckResult(hash, hash, true, false);
    +        }
    +        for (Hasher hasher : Hasher.values()) {
    +            if (hasher != DEFAULT_HASHER) {
    +                String oldHash = hasher.hash(script, language.getName());
    +                if (approvedScriptHashes.contains(oldHash)) {
    +                    LOG.fine("A script is approved with an old hash algorithm. " +
    +                            "Converting now, this may cause performance issues until all old hashes has been converted or removed.");
    +                    approvedScriptHashes.remove(oldHash);
    +                    approvedScriptHashes.add(hash);
    +                    save();
    +                    return new ConversionCheckResult(oldHash, hash, true, true);
    +                }
    +            }
    +        }
    +        return new ConversionCheckResult(hash, hash, false, false);
    +    }
    +
    +    @Restricted(NoExternalUse.class) @NonNull
    +    private synchronized ConversionCheckResult checkAndConvertApprovedClasspath(@NonNull URL url) throws IOException {
    +        String hash = DEFAULT_HASHER.hashClasspathEntry(url);
    +        ApprovedClasspathEntry acp = new ApprovedClasspathEntry(hash, url);
    +        if (approvedClasspathEntries.contains(acp)) {
    +            return new ConversionCheckResult(hash, hash, true, false);
    +        }
    +        for (Hasher hasher : Hasher.values()) {
    +            if (hasher != DEFAULT_HASHER) {
    +                String oldHash = hasher.hashClasspathEntry(url);
    +                ApprovedClasspathEntry oacp = new ApprovedClasspathEntry(oldHash, url);
    +                if (approvedClasspathEntries.contains(oacp)) {
    +                    LOG.fine("A classpath is approved with an old hash algorithm. " +
    +                            "Converting now, this may cause performance issues until all old hashes has been converted or removed.");
    +                    approvedClasspathEntries.remove(oacp);
    +                    approvedClasspathEntries.add(acp);
    +                    save();
    +                    return new ConversionCheckResult(oldHash, hash, true, true);
    +                }
    +            }
    +        }
    +        return new ConversionCheckResult(hash, hash, false, false);
         }
     
         @Restricted(NoExternalUse.class) // for use from Jelly
    @@ -226,7 +371,7 @@ public static final class PendingScript extends PendingThing {
                 this.language = language.getName();
             }
             public String getHash() {
    -            return hash(script, language);
    +            return DEFAULT_HASHER.hash(script, language);
             }
             public Language getLanguage() {
                 for (Language l : ExtensionList.lookup(Language.class)) {
    @@ -365,17 +510,61 @@ public ScriptApproval() {
             }
             // Check for loaded class directories
             boolean changed = false;
    +        int dcp = 0;
             for (Iterator<ApprovedClasspathEntry> i = approvedClasspathEntries.iterator(); i.hasNext();) {
    -            if (i.next().isClassDirectory()) {
    +            final ApprovedClasspathEntry entry = i.next();
    +            if (entry.isClassDirectory()) {
                     i.remove();
                     changed = true;
                 }
    +            if (!DEFAULT_HASHER.pattern().matcher(entry.hash).matches()) {
    +                dcp++;
    +            }
    +        }
    +        int dsh = countDeprecatedApprovedScriptHashes();
    +        if (dcp > 0 || dsh > 0) {
    +            LOG.log(Level.WARNING, "There are {0} deprecated approved script hashes " +
    +                    "and {1} deprecated approved classpath hashes. " +
    +                    "They will be rehashed upon next use and that may cause performance issues " +
    +                    "until all of them are converted or removed.", new Object[]{dsh, dcp});
             }
             if (changed) {
                 save();
             }
         }
     
    +    @Restricted(NoExternalUse.class)
    +    public synchronized boolean hasDeprecatedApprovedScriptHashes() {
    +        return countDeprecatedApprovedScriptHashes() > 0;
    +    }
    +
    +    @Restricted(NoExternalUse.class)
    +    public synchronized int countDeprecatedApprovedScriptHashes() {
    +        int dsh = 0;
    +        for (String hash : approvedScriptHashes) {
    +            if (!DEFAULT_HASHER.pattern().matcher(hash).matches()) {
    +                dsh++;
    +            }
    +        }
    +        return dsh;
    +    }
    +
    +    @Restricted(NoExternalUse.class)
    +    public synchronized int countDeprecatedApprovedClasspathHashes() {
    +        int dcp = 0;
    +        for (ApprovedClasspathEntry entry : approvedClasspathEntries) {
    +            if (!DEFAULT_HASHER.pattern().matcher(entry.getHash()).matches()) {
    +                dcp++;
    +            }
    +        }
    +        return dcp;
    +    }
    +
    +    @Restricted(NoExternalUse.class)
    +    public synchronized boolean hasDeprecatedApprovedClasspathHashes() {
    +        return countDeprecatedApprovedClasspathHashes() > 0;
    +    }
    +
         /** Nothing has ever been approved or is pending. */
         boolean isEmpty() {
             return approvedScriptHashes.isEmpty() &&
    @@ -387,46 +576,6 @@ boolean isEmpty() {
                    pendingClasspathEntries.isEmpty();
         }
     
    -    private static String hash(String script, String language) {
    -        try {
    -            MessageDigest digest = MessageDigest.getInstance("SHA-1");
    -            digest.update(language.getBytes("UTF-8"));
    -            digest.update((byte) ':');
    -            digest.update(script.getBytes("UTF-8"));
    -            return Util.toHexString(digest.digest());
    -        } catch (NoSuchAlgorithmException | UnsupportedEncodingException x) {
    -            throw new AssertionError(x);
    -        }
    -    }
    -
    -    /**
    -     * Creates digest of JAR contents.
    -     * Package visibility to be used in tests.
    -     */
    -    static String hashClasspathEntry(URL entry) throws IOException {
    -        InputStream is = entry.openStream();
    -        try {
    -            DigestInputStream input = null;
    -            try {
    -                MessageDigest digest = MessageDigest.getInstance("SHA-1");
    -                input = new DigestInputStream(new BufferedInputStream(is), digest);
    -                byte[] buffer = new byte[1024];
    -                while (input.read(buffer) != -1) {
    -                    // discard
    -                }
    -                return Util.toHexString(digest.digest());
    -            } catch (NoSuchAlgorithmException x) {
    -                throw new AssertionError(x);
    -            } finally {
    -                if (input != null) {
    -                    input.close();
    -                }
    -            }
    -        } finally {
    -            is.close();
    -        }
    -    }
    -
         /**
          * Used when someone is configuring a script.
          * Typically you would call this from a {@link DataBoundConstructor}.
    @@ -443,13 +592,14 @@ static String hashClasspathEntry(URL entry) throws IOException {
          * @return {@code script}, for convenience
          */
         public synchronized String configuring(@NonNull String script, @NonNull Language language, @NonNull ApprovalContext context, boolean approveIfAdmin) {
    -        final String hash = hash(script, language.getName());
    -        if (!approvedScriptHashes.contains(hash)) {
    +        final ConversionCheckResult result = checkAndConvertApprovedScript(script, language);
    +        if (!result.approved) {
                 if (!Jenkins.get().isUseSecurity() || 
                         ((Jenkins.getAuthentication() != ACL.SYSTEM && Jenkins.get().hasPermission(Jenkins.ADMINISTER)) 
                                 && (ADMIN_AUTO_APPROVAL_ENABLED || approveIfAdmin))) {
    -                approvedScriptHashes.add(hash);
    -                removePendingScript(hash);
    +                approvedScriptHashes.add(result.newHash);
    +                //Pending scripts are not stored with a precalculated hash, so no need to remove any old hashes
    +                removePendingScript(result.newHash); 
                 } else {
                     String key = context.getKey();
                     if (key != null) {
    @@ -483,10 +633,10 @@ public synchronized String using(@NonNull String script, @NonNull Language langu
                 // and in many cases there is some sensible behavior for an emoty script which we want to permit.
                 return script;
             }
    -        String hash = hash(script, language.getName());
    -        if (!approvedScriptHashes.contains(hash)) {
    +        ConversionCheckResult result = checkAndConvertApprovedScript(script, language);
    +        if (!result.approved) {
                 // Probably need not add to pendingScripts, since generally that would have happened already in configuring.
    -            throw new UnapprovedUsageException(hash);
    +            throw new UnapprovedUsageException(result.newHash);
             }
             return script;
         }
    @@ -515,29 +665,29 @@ public synchronized void configuring(@NonNull ClasspathEntry entry, @NonNull App
             }
             //TODO: better error propagation
             URL url = entry.getURL();
    -        String hash;
    +        ConversionCheckResult result;
             try {
    -            hash = hashClasspathEntry(url);
    +            result = checkAndConvertApprovedClasspath(url);
             } catch (IOException x) {
                 // This is a case the path doesn't really exist
                 LOG.log(Level.WARNING, null, x);
                 return;
             }
    -        
    -        ApprovedClasspathEntry acp = new ApprovedClasspathEntry(hash, url);
    -        if (!approvedClasspathEntries.contains(acp)) {
    +
    +        if (!result.approved) {
                 boolean shouldSave = false;
    -            PendingClasspathEntry pcp = new PendingClasspathEntry(hash, url, context);
    +            PendingClasspathEntry pcp = new PendingClasspathEntry(result.newHash, url, context);
                 if (!Jenkins.get().isUseSecurity() ||
                         ((Jenkins.getAuthentication() != ACL.SYSTEM && Jenkins.get().hasPermission(Jenkins.ADMINISTER))
                                 && (ADMIN_AUTO_APPROVAL_ENABLED || entry.isShouldBeApproved() || !StringUtils.equals(entry.getOldPath(), entry.getPath())))) {
    -                LOG.log(Level.FINE, "Classpath entry {0} ({1}) is approved as configured with ADMINISTER permission.", new Object[] {url, hash});
    +                LOG.log(Level.FINE, "Classpath entry {0} ({1}) is approved as configured with ADMINISTER permission.", new Object[] {url, result.newHash});
    +                ApprovedClasspathEntry acp = new ApprovedClasspathEntry(result.newHash, url);
                     pendingClasspathEntries.remove(pcp);
                     approvedClasspathEntries.add(acp);
                     shouldSave = true;
                 } else {
                     if (pendingClasspathEntries.add(pcp)) {
    -                    LOG.log(Level.FINE, "{0} ({1}) is pending", new Object[] {url, hash});
    +                    LOG.log(Level.FINE, "{0} ({1}) is pending", new Object[] {url, result.newHash});
                         shouldSave = true;
                     }
                 }
    @@ -577,25 +727,24 @@ public synchronized FormValidation checking(@NonNull ClasspathEntry entry) {
          */
         public synchronized void using(@NonNull ClasspathEntry entry) throws IOException, UnapprovedClasspathException {
             URL url = entry.getURL();
    -        String hash = hashClasspathEntry(url);
    -        
    -        if (!approvedClasspathEntries.contains(new ApprovedClasspathEntry(hash, url))) {
    -            // Don't add it to pending if it is a class directory
    -            if (entry.isClassDirectory()) {
    -                LOG.log(Level.WARNING, "Classpath {0} ({1}) is a class directory, which are not allowed.", new Object[] {url, hash});
    -                throw new UnapprovedClasspathException("classpath entry %s is a class directory, which are not allowed.", url, hash);
    -            } else {
    -                // Never approve classpath here.
    -                ApprovalContext context = ApprovalContext.create();
    -                if (pendingClasspathEntries.add(new PendingClasspathEntry(hash, url, context))) {
    -                    LOG.log(Level.FINE, "{0} ({1}) is pending.", new Object[] {url, hash});
    -                    save();
    -                }
    +        // Don't add it to pending if it is a class directory
    +        if (entry.isClassDirectory()) {
    +            LOG.log(Level.WARNING, "Classpath {0} is a class directory, which are not allowed.", url);
    +            throw new UnapprovedClasspathException("classpath entry %s is a class directory, which are not allowed.", url, "");
    +        }
    +        ConversionCheckResult result = checkAndConvertApprovedClasspath(url);
    +
    +        if (!result.approved) {
    +            // Never approve classpath here.
    +            ApprovalContext context = ApprovalContext.create();
    +            if (pendingClasspathEntries.add(new PendingClasspathEntry(result.newHash, url, context))) {
    +                LOG.log(Level.FINE, "{0} ({1}) is pending.", new Object[]{url, result.newHash});
    +                save();
                 }
    -            throw new UnapprovedClasspathException(url, hash);
    +            throw new UnapprovedClasspathException(url, result.newHash);
             }
             
    -        LOG.log(Level.FINER, "{0} ({1}) had been approved", new Object[] {url, hash});
    +        LOG.log(Level.FINER, "{0} ({1}) had been approved", new Object[] {url, result.newHash});
         }
     
         /**
    @@ -613,7 +762,8 @@ public synchronized FormValidation checking(@NonNull String script, @NonNull Lan
             if (StringUtils.isEmpty(script)) {
                 return FormValidation.ok();
             }
    -        if (approvedScriptHashes.contains(hash(script, language.getName()))) {
    +        final ConversionCheckResult result = checkAndConvertApprovedScript(script, language);
    +        if (result.approved) {
                 return FormValidation.okWithMarkup("The script is already approved");
             }
     
    @@ -640,7 +790,8 @@ public synchronized FormValidation checking(@NonNull String script, @NonNull Lan
     
         synchronized boolean isClasspathEntryApproved(URL url) {
             try {
    -            return approvedClasspathEntries.contains(new ApprovedClasspathEntry(hashClasspathEntry(url), url));
    +            final ConversionCheckResult result = checkAndConvertApprovedClasspath(url);
    +            return result.approved;
             } catch (IOException e) {
                 return false;
             }
    @@ -655,7 +806,7 @@ synchronized boolean isClasspathEntryApproved(URL url) {
          * @return {@code script}, for convenience
          */
         public synchronized String preapprove(@NonNull String script, @NonNull Language language) {
    -        approvedScriptHashes.add(hash(script, language.getName()));
    +        approvedScriptHashes.add(DEFAULT_HASHER.hash(script, language.getName()));
             return script;
         }
     
    @@ -751,12 +902,25 @@ public synchronized String[] getAclApprovedSignatures() {
         public synchronized void setApprovedScriptHashes(String[] scriptHashes) throws IOException {
             Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS);
             approvedScriptHashes.clear();
    -        Pattern sha1Pattern = Pattern.compile("[a-fA-F0-9]{40}");
             for (String scriptHash : scriptHashes) {
    -            if (scriptHash != null && sha1Pattern.matcher(scriptHash).matches()) {
    -                approvedScriptHashes.add(scriptHash);
    -            } else {
    -                LOG.warning(() -> "Ignoring malformed script hash: " + scriptHash);
    +            if (StringUtils.isNotEmpty(scriptHash)) {
    +                if (DEFAULT_HASHER.pattern().matcher(scriptHash).matches()) {
    +                    approvedScriptHashes.add(scriptHash);
    +                } else {
    +                    boolean allowed = false;
    +                    for (Hasher hasher : Hasher.values()) {
    +                        if (hasher != DEFAULT_HASHER && hasher.pattern().matcher(scriptHash).matches()) {
    +                            allowed = true;
    +                            break;
    +                        }
    +                    }
    +                    if (allowed) {
    +                        LOG.warning(() -> "Adding deprecated script hash that will be converted on next use: " + scriptHash);
    +                        approvedScriptHashes.add(scriptHash);
    +                    } else {
    +                        LOG.warning(() -> "Ignoring malformed script hash: " + scriptHash);
    +                    }
    +                }
                 }
             }
             save();
    @@ -843,6 +1007,82 @@ synchronized void removePendingScript(String hash) {
             save();
         }
     
    +
    +    /**
    +     * Clears {@link #approvedScriptHashes} from all entries not matching {@link #DEFAULT_HASHER}.
    +     * @throws IOException if so when saving to disk.
    +     */
    +    @Restricted(NoExternalUse.class) // for use from AJAX
    +    @JavaScriptMethod public synchronized void clearDeprecatedApprovedScripts() throws IOException {
    +        Jenkins.get().checkPermission(Jenkins.ADMINISTER);
    +        approvedScriptHashes.removeIf(s -> !DEFAULT_HASHER.pattern().matcher(s).matches());
    +        save();
    +    }
    +
    +    @Restricted(NoExternalUse.class)
    +    public String getSpinnerIconClassName() {
    +        return BallColor.GREY_ANIME.getIconClassName();
    +    }
    +
    +    /**
    +     * Schedules a {@link Thread} task that rehashes/converts all approved classpath entries
    +     * that are hashed not using {@link #DEFAULT_HASHER}.
    +     */
    +    @Restricted(NoExternalUse.class) // for use from AJAX
    +    @JavaScriptMethod public synchronized void convertDeprecatedApprovedClasspathEntries() {
    +        Jenkins.get().checkPermission(Jenkins.ADMINISTER);
    +        if (!isConvertingDeprecatedApprovedClasspathEntries()) {
    +            final List<ApprovedClasspathEntry> entries = approvedClasspathEntries.stream()
    +                    .filter(e -> !DEFAULT_HASHER.pattern().matcher(e.getHash()).matches())
    +                    .collect(Collectors.toList());
    +            if (!entries.isEmpty()) {
    +                LOG.log(Level.INFO, "Scheduling conversion of {0} deprecated approved classpathentry hashes.", entries.size());
    +                convertDeprecatedApprovedClasspathEntriesThread = new Thread(() -> {
    +                    final Map<String, ApprovedClasspathEntry> result = new HashMap<>();
    +                    for (int i = 0; i < entries.size(); i++) {
    +                        final ApprovedClasspathEntry entry = entries.get(i);
    +                        final URL entryURL = entry.getURL();
    +                        LOG.log(Level.INFO, String.format("Converting %s\t(%d/%d)", entryURL, i + 1, entries.size()));
    +                        try {
    +                            final String hash = DEFAULT_HASHER.hashClasspathEntry(entryURL);
    +                            result.put(entryURL.toExternalForm(), new ApprovedClasspathEntry(hash, entryURL));
    +                        } catch (Throwable e) {
    +                            LOG.log(Level.WARNING, "Failed to convert " + entryURL, e);
    +                        }
    +                        Thread.yield(); //Technically not needed as there is plenty of IO happening in this thread.
    +                    }
    +                    synchronized (ScriptApproval.this) {
    +                        approvedClasspathEntries.removeIf(e -> result.containsKey(e.getURL().toExternalForm()));
    +                        approvedClasspathEntries.addAll(result.values());
    +                        try {
    +                            save();
    +                        } catch (Exception e) {
    +                            LOG.log(Level.WARNING, "Failed to store conversion result.", e);
    +                        }
    +                    }
    +                    LOG.info("Conversion done.");
    +                }, "Approved Classpaths rehasher");
    +                convertDeprecatedApprovedClasspathEntriesThread.setDaemon(true);
    +                convertDeprecatedApprovedClasspathEntriesThread.start();
    +                LOG.fine("Background conversion task scheduled.");
    +            } else {
    +                LOG.info("Nothing to convert.");
    +            }
    +        } else {
    +            LOG.fine("Background conversion task already running.");
    +        }
    +    }
    +
    +    /**
    +     * Checks if {@link #convertDeprecatedApprovedClasspathEntriesThread} is active.
    +     * @return true if so.
    +     */
    +    @Restricted(NoExternalUse.class)
    +    public synchronized boolean isConvertingDeprecatedApprovedClasspathEntries() {
    +        return convertDeprecatedApprovedClasspathEntriesThread != null
    +                && convertDeprecatedApprovedClasspathEntriesThread.isAlive();
    +    }
    +
         @Restricted(NoExternalUse.class) // for use from Jelly
         public Set<PendingSignature> getPendingSignatures() {
             return pendingSignatures;
    
  • src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/index.jelly+41 0 modified
    @@ -203,6 +203,17 @@ THE SOFTWARE.
                         </j:forEach>
                     </j:otherwise>
                 </j:choose>
    +            <j:if test="${it.hasDeprecatedApprovedScriptHashes()}">
    +                <p id="deprecated-approvedScripts-clear">
    +                    You have <st:out value="${it.countDeprecatedApprovedScriptHashes()}"/> script approvals with deprecated hashes:
    +                    <button onclick="if (confirm('Really delete all deprecated approvals? Any existing scripts will need to be requeued and reapproved.')) {mgr.clearDeprecatedApprovedScripts(); $('deprecated-approvedScripts-clear').hide();}">Clear Deprecated Approvals</button>
    +                </p>
    +                <p class="setting-description">
    +                    Script approvals are stored in Jenkins as the hashed value of the script. Old approvals were hashed using SHA-1, which is deprecated.
    +                    Because only the hash of the script is stored, they cannot be immediately converted to use a new hash algorithm. Instead, they will be automatically rehashed when the script is next used.
    +                    To minimize potential security risks, you can immediately revoke all script approvals that were hashed using SHA-1. <strong>This will cause all jobs and features that use those scripts to fail until they are reconfigured and then then approved by a Jenkins administrator.</strong>
    +                </p>
    +            </j:if>
                 <p id="approvedScripts-clear">
                     You can also remove all previous script approvals:
                     <button onclick="if (confirm('Really delete all approvals? Any existing scripts will need to be requeued and reapproved.')) {mgr.clearApprovedScripts()}">Clear Approvals</button>
    @@ -271,6 +282,36 @@ THE SOFTWARE.
                 </p>
                 <div id="approvedClasspathEntries">
                 </div>
    +            <j:if test="${it.hasDeprecatedApprovedClasspathHashes()}">
    +                <p id="deprecated-approvedClasspaths-clear">
    +                    You have ${it.countDeprecatedApprovedClasspathHashes()} approved classpath entries with deprecated hashes:
    +                    <span id="deprecated-approvedClasspaths-clear-btn">
    +                        <button onclick="if (confirm('This will be scheduled on a background thread. You can follow the progress in the system log')) {mgr.convertDeprecatedApprovedClasspathEntries(); $('deprecated-approvedClasspaths-clear-btn').hide(); $('deprecated-approvedClasspaths-clear-spinner').show();}">Rehash Deprecated Approvals</button>
    +                    </span>
    +                    <span id="deprecated-approvedClasspaths-clear-spinner">
    +                        <l:icon alt="${%Converting...}" class="${it.spinnerIconClassName} icon-md"/>
    +                    </span>
    +                </p>
    +                <p class="setting-description">
    +                    Approved classpath entries are stored in Jenkins with the URL and the hashed content of the resource the URL refers to. Old approvals were hashed using SHA-1, which is deprecated.
    +                    Because the URL is known they can be rehashed in bulk, but that might take time, so they will each be rehashed when next used to not cause any disruption.
    +                    To minimize potential security risks, you can schedule a background task to automatically convert all existing approved classpath entries to the new hash format.
    +                </p>
    +                <j:choose>
    +                    <j:when test="${it.isConvertingDeprecatedApprovedClasspathEntries()}">
    +                        <script>
    +                            $('deprecated-approvedClasspaths-clear-btn').hide();
    +                            $('deprecated-approvedClasspaths-clear-spinner').show();
    +                        </script>
    +                    </j:when>
    +                    <j:otherwise>
    +                        <script>
    +                            $('deprecated-approvedClasspaths-clear-btn').show();
    +                            $('deprecated-approvedClasspaths-clear-spinner').hide();
    +                        </script>
    +                    </j:otherwise>
    +                </j:choose>
    +            </j:if>
                 <p id="approvedClasspathEntries-clear">
                     You can also remove all previous classpath entry approvals:
                     <button onclick="if (confirm('Really delete all approvals? Any existing scripts using a classpath will need to be rerun and entries reapproved.')) {clearApprovedClasspathEntries()}">Clear Classpath Entries</button>
    
  • src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/EntryApprovalTest.java+1 1 modified
    @@ -100,7 +100,7 @@ static final class Entry extends Approvable<Entry> {
                 this.entry = entry;
                 ScriptApproval.get().configuring(entry, ApprovalContext.create());
                 // If configure is successful, calculate the hash
    -            this.hash = ScriptApproval.hashClasspathEntry(entry.getURL());
    +            this.hash = ScriptApproval.DEFAULT_HASHER.hashClasspathEntry(entry.getURL());
             }
     
             @Override
    
  • src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/HasherScriptApprovalTest.java+172 0 added
    @@ -0,0 +1,172 @@
    +package org.jenkinsci.plugins.scriptsecurity.scripts;
    +
    +import org.hamcrest.Matcher;
    +import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage;
    +import org.jetbrains.annotations.NotNull;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.jvnet.hudson.test.Issue;
    +import org.jvnet.hudson.test.JenkinsSessionRule;
    +import org.jvnet.hudson.test.LoggerRule;
    +
    +import java.io.IOException;
    +import java.net.MalformedURLException;
    +import java.net.URL;
    +import java.util.logging.Level;
    +
    +import static org.hamcrest.MatcherAssert.assertThat;
    +import static org.hamcrest.Matchers.containsInAnyOrder;
    +import static org.hamcrest.Matchers.containsInRelativeOrder;
    +import static org.hamcrest.Matchers.containsString;
    +import static org.hamcrest.Matchers.not;
    +import static org.junit.Assert.assertEquals;
    +import static org.junit.Assert.assertTrue;
    +
    +public class HasherScriptApprovalTest {
    +    @Rule
    +    public JenkinsSessionRule session = new JenkinsSessionRule();
    +    @Rule
    +    public LoggerRule log = new LoggerRule();
    +
    +    @Test
    +    @Issue("SECURITY-2564")
    +    public void hasherMatchesItsOwnHashes() throws Throwable {
    +        session.then(r -> {
    +            for (ScriptApproval.Hasher hasher : ScriptApproval.Hasher.values()) {
    +                assertTrue(hasher.pattern().matcher(hasher.hash("Hello World", "Text")).matches());
    +            }
    +        });
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-2564")
    +    public void warnsAndClearsDeprecatedScriptHashes() throws Throwable {
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            approval.approveScript(ScriptApproval.Hasher.SHA1.hash("Hello World", "Text"));
    +            approval.approveScript(ScriptApproval.Hasher.SHA1.hash("node { echo 'Hello World' }", "Groovy"));
    +            approval.approveScript(ScriptApproval.DEFAULT_HASHER.hash("have you tried it sometime?", "Text"));
    +        });
    +        log.record(ScriptApproval.class.getName(), Level.FINE).capture(10000);
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            assertEquals(2, approval.countDeprecatedApprovedScriptHashes());
    +            assertThat(log.getMessages(), containsInAnyOrder(
    +                    containsString("There are 2 deprecated approved script hashes " +
    +                            "and 0 deprecated approved classpath hashes.")));
    +            approval.clearDeprecatedApprovedScripts();
    +            assertEquals(0, approval.countDeprecatedApprovedScriptHashes());
    +        });
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-2564")
    +    public void convertsScriptApprovalsOnUse() throws Throwable {
    +        final String script = "node { echo 'Hello World' }";
    +        final Matcher<Iterable<? extends String>> logMatcher = containsInRelativeOrder(
    +                containsString("A script is approved with an old hash algorithm. Converting now, "));
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            approval.approveScript(ScriptApproval.Hasher.SHA1.hash("Hello World", "Text"));
    +            approval.approveScript(ScriptApproval.Hasher.SHA1.hash(script, GroovyLanguage.get().getName()));
    +            approval.approveScript(ScriptApproval.DEFAULT_HASHER.hash("have you tried it sometime?", "Text"));
    +        });
    +        log.record(ScriptApproval.class.getName(), Level.FINE).capture(10000);
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            assertEquals(2, approval.countDeprecatedApprovedScriptHashes());
    +            approval.using(script, GroovyLanguage.get());
    +            assertEquals(1, approval.countDeprecatedApprovedScriptHashes());
    +            assertThat(log.getMessages(), logMatcher);
    +        });
    +        log.capture(10000);
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            assertEquals(1, approval.countDeprecatedApprovedScriptHashes());
    +            approval.using(script, GroovyLanguage.get());
    +            assertEquals(1, approval.countDeprecatedApprovedScriptHashes());
    +            assertThat(log.getMessages(), not(logMatcher));
    +        });
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-2564")
    +    public void testConvertApprovedClasspathEntries() throws Throwable {
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            addApprovedClasspathEntries(approval);
    +            assertEquals(2, approval.countDeprecatedApprovedClasspathHashes());
    +        });
    +        log.record(ScriptApproval.class.getName(), Level.FINE).capture(10000);
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            assertEquals(2, approval.countDeprecatedApprovedClasspathHashes());
    +
    +            assertThat(log.getMessages(), containsInAnyOrder(
    +                    containsString("There are 0 deprecated approved script hashes " +
    +                            "and 2 deprecated approved classpath hashes.")));
    +
    +            approval.convertDeprecatedApprovedClasspathEntries();
    +            assertThat(log.getMessages(), containsInRelativeOrder(
    +                    containsString("Scheduling conversion of 2 deprecated approved classpathentry hashes."),
    +                    containsString("Background conversion task scheduled.")));
    +            try {
    +                while (approval.isConvertingDeprecatedApprovedClasspathEntries()) {
    +                    Thread.sleep(500);
    +                }
    +            } catch (InterruptedException ignored) {
    +            }
    +            assertEquals(0, approval.countDeprecatedApprovedClasspathHashes());
    +        });
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-2564")
    +    public void testClasspathEntriesConvertedOnUse() throws Throwable {
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            addApprovedClasspathEntries(approval);
    +            assertEquals(2, approval.countDeprecatedApprovedClasspathHashes());
    +        });
    +        log.record(ScriptApproval.class.getName(), Level.FINE).capture(10000);
    +        session.then(r -> {
    +            final ScriptApproval approval = ScriptApproval.get();
    +            assertEquals(2, approval.countDeprecatedApprovedClasspathHashes());
    +            URL url = getJar("org/apache/commons/lang3/StringUtils.class");
    +            approval.using(new ClasspathEntry(url.getPath()));
    +            assertEquals(1, approval.countDeprecatedApprovedClasspathHashes());
    +            final Matcher<Iterable<? extends String>> logMatcher = containsInRelativeOrder(
    +                    containsString("A classpath is approved with an old hash algorithm. Converting now, "));
    +            assertThat(log.getMessages(), logMatcher);
    +            log.capture(1000);
    +            approval.using(new ClasspathEntry(url.getPath())); //Using it again should not convert it again.
    +            assertThat(log.getMessages(), not(logMatcher));
    +            assertEquals(1, approval.countDeprecatedApprovedClasspathHashes());
    +        });
    +    }
    +
    +    private void addApprovedClasspathEntries(final ScriptApproval approval) throws IOException {
    +        URL url = getJar("org/apache/commons/lang3/StringUtils.class");
    +        ScriptApproval.ApprovedClasspathEntry acp = new ScriptApproval.ApprovedClasspathEntry(
    +                ScriptApproval.Hasher.SHA1.hashClasspathEntry(url),
    +                url
    +        );
    +        approval.addApprovedClasspathEntry(acp);
    +
    +        url = getJar("net/sf/json/JSON.class");
    +        acp = new ScriptApproval.ApprovedClasspathEntry(
    +                ScriptApproval.Hasher.SHA1.hashClasspathEntry(url),
    +                url
    +        );
    +        approval.addApprovedClasspathEntry(acp);
    +        approval.save();
    +    }
    +
    +    @NotNull
    +    private URL getJar(final String resource) throws MalformedURLException {
    +        URL url = getClass().getClassLoader().getResource(resource);
    +        String path = url.getPath();
    +        path = path.substring(0, path.indexOf('!'));
    +        return new URL(path);
    +    }
    +}
    
  • src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/JcascTest.java+16 1 modified
    @@ -8,19 +8,31 @@
     import io.jenkins.plugins.casc.model.CNode;
     import org.junit.ClassRule;
     import org.junit.Test;
    +import org.jvnet.hudson.test.LoggerRule;
    +
    +import java.util.logging.Level;
     
     import static io.jenkins.plugins.casc.misc.Util.getSecurityRoot;
     import static io.jenkins.plugins.casc.misc.Util.toStringFromYamlFile;
     import static io.jenkins.plugins.casc.misc.Util.toYamlString;
    +import static org.hamcrest.MatcherAssert.assertThat;
    +import static org.hamcrest.collection.IsIterableContainingInAnyOrder.containsInAnyOrder;
    +import static org.hamcrest.core.StringContains.containsString;
     import static org.junit.Assert.assertEquals;
     import static org.junit.Assert.assertTrue;
     
     public class JcascTest {
     
    -    @ClassRule
    +    @ClassRule(order = 1)
    +    public static LoggerRule logger = new LoggerRule().record(ScriptApproval.class.getName(), Level.WARNING)
    +            .capture(100);
    +
    +    @ClassRule(order = 2)
         @ConfiguredWithCode("smoke_test.yaml")
         public static JenkinsConfiguredWithCodeRule j = new JenkinsConfiguredWithCodeRule();
     
    +
    +
         @Test
         public void smokeTestEntry() throws Exception {
             String[] approved = ScriptApproval.get().getApprovedSignatures();
    @@ -29,6 +41,9 @@ public void smokeTestEntry() throws Exception {
             String[] approvedScriptHashes = ScriptApproval.get().getApprovedScriptHashes();
             assertTrue(approvedScriptHashes.length == 1);
             assertEquals(approvedScriptHashes[0], "fccae58c5762bdd15daca97318e9d74333203106");
    +        assertThat(logger.getMessages(), containsInAnyOrder(
    +                containsString("Adding deprecated script hash " +
    +                        "that will be converted on next use: fccae58c5762bdd15daca97318e9d74333203106")));
         }
     
         @Test
    

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