Skip to content

Commit

Permalink
[FIXED JENKINS-22834] Added feature for classpath approval.
Browse files Browse the repository at this point in the history
  • Loading branch information
ikedam committed Jul 27, 2014
1 parent 1bd2137 commit f02fb0d
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 14 deletions.
Expand Up @@ -24,8 +24,6 @@

package org.jenkinsci.plugins.scriptsecurity.sandbox.groovy;

import java.io.File;

import org.kohsuke.stapler.DataBoundConstructor;

import hudson.Extension;
Expand All @@ -47,10 +45,6 @@ public String getPath() {
return path;
}

public File getClasspath() {
return new File(getPath());
}

@Override
public String toString() {
return String.format("Classpath: %s", getPath());
Expand Down
Expand Up @@ -108,6 +108,11 @@ public SecureGroovyScript configuring(ApprovalContext context) {
if (!sandbox) {
ScriptApproval.get().configuring(script, GroovyLanguage.get(), context);
}
if (getAdditionalClasspathList() != null && !getAdditionalClasspathList().isEmpty()) {
for (AdditionalClasspath classpath: getAdditionalClasspathList()) {
ScriptApproval.get().configureingClasspath(classpath.getPath(), context);
}
}
return this;
}

Expand All @@ -119,18 +124,14 @@ public SecureGroovyScript configuring(ApprovalContext context) {
/** Convenience form of {@link #configuring} that calls {@link ApprovalContext#withCurrentUser} and {@link ApprovalContext#withItemAsKey}. */
public SecureGroovyScript configuringWithKeyItem() {
ApprovalContext context = ApprovalContext.create();
if (!sandbox) {
context = context.withCurrentUser().withItemAsKey(currentItem());
}
context = context.withCurrentUser().withItemAsKey(currentItem());
return configuring(context);
}

/** Convenience form of {@link #configuring} that calls {@link ApprovalContext#withCurrentUser} and {@link ApprovalContext#withItem}. */
public SecureGroovyScript configuringWithNonKeyItem() {
ApprovalContext context = ApprovalContext.create();
if (!sandbox) {
context = context.withCurrentUser().withItem(currentItem());
}
context = context.withCurrentUser().withItem(currentItem());
return configuring(context);
}

Expand All @@ -143,17 +144,17 @@ public SecureGroovyScript configuringWithNonKeyItem() {
* @throws Exception in case of a general problem
* @throws RejectedAccessException in case of a sandbox issue
* @throws UnapprovedUsageException in case of a non-sandbox issue
* @throws UnapprovedClasspathException in case requiring classpath approving
*/
public Object evaluate(BuildListener listener, ClassLoader loader, Binding binding) throws Exception {
if (!calledConfiguring) {
throw new IllegalStateException("you need to call configuring or a related method before using GroovyScript");
}
if (getAdditionalClasspathList() != null && !getAdditionalClasspathList().isEmpty()) {
// TODO check approval of classpath
List<URL> urlList = new ArrayList<URL>(getAdditionalClasspathList().size());

for (AdditionalClasspath classpath: getAdditionalClasspathList()) {
File file = classpath.getClasspath();
File file = new File(classpath.getPath());
if (!file.isAbsolute()) {
listener.getLogger().println(String.format("%s: classpath should be absolute. Not added to class loader", file));
continue;
Expand All @@ -162,6 +163,7 @@ public Object evaluate(BuildListener listener, ClassLoader loader, Binding bindi
listener.getLogger().println(String.format("%s: Does not exist. Not added to class loader", file));
continue;
}
ScriptApproval.get().checkClasspathApproved(classpath.getPath());
try {
urlList.add(file.toURI().toURL());
} catch (MalformedURLException e) {
Expand Down
Expand Up @@ -32,19 +32,31 @@
import hudson.Extension;
import hudson.Util;
import hudson.XmlFile;
import hudson.model.Item;
import hudson.model.RootAction;
import hudson.model.Saveable;
import hudson.model.AbstractBuild;
import hudson.model.AbstractProject;
import hudson.model.Executor;
import hudson.model.Queue;
import hudson.security.ACL;
import hudson.util.FormValidation;
import hudson.util.XStream2;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.logging.Level;
Expand All @@ -53,6 +65,8 @@
import jenkins.model.Jenkins;
import org.acegisecurity.context.SecurityContext;
import org.acegisecurity.context.SecurityContextHolder;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.types.FileSet;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.DataBoundConstructor;
Expand All @@ -75,6 +89,7 @@
XSTREAM2.alias("scriptApproval", ScriptApproval.class);
XSTREAM2.alias("pendingScript", PendingScript.class);
XSTREAM2.alias("pendingSignature", PendingSignature.class);
XSTREAM2.alias("pendingClasspath", PendingClasspath.class);
}

/** Gets the singleton instance. */
Expand All @@ -91,6 +106,9 @@ public static ScriptApproval get() {
/** All sandbox signatures which are already whitelisted for ACL-only use, in {@link StaticWhitelist} format. */
private /*final*/ Set<String> aclApprovedSignatures;

/** All external classpaths allowed used for scripts. Keys are hash, values are path (used only for displaying convenience).*/
private /*final*/ Map<String, String> approvedClasspaths /*= new HashMap<String, String>()*/;

@Restricted(NoExternalUse.class) // for use from Jelly
public static abstract class PendingThing {

Expand Down Expand Up @@ -172,19 +190,61 @@ public String getHash() {
}
}

/**
* A classpath requiring approval by an administrator.
*/
@Restricted(NoExternalUse.class) // for use from Jelly
public static final class PendingClasspath extends PendingThing {
private final String path;
private final String hash;

PendingClasspath(@Nonnull String path, @Nonnull String hash, @Nonnull ApprovalContext context) {
super(context);
/**
* hash should be stored as files located at the classpath can be modified.
*/
this.hash = hash;
this.path = path;
}

public String getHash() {
return hash;
}

public String getPath() {
return path;
}
@Override public int hashCode() {
// classpaths are distinguished only with its hash.
return getHash().hashCode();
}
@Override public boolean equals(Object obj) {
return obj instanceof PendingClasspath && ((PendingClasspath) obj).getHash().equals(getHash());
}
}

private final Set<PendingScript> pendingScripts = new LinkedHashSet<PendingScript>();

private final Set<PendingSignature> pendingSignatures = new LinkedHashSet<PendingSignature>();

private /*final*/ Set<PendingClasspath> pendingClasspaths /*= new LinkedHashSet<PendingClasspath>()*/;

public ScriptApproval() {
try {
load();
} catch (IOException x) {
LOG.log(Level.WARNING, null, x);
}
/* can be null when upgraded from old versions.*/
if (aclApprovedSignatures == null) {
aclApprovedSignatures = new TreeSet<String>();
}
if (approvedClasspaths == null) {
approvedClasspaths = new HashMap<String, String>();
}
if (pendingClasspaths == null) {
pendingClasspaths = new LinkedHashSet<PendingClasspath>();
}
}

private static String hash(String script, String language) {
Expand All @@ -201,6 +261,56 @@ private static String hash(String script, String language) {
}
}

private static String hashClasspath(String classpath) throws IOException{
File file = new File(classpath);
if (!file.exists()) {
throw new FileNotFoundException(String.format("Not found: %s", file.getAbsolutePath()));
}
if (!file.isDirectory()) {
// for a jar file.
// simply use the digest of the file.
try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
DigestInputStream input = new DigestInputStream(new FileInputStream(file), digest);
while(input.read() != -1) {}
input.close();
return Util.toHexString(digest.digest());
} catch(NoSuchAlgorithmException x) {
throw new AssertionError(x);
}
}

// for a class directory.
// digest of "\0filename1\0filesize1\0filecontents1\0filename2\0filesize2\0filecontents2..."
// order all files in the alphabetical order.
FileSet fs = Util.createFileSet(file, "**");
fs.setDefaultexcludes(false);
DirectoryScanner ds = fs.getDirectoryScanner();
String[] files = ds.getIncludedFiles();
Arrays.sort(files);

try {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
for (String targetPath: files) {
File targetFile = new File(file, targetPath);

digest.update((byte)0);
digest.update(targetPath.getBytes("UTF-8"));
digest.update((byte)0);
digest.update(ByteBuffer.allocate(8).putLong(targetFile.length()).array());
digest.update((byte)0);
DigestInputStream input = new DigestInputStream(new FileInputStream(targetFile), digest);
while(input.read() != -1) {}
input.close();
}
return Util.toHexString(digest.digest());
} catch(NoSuchAlgorithmException x) {
throw new AssertionError(x);
} catch (UnsupportedEncodingException x) {
throw new AssertionError(x);
}
}

/**
* Used when someone is configuring a script.
* Typically you would call this from a {@link DataBoundConstructor}.
Expand Down Expand Up @@ -261,6 +371,102 @@ public synchronized String using(@Nonnull String script, @Nonnull Language langu
return script;
}

/**
* Check whether classpath is approved. if not, add it as pending.
*
* @param path
* @param context
*/
public synchronized void configureingClasspath(@Nonnull String path, @Nonnull ApprovalContext context) {
String hash;
try {
hash = hashClasspath(path);
} catch (IOException x) {
// This is a case the path doesn't really exist
LOG.log(Level.WARNING, null, x);
return;
}

if (!approvedClasspaths.containsKey(hash)) {
boolean shouldSave = false;
if (!Jenkins.getInstance().isUseSecurity() || (Jenkins.getAuthentication() != ACL.SYSTEM && Jenkins.getInstance().hasPermission(Jenkins.RUN_SCRIPTS))) {
LOG.info(String.format("Classpath %s (%s) is approved as configured with RUN_SCRIPTS permission.", path, hash));
approvedClasspaths.put(hash, path);
shouldSave = true;
} else {
if (pendingClasspaths.add(new PendingClasspath(path, hash, context))) {
LOG.info(String.format("%s (%s) is pended.", path, hash));
shouldSave = true;
}
}
if (shouldSave) {
try {
save();
} catch (IOException x) {
LOG.log(Level.WARNING, null, x);
}
}
}
return;
}

/**
* @param path
* @return whether a classpath is approved.
* @throws IOException when failed to access classpath.
*/
public synchronized boolean isClasspathApproved(@Nonnull String path) throws IOException {
String hash = hashClasspath(path);

String approvedPath = approvedClasspaths.get(hash);
if (approvedPath != null) {
LOG.fine(String.format("%s (%s) has been approved as %s.", path, hash, approvedPath));
}

return (approvedPath != null);
}

private static Item currentExecutingItem() {
if (Executor.currentExecutor() == null) {
return null;
}
Queue.Executable exe = Executor.currentExecutor().getCurrentExecutable();
if (exe == null || !(exe instanceof AbstractBuild)) {
return null;
}
AbstractBuild<?,?> build = (AbstractBuild<?,?>)exe;
AbstractProject<?,?> project = build.getParent();
return project.getRootProject();
}

/**
* Asserts a classpath is approved. Also records it as a pending classpath if not approved.
*
* @param path classpath
* @throws IOException when failed to access classpath.
* @throws UnapprovedClasspathException when the classpath is not approved.
*/
public synchronized void checkClasspathApproved(@Nonnull String path) throws IOException, UnapprovedClasspathException {
String hash = hashClasspath(path);

if (!approvedClasspaths.containsKey(hash)) {
// Never approve classpath here.
ApprovalContext context = ApprovalContext.create();
context = context.withCurrentUser().withItemAsKey(currentExecutingItem());
if(pendingClasspaths.add(new PendingClasspath(path, hash, context))) {
LOG.info(String.format("%s (%s) is pended.", path, hash));
try {
save();
} catch (IOException x) {
LOG.log(Level.WARNING, null, x);
}
}
throw new UnapprovedClasspathException(path, hash);
}

LOG.fine(String.format("%s (%s) has been approved as %s.", path, hash, approvedClasspaths.get(hash)));
}

/**
* To be used from form validation, in a {@code doCheckFieldName} method.
* @param script a possibly unapproved script
Expand Down Expand Up @@ -460,4 +666,13 @@ public Set<PendingSignature> getPendingSignatures() {
return Jenkins.getInstance().getExtensionList(Whitelist.class).get(ApprovedWhitelist.class).reconfigure();
}

@Restricted(NoExternalUse.class) // for use from Jelly
public Map<String, String> getApprovedClasspaths() {
return approvedClasspaths;
}

@Restricted(NoExternalUse.class) // for use from Jelly
public Set<PendingClasspath> getPendingClasspaths() {
return pendingClasspaths;
}
}

0 comments on commit f02fb0d

Please sign in to comment.