CVE-2013-0158: Insufficient Information (NVD-CWE-noinfo)
value: Unspecified vulnerability in Jenkins before 1.498, Jenkins LTS before 1.480.2, and Jenkins Enterprise 1.447.x before 1.447.6.1 and 1.466.x before 1.466.12.1, when a slave is attached and anonymous read access is enabled, allows remote attackers to obtain the master cryptographic key via unknown vectors.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2013-02-24T22:55Z
Repository: https://github.com/jenkinsci/jenkins
Language: Java
Files changed: core/src/main/java/hudson/util/Secret.java, core/src/main/java/hudson/util/SecretRewriter.java, core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java, core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy, core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.groovy, core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.properties, core/src/test/java/hudson/util/MockSecretRule.java, core/src/test/java/hudson/util/SecretRewriterTest.groovy, core/src/test/java/hudson/util/SecretTest.groovy, test/src/main/java/hudson/util/SecretHelper.java, test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java
Commit date: 2013-01-05 09:26:10 -0800
Commit message: [SECURITY-49] added a tool to re-key secrets

As an AdministrativeMonitor, it shows up in the manage Jenkins UI, and
allows the administrator to run a re-keying operation.

Diff: diff --git a/core/src/main/java/hudson/util/Secret.java b/core/src/main/java/hudson/util/Secret.java
index 32cba8dce..90144ed6a 100644
--- a/core/src/main/java/hudson/util/Secret.java
+++ b/core/src/main/java/hudson/util/Secret.java
@@ -32,12 +32,10 @@ import com.trilead.ssh2.crypto.Base64;
 import jenkins.model.Jenkins;
 import hudson.Util;
 import jenkins.security.CryptoConfidentialKey;
 import org.kohsuke.stapler.Stapler;
 
-import javax.crypto.BadPaddingException;
-import javax.crypto.IllegalBlockSizeException;
 import javax.crypto.SecretKey;
 import javax.crypto.Cipher;
 import java.io.Serializable;
 import java.io.UnsupportedEncodingException;
 import java.io.IOException;
@@ -103,11 +101,11 @@ public final class Secret implements Serializable {
      *
      * @deprecated
      * This is no longer the key we use to encrypt new information, but we still need this
      * to be able to decrypt what's already persisted.
      */
-    /*package*/ static SecretKey getLegacyKey() throws UnsupportedEncodingException, GeneralSecurityException {
+    /*package*/ static SecretKey getLegacyKey() throws GeneralSecurityException {
         String secret = SECRET;
         if(secret==null)    return Jenkins.getInstance().getSecretKeyAsAES128();
         return Util.toAes128Key(secret);
     }
 
@@ -150,18 +148,18 @@ public final class Secret implements Serializable {
         } catch (IOException e) {
             return null;
         }
     }
 
-    private static Secret tryDecrypt(Cipher cipher, byte[] in) throws UnsupportedEncodingException {
+    /*package*/ static Secret tryDecrypt(Cipher cipher, byte[] in) throws UnsupportedEncodingException {
         try {
             String plainText = new String(cipher.doFinal(in), "UTF-8");
             if(plainText.endsWith(MAGIC))
                 return new Secret(plainText.substring(0,plainText.length()-MAGIC.length()));
             return null;
         } catch (GeneralSecurityException e) {
-            return null;
+            return null; // if the key doesn't match with the bytes, it can result in BadPaddingException
         }
     }
 
     /**
      * Workaround for JENKINS-6459 / http://java.net/jira/browse/GLASSFISH-11862
diff --git a/core/src/main/java/hudson/util/SecretRewriter.java b/core/src/main/java/hudson/util/SecretRewriter.java
new file mode 100644
index 000000000..7e24dfda9
--- /dev/null
+++ b/core/src/main/java/hudson/util/SecretRewriter.java
@@ -0,0 +1,223 @@
+package hudson.util;
+
+import com.trilead.ssh2.crypto.Base64;
+import hudson.model.TaskListener;
+import org.apache.commons.io.FileUtils;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Rewrites XML files by looking for Secrets that are stored with the old key and replaces them
+ * by the new encrypted values.
+ *
+ * @author Kohsuke Kawaguchi
+ */
+public class SecretRewriter {
+    private final Cipher cipher;
+    private final SecretKey key;
+
+    /**
+     * How many files have been scanned?
+     */
+    private int count;
+
+    /**
+     * If non-null the original file before rewrite gets in here.
+     */
+    private final File backupDirectory;
+
+    /**
+     * Canonical paths of the directories we are recursing to protect
+     * against symlink induced cycles.
+     */
+    private Set<String> callstack = new HashSet<String>();
+
+    public SecretRewriter(File backupDirectory) throws GeneralSecurityException {
+        cipher = Secret.getCipher("AES");
+        key = Secret.getLegacyKey();
+        this.backupDirectory = backupDirectory;
+    }
+
+    private String tryRewrite(String s) throws IOException, InvalidKeyException {
+        if (s.length()<24)
+            return s;   // Encrypting "" in Secret produces 24-letter characters, so this must be the minimum length
+        if (!isBase64(s))
+            return s;   // decode throws IOException if the input is not base64, and this is also a very quick way to filter
+
+        byte[] in;
+        try {
+            in = Base64.decode(s.toCharArray());
+        } catch (IOException e) {
+            return s;   // not a valid base64
+        }
+        cipher.init(Cipher.DECRYPT_MODE, key);
+        Secret sec = Secret.tryDecrypt(cipher, in);
+        if(sec!=null) // matched
+            return sec.getEncryptedValue(); // replace by the new encrypted value
+        else // not encrypted with the legacy key. leave it unmodified
+            return s;
+    }
+
+    /**
+     * @param backup
+     *      if non-null, the original file will be copied here before rewriting.
+     *      if the rewrite doesn't happen, no copying.
+     */
+    public boolean rewrite(File f, File backup) throws InvalidKeyException, IOException {
+        FileInputStream fin = new FileInputStream(f);
+        try {
+            BufferedReader r = new BufferedReader(new InputStreamReader(fin, "UTF-8"));
+            AtomicFileWriter w = new AtomicFileWriter(f, "UTF-8");
+            try {
+                PrintWriter out = new PrintWriter(new BufferedWriter(w));
+
+                boolean modified = false; // did we actually change anything?
+                try {
+                    String line;
+                    StringBuilder buf = new StringBuilder();
+
+                    while ((line=r.readLine())!=null) {
+                        int copied=0;
+                        buf.setLength(0);
+                        while (true) {
+                            int sidx = line.indexOf('>',copied);
+                            if (sidx<0) break;
+                            int eidx = line.indexOf('<',sidx);
+                            if (eidx<0) break;
+
+                            String elementText = line.substring(sidx+1,eidx);
+                            String replacement = tryRewrite(elementText);
+                            if (!replacement.equals(elementText))
+                                modified = true;
+
+                            buf.append(line.substring(copied,sidx+1));
+                            buf.append(replacement);
+                            copied = eidx;
+                        }
+                        buf.append(line.substring(copied));
+                        out.println(buf.toString());
+                    }
+                } finally {
+                    out.close();
+                }
+
+                if (modified) {
+                    if (backup!=null) {
+                        backup.getParentFile().mkdirs();
+                        FileUtils.copyFile(f,backup);
+                    }
+                    w.commit();
+                }
+                return modified;
+            } finally {
+                w.abort();
+            }
+        } finally {
+            fin.close();
+        }
+    }
+
+
+    /**
+     * Recursively scans and rewrites a directory.
+     *
+     * This method shouldn't abort just because one file fails to rewrite.
+     *
+     * @return
+     *      Number of files that were actually rewritten.
+     */
+    // synchronized to prevent accidental concurrent use. this instance is not thread safe
+    public synchronized int rewriteRecursive(File dir, TaskListener listener) throws InvalidKeyException {
+        return rewriteRecursive(dir,"",listener);
+    }
+    private int rewriteRecursive(File dir, String relative, TaskListener listener) throws InvalidKeyException {
+        String canonical;
+        try {
+            canonical = dir.getCanonicalPath();
+        } catch (IOException e) {
+            canonical = dir.getAbsolutePath(); //
+        }
+        if (!callstack.add(canonical)) {
+            listener.getLogger().println("Cycle detected: "+dir);
+            return 0;
+        }
+
+        try {
+            File[] children = dir.listFiles();
+            if (children==null)     return 0;
+
+            int rewritten=0;
+            for (File child : children) {
+                String cn = child.getName();
+                if (cn.endsWith(".xml")) {
+                    if ((count++)%100==0)
+                        listener.getLogger().println("Scanning "+child);
+                    try {
+                        File backup = null;
+                        if (backupDirectory!=null)  backup = new File(backupDirectory,relative+'/'+ cn);
+                        if (rewrite(child,backup)) {
+                            if (backup!=null)
+                                listener.getLogger().println("Copied "+child+" to "+backup+" as a backup");
+                            listener.getLogger().println("Rewritten "+child);
+                            rewritten++;
+                        }
+                    } catch (IOException e) {
+                        e.printStackTrace(listener.error("Failed to rewrite "+child));
+                    }
+                }
+                if (child.isDirectory()) {
+                    if (!isIgnoredDir(child))
+                        rewritten += rewriteRecursive(child,
+                                relative.length()==0 ? cn : relative+'/'+ cn,
+                                listener);
+                }
+            }
+            return rewritten;
+        } finally {
+            callstack.remove(canonical);
+        }
+    }
+
+    /**
+     * Decides if this directory is worth visiting or not.
+     */
+    protected boolean isIgnoredDir(File dir) {
+        // ignoring the workspace and the artifacts directories. Both of them
+        // are potentially large and they do not store any secrets.
+        String n = dir.getName();
+        return n.equals("workspace") || n.equals("artifacts")
+            || n.equals("plugins") // no mutable data here
+            || n.equals("jenkins.security.RekeySecretAdminMonitor") // we don't want to rewrite backups
+            || n.equals(".") || n.equals("..");
+    }
+
+    private static boolean isBase64(char ch) {
+        return 0<=ch && ch<128 && IS_BASE64[ch];
+    }
+
+    private static boolean isBase64(String s) {
+        for (int i=0; i<s.length(); i++)
+            if (!isBase64(s.charAt(i)))
+                return false;
+        return true;
+    }
+
+    private static final boolean[] IS_BASE64 = new boolean[128];
+    static {
+        String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
+        for (int i=0; i<chars.length();i++)
+            IS_BASE64[chars.charAt(i)] = true;
+    }
+}
diff --git a/core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java b/core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java
new file mode 100644
index 000000000..5bdd61031
--- /dev/null
+++ b/core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java
@@ -0,0 +1,196 @@
+package jenkins.security;
+
+import hudson.Extension;
+import hudson.console.AnnotatedLargeText;
+import hudson.init.InitMilestone;
+import hudson.init.Initializer;
+import hudson.model.AdministrativeMonitor;
+import hudson.util.HttpResponses;
+import hudson.util.SecretRewriter;
+import hudson.util.StreamTaskListener;
+import hudson.util.VersionNumber;
+import jenkins.model.Jenkins;
+import jenkins.util.io.FileBoolean;
+import org.kohsuke.stapler.HttpResponse;
+import org.kohsuke.stapler.StaplerProxy;
+import org.kohsuke.stapler.StaplerRequest;
+import org.kohsuke.stapler.interceptor.RequirePOST;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.charset.Charset;
+import java.security.GeneralSecurityException;
+import java.util.Date;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Warns the administrator to run {@link SecretRewriter}
+ *
+ * @author Kohsuke Kawaguchi
+ */
+@Extension
+public class RekeySecretAdminMonitor extends AdministrativeMonitor implements StaplerProxy {
+
+    /**
+     * Whether we detected a need to run the rewrite program.
+     * Once we set it to true, we'll never turn it off.
+     *
+     * If the admin decides to dismiss this warning, we use {@link #isEnabled()} for that.
+     *
+     * In this way we can correctly differentiate all the different states.
+     */
+    private final FileBoolean needed = state("needed");
+
+    /**
+     * If the scanning process has run to the completion, we set to this true.
+     */
+    private final FileBoolean done = state("done");
+
+    /**
+     * If the rewrite process is scheduled upon the next boot.
+     */
+    private final FileBoolean scanOnBoot = state("scanOnBoot");
+
+    /**
+     * Set to non-null once the rewriting activities starts running.
+     */
+    private volatile RekeyThread rekeyThread;
+
+
+    public RekeySecretAdminMonitor() throws IOException {
+        // if JENKINS_HOME existed <1.497, we need to offer rewrite
+        // this computation needs to be done and the value be captured,
+        // since $JENKINS_HOME/config.xml can be saved later before the user has
+        // actually rewritten XML files.
+        if (Jenkins.getInstance().isUpgradedFromBefore(new VersionNumber("1.496.*")))
+            needed.on();
+    }
+
+    /**
+     * Requires ADMINISTER permission for any operation in here.
+     */
+    public Object getTarget() {
+        Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER);
+        return this;
+    }
+
+    @Override
+    public boolean isActivated() {
+        return needed.isOn();
+    }
+
+    /**
+     * Indicates that the re-keying has run to the completion.
+     */
+    public boolean isDone() {
+        return done.isOn();
+    }
+
+    public void setNeeded() {
+        needed.on();
+    }
+
+    public boolean isScanOnBoot() {
+        return scanOnBoot.isOn();
+    }
+
+    @RequirePOST
+    public HttpResponse doScan(StaplerRequest req) throws IOException, GeneralSecurityException {
+        if(req.hasParameter("background")) {
+            synchronized (this) {
+                if (!isRewriterActive()) {
+                    rekeyThread = new RekeyThread();
+                    rekeyThread.start();
+                }
+            }
+        } else
+        if(req.hasParameter("schedule")) {
+            scanOnBoot.on();
+        } else
+        if(req.hasParameter("dismiss")) {
+            disable(true);
+        } else
+            throw HttpResponses.error(400,"Invalid request submission");
+
+        return HttpResponses.redirectViaContextPath("/manage");
+    }
+
+    /**
+     * Is there an active rewriting process going on?
+     */
+    public boolean isRewriterActive() {
+        return rekeyThread !=null && rekeyThread.isAlive();
+    }
+
+    /**
+     * Used to URL-bind {@link AnnotatedLargeText}.
+     */
+    public AnnotatedLargeText getLogText() {
+        return new AnnotatedLargeText<RekeySecretAdminMonitor>(getLogFile(), Charset.defaultCharset(),
+                !isRewriterActive(),this);
+    }
+
+    private static FileBoolean state(String name) {
+        return new FileBoolean(new File(getBaseDir(),name));
+    }
+
+    @Initializer(fatal=false,after=InitMilestone.PLUGINS_STARTED,before=InitMilestone.EXTENSIONS_AUGMENTED)
+    // as early as possible, but this needs to be late enough that the ConfidentialStore is available
+    public static void scanOnReboot() throws InterruptedException, IOException, GeneralSecurityException {
+        FileBoolean flag = new RekeySecretAdminMonitor().scanOnBoot;
+        if (flag.isOn()) {
+            flag.off();
+            RekeyThread t = new RekeyThread();
+            t.start();
+            t.join();
+            // block the boot until the rewrite process is complete
+            // don't let the failure in RekeyThread block Jenkins boot.
+        }
+    }
+
+    /**
+     * Rewrite log file.
+     */
+    public static File getLogFile() {
+        return new File(getBaseDir(),"rekey.log");
+    }
+
+    private static File getBaseDir() {
+        return new File(Jenkins.getInstance().getRootDir(),RekeySecretAdminMonitor.class.getName());
+    }
+
+    private static class RekeyThread extends Thread {
+        private final SecretRewriter rewriter;
+
+        RekeyThread() throws GeneralSecurityException {
+            super("Rekey secret thread");
+            rewriter = new SecretRewriter(new File(getBaseDir(),"backups"));
+        }
+
+        @Override
+        public void run() {
+            try {
+                LOGGER.info("Initiating a re-keying of secrets. See "+getLogFile());
+                StreamTaskListener listener = new StreamTaskListener(getLogFile());
+                try {
+                    PrintStream log = listener.getLogger();
+                    log.println("Started re-keying " + new Date());
+                    int count = rewriter.rewriteRecursive(Jenkins.getInstance().getRootDir(), listener);
+                    log.printf("Completed re-keying %d files on %s\n",count,new Date());
+                    new RekeySecretAdminMonitor().done.on();
+                    LOGGER.info("Secret re-keying completed");
+                } catch (Exception e) {
+                    LOGGER.log(Level.SEVERE, "Fatal failure in re-keying secrets",e);
+                    e.printStackTrace(listener.error("Fatal failure in rewriting secrets"));
+                }
+            } catch (IOException e) {
+                LOGGER.log(Level.SEVERE, "Catastrophic failure to rewrite secrets",e);
+            }
+        }
+    }
+
+    private static final Logger LOGGER = Logger.getLogger(RekeySecretAdminMonitor.class.getName());
+
+}
diff --git a/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy
new file mode 100644
index 000000000..ee6eb6e68
--- /dev/null
+++ b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy
@@ -0,0 +1,45 @@
+/*
+The MIT License
+
+Copyright (c) 2013, CloudBees, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+package jenkins.security.RekeySecretAdminMonitor;
+
+def l = namespace(lib.LayoutTagLib)
+def t = namespace(lib.JenkinsTagLib)
+
+l.layout {
+    l.main_panel() {
+        h1 _("Re-keying log")
+
+        if (my.isRewriterActive()) {
+            pre(id: "out")
+            div(id: "spinner") {
+                img(src: "${imagesURL}/spinner.gif", alt: "")
+            }
+            t.progressiveText(spinner: "spinner", href: "logText/progressiveHtml", idref: "out")
+        } else {
+            pre {
+                my.logText.writeHtmlTo(0, output.asWriter())
+            }
+        }
+    }
+}
diff --git a/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.groovy b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.groovy
new file mode 100644
index 000000000..ec3eae399
--- /dev/null
+++ b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.groovy
@@ -0,0 +1,59 @@
+/*
+The MIT License
+
+Copyright (c) 2013, CloudBees, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+package jenkins.security.RekeySecretAdminMonitor;
+
+def f = namespace(lib.FormTagLib)
+
+if (!my.isDone()) {
+    div(class:"error") {
+        raw _("pleaseRekeyAsap",app.rootDir,my.url)
+    }
+}
+
+if (my.isRewriterActive()) {
+    div(class:"info") {
+        raw _("rekeyInProgress",my.url)
+    }
+} else if (my.logFile.exists()) {
+    if (my.isDone()) {
+        div(class:"info") {
+            raw _("rekeySuccessful",my.url)
+        }
+    } else {
+        div(class:"warning") {
+            raw _("rekeyHadProblems",my.url)
+        }
+    }
+}
+
+form(method:"POST",action:"${my.url}/scan",style:"text-align:center; margin-top:0.5em;",name:"rekey") {
+    f.submit(name:"background",value:_("Re-key in background now"))
+    if (my.isScanOnBoot()) {
+        input(type:"button",class:"yui-button",disabled:"true",
+                value:_("Re-keying currently scheduled during the next startup"))
+    } else {
+        f.submit(name:"schedule",  value:_("Schedule a re-key during the next startup"))
+    }
+    f.submit(name:"dismiss",   value:_("Dismiss this message"))
+}
diff --git a/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.properties b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.properties
new file mode 100644
index 000000000..2daf3f7e8
--- /dev/null
+++ b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.properties
@@ -0,0 +1,17 @@
+pleaseRekeyAsap=\
+    Because of <a href="https://wiki.jenkins-ci.org/display/SECURITY/Jenkins+Security+Advisory+2013-01-04">a security vulnerability</a> discovered earlier, we need to \
+    change the encryption key used to protect secrets in your configuration files on the disk. \
+    This process scans a large portion of your <tt>$JENKINS_HOME</tt> ({0}), \
+    find encrypted data, re-key them, which will take some time. \
+    See <a href="http://jenkins-ci.org/rekey">this document</a> for more implications about different ways of doing this \
+    (or not doing this.) This operation can be safely run in background, but cautious users \
+    are recommended to take backups.
+
+rekeyInProgress=Re-keying is in progress. <a href="{0}/log">You can check the log</a>.
+
+rekeySuccessful=\
+  Secrets in your <tt>$JENKINS_HOME</tt> has been re-keyed successfully. \
+  <a href="{0}/log">Please check the log</a>, confirm the success, and then dismiss or re-run.
+
+rekeyHadProblems=\
+  Re-keying has completed, but there were problems. <a href="{0}">Please check the log</a>.
\ No newline at end of file
diff --git a/core/src/test/java/hudson/util/MockSecretRule.java b/core/src/test/java/hudson/util/MockSecretRule.java
new file mode 100644
index 000000000..f3c11ea38
--- /dev/null
+++ b/core/src/test/java/hudson/util/MockSecretRule.java
@@ -0,0 +1,33 @@
+package hudson.util;
+
+import hudson.Util;
+import org.junit.rules.ExternalResource;
+
+import java.security.SecureRandom;
+
+/**
+ * JUnit rule that cleans that sets a temporary {@link Secret#SECRET} value.
+ *
+ * @author Kohsuke Kawaguchi
+ */
+public class MockSecretRule extends ExternalResource {
+
+    private String value;
+
+    @Override
+    protected void before() throws Throwable {
+        byte[] random = new byte[32];
+        sr.nextBytes(random);
+        value = Util.toHexString(random);
+        Secret.SECRET = value;
+    }
+
+    @Override
+    protected void after() {
+        if (!Secret.SECRET.equals(value))
+            throw new IllegalStateException("Someone tinkered with Secret.SECRET");
+        Secret.SECRET = null;
+    }
+
+    private static final SecureRandom sr = new SecureRandom();
+}
diff --git a/core/src/test/java/hudson/util/SecretRewriterTest.groovy b/core/src/test/java/hudson/util/SecretRewriterTest.groovy
new file mode 100644
index 000000000..13522c22a
--- /dev/null
+++ b/core/src/test/java/hudson/util/SecretRewriterTest.groovy
@@ -0,0 +1,114 @@
+package hudson.util
+
+import com.trilead.ssh2.crypto.Base64
+import hudson.FilePath
+import jenkins.security.ConfidentialStoreRule
+import org.junit.Rule
+import org.junit.Test
+
+import javax.crypto.Cipher
+
+import static hudson.Util.createTempDir
+
+/**
+ *
+ *
+ * @author Kohsuke Kawaguchi
+ */
+class SecretRewriterTest {
+    @Rule
+    public MockSecretRule mockSecretRule = new MockSecretRule()
+
+    @Rule
+    public ConfidentialStoreRule confidentialStoreRule = new ConfidentialStoreRule();
+
+    @Test
+    void singleFileRewrite() {
+        def o = encryptOld('foobar') // old
+        def n = encryptNew('foobar') // new
+        roundtrip "<foo>${o}</foo>",
+                  "<foo>${n}</foo>"
+
+        roundtrip "<foo>${o}</foo><foo>${o}</foo>",
+                  "<foo>${n}</foo><foo>${n}</foo>"
+
+        roundtrip "<foo>${n}</foo>",
+                  "<foo>${n}</foo>"
+
+        roundtrip "  <foo>thisIsLegalBase64AndLongEnoughThatItCouldLookLikeSecret</foo>  ",
+                  "  <foo>thisIsLegalBase64AndLongEnoughThatItCouldLookLikeSecret</foo>  "
+
+        // to be rewritten, it needs to be between a tag
+        roundtrip "<foo>$o", "<foo>$o"
+        roundtrip "$o</foo>", "$o</foo>"
+
+        //
+        roundtrip "<abc>\n<foo>$o</foo>\n</abc>", "<abc>\n<foo>$n</foo>\n</abc>"
+    }
+
+    void roundtrip(String before, String after) {
+        def sr = new SecretRewriter(null);
+        def f = File.createTempFile("test","xml");
+        try {
+            f.text = before
+            sr.rewrite(f,null)
+            assert after.trim()==f.text.trim()
+        } finally {
+            f.delete()
+        }
+    }
+
+    String encryptOld(str) {
+        def cipher = Secret.getCipher("AES");
+        cipher.init(Cipher.ENCRYPT_MODE, Secret.legacyKey);
+        return new String(Base64.encode(cipher.doFinal((str + Secret.MAGIC).getBytes("UTF-8"))))
+    }
+
+    String encryptNew(str) {
+        return Secret.fromString(str).encryptedValue
+    }
+
+    /**
+     * Directory rewrite and recursion detection
+     */
+    @Test
+    void recursionDetection() {
+        def backup = createTempDir()
+        def sw = new SecretRewriter(backup);
+        def st = StreamTaskListener.fromStdout()
+
+        def o = encryptOld("Hello world")
+        def n = encryptNew("Hello world")
+        def payload = "<msg>$o</msg>"
+        def answer = "<msg>$n</msg>"
+
+        // set up some directories with stuff
+        def t = createTempDir()
+        def dirs = ["a", "b", "c", "c/d", "c/d/e"]
+        dirs.each { p ->
+            def d = new File(t, p)
+            d.mkdir()
+            new File(d,"foo.xml").text = payload
+        }
+
+        // stuff outside
+        def t2 = createTempDir()
+        new File(t2,"foo.xml").text = payload
+
+        // some recursions as well as valid symlinks
+        new FilePath(t).child("c/symlink").symlinkTo("..",st)
+        new FilePath(t).child("b/symlink").symlinkTo(".",st)
+        new FilePath(t).child("a/symlink").symlinkTo(t2.absolutePath,st)
+
+        assert 6==sw.rewriteRecursive(t, st)
+
+        dirs.each { p->
+            assert new File(t,"$p/foo.xml").text.trim()==answer
+            assert new File(backup,"$p/foo.xml").text.trim()==payload
+        }
+
+        // t2 is only reachable by following a symlink. this should be covered, too
+        assert new File(t2,"foo.xml").text.trim()==answer.trim();
+    }
+
+}
diff --git a/core/src/test/java/hudson/util/SecretTest.groovy b/core/src/test/java/hudson/util/SecretTest.groovy
index ef9385029..e6532dd8f 100644
--- a/core/src/test/java/hudson/util/SecretTest.groovy
+++ b/core/src/test/java/hudson/util/SecretTest.groovy
@@ -24,39 +24,24 @@
 package hudson.util
 
 import com.trilead.ssh2.crypto.Base64;
 import jenkins.model.Jenkins
 import jenkins.security.ConfidentialStoreRule;
-import org.junit.After
-import org.junit.Before
 import org.junit.Rule
 import org.junit.Test
 
 import javax.crypto.Cipher;
-import java.security.SecureRandom;
-
-import hudson.Util;
 
 /**
  * @author Kohsuke Kawaguchi
  */
 public class SecretTest {
     @Rule
     public ConfidentialStoreRule confidentialStore = new ConfidentialStoreRule()
 
-    @Test @Before
-    void setUp() {
-        def sr = new SecureRandom();
-        byte[] random = new byte[32];
-        sr.nextBytes(random);
-        Secret.SECRET = Util.toHexString(random);
-    }
-
-    @Test @After
-    void tearDown() {
-        Secret.SECRET = null;
-    }
+    @Rule
+    public MockSecretRule mockSecretRule = new MockSecretRule()
 
     @Test
     void testEncrypt() {
         def secret = Secret.fromString("abc");
         assert "abc"==secret.plainText;
diff --git a/test/src/main/java/hudson/util/SecretHelper.java b/test/src/main/java/hudson/util/SecretHelper.java
new file mode 100644
index 000000000..b76b1f8be
--- /dev/null
+++ b/test/src/main/java/hudson/util/SecretHelper.java
@@ -0,0 +1,10 @@
+package hudson.util;
+
+/**
+ * @author Kohsuke Kawaguchi
+ */
+public class SecretHelper {
+    public static void set(String s) {
+        Secret.SECRET = s;
+    }
+}
diff --git a/test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java b/test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java
new file mode 100644
index 000000000..270b73869
--- /dev/null
+++ b/test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java
@@ -0,0 +1,157 @@
+package jenkins.security;
+
+import com.gargoylesoftware.htmlunit.ElementNotFoundException;
+import com.gargoylesoftware.htmlunit.html.HtmlButton;
+import com.gargoylesoftware.htmlunit.html.HtmlForm;
+import com.gargoylesoftware.htmlunit.html.HtmlPage;
+import com.trilead.ssh2.crypto.Base64;
+import hudson.FilePath;
+import hudson.Util;
+import hudson.util.Secret;
+import hudson.util.SecretHelper;
+import org.apache.commons.io.FileUtils;
+import org.jvnet.hudson.test.HudsonTestCase;
+import org.jvnet.hudson.test.recipes.Recipe.Runner;
+import org.xml.sax.SAXException;
+
+import javax.crypto.Cipher;
+import javax.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+
+/**
+ * @author Kohsuke Kawaguchi
+ */
+public class RekeySecretAdminMonitorTest extends HudsonTestCase {
+    @Inject
+    RekeySecretAdminMonitor monitor;
+
+    @Override
+    protected void setUp() throws Exception {
+        SecretHelper.set(TEST_KEY);
+        super.setUp();
+        monitor.setNeeded();
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        SecretHelper.set(null);
+        super.tearDown();
+    }
+
+    @Override
+    protected void recipe() throws Exception {
+        super.recipe();
+        recipes.add(new Runner() {
+            @Override
+            public void setup(HudsonTestCase testCase, Annotation recipe) throws Exception {
+            }
+
+            @Override
+            public void decorateHome(HudsonTestCase testCase, File home) throws Exception {
+                if (getName().endsWith("testScanOnBoot")) {
+                    // schedule a scan on boot
+                    File f = new File(home, RekeySecretAdminMonitor.class.getName() + "/scanOnBoot");
+                    f.getParentFile().mkdirs();
+                    new FilePath(f).touch(0);
+
+                    // and stage some data
+                    putSomeOldData(home);
+                }
+            }
+
+            @Override
+            public void tearDown(HudsonTestCase testCase, Annotation recipe) throws Exception {
+            }
+        });
+    }
+
+
+    private void putSomeOldData(File dir) throws Exception {
+        File xml = new File(dir, "foo.xml");
+        FileUtils.writeStringToFile(xml,"<foo>" + encryptOld(TEST_KEY) + "</foo>");
+    }
+
+    private void verifyRewrite(File dir) throws Exception {
+        File xml = new File(dir, "foo.xml");
+        assertEquals("<foo>" + encryptNew(TEST_KEY) + "</foo>".trim(),
+                FileUtils.readFileToString(xml).trim());
+    }
+
+    public void testBasicWorkflow() throws Exception {
+        putSomeOldData(jenkins.getRootDir());
+
+        WebClient wc = createWebClient();
+
+        // one should see the warning. try scheduling it
+        assertTrue(!monitor.isScanOnBoot());
+        HtmlForm form = getRekeyForm(wc);
+        submit(form, "schedule");
+        assertTrue(monitor.isScanOnBoot());
+        form = getRekeyForm(wc);
+        assertTrue(getButton(form, 1).isDisabled());
+
+        // run it now
+        assertTrue(!monitor.getLogFile().exists());
+        submit(form, "background");
+        assertTrue(monitor.getLogFile().exists());
+
+        // should be no warning/error now
+        HtmlPage manage = wc.goTo("/manage");
+        assertEquals(0,manage.selectNodes("//*[class='error']").size());
+        assertEquals(0,manage.selectNodes("//*[class='warning']").size());
+
+        // and the data should be rewritten
+        verifyRewrite(jenkins.getRootDir());
+        assertTrue(monitor.isDone());
+
+        // dismiss and the message will be gone
+        assertTrue(monitor.isEnabled());
+        form = getRekeyForm(wc);
+        submit(form, "dismiss");
+        assertFalse(monitor.isEnabled());
+        try {
+            getRekeyForm(wc);
+            fail();
+        } catch (ElementNotFoundException e) {
+            // expected
+        }
+    }
+
+    private HtmlForm getRekeyForm(WebClient wc) throws IOException, SAXException {
+        return wc.goTo("/manage").getFormByName("rekey");
+    }
+
+    private HtmlButton getButton(HtmlForm form, int index) {
+        return form.<HtmlButton>getHtmlElementsByTagName("button").get(index);
+    }
+
+    public void testScanOnBoot() throws Exception {
+        WebClient wc = createWebClient();
+
+        // scan on boot should have run the scan
+        assertTrue(monitor.getLogFile().exists());
+        assertTrue("scan on boot should have turned this off",!monitor.isScanOnBoot());
+
+        // and data should be migrated
+        verifyRewrite(jenkins.getRootDir());
+
+        // should be no warning/error now
+        HtmlPage manage = wc.goTo("/manage");
+        assertEquals(0,manage.selectNodes("//*[class='error']").size());
+        assertEquals(0,manage.selectNodes("//*[class='warning']").size());
+    }
+
+    private String encryptOld(String str) throws Exception {
+        Cipher cipher = Secret.getCipher("AES");
+        cipher.init(Cipher.ENCRYPT_MODE, Util.toAes128Key(TEST_KEY));
+        return new String(Base64.encode(cipher.doFinal((str + "::::MAGIC::::").getBytes("UTF-8"))));
+    }
+
+    private String encryptNew(String str) {
+        return Secret.fromString(str).getEncryptedValue();
+    }
+
+    private static final String TEST_KEY = "superDuperSecretWasNotSoSecretAfterAll";
+}

Vulnerable code: import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
    /*package*/ static SecretKey getLegacyKey() throws UnsupportedEncodingException, GeneralSecurityException {
    private static Secret tryDecrypt(Cipher cipher, byte[] in) throws UnsupportedEncodingException {
            return null;
import org.junit.After
import org.junit.Before
import java.security.SecureRandom;

import hudson.Util;
    @Test @Before
    void setUp() {
        def sr = new SecureRandom();
        byte[] random = new byte[32];
        sr.nextBytes(random);
        Secret.SECRET = Util.toHexString(random);
    }

    @Test @After
    void tearDown() {
        Secret.SECRET = null;
    }
Fixed code:     /*package*/ static SecretKey getLegacyKey() throws GeneralSecurityException {
    /*package*/ static Secret tryDecrypt(Cipher cipher, byte[] in) throws UnsupportedEncodingException {
            return null; // if the key doesn't match with the bytes, it can result in BadPaddingException
package hudson.util;

import com.trilead.ssh2.crypto.Base64;
import hudson.model.TaskListener;
import org.apache.commons.io.FileUtils;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.security.GeneralSecurityException;
import java.security.InvalidKeyException;
import java.util.HashSet;
import java.util.Set;

/**
 * Rewrites XML files by looking for Secrets that are stored with the old key and replaces them
 * by the new encrypted values.
 *
 * @author Kohsuke Kawaguchi
 */
public class SecretRewriter {
    private final Cipher cipher;
    private final SecretKey key;

    /**
     * How many files have been scanned?
     */
    private int count;

    /**
     * If non-null the original file before rewrite gets in here.
     */
    private final File backupDirectory;

    /**
     * Canonical paths of the directories we are recursing to protect
     * against symlink induced cycles.
     */
    private Set<String> callstack = new HashSet<String>();

    public SecretRewriter(File backupDirectory) throws GeneralSecurityException {
        cipher = Secret.getCipher("AES");
        key = Secret.getLegacyKey();
        this.backupDirectory = backupDirectory;
    }

    private String tryRewrite(String s) throws IOException, InvalidKeyException {
        if (s.length()<24)
            return s;   // Encrypting "" in Secret produces 24-letter characters, so this must be the minimum length
        if (!isBase64(s))
            return s;   // decode throws IOException if the input is not base64, and this is also a very quick way to filter

        byte[] in;
        try {
            in = Base64.decode(s.toCharArray());
        } catch (IOException e) {
            return s;   // not a valid base64
        }
        cipher.init(Cipher.DECRYPT_MODE, key);
        Secret sec = Secret.tryDecrypt(cipher, in);
        if(sec!=null) // matched
            return sec.getEncryptedValue(); // replace by the new encrypted value
        else // not encrypted with the legacy key. leave it unmodified
            return s;
    }

    /**
     * @param backup
     *      if non-null, the original file will be copied here before rewriting.
     *      if the rewrite doesn't happen, no copying.
     */
    public boolean rewrite(File f, File backup) throws InvalidKeyException, IOException {
        FileInputStream fin = new FileInputStream(f);
        try {
            BufferedReader r = new BufferedReader(new InputStreamReader(fin, "UTF-8"));
            AtomicFileWriter w = new AtomicFileWriter(f, "UTF-8");
            try {
                PrintWriter out = new PrintWriter(new BufferedWriter(w));

                boolean modified = false; // did we actually change anything?
                try {
                    String line;
                    StringBuilder buf = new StringBuilder();

                    while ((line=r.readLine())!=null) {
                        int copied=0;
                        buf.setLength(0);
                        while (true) {
                            int sidx = line.indexOf('>',copied);
                            if (sidx<0) break;
                            int eidx = line.indexOf('<',sidx);
                            if (eidx<0) break;

                            String elementText = line.substring(sidx+1,eidx);
                            String replacement = tryRewrite(elementText);
                            if (!replacement.equals(elementText))
                                modified = true;

                            buf.append(line.substring(copied,sidx+1));
                            buf.append(replacement);
                            copied = eidx;
                        }
                        buf.append(line.substring(copied));
                        out.println(buf.toString());
                    }
                } finally {
                    out.close();
                }

                if (modified) {
                    if (backup!=null) {
                        backup.getParentFile().mkdirs();
                        FileUtils.copyFile(f,backup);
                    }
                    w.commit();
                }
                return modified;
            } finally {
                w.abort();
            }
        } finally {
            fin.close();
        }
    }


    /**
     * Recursively scans and rewrites a directory.
     *
     * This method shouldn't abort just because one file fails to rewrite.
     *
     * @return
     *      Number of files that were actually rewritten.
     */
    // synchronized to prevent accidental concurrent use. this instance is not thread safe
    public synchronized int rewriteRecursive(File dir, TaskListener listener) throws InvalidKeyException {
        return rewriteRecursive(dir,"",listener);
    }
    private int rewriteRecursive(File dir, String relative, TaskListener listener) throws InvalidKeyException {
        String canonical;
        try {
            canonical = dir.getCanonicalPath();
        } catch (IOException e) {
            canonical = dir.getAbsolutePath(); //
        }
        if (!callstack.add(canonical)) {
            listener.getLogger().println("Cycle detected: "+dir);
            return 0;
        }

        try {
            File[] children = dir.listFiles();
            if (children==null)     return 0;

            int rewritten=0;
            for (File child : children) {
                String cn = child.getName();
                if (cn.endsWith(".xml")) {
                    if ((count++)%100==0)
                        listener.getLogger().println("Scanning "+child);
                    try {
                        File backup = null;
                        if (backupDirectory!=null)  backup = new File(backupDirectory,relative+'/'+ cn);
                        if (rewrite(child,backup)) {
                            if (backup!=null)
                                listener.getLogger().println("Copied "+child+" to "+backup+" as a backup");
                            listener.getLogger().println("Rewritten "+child);
                            rewritten++;
                        }
                    } catch (IOException e) {
                        e.printStackTrace(listener.error("Failed to rewrite "+child));
                    }
                }
                if (child.isDirectory()) {
                    if (!isIgnoredDir(child))
                        rewritten += rewriteRecursive(child,
                                relative.length()==0 ? cn : relative+'/'+ cn,
                                listener);
                }
            }
            return rewritten;
        } finally {
            callstack.remove(canonical);
        }
    }

    /**
     * Decides if this directory is worth visiting or not.
     */
    protected boolean isIgnoredDir(File dir) {
        // ignoring the workspace and the artifacts directories. Both of them
        // are potentially large and they do not store any secrets.
        String n = dir.getName();
        return n.equals("workspace") || n.equals("artifacts")
            || n.equals("plugins") // no mutable data here
            || n.equals("jenkins.security.RekeySecretAdminMonitor") // we don't want to rewrite backups
            || n.equals(".") || n.equals("..");
    }

    private static boolean isBase64(char ch) {
        return 0<=ch && ch<128 && IS_BASE64[ch];
    }

    private static boolean isBase64(String s) {
        for (int i=0; i<s.length(); i++)
            if (!isBase64(s.charAt(i)))
                return false;
        return true;
    }

    private static final boolean[] IS_BASE64 = new boolean[128];
    static {
        String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
        for (int i=0; i<chars.length();i++)
            IS_BASE64[chars.charAt(i)] = true;
    }
}
package jenkins.security;

import hudson.Extension;
import hudson.console.AnnotatedLargeText;
import hudson.init.InitMilestone;
import hudson.init.Initializer;
import hudson.model.AdministrativeMonitor;
import hudson.util.HttpResponses;
import hudson.util.SecretRewriter;
import hudson.util.StreamTaskListener;
import hudson.util.VersionNumber;
import jenkins.model.Jenkins;
import jenkins.util.io.FileBoolean;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.StaplerProxy;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.interceptor.RequirePOST;

import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.Charset;
import java.security.GeneralSecurityException;
import java.util.Date;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Warns the administrator to run {@link SecretRewriter}
 *
 * @author Kohsuke Kawaguchi
 */
@Extension
public class RekeySecretAdminMonitor extends AdministrativeMonitor implements StaplerProxy {

    /**
     * Whether we detected a need to run the rewrite program.
     * Once we set it to true, we'll never turn it off.
     *
     * If the admin decides to dismiss this warning, we use {@link #isEnabled()} for that.
     *
     * In this way we can correctly differentiate all the different states.
     */
    private final FileBoolean needed = state("needed");

    /**
     * If the scanning process has run to the completion, we set to this true.
     */
    private final FileBoolean done = state("done");

    /**
     * If the rewrite process is scheduled upon the next boot.
     */
    private final FileBoolean scanOnBoot = state("scanOnBoot");

    /**
     * Set to non-null once the rewriting activities starts running.
     */
    private volatile RekeyThread rekeyThread;


    public RekeySecretAdminMonitor() throws IOException {
        // if JENKINS_HOME existed <1.497, we need to offer rewrite
        // this computation needs to be done and the value be captured,
        // since $JENKINS_HOME/config.xml can be saved later before the user has
        // actually rewritten XML files.
        if (Jenkins.getInstance().isUpgradedFromBefore(new VersionNumber("1.496.*")))
            needed.on();
    }

    /**
     * Requires ADMINISTER permission for any operation in here.
     */
    public Object getTarget() {
        Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER);
        return this;
    }

    @Override
    public boolean isActivated() {
        return needed.isOn();
    }

    /**
     * Indicates that the re-keying has run to the completion.
     */
    public boolean isDone() {
        return done.isOn();
    }

    public void setNeeded() {
        needed.on();
    }

    public boolean isScanOnBoot() {
        return scanOnBoot.isOn();
    }

    @RequirePOST
    public HttpResponse doScan(StaplerRequest req) throws IOException, GeneralSecurityException {
        if(req.hasParameter("background")) {
            synchronized (this) {
                if (!isRewriterActive()) {
                    rekeyThread = new RekeyThread();
                    rekeyThread.start();
                }
            }
        } else
        if(req.hasParameter("schedule")) {
            scanOnBoot.on();
        } else
        if(req.hasParameter("dismiss")) {
            disable(true);
        } else
            throw HttpResponses.error(400,"Invalid request submission");

        return HttpResponses.redirectViaContextPath("/manage");
    }

    /**
     * Is there an active rewriting process going on?
     */
    public boolean isRewriterActive() {
        return rekeyThread !=null && rekeyThread.isAlive();
    }

    /**
     * Used to URL-bind {@link AnnotatedLargeText}.
     */
    public AnnotatedLargeText getLogText() {
        return new AnnotatedLargeText<RekeySecretAdminMonitor>(getLogFile(), Charset.defaultCharset(),
                !isRewriterActive(),this);
    }

    private static FileBoolean state(String name) {
        return new FileBoolean(new File(getBaseDir(),name));
    }

    @Initializer(fatal=false,after=InitMilestone.PLUGINS_STARTED,before=InitMilestone.EXTENSIONS_AUGMENTED)
    // as early as possible, but this needs to be late enough that the ConfidentialStore is available
    public static void scanOnReboot() throws InterruptedException, IOException, GeneralSecurityException {
        FileBoolean flag = new RekeySecretAdminMonitor().scanOnBoot;
        if (flag.isOn()) {
            flag.off();
            RekeyThread t = new RekeyThread();
            t.start();
            t.join();
            // block the boot until the rewrite process is complete
            // don't let the failure in RekeyThread block Jenkins boot.
        }
    }

    /**
     * Rewrite log file.
     */
    public static File getLogFile() {
        return new File(getBaseDir(),"rekey.log");
    }

    private static File getBaseDir() {
        return new File(Jenkins.getInstance().getRootDir(),RekeySecretAdminMonitor.class.getName());
    }

    private static class RekeyThread extends Thread {
        private final SecretRewriter rewriter;

        RekeyThread() throws GeneralSecurityException {
            super("Rekey secret thread");
            rewriter = new SecretRewriter(new File(getBaseDir(),"backups"));
        }

        @Override
        public void run() {
            try {
                LOGGER.info("Initiating a re-keying of secrets. See "+getLogFile());
                StreamTaskListener listener = new StreamTaskListener(getLogFile());
                try {
                    PrintStream log = listener.getLogger();
                    log.println("Started re-keying " + new Date());
                    int count = rewriter.rewriteRecursive(Jenkins.getInstance().getRootDir(), listener);
                    log.printf("Completed re-keying %d files on %s\n",count,new Date());
                    new RekeySecretAdminMonitor().done.on();
                    LOGGER.info("Secret re-keying completed");
                } catch (Exception e) {
                    LOGGER.log(Level.SEVERE, "Fatal failure in re-keying secrets",e);
                    e.printStackTrace(listener.error("Fatal failure in rewriting secrets"));
                }
            } catch (IOException e) {
                LOGGER.log(Level.SEVERE, "Catastrophic failure to rewrite secrets",e);
            }
        }
    }

    private static final Logger LOGGER = Logger.getLogger(RekeySecretAdminMonitor.class.getName());

}
/*
The MIT License

Copyright (c) 2013, CloudBees, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package jenkins.security.RekeySecretAdminMonitor;

def l = namespace(lib.LayoutTagLib)
def t = namespace(lib.JenkinsTagLib)

l.layout {
    l.main_panel() {
        h1 _("Re-keying log")

        if (my.isRewriterActive()) {
            pre(id: "out")
            div(id: "spinner") {
                img(src: "${imagesURL}/spinner.gif", alt: "")
            }
            t.progressiveText(spinner: "spinner", href: "logText/progressiveHtml", idref: "out")
        } else {
            pre {
                my.logText.writeHtmlTo(0, output.asWriter())
            }
        }
    }
}
/*
The MIT License

Copyright (c) 2013, CloudBees, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package jenkins.security.RekeySecretAdminMonitor;

def f = namespace(lib.FormTagLib)

if (!my.isDone()) {
    div(class:"error") {
        raw _("pleaseRekeyAsap",app.rootDir,my.url)
    }
}

if (my.isRewriterActive()) {
    div(class:"info") {
        raw _("rekeyInProgress",my.url)
    }
} else if (my.logFile.exists()) {
    if (my.isDone()) {
        div(class:"info") {
            raw _("rekeySuccessful",my.url)
        }
    } else {
        div(class:"warning") {
            raw _("rekeyHadProblems",my.url)
        }
    }
}

form(method:"POST",action:"${my.url}/scan",style:"text-align:center; margin-top:0.5em;",name:"rekey") {
    f.submit(name:"background",value:_("Re-key in background now"))
    if (my.isScanOnBoot()) {
        input(type:"button",class:"yui-button",disabled:"true",
                value:_("Re-keying currently scheduled during the next startup"))
    } else {
        f.submit(name:"schedule",  value:_("Schedule a re-key during the next startup"))
    }
    f.submit(name:"dismiss",   value:_("Dismiss this message"))
}
pleaseRekeyAsap=\
    Because of <a href="https://wiki.jenkins-ci.org/display/SECURITY/Jenkins+Security+Advisory+2013-01-04">a security vulnerability</a> discovered earlier, we need to \
    change the encryption key used to protect secrets in your configuration files on the disk. \
    This process scans a large portion of your <tt>$JENKINS_HOME</tt> ({0}), \
    find encrypted data, re-key them, which will take some time. \
    See <a href="http://jenkins-ci.org/rekey">this document</a> for more implications about different ways of doing this \
    (or not doing this.) This operation can be safely run in background, but cautious users \
    are recommended to take backups.

rekeyInProgress=Re-keying is in progress. <a href="{0}/log">You can check the log</a>.

rekeySuccessful=\
  Secrets in your <tt>$JENKINS_HOME</tt> has been re-keyed successfully. \
  <a href="{0}/log">Please check the log</a>, confirm the success, and then dismiss or re-run.

rekeyHadProblems=\
  Re-keying has completed, but there were problems. <a href="{0}">Please check the log</a>.
package hudson.util;

import hudson.Util;
import org.junit.rules.ExternalResource;

import java.security.SecureRandom;

/**
 * JUnit rule that cleans that sets a temporary {@link Secret#SECRET} value.
 *
 * @author Kohsuke Kawaguchi
 */
public class MockSecretRule extends ExternalResource {

    private String value;

    @Override
    protected void before() throws Throwable {
        byte[] random = new byte[32];
        sr.nextBytes(random);
        value = Util.toHexString(random);
        Secret.SECRET = value;
    }

    @Override
    protected void after() {
        if (!Secret.SECRET.equals(value))
            throw new IllegalStateException("Someone tinkered with Secret.SECRET");
        Secret.SECRET = null;
    }

    private static final SecureRandom sr = new SecureRandom();
}
package hudson.util

import com.trilead.ssh2.crypto.Base64
import hudson.FilePath
import jenkins.security.ConfidentialStoreRule
import org.junit.Rule
import org.junit.Test

import javax.crypto.Cipher

import static hudson.Util.createTempDir

/**
 *
 *
 * @author Kohsuke Kawaguchi
 */
class SecretRewriterTest {
    @Rule
    public MockSecretRule mockSecretRule = new MockSecretRule()

    @Rule
    public ConfidentialStoreRule confidentialStoreRule = new ConfidentialStoreRule();

    @Test
    void singleFileRewrite() {
        def o = encryptOld('foobar') // old
        def n = encryptNew('foobar') // new
        roundtrip "<foo>${o}</foo>",
                  "<foo>${n}</foo>"

        roundtrip "<foo>${o}</foo><foo>${o}</foo>",
                  "<foo>${n}</foo><foo>${n}</foo>"

        roundtrip "<foo>${n}</foo>",
                  "<foo>${n}</foo>"

        roundtrip "  <foo>thisIsLegalBase64AndLongEnoughThatItCouldLookLikeSecret</foo>  ",
                  "  <foo>thisIsLegalBase64AndLongEnoughThatItCouldLookLikeSecret</foo>  "

        // to be rewritten, it needs to be between a tag
        roundtrip "<foo>$o", "<foo>$o"
        roundtrip "$o</foo>", "$o</foo>"

        //
        roundtrip "<abc>\n<foo>$o</foo>\n</abc>", "<abc>\n<foo>$n</foo>\n</abc>"
    }

    void roundtrip(String before, String after) {
        def sr = new SecretRewriter(null);
        def f = File.createTempFile("test","xml");
        try {
            f.text = before
            sr.rewrite(f,null)
            assert after.trim()==f.text.trim()
        } finally {
            f.delete()
        }
    }

    String encryptOld(str) {
        def cipher = Secret.getCipher("AES");
        cipher.init(Cipher.ENCRYPT_MODE, Secret.legacyKey);
        return new String(Base64.encode(cipher.doFinal((str + Secret.MAGIC).getBytes("UTF-8"))))
    }

    String encryptNew(str) {
        return Secret.fromString(str).encryptedValue
    }

    /**
     * Directory rewrite and recursion detection
     */
    @Test
    void recursionDetection() {
        def backup = createTempDir()
        def sw = new SecretRewriter(backup);
        def st = StreamTaskListener.fromStdout()

        def o = encryptOld("Hello world")
        def n = encryptNew("Hello world")
        def payload = "<msg>$o</msg>"
        def answer = "<msg>$n</msg>"

        // set up some directories with stuff
        def t = createTempDir()
        def dirs = ["a", "b", "c", "c/d", "c/d/e"]
        dirs.each { p ->
            def d = new File(t, p)
            d.mkdir()
            new File(d,"foo.xml").text = payload
        }

        // stuff outside
        def t2 = createTempDir()
        new File(t2,"foo.xml").text = payload

        // some recursions as well as valid symlinks
        new FilePath(t).child("c/symlink").symlinkTo("..",st)
        new FilePath(t).child("b/symlink").symlinkTo(".",st)
        new FilePath(t).child("a/symlink").symlinkTo(t2.absolutePath,st)

        assert 6==sw.rewriteRecursive(t, st)

        dirs.each { p->
            assert new File(t,"$p/foo.xml").text.trim()==answer
            assert new File(backup,"$p/foo.xml").text.trim()==payload
        }

        // t2 is only reachable by following a symlink. this should be covered, too
        assert new File(t2,"foo.xml").text.trim()==answer.trim();
    }

}
    @Rule
    public MockSecretRule mockSecretRule = new MockSecretRule()
package hudson.util;

/**
 * @author Kohsuke Kawaguchi
 */
public class SecretHelper {
    public static void set(String s) {
        Secret.SECRET = s;
    }
}
package jenkins.security;

import com.gargoylesoftware.htmlunit.ElementNotFoundException;
import com.gargoylesoftware.htmlunit.html.HtmlButton;
import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import com.trilead.ssh2.crypto.Base64;
import hudson.FilePath;
import hudson.Util;
import hudson.util.Secret;
import hudson.util.SecretHelper;
import org.apache.commons.io.FileUtils;
import org.jvnet.hudson.test.HudsonTestCase;
import org.jvnet.hudson.test.recipes.Recipe.Runner;
import org.xml.sax.SAXException;

import javax.crypto.Cipher;
import javax.inject.Inject;
import java.io.File;
import java.io.IOException;
import java.lang.annotation.Annotation;

/**
 * @author Kohsuke Kawaguchi
 */
public class RekeySecretAdminMonitorTest extends HudsonTestCase {
    @Inject
    RekeySecretAdminMonitor monitor;

    @Override
    protected void setUp() throws Exception {
        SecretHelper.set(TEST_KEY);
        super.setUp();
        monitor.setNeeded();
    }

    @Override
    protected void tearDown() throws Exception {
        SecretHelper.set(null);
        super.tearDown();
    }

    @Override
    protected void recipe() throws Exception {
        super.recipe();
        recipes.add(new Runner() {
            @Override
            public void setup(HudsonTestCase testCase, Annotation recipe) throws Exception {
            }

            @Override
            public void decorateHome(HudsonTestCase testCase, File home) throws Exception {
                if (getName().endsWith("testScanOnBoot")) {
                    // schedule a scan on boot
                    File f = new File(home, RekeySecretAdminMonitor.class.getName() + "/scanOnBoot");
                    f.getParentFile().mkdirs();
                    new FilePath(f).touch(0);

                    // and stage some data
                    putSomeOldData(home);
                }
            }

            @Override
            public void tearDown(HudsonTestCase testCase, Annotation recipe) throws Exception {
            }
        });
    }


    private void putSomeOldData(File dir) throws Exception {
        File xml = new File(dir, "foo.xml");
        FileUtils.writeStringToFile(xml,"<foo>" + encryptOld(TEST_KEY) + "</foo>");
    }

    private void verifyRewrite(File dir) throws Exception {
        File xml = new File(dir, "foo.xml");
        assertEquals("<foo>" + encryptNew(TEST_KEY) + "</foo>".trim(),
                FileUtils.readFileToString(xml).trim());
    }

    public void testBasicWorkflow() throws Exception {
        putSomeOldData(jenkins.getRootDir());

        WebClient wc = createWebClient();

        // one should see the warning. try scheduling it
        assertTrue(!monitor.isScanOnBoot());
        HtmlForm form = getRekeyForm(wc);
        submit(form, "schedule");
        assertTrue(monitor.isScanOnBoot());
        form = getRekeyForm(wc);
        assertTrue(getButton(form, 1).isDisabled());

        // run it now
        assertTrue(!monitor.getLogFile().exists());
        submit(form, "background");
        assertTrue(monitor.getLogFile().exists());

        // should be no warning/error now
        HtmlPage manage = wc.goTo("/manage");
        assertEquals(0,manage.selectNodes("//*[class='error']").size());
        assertEquals(0,manage.selectNodes("//*[class='warning']").size());

        // and the data should be rewritten
        verifyRewrite(jenkins.getRootDir());
        assertTrue(monitor.isDone());

        // dismiss and the message will be gone
        assertTrue(monitor.isEnabled());
        form = getRekeyForm(wc);
        submit(form, "dismiss");
        assertFalse(monitor.isEnabled());
        try {
            getRekeyForm(wc);
            fail();
        } catch (ElementNotFoundException e) {
            // expected
        }
    }

    private HtmlForm getRekeyForm(WebClient wc) throws IOException, SAXException {
        return wc.goTo("/manage").getFormByName("rekey");
    }

    private HtmlButton getButton(HtmlForm form, int index) {
        return form.<HtmlButton>getHtmlElementsByTagName("button").get(index);
    }

    public void testScanOnBoot() throws Exception {
        WebClient wc = createWebClient();

        // scan on boot should have run the scan
        assertTrue(monitor.getLogFile().exists());
        assertTrue("scan on boot should have turned this off",!monitor.isScanOnBoot());

        // and data should be migrated
        verifyRewrite(jenkins.getRootDir());

        // should be no warning/error now
        HtmlPage manage = wc.goTo("/manage");
        assertEquals(0,manage.selectNodes("//*[class='error']").size());
        assertEquals(0,manage.selectNodes("//*[class='warning']").size());
    }

    private String encryptOld(String str) throws Exception {
        Cipher cipher = Secret.getCipher("AES");
        cipher.init(Cipher.ENCRYPT_MODE, Util.toAes128Key(TEST_KEY));
        return new String(Base64.encode(cipher.doFinal((str + "::::MAGIC::::").getBytes("UTF-8"))));
    }

    private String encryptNew(String str) {
        return Secret.fromString(str).getEncryptedValue();
    }

    private static final String TEST_KEY = "superDuperSecretWasNotSoSecretAfterAll";
}