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.nio.file.LinkOption;
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 {
AtomicFileWriter w = new AtomicFileWriter(f, "UTF-8");
try {
PrintWriter out = new PrintWriter(new BufferedWriter(w));
boolean modified = false; // did we actually change anything?
try {
FileInputStream fin = new FileInputStream(f);
try {
BufferedReader r = new BufferedReader(new InputStreamReader(fin, "UTF-8"));
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 {
fin.close();
}
} finally {
out.close();
}
if (modified) {
if (backup!=null) {
backup.getParentFile().mkdirs();
FileUtils.copyFile(f,backup);
}
w.commit();
}
return modified;
} finally {
w.abort();
}
}
/**
* 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.toPath().toRealPath(new LinkOption[0]).toString();
} 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;
}
}