diff --git a/README.md b/README.md index c24aa5bcc..f1668fad5 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ StaticWhitelist.from and loading a text file listing whitelisted methods. ### Classpath for evaluating scripts When constructing a GroovyShell to evaluate a script, or calling -`ecureGroovyScript.evaluate`, you must pass a `ClassLoader` which represents the effective +`secureGroovyScript.evaluate`, you must pass a `ClassLoader` which represents the effective classpath for the script. You could use the loader of Jenkins core, or your plugin, or `Jenkins.getInstance().getPluginManager().uberClassLoader`. diff --git a/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ApprovalContext.java b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ApprovalContext.java index 33abb9411..a398148df 100644 --- a/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ApprovalContext.java +++ b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ApprovalContext.java @@ -31,6 +31,8 @@ import jenkins.model.Jenkins; import org.kohsuke.stapler.DataBoundConstructor; +import java.util.Objects; + /** * Represents background information about who requested that a script or signature be approved and for what purpose. * When created from a thread that generally carries authentication, such as within a {@link DataBoundConstructor}, be sure to use {@link #withCurrentUser}. @@ -125,4 +127,22 @@ public ApprovalContext withItemAsKey(@CheckForNull Item item) { return new ApprovalContext(user, n, n); } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ApprovalContext that = (ApprovalContext) o; + return Objects.equals(user, that.user) && + Objects.equals(item, that.item) && + Objects.equals(key, that.key); + } + + @Override + public int hashCode() { + return Objects.hash(user, item, key); + } } diff --git a/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval.java b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval.java index 5bce32055..c2bbd8f58 100644 --- a/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval.java +++ b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval.java @@ -24,10 +24,14 @@ package org.jenkinsci.plugins.scriptsecurity.scripts; +import com.google.common.annotations.VisibleForTesting; +import hudson.security.ACLContext; +import hudson.util.HttpResponses; import jenkins.model.GlobalConfiguration; import jenkins.model.GlobalConfigurationCategory; import net.sf.json.JSONArray; import net.sf.json.JSONObject; +import org.apache.commons.lang.StringUtils; import org.jenkinsci.Symbol; import org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.AclAwareWhitelist; import org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.ProxyWhitelist; @@ -54,9 +58,9 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; @@ -66,17 +70,30 @@ import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Stream; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; +import javax.annotation.concurrent.GuardedBy; +import javax.servlet.ServletException; + import jenkins.model.Jenkins; import net.sf.json.JSON; -import org.acegisecurity.context.SecurityContext; -import org.acegisecurity.context.SecurityContextHolder; +import org.jenkinsci.plugins.scriptsecurity.scripts.languages.LanguageHelper; +import org.jenkinsci.plugins.scriptsecurity.scripts.metadata.FullScriptMetadata; +import org.jenkinsci.plugins.scriptsecurity.scripts.metadata.HashAndFullScriptMetadata; +import org.jenkinsci.plugins.scriptsecurity.scripts.metadata.MetadataStorage; +import org.jvnet.localizer.Localizable; import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.DoNotUse; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.bind.JavaScriptMethod; +import org.kohsuke.stapler.interceptor.RequirePOST; +import org.kohsuke.stapler.json.JsonBody; /** * Manages approved scripts. @@ -101,6 +118,9 @@ public class ScriptApproval extends GlobalConfiguration implements RootAction { XSTREAM2.alias("pendingClasspathEntry", PendingClasspathEntry.class); } + public static final String METADATA_GATHERING_PROP_NAME = ScriptApproval.class.getName() + ".metadataGathering"; + public static /* final */ boolean METADATA_GATHERING = Boolean.parseBoolean(System.getProperty(METADATA_GATHERING_PROP_NAME, "true")); + @Override protected XmlFile getConfigFile() { return new XmlFile(XSTREAM2, new File(Jenkins.getInstance().getRootDir(),getUrlName() + ".xml")); @@ -120,6 +140,113 @@ public GlobalConfigurationCategory getCategory() { return instance; } + // Used by Jelly + @Restricted(NoExternalUse.class) + public static final class Tab { + /** + * HTML safe + */ + public final String parameterName; + /** + * No path traversal possible + */ + public final String viewName; + + private final Localizable i18nCode; + + private Tab(String parameterName, String viewName, Localizable i18nCode) { + this.parameterName = parameterName; + this.viewName = viewName; + this.i18nCode = i18nCode; + } + + // used by Jelly + public String getI18nName() { + return this.i18nCode.toString(); + } + } + + private static final Tab FULL_SCRIPT_PENDING = new Tab("fullScriptPending", "fullScript_pending.jelly", Messages._ScriptApproval_tab_fullScriptPending()); + private static final Tab FULL_SCRIPT_APPROVED = new Tab("fullScriptApproved", "fullScript_approved.jelly", Messages._ScriptApproval_tab_fullScriptApproved()); + private static final Tab SIGNATURE_PENDING = new Tab("signaturePending", "signature_pending.jelly", Messages._ScriptApproval_tab_signaturePending()); + private static final Tab SIGNATURE_APPROVED = new Tab("signatureApproved", "signature_approved.jelly", Messages._ScriptApproval_tab_signatureApproved()); + private static final Tab CLASS_PATH_PENDING = new Tab("classPathPending", "classPath_pending.jelly", Messages._ScriptApproval_tab_classPathPending()); + private static final Tab CLASS_PATH_APPROVED = new Tab("classPathApproval", "classPath_approved.jelly", Messages._ScriptApproval_tab_classPathApproved()); + + private static final Tab[] ALL_TABS = new Tab[]{ + FULL_SCRIPT_PENDING, FULL_SCRIPT_APPROVED, + SIGNATURE_PENDING, SIGNATURE_APPROVED, + CLASS_PATH_PENDING, CLASS_PATH_APPROVED + }; + + private static final int FULL_SCRIPT_PENDING_INDEX = 0; + private static final int FULL_SCRIPT_APPROVED_INDEX = 1; + private static final int SIGNATURE_PENDING_INDEX = 2; + private static final int SIGNATURE_APPROVED_INDEX = 3; + private static final int CLASS_PATH_PENDING_INDEX = 4; + private static final int CLASS_PATH_APPROVED_INDEX = 5; + + // Used by Jelly + @Restricted(NoExternalUse.class) + public static final class TabInfo { + public Tab tab; + public int numOfNotification; + public boolean primaryColor; + public boolean active; + + private TabInfo(Tab tab) { + this.tab = tab; + } + } + + @Restricted(DoNotUse.class) // Web only + public void doIndex(StaplerRequest req, StaplerResponse rsp, @QueryParameter("tab") String tab) throws IOException, ServletException { + Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS); + + TabInfo[] tabInfos = new TabInfo[ALL_TABS.length]; + Tab activeTab = null; + for (int i = 0; i < ALL_TABS.length; i++) { + Tab t = ALL_TABS[i]; + TabInfo info = new TabInfo(t); + if (t.parameterName.equals(tab)) { + activeTab = t; + info.active = true; + } + tabInfos[i] = info; + } + + if (activeTab == null) { + if (!StringUtils.isBlank(tab)) { + LOG.log(Level.FINER, "Invalid tab name received: {0}, redirecting to default one", tab); + } + for (int i = 0; i < ALL_TABS.length && activeTab == null; i++) { + if (tabInfos[i].numOfNotification > 0) { + tabInfos[i].active = true; + activeTab = tabInfos[i].tab; + } + } + if (activeTab == null) { + tabInfos[0].active = true; + activeTab = tabInfos[0].tab; + } + } + + tabInfos[FULL_SCRIPT_PENDING_INDEX].numOfNotification = pendingScripts.size(); + tabInfos[FULL_SCRIPT_PENDING_INDEX].primaryColor = true; + + tabInfos[SIGNATURE_PENDING_INDEX].numOfNotification = pendingSignatures.size(); + tabInfos[SIGNATURE_PENDING_INDEX].primaryColor = true; + + tabInfos[CLASS_PATH_PENDING_INDEX].numOfNotification = pendingClasspathEntries.size(); + tabInfos[CLASS_PATH_PENDING_INDEX].primaryColor = true; + + // will be injected as a variable inside Jelly view + req.setAttribute("activeTab", activeTab); + req.setAttribute("tabInfos", tabInfos); + + req.getView(this, "index.jelly").forward(req, rsp); + } + /** * Approved classpath entry. * @@ -165,6 +292,7 @@ boolean isClassDirectory() { } /** All scripts which are already approved, via {@link #hash}. */ + @GuardedBy("this") private final TreeSet approvedScriptHashes = new TreeSet(); /** All sandbox signatures which are already whitelisted, in {@link StaticWhitelist} format. */ @@ -180,6 +308,21 @@ boolean isClassDirectory() { approvedClasspathEntries.add(acp); } + @Restricted(NoExternalUse.class) // for use from Jelly + public synchronized List getApprovedFullScriptMetadata() { + List result = metadataStorage.getMetadataUsingHashes(this.approvedScriptHashes); + + // last used entries at the top + // then last approved entries + result.sort( + Comparator.comparingLong((HashAndFullScriptMetadata item) -> -item.metadata.getLastTimeUsed()) + .thenComparing((HashAndFullScriptMetadata item) -> -item.metadata.getLastApprovalTime()) + .thenComparing((HashAndFullScriptMetadata item) -> item.hash) + ); + + return result; + } + @Restricted(NoExternalUse.class) // for use from Jelly public static abstract class PendingThing { @@ -188,14 +331,29 @@ public static abstract class PendingThing { private @Nonnull ApprovalContext context; + private long approvalRequestTime; + PendingThing(@Nonnull ApprovalContext context) { this.context = context; + this.approvalRequestTime = new Date().getTime(); } public @Nonnull ApprovalContext getContext() { return context; } + public long getApprovalRequestTime() { + return approvalRequestTime; + } + + public @CheckForNull Date getApprovalRequestTimeDate() { + if (approvalRequestTime <= 0) { + // for legacy + return null; + } + return new Date(approvalRequestTime); + } + private Object readResolve() { if (user != null) { context = ApprovalContext.create().withUser(user); @@ -219,20 +377,16 @@ public String getHash() { return hash(script, language); } public Language getLanguage() { - for (Language l : ExtensionList.lookup(Language.class)) { - if (l.getName().equals(language)) { - return l; - } - } - return new Language() { - @Override public String getName() { - return language; - } - @Override public String getDisplayName() { - return ""; - } - }; + return LanguageHelper.getLanguageFromName(language); + } + + /** + * Prevent the transformation to Language if the name is sufficient + */ + public String getLanguageName() { + return this.language; } + @Override public int hashCode() { return script.hashCode() ^ language.hashCode(); } @@ -340,9 +494,13 @@ private PendingClasspathEntry getPendingClasspathEntry(@Nonnull String hash) { pendingClasspathEntries.add(pcp); } + @GuardedBy("this") + @VisibleForTesting transient MetadataStorage metadataStorage; + @DataBoundConstructor public ScriptApproval() { load(); + this.metadataStorage = new MetadataStorage("scriptApproval/scripts"); /* can be null when upgraded from old versions.*/ if (aclApprovedSignatures == null) { aclApprovedSignatures = new TreeSet(); @@ -367,14 +525,14 @@ public ScriptApproval() { } /** Nothing has ever been approved or is pending. */ - boolean isEmpty() { + synchronized boolean isEmpty() { return approvedScriptHashes.isEmpty() && - approvedSignatures.isEmpty() && - aclApprovedSignatures.isEmpty() && - approvedClasspathEntries.isEmpty() && - pendingScripts.isEmpty() && - pendingSignatures.isEmpty() && - pendingClasspathEntries.isEmpty(); + approvedSignatures.isEmpty() && + aclApprovedSignatures.isEmpty() && + approvedClasspathEntries.isEmpty() && + pendingScripts.isEmpty() && + pendingSignatures.isEmpty() && + pendingClasspathEntries.isEmpty(); } private static String hash(String script, String language) { @@ -438,6 +596,12 @@ public synchronized String configuring(@Nonnull String script, @Nonnull Language if (!approvedScriptHashes.contains(hash)) { if (!Jenkins.getInstance().isUseSecurity() || Jenkins.getAuthentication() != ACL.SYSTEM && Jenkins.getInstance().hasPermission(Jenkins.RUN_SCRIPTS)) { approvedScriptHashes.add(hash); + + if (METADATA_GATHERING) { + this.metadataStorage.withMetadata(hash, script, metadata -> + metadata.notifyApprovalDuringConfiguring(script, language, context) + ); + } } else { String key = context.getKey(); if (key != null) { @@ -465,7 +629,7 @@ public synchronized String configuring(@Nonnull String script, @Nonnull Language public synchronized String using(@Nonnull String script, @Nonnull Language language) throws UnapprovedUsageException { if (script.length() == 0) { // As a special case, always consider the empty script preapproved, as this is usually the default for new fields, - // and in many cases there is some sensible behavior for an emoty script which we want to permit. + // and in many cases there is some sensible behavior for an empty script which we want to permit. return script; } String hash = hash(script, language.getName()); @@ -473,6 +637,13 @@ public synchronized String using(@Nonnull String script, @Nonnull Language langu // Probably need not add to pendingScripts, since generally that would have happened already in configuring. throw new UnapprovedUsageException(hash); } + + if (METADATA_GATHERING) { + this.metadataStorage.withMetadata(hash, script, metadata -> + metadata.notifyUsage(script, language) + ); + } + return script; } @@ -603,13 +774,25 @@ public synchronized FormValidation checking(@Nonnull String script, @Nonnull Lan /** * Unconditionally approve a script. * Does no access checks and does not automatically save changes to disk. - * Useful mainly for testing. + * Useful mainly for testing. + * 2020-04-17, this method is used only in tests, except for CommandLauncher constructor + * * @param script the text of a possibly novel script * @param language the language in which it is written * @return {@code script}, for convenience */ public synchronized String preapprove(@Nonnull String script, @Nonnull Language language) { - approvedScriptHashes.add(hash(script, language.getName())); + String hash = hash(script, language.getName()); + approvedScriptHashes.add(hash); + + if (METADATA_GATHERING) { + String userLogin = this.getCurrentUserLogin(); + + this.metadataStorage.withMetadata(hash, script, metadata -> + metadata.notifyPreapproveSingle(script, language, userLogin) + ); + } + return script; } @@ -617,10 +800,20 @@ public synchronized String preapprove(@Nonnull String script, @Nonnull Language * Unconditionally approves all pending scripts. * Does no access checks and does not automatically save changes to disk. * Useful mainly for testing in combination with {@code @LocalData}. + *

+ * 2020-04-17, this method is used only in tests. */ public synchronized void preapproveAll() { + String userLogin = this.getCurrentUserLogin(); + for (PendingScript ps : pendingScripts) { - approvedScriptHashes.add(ps.getHash()); + String hash = ps.getHash(); + approvedScriptHashes.add(hash); + if (METADATA_GATHERING) { + this.metadataStorage.withMetadata(hash, ps.script, metadata -> + metadata.notifyPreapproveAll(ps, userLogin) + ); + } } pendingScripts.clear(); } @@ -728,47 +921,192 @@ String[][] reconfigure() throws IOException { return "scriptApproval"; } + @Override + public @Nonnull String getDisplayName() { + return "Script Approval"; + } + + @Deprecated @Restricted(NoExternalUse.class) // for use from Jelly public Set getPendingScripts() { return pendingScripts; } - @Restricted(NoExternalUse.class) // for use from AJAX - @JavaScriptMethod public void approveScript(String hash) throws IOException { + @Restricted(NoExternalUse.class) // for use from Jelly + public synchronized List getPendingScriptsSorted() { + List result = new ArrayList<>(pendingScripts.size()); + result.addAll(this.pendingScripts); + result.sort( + Comparator.comparingLong((PendingScript pendingScript) -> -pendingScript.getApprovalRequestTime()) + .thenComparing(PendingScript::getHash) + ); + return result; + } + + // @JavaScriptMethod, no longer accessible by JavaScript but still kept for compatibility + @VisibleForTesting + @Restricted(NoExternalUse.class) + public void approveScript(String hash) { Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS); + + String userLogin = this.getCurrentUserLogin(); + synchronized (this) { approvedScriptHashes.add(hash); - removePendingScript(hash); + PendingScript pendingScript = removePendingScript(hash); + + if (METADATA_GATHERING) { + String script = pendingScript == null ? null : pendingScript.script; + this.metadataStorage.withMetadata(hash, script, metadata -> + metadata.notifyApproval(pendingScript, userLogin) + ); + } + save(); } - SecurityContext orig = ACL.impersonate(ACL.SYSTEM); - try { + + try (ACLContext unused = ACL.as(ACL.SYSTEM)) { for (ApprovalListener listener : ExtensionList.lookup(ApprovalListener.class)) { listener.onApproved(hash); } - } finally { - SecurityContextHolder.setContext(orig); } } - @Restricted(NoExternalUse.class) // for use from AJAX - @JavaScriptMethod public synchronized void denyScript(String hash) throws IOException { + // @JavaScriptMethod, no longer accessible by JavaScript but still kept for compatibility + @VisibleForTesting + @Restricted(NoExternalUse.class) + synchronized void denyScript(String hash) { Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS); approvedScriptHashes.remove(hash); removePendingScript(hash); save(); } - private synchronized void removePendingScript(String hash) { + @RequirePOST + @Restricted(NoExternalUse.class) + public void doScriptContent(StaplerRequest req, StaplerResponse rsp, @QueryParameter(required = true) String hash) throws Exception { + Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS); + + synchronized (this) { + FullScriptMetadata metadata = this.metadataStorage.getExisting(hash); + if (metadata == null) { + // this can occur naturally only if you have concurrent views of the scriptApproval page + LOG.log(Level.FINER, "Requesting a non-existing metadata {0}", hash); + throw HttpResponses.notFound(); + } + Language language = metadata.getLanguage(); + if (language != null && metadata.getScriptLength() > 0) { + String script = this.metadataStorage.readScript(hash); + if (script != null) { + req.setAttribute("script", script); + req.setAttribute("languageCodeMirrorMode", language.getCodeMirrorMode()); + req.getView(this, "tabs/_scriptContent.jelly").forward(req, rsp); + return; + } else { + // expected for legacy approved scripts (until first usage) + LOG.log(Level.FINER, "Requesting a non-existing script {0}", hash); + } + } + throw HttpResponses.notFound(); + } + } + + @Restricted(NoExternalUse.class) + public static final class AllSelectedHashesModel { + public String[] hashes; + } + + @RequirePOST + @Restricted(NoExternalUse.class) + public void doApprovePendingScripts(@JsonBody AllSelectedHashesModel content) { + Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS); + LOG.log(Level.FINE, "Approval granted for selected {0}", content.hashes); + + synchronized (this) { + String userLogin = this.getCurrentUserLogin(); + + for (String hash : content.hashes) { + approvedScriptHashes.add(hash); + PendingScript pendingScript = removePendingScript(hash); + + if (METADATA_GATHERING) { + String script = pendingScript == null ? null : pendingScript.script; + this.metadataStorage.withMetadata(hash, script, metadata -> + metadata.notifyApproval(pendingScript, userLogin) + ); + } + } + + save(); + } + + try (ACLContext unused = ACL.as(ACL.SYSTEM)) { + for (ApprovalListener listener : ExtensionList.lookup(ApprovalListener.class)) { + for (String hash : content.hashes) { + listener.onApproved(hash); + } + } + } + } + + /** + * Called on pending scripts + */ + @RequirePOST + @Restricted(NoExternalUse.class) + public void doDenyPendingScripts(@JsonBody AllSelectedHashesModel content) { + Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS); + LOG.log(Level.FINE, "Deny pending scripts for selected {0}", content.hashes); + // at this point, no such script should be approved + synchronized (this) { + for (String hash : content.hashes) { + approvedScriptHashes.remove(hash); + removePendingScript(hash); + } + + save(); + } + } + + @RequirePOST + @Restricted(NoExternalUse.class) + public synchronized void doRevokeApprovalForScripts(@JsonBody AllSelectedHashesModel content) { + Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS); + LOG.log(Level.FINE, "Approval revocation for selected {0}", content.hashes); + + for (String hash : content.hashes) { + approvedScriptHashes.remove(hash); + // at this point, no such script should be pending + removePendingScript(hash); + + if (METADATA_GATHERING) { + this.metadataStorage.removeHash(hash); + } + } + + save(); + } + + /** + * @return the script corresponding to the hash otherwise {@code null} + */ + private synchronized @CheckForNull PendingScript removePendingScript(String hash) { Iterator it = pendingScripts.iterator(); while (it.hasNext()) { - if (it.next().getHash().equals(hash)) { + PendingScript curr = it.next(); + if (curr.getHash().equals(hash)) { it.remove(); - break; + return curr; } } + + return null; } + /** + * @deprecated use {@link #doRevokeApprovalForScripts(AllSelectedHashesModel)} instead for better granularity + */ + @Deprecated @Restricted(NoExternalUse.class) // for use from AJAX @JavaScriptMethod public synchronized void clearApprovedScripts() throws IOException { Jenkins.getInstance().checkPermission(Jenkins.RUN_SCRIPTS); @@ -900,13 +1238,10 @@ public JSON approveClasspathEntry(String hash) throws IOException { } } if (url != null) { - SecurityContext orig = ACL.impersonate(ACL.SYSTEM); - try { + try (ACLContext unused = ACL.as(ACL.SYSTEM)) { for (ApprovalListener listener : ExtensionList.lookup(ApprovalListener.class)) { listener.onApprovedClasspathEntry(hash, url); } - } finally { - SecurityContextHolder.setContext(orig); } } return getClasspathRenderInfo(); @@ -943,4 +1278,20 @@ public synchronized JSON clearApprovedClasspathEntries() throws IOException { return getClasspathRenderInfo(); } + /** + * To have an effectively final variable + */ + private @CheckForNull String getCurrentUserLogin() { + // used to remove completely the dependency on Acegi Security + return Stream.of(Jenkins.getAuthentication()) + .filter(auth -> !auth.equals(Jenkins.ANONYMOUS)) + .findFirst() + .map(auth -> auth.getName()) + .orElse(null); + } + + @Restricted(NoExternalUse.class) // for jelly only + public boolean isMetadataGatheringEnabled() { + return METADATA_GATHERING; + } } diff --git a/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/languages/LanguageHelper.java b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/languages/LanguageHelper.java new file mode 100644 index 000000000..acb9e377d --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/languages/LanguageHelper.java @@ -0,0 +1,61 @@ +/* + * The MIT License + * + * Copyright (c) 2020, 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 org.jenkinsci.plugins.scriptsecurity.scripts.languages; + +import hudson.ExtensionList; +import org.jenkinsci.plugins.scriptsecurity.scripts.Language; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.annotation.Nonnull; + +@Restricted(NoExternalUse.class) +public final class LanguageHelper { + public static @Nonnull Language getLanguageFromName(@Nonnull String languageName) { + for (Language l : ExtensionList.lookup(Language.class)) { + if (l.getName().equals(languageName)) { + return l; + } + } + return new UnknownLanguage(languageName); + } + + private static class UnknownLanguage extends Language { + private final String languageName; + + private UnknownLanguage(String languageName){ + this.languageName = languageName; + } + + @Override + public String getName() { + return languageName; + } + + @Override + public String getDisplayName() { + return ""; + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/FullScriptMetadata.java b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/FullScriptMetadata.java new file mode 100644 index 000000000..99cec5107 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/FullScriptMetadata.java @@ -0,0 +1,230 @@ +/* + * The MIT License + * + * Copyright (c) 2020, 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 org.jenkinsci.plugins.scriptsecurity.scripts.metadata; + +import org.jenkinsci.plugins.scriptsecurity.scripts.ApprovalContext; +import org.jenkinsci.plugins.scriptsecurity.scripts.Language; +import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval; +import org.jenkinsci.plugins.scriptsecurity.scripts.languages.LanguageHelper; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashSet; + +@Restricted(NoExternalUse.class) +public class FullScriptMetadata { + + public static final FullScriptMetadata EMPTY = new FullScriptMetadata(true); + + /** + * In characters + */ + private int scriptLength = -1; + private String languageName; + + private int usageCount = 0; + private long lastTimeUsed = -1; + + private long lastApprovalTime = -1; + private boolean wasPreapproved; + + /** + * Only used for the cases where the metadata is missing (legacy or disabled) + */ + private boolean empty = false; + + /** + * When the approver authentication was passed in the context. + * Not necessarily useful for common usage but the difference with {@link #lastApprovalTime} could be valuable for deeper investigation. + */ + private long lastKnownApprovalTime = -1; + private String lastKnownApproverLogin; + + private ApprovalContext lastContext; + private HashSet contextList = new LinkedHashSet<>(); + + public FullScriptMetadata() { + } + + private FullScriptMetadata(boolean empty) { + this.empty = true; + } + + /** + * Called during the configuration of a script + */ + public void notifyApprovalDuringConfiguring(@Nonnull String script, @Nonnull Language language, @Nonnull ApprovalContext context) { + this.updateScriptAndLanguage(script, language.getName()); + + this.updateContext(context); + + long now = new Date().getTime(); + this.lastApprovalTime = now; + + String user = context.getUser(); + if (user != null) { + this.lastKnownApproverLogin = user; + this.lastKnownApprovalTime = now; + } + // as it's approved during the configuration it means that was done by an admin + this.wasPreapproved = true; + } + + /** + * Called when someone/something uses the approved script + */ + public void notifyUsage(@Nonnull String script, @Nonnull Language language) { + this.usageCount++; + this.lastTimeUsed = new Date().getTime(); + + this.updateScriptAndLanguage(script, language.getName()); + } + + /** + * Called when a plugin decides to preapprove a script. + * It's not necessarily a reaction to a user interaction. + */ + public void notifyPreapproveSingle(@Nonnull String script, @Nonnull Language language, @CheckForNull String user) { + long now = new Date().getTime(); + this.lastApprovalTime = now; + + this.updateScriptAndLanguage(script, language.getName()); + + if (user != null) { + this.lastKnownApproverLogin = user; + this.lastKnownApprovalTime = now; + } + + this.wasPreapproved = true; + } + + /** + * Called when a plugin decides to preapprove a script. + * It's not necessarily a reaction to a user interaction. + * + * Expected to come only from test code, not production code. + */ + public void notifyPreapproveAll(@Nonnull ScriptApproval.PendingScript pendingScript, @CheckForNull String approverLogin) { + long now = new Date().getTime(); + this.lastApprovalTime = now; + + this.updateScriptAndLanguage(pendingScript.script, pendingScript.getLanguageName()); + + ApprovalContext context = pendingScript.getContext(); + this.updateContext(context); + + if (approverLogin != null) { + this.lastKnownApproverLogin = approverLogin; + this.lastKnownApprovalTime = now; + } + + this.wasPreapproved = true; + } + + /** + * Called when a script was approved from the ScriptSecurity page + */ + public void notifyApproval(@CheckForNull ScriptApproval.PendingScript pendingScript, @CheckForNull String approverLogin) { + long now = new Date().getTime(); + if (pendingScript != null) { + this.updateScriptAndLanguage(pendingScript.script, pendingScript.getLanguageName()); + + ApprovalContext context = pendingScript.getContext(); + this.updateContext(context); + + if (approverLogin != null) { + this.lastKnownApproverLogin = approverLogin; + this.lastKnownApprovalTime = now; + } + } + this.lastApprovalTime = now; + } + + private void updateScriptAndLanguage(@Nonnull String script, @Nonnull String languageName) { + this.scriptLength = script.length(); + this.languageName = languageName; + } + + private void updateContext(@Nonnull ApprovalContext context) { + this.contextList.add(context); + this.lastContext = context; + } + + public boolean isEmpty() { + return empty; + } + + public int getUsageCount() { + return usageCount; + } + + public @CheckForNull Date getLastTimeUsedDate() { + if (lastTimeUsed == -1) { + return null; + } + return new Date(lastTimeUsed); + } + + public long getLastTimeUsed() { + return lastTimeUsed; + } + + public boolean isWasPreapproved() { + return wasPreapproved; + } + + public @CheckForNull Date getLastApprovalTimeDate() { + if (lastApprovalTime == -1) { + return null; + } + return new Date(lastApprovalTime); + } + + public long getLastApprovalTime() { + return lastApprovalTime; + } + + public @CheckForNull String getLastKnownApproverLogin() { + return lastKnownApproverLogin; + } + + public @CheckForNull Language getLanguage() { + if (languageName == null) { + return null; + } + return LanguageHelper.getLanguageFromName(languageName); + } + + public @CheckForNull ApprovalContext getLastContext() { + return lastContext; + } + + public int getScriptLength() { + return scriptLength; + } +} diff --git a/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/HashAndFullScriptMetadata.java b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/HashAndFullScriptMetadata.java new file mode 100644 index 000000000..ec8a803ae --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/HashAndFullScriptMetadata.java @@ -0,0 +1,40 @@ +/* + * The MIT License + * + * Copyright (c) 2020, 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 org.jenkinsci.plugins.scriptsecurity.scripts.metadata; + +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.annotation.Nonnull; + +@Restricted(NoExternalUse.class) +public class HashAndFullScriptMetadata { + public final String hash; + public final FullScriptMetadata metadata; + + public HashAndFullScriptMetadata(@Nonnull String hash, @Nonnull FullScriptMetadata metadata) { + this.hash = hash; + this.metadata = metadata; + } +} diff --git a/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/MetadataStorage.java b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/MetadataStorage.java new file mode 100644 index 000000000..4135ba97e --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/scriptsecurity/scripts/metadata/MetadataStorage.java @@ -0,0 +1,232 @@ +/* + * The MIT License + * + * Copyright (c) 2020, 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 org.jenkinsci.plugins.scriptsecurity.scripts.metadata; + +import hudson.XmlFile; +import jenkins.model.Jenkins; +import org.apache.commons.io.FileUtils; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +@Restricted(NoExternalUse.class) +public class MetadataStorage { + private static final Logger LOGGER = Logger.getLogger(MetadataStorage.class.getName()); + /** + * Currently only lowercase hex characters are necessary but we will need additional characters + * to support SHA-256 or other, in order to support migration. + * + * This prevents path traversal attempts + */ + private static final Pattern HASH_REGEX = Pattern.compile("[a-zA-Z0-9_\\-]+"); + private static final String METADATA_FILE_NAME = "metadata.xml"; + private static final String SCRIPT_FILE_NAME = "script.txt"; + + private final File metadataFolder; + + /** + * Metadata about full scripts. + * They can exist before approval and remain after revocation. + */ + private HashMap hashToMetadata; + + public MetadataStorage(@Nonnull String metadataFolderPath) { + this.metadataFolder = new File(Jenkins.getInstance().getRootDir(), metadataFolderPath); + if (metadataFolder.mkdirs()) { + LOGGER.log(Level.FINER, "Metadata storage folder created: {0}", metadataFolder.getAbsolutePath()); + } + } + + public @Nonnull List getMetadataUsingHashes(@Nonnull Collection approvedScriptHashes) { + this.ensureLoaded(); + List result = new ArrayList<>(approvedScriptHashes.size()); + + for (String hash : approvedScriptHashes) { + FullScriptMetadata metadata = hashToMetadata.getOrDefault(hash, FullScriptMetadata.EMPTY); + HashAndFullScriptMetadata hashAndMeta = new HashAndFullScriptMetadata(hash, metadata); + result.add(hashAndMeta); + } + return result; + } + + /** + * Lazy loading to avoid slowing down the startup of the instance + */ + private void ensureLoaded() { + if (hashToMetadata != null) { + return; + } + + LOGGER.log(Level.INFO, "Loading script approval metadata..."); + long startTime = System.currentTimeMillis(); + + loadAllMetadata(); + + long endTime = System.currentTimeMillis(); + LOGGER.log(Level.INFO, "All metadata loaded in {0} ms", endTime - startTime); + } + + /** + * This method could be called by Script Console to reload the metadata + * + * org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval.get().metadataStorage.loadAllMetadata() + */ + private void loadAllMetadata() { + hashToMetadata = new HashMap<>(); + String[] hashes = metadataFolder.list(); + for (int i = 0; i < hashes.length; i++) { + String hash = hashes[i]; + File metadataFile = new File(metadataFolder, hash + File.separator + METADATA_FILE_NAME); + if (metadataFile.exists()) { + XmlFile xmlFile = new XmlFile(metadataFile); + try { + Object content = xmlFile.read(); + if (content instanceof FullScriptMetadata) { + FullScriptMetadata metadata = (FullScriptMetadata) content; + this.hashToMetadata.put(hash, metadata); + } else { + LOGGER.log(Level.WARNING, "Invalid class read for the metadata for hash {0}, found: {1}", new Object[]{hash, content.getClass()}); + } + } catch (IOException e) { + LOGGER.log(Level.INFO, "Impossible to read the metadata for hash {0}.", hash); + } + } + } + } + + public @CheckForNull FullScriptMetadata getExisting(@Nonnull String hash) { + ensureLoaded(); + return hashToMetadata.get(hash); + } + + public void withMetadata(@Nonnull String hash, @CheckForNull String script, @Nonnull Consumer consumer) { + ensureLoaded(); + ensureValidHash(hash); + + FullScriptMetadata metadata = hashToMetadata.get(hash); + if (metadata == null) { + metadata = new FullScriptMetadata(); + hashToMetadata.put(hash, metadata); + } + + consumer.accept(metadata); + + saveMetadata(hash, metadata); + if (script != null) { + saveScript(hash, script); + } + } + + public void removeHash(@Nonnull String hash) { + ensureLoaded(); + ensureValidHash(hash); + FullScriptMetadata metadata = hashToMetadata.remove(hash); + if (metadata == null) { + return; + } + + deleteHashFolder(hash); + } + + private void saveMetadata(@Nonnull String hash, @Nonnull FullScriptMetadata metadata) { + File hashFolder = new File(metadataFolder, hash); + if (hashFolder.mkdirs()) { + LOGGER.log(Level.FINER, "Metadata folder created for hash {0}", hash); + } + + XmlFile xmlFile = new XmlFile(new File(hashFolder, METADATA_FILE_NAME)); + try { + xmlFile.write(metadata); + } catch (IOException e) { + LOGGER.log(Level.WARNING, e, () -> "Failed to save the metadata for hash: " + hash); + } + } + + private void deleteHashFolder(@Nonnull String hash) { + File targetFolder = new File(metadataFolder, hash); + + try { + boolean existed = targetFolder.exists(); + FileUtils.deleteDirectory(targetFolder); + if (existed) { + LOGGER.log(Level.FINER, "Metadata related to {0} removed", hash); + } else { + LOGGER.log(Level.FINER, "Metadata related to {0} did not exist", hash); + } + } catch (IOException e) { + LOGGER.log(Level.FINE, "Impossible to delete the metadata folder found for {0}", hash); + } + } + + private void ensureValidHash(@Nonnull String hash) { + if (!HASH_REGEX.matcher(hash).matches()) { + throw new IllegalArgumentException("The provided hash is invalid: " + hash); + } + } + + /** + * Assume the {@link #saveMetadata(String, FullScriptMetadata)} is called first to create the parent folder + */ + private void saveScript(@Nonnull String hash, @Nonnull String script) { + File file = new File(metadataFolder, hash + File.separator + SCRIPT_FILE_NAME); + try { + FileUtils.write(file, script, StandardCharsets.UTF_8); + } catch (IOException e) { + LOGGER.log(Level.WARNING, e, () -> "Failed to save the script for hash: " + hash); + } + } + + /** + * Read the script if the file exists + */ + public @CheckForNull String readScript(@Nonnull String hash) { + ensureLoaded(); + ensureValidHash(hash); + + File file = new File(metadataFolder, hash + File.separator + SCRIPT_FILE_NAME); + if (!file.exists()) { + return null; + } + try { + String script = FileUtils.readFileToString(file, StandardCharsets.UTF_8); + return script; + } catch (IOException e) { + LOGGER.log(Level.WARNING, e, () -> "Failed to save the script for hash: " + hash); + } + return null; + } +} diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/Messages.properties b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/Messages.properties index 522fb4e9e..043dcda81 100644 --- a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/Messages.properties +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/Messages.properties @@ -2,3 +2,10 @@ ClasspathEntry.path.notExists=Specified path does not exist ClasspathEntry.path.notApproved=This classpath entry is not approved. Require an approval before execution. ClasspathEntry.path.noDirsAllowed=Class directories are not allowed as classpath entries. ScriptApprovalNote.message=Administrators can decide whether to approve or reject this signature. + +ScriptApproval.tab.fullScriptPending=Scripts - pending approval +ScriptApproval.tab.fullScriptApproved=Scripts - approved +ScriptApproval.tab.signaturePending=Signatures - pending approval +ScriptApproval.tab.signatureApproved=Signatures - approved +ScriptApproval.tab.classPathPending=Classpaths - pending approval +ScriptApproval.tab.classPathApproved=Classpaths - approved diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/_resources.css b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/_resources.css new file mode 100644 index 000000000..58607d187 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/_resources.css @@ -0,0 +1,105 @@ +/* TODO using a plugin-specific style until the core contains such style and this plugin depends on it */ +/* copied from Bootstrap 4 style */ +.script-approval-page .custom-badge { + display: inline-block; + padding: .25em .4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25rem; + transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out; +} + +.script-approval-page .custom-badge-primary { + color: #fff; + background-color: #007bff; +} + +.script-approval-page .custom-badge-standard { + color: #fff; + background-color: #6c757d; +} + +.script-approval-page h2 ~ h2 { + margin-top: 35px; +} + +.script-approval-page table th { + text-align: left; +} + +.script-approval-page tr.selected { + background-color: #f9f8de; +} + +.script-approval-page td.hash { + font-family: monospace; +} + +.script-approval-page tr td.hidden-by-default { + display: none; +} +.script-approval-page .toggle-parent { + display: inline-block; + font-size: 14px; + font-weight: normal; +} + +.script-approval-page .hidden-element { + display: none; +} + +/* + Hack over the Jenkins textarea.js that computes the textarea height to force CodeMirror + but it has a different line-height (at least) and thus, the height becomes too big for it. +*/ +.script-approval-page textarea { + font-family: monospace; + font-size: 14px; + line-height: 14px; +} + +.script-approval-page .no-pending-script-approvals, +.script-approval-page .no-approved-scripts { + margin: 12px; + font-style: italic; +} + +.script-approval-page .info-cursor { + cursor: help; +} + +.script-approval-page .action-panel { + margin-top: 12px; +} + +.script-approval-page .action-panel button { + margin-top: 5px; + margin-right: 8px; +} + +.script-approval-page .action-link { + text-decoration: none; + color: #204A87; + cursor: pointer; +} + +.script-approval-page .action-link:hover, +.script-approval-page .action-link:focus { + text-decoration: underline; +} + +.script-approval-page .action-link:visited { + /* avoid visited behavior */ + color: #204A87; +} +.script-approval-page .add-margin { + margin: 5px; +} + +.script-approval-page .space-before { + margin-top: 12px; +} diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/_resources.js b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/_resources.js new file mode 100644 index 000000000..dbc41488d --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/_resources.js @@ -0,0 +1,215 @@ +(function() { + function toggleTargetDisplay(element, isShowDesired) { + var parentToggle = element.parentElement; + var targetId = parentToggle.getAttribute('data-expand-target-id'); + var isCodeMirror = 'true' === parentToggle.getAttribute('data-expand-codemirror'); + var isAsyncCodeMirror = 'true' === parentToggle.getAttribute('data-expand-async-codemirror'); + var target = document.getElementById(targetId); + if (!target) { + console.warn('No target found for id', targetId); + } + + var showButton = parentToggle.querySelector('.js-toggle-show-icon'); + var hideButton = parentToggle.querySelector('.js-toggle-hide-icon'); + if (isShowDesired) { + target.classList.remove('hidden-element'); + showButton.classList.add('hidden-element'); + hideButton.classList.remove('hidden-element'); + } else { + target.classList.add('hidden-element'); + hideButton.classList.add('hidden-element'); + showButton.classList.remove('hidden-element'); + } + + if (isCodeMirror) { + var textArea = target.querySelector('textarea'); + var cm = textArea.codemirrorObject; + if (cm) { + // checking existence first for languages not supported by CodeMirror (like System Commands) + // refresh prevents a buggy scrollbar appearance due to dynamical render + cm.refresh(); + } + } else if (isShowDesired && isAsyncCodeMirror) { + var asyncUrl = parentToggle.getAttribute('data-expand-url'); + + var loadingElement = target.querySelector('.js-expand-async-loading'); + var errorElementOther = target.querySelector('.js-expand-async-error'); + var errorElement403 = target.querySelector('.js-expand-async-error-403'); + // reset status first + if (loadingElement) { + loadingElement.classList.remove('hidden-element'); + } + if (errorElementOther) { + errorElementOther.classList.add('hidden-element'); + } + if (errorElement403) { + errorElement403.classList.add('hidden-element'); + } + + // no need to re-load the script the next time + parentToggle.setAttribute('data-expand-async-codemirror', 'already-loaded'); + + new Ajax.Request(asyncUrl, { + contentType:"application/json", + encoding:"UTF-8", + onSuccess: function(rsp) { + if (loadingElement) { + loadingElement.classList.add('hidden-element'); + } + + var scriptContent = rsp.responseText; + + // scriptContent is escaped inside _scriptContent.jelly + target.innerHTML = scriptContent; + + // from hudson-behavior.js + evalInnerHtmlScripts(scriptContent, function() { + Behaviour.applySubtree(target); + var textArea = target.querySelector('textarea'); + var cm = textArea.codemirrorObject; + if (cm) { + // checking existence first for languages not supported by CodeMirror (like System Commands) + // refresh prevents a buggy scrollbar appearance due to dynamical render + cm.refresh(); + } + // next expansion the code has to be refreshed + parentToggle.setAttribute('data-expand-codemirror', 'true'); + }); + }, + onFailure: function(response) { + if (loadingElement) { + loadingElement.classList.add('hidden-element'); + } + if (response.status === 403) { + // most likely to be a CSRF issue due to disconnection + if (errorElement403) { + errorElement403.classList.remove('hidden-element'); + } + } else { + // 404 or otherthing, generic message + if (errorElementOther) { + errorElementOther.classList.remove('hidden-element'); + } + } + } + }); + } + } + + Behaviour.specify(".js-toggle-show-icon", "show-icon", 0, function(element) { + element.observe('click', function() { + toggleTargetDisplay(element, true); + }); + }); + + Behaviour.specify(".js-toggle-hide-icon", "hide-icon", 0, function(element) { + element.observe('click', function() { + toggleTargetDisplay(element, false); + }); + }); + + // the priority ensures it's executed after "TEXTAREA.codemirror" + Behaviour.specify(".js-async-hidden-element", "replace-js-async", /* priority */ 1, function(element) { + element.classList.add('hidden-element'); + element.classList.remove('js-async-hidden-element'); + }); + + Behaviour.specify("table tr.js-selectable-row", "click-to-select", 0, function(row) { + row.observe('click', onLineClicked); + }); + + Behaviour.specify("table tr.js-selectable-row input[type='checkbox']", "click-to-select", 0, function(checkbox) { + checkbox.observe('change', function() { onCheckChanged(this) }); + }); + + function onLineClicked(event){ + var line = this; + // to allow click on checkbox or on label to act normally + var targetBypassClick = event.target && event.target.classList.contains('js-bypass-click'); + if (targetBypassClick) { + return; + } + + var checkbox = line.querySelector('input[type="checkbox"]'); + checkbox.checked = !checkbox.checked; + onCheckChanged(checkbox); + } + + function onCheckChanged(checkBox){ + // up is a prototype helper method + var line = checkBox.up('tr'); + if (checkBox.checked) { + line.addClassName('selected'); + } else { + line.removeClassName('selected'); + } + } + + // ######### For fullScript_pending.jelly ######### + + Behaviour.specify(".js-button-pending-approve-all", "approved-approve-all", 0, function(element) { + element.observe('click', handleClickToProcessAction); + }); + + Behaviour.specify(".js-button-pending-deny-all", "approved-deny-all", 0, function(element) { + element.observe('click', handleClickToProcessAction); + }); + + // ######### For fullScript_approved.jelly ######### + + Behaviour.specify(".js-button-approved-revoke-all", "approved-revoke-all", 0, function(element) { + element.observe('click', handleClickToProcessAction); + }); + + function handleClickToProcessAction() { + var element = this; + var containerId = element.getAttribute('data-container-id'); + var actionUrl = element.getAttribute('data-action-url'); + var confirmMessage = element.getAttribute('data-action-confirm-message'); + + processAction(containerId, actionUrl, confirmMessage); + } + + function processAction(containerId, actionUrl, confirmMessage) { + var container = document.getElementById(containerId); + var allCheckboxes = container.querySelectorAll('input[type="checkbox"].js-checkbox-hash'); + var checkedHashes = []; + allCheckboxes.forEach(function(c){ + if (c.checked) { + var hash = c.getAttribute('data-hash'); + checkedHashes.push(hash); + } + }); + + var errorPanel = container.querySelector('.js-error-no-selected'); + if (checkedHashes.length === 0) { + if (errorPanel) { + errorPanel.classList.remove('hidden-element'); + } else { + console.warn('There is no selected item, cannot proceed with the action.'); + } + return; + } else { + if (errorPanel) { + errorPanel.classList.add('hidden-element'); + } + } + + if (confirmMessage) { + var wasConfirmed = confirm(confirmMessage); + if (!wasConfirmed) { + return; + } + } + + var params = {hashes: checkedHashes}; + new Ajax.Request(actionUrl, { + postBody: Object.toJSON(params), + contentType:"application/json", + encoding:"UTF-8", + onComplete: function(rsp) { + window.location.reload(); + } + }); + } +})(); \ No newline at end of file diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/index.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/index.jelly index 31fd0beb3..9b92ad788 100644 --- a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/index.jelly +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/index.jelly @@ -23,256 +23,22 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --> - + - + + - - - -

- No pending script approvals. -

- - - -
-

- / ${ps.language.displayName} script - : -

- -
+
+ + + - - -

- You can also remove all previous script approvals: - -

-
- - -

- No pending signature approvals. -

-
- - -
-

- / - - / - - signature - : - ${s.signature} - - Approving this signature may introduce a security vulnerability! You are advised to deny it. - -

-
-
-
-
-

Signatures already approved:

- -

Signatures already approved assuming permission check:

- - - -

Signatures already approved which may have introduced a security vulnerability (recommend clearing):

- -
-

- You can also remove all previous signature approvals: - -

- - Or you can just remove the dangerous ones: - - -
-

- No pending classpath entry approvals. -

-
- Classpath entries pending approval. (Beware of remote URLs, workspace files, or anything else that might change without your notice.) -
-

Classpath entries already approved:

-

- No approved classpath entries. -

-
+ +
-

- You can also remove all previous classpath entry approvals: - -

diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-custom.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-custom.jelly new file mode 100644 index 000000000..ad07f81cd --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-custom.jelly @@ -0,0 +1,57 @@ + + + + + + + + + The name of the tab + + + The url of the tab + + + Whether or not the tab is active + + + The title of the tab + + + The number, of notifications, to be displayed as a badge inside the tab name + + + Determine whether the color of the badge should be the primary color or a less highlighted one + + + + ${attrs.name} + + + ${attrs.numOfNotification} + + + diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-custom.properties b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-custom.properties new file mode 100644 index 000000000..d8eabe8c4 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-custom.properties @@ -0,0 +1 @@ +numberOfPendingEntries=Number of pending entries diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-with-body.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-with-body.jelly new file mode 100644 index 000000000..d1fed2919 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tab-with-body.jelly @@ -0,0 +1,54 @@ + + + + + + + + + The url of the tab + + + Whether or not the tab is active + + + The title of the tab + + +
+ + + + + + + + + + + +
+ +
diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/_scriptContent.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/_scriptContent.jelly new file mode 100644 index 000000000..0c3d146cf --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/_scriptContent.jelly @@ -0,0 +1,10 @@ + + + + + + + diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/classPath_approved.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/classPath_approved.jelly new file mode 100644 index 000000000..969764589 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/classPath_approved.jelly @@ -0,0 +1,77 @@ + + +
+

Classpath entries already approved:

+

+ No approved classpath entries. +

+
+
+

+ You can also remove all previous classpath entry approvals: + +

+
+ + +
diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/classPath_pending.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/classPath_pending.jelly new file mode 100644 index 000000000..5c391284f --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/classPath_pending.jelly @@ -0,0 +1,76 @@ + + +
+

+ No pending classpath entry approvals. +

+
+ Classpath entries pending approval. (Beware of remote URLs, workspace files, or anything else that might change without you noticing.) +
+
+ + +
diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_approved.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_approved.jelly new file mode 100644 index 000000000..dd46eea02 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_approved.jelly @@ -0,0 +1,194 @@ + + +
+ + +
+ ${%metadataGatheringDisabled} +
+
+ + +

+ ${%noApprovedScripts} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
${%approvedExpandCode}${%approvedLanguage}${%approvedContextUser}${%approvedContextItem}${%usageCount}${%lastTimeUsed} + + ${%lastApprovalTime}${%lastKnownApproverLogin}
+ + + + ${%emptyMetadata} + + + + +
+ ${%approvedExpand} + ${%approvedMinimize} +
+
+ + + ${%noScriptCodeSinceMetadata} + + +
+
${language.displayName} + + + + ${user} + + + + ${%noRequesterSinceMetadata} + + + + + + + + ${contextItem.fullDisplayName} + + + + ${%noContextItemSinceMetadata} + + + + + + + + ${usageCount} + + + + ${%noUsageCountSinceMetadata} + + + + + + + + + + + + ${%notUsedSinceMetadata} + + + + + + + + + + + + + + + + + + ${%notApprovedSinceMetadata} + + + + + + + + ${lastKnownApproverLogin} + + + + ${%noKnownApproverSinceMetadata} + + + +
+
+ ${%scriptContentLoadingMessage} +
+
+ ${%scriptContentLoadError} +
+
+ ${%scriptContentLoadError403} +
+
+ +

+ ${%approvedNoSelectedItemError} +

+ +
+ + + +
+
+
+
+
diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_approved.properties b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_approved.properties new file mode 100644 index 000000000..5fa49e954 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_approved.properties @@ -0,0 +1,58 @@ +# approved scripts +metadataGatheringDisabled=Metadata gathering disabled. \ +

You can re-enable it permanently by removing the system property:
\ + org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval.metadataGathering

\ +

or temporarily (until restart) by running this in the Script Console:
\ + org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval.METADATA_GATHERING = true

+ +## table headers +approvedExpandCode=Code +approvedLanguage=Language +usageCount=# of uses +usageCount_tooltip=How often this script was executed. Only executions since Script Security plugin was updated to version 1.75. +lastTimeUsed=Date of last use +wasPreapproved_header_tooltip=The presence of a green tick means the script was approved during the configuration. \ + Otherwise it was created by a user without the permission to run scripts and was then approved using this page. +lastApprovalTime=Date of the last approval +lastApprovalTime_tooltip=A single script can be approved multiple times. It occurs every time the related configuration is saved. +lastKnownApproverLogin=Last approver + +## table content +noRequesterSinceMetadata=N/A +noRequesterSinceMetadata_tooltip=The requester was not recorded. \ + It could be because the request was created before the metadata was introduced or the code calling this script did not provide user information. +noContextItemSinceMetadata=N/A +noContextItemSinceMetadata_tooltip=The context item was not provided by the code asking for approval. + +emptyMetadata=There is no metadata for this approval. +emptyMetadata_tooltip=If the script is used after the metadata introduction, some metadata will be displayed here. \ + It means that an unused approval will not have metadata and could be reasonably removed after a certain period of time. +noScriptCodeSinceMetadata=N/A +noScriptCodeSinceMetadata_tooltip=The approval was given while the metadata was not gathered. +approvedContextUser=Requester +approvedContextItem=Context +wasPreapproved_tooltip=The script content was approved directly during configuration, meaning it comes from a user with the permission to run scripts. +wasPreapproved_alt=Preapproved +noUsageCountSinceMetadata=N/A +noUsageCountSinceMetadata_tooltip=It was not used since the introduction of metadata. +notUsedSinceMetadata=N/A +notUsedSinceMetadata_tooltip=It was not used since the introduction of metadata. +notApprovedSinceMetadata=N/A +notApprovedSinceMetadata_tooltip=It was not approved since the introduction of metadata. +noKnownApproverSinceMetadata=N/A +noKnownApproverSinceMetadata_tooltip=It was not approved since the introduction of metadata. + +revokeApproval=Remove selected +revokeApproval_tooltip=Removing a script from this list will revoke approval for its execution. \ + The next attempt to execute the script will fail, and the script will be added to the list of scripts requiring approval. +revokeApproval_confirm=Really revoke approvals for all selected scripts? \ + All scripts not using the sandbox will need to be approved again before they can be executed successfully, and will fail until they are. +approvedNoSelectedItemError=Please select at least one line. + +approvedExpand=show code +approvedExpand_tooltip=Expand the source code visualization +approvedMinimize=hide code +approvedMinimize_tooltip=Minimize the source code visualization +scriptContentLoadingMessage=Loading... +scriptContentLoadError=Unable to find the script content. It was perhaps removed manually. On next use this should be self-repaired. +scriptContentLoadError403=Unable to request the script content due to lack of authorization. Try reloading the page. diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_pending.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_pending.jelly new file mode 100644 index 000000000..649dd9fe5 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_pending.jelly @@ -0,0 +1,106 @@ + + +
+ + + + +

+ ${%noPendingScripts} +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
${%pendingExpandCode}${%pendingLanguage}${%contextUser}${%contextItem}${%requestApprovalTime}
+ + +
+ ${%pendingExpand} + ${%pendingMinimize} +
+
${ps.language.displayName} + + + + ${user} + + + + ${%noRequesterSinceMetadata} + + + + + + + + ${contextItem.fullDisplayName} + + + + ${%noContextItemSinceMetadata} + + + + + + + + + + + + ${%noRequestApprovalTimeSinceMetadata} + + + +
+ +
+ +

+ ${%pendingNoSelectedItemError} +

+ +
+ + + + + + +
+
+
+
+
diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_pending.properties b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_pending.properties new file mode 100644 index 000000000..edb6fe1ea --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/fullScript_pending.properties @@ -0,0 +1,33 @@ +# pending scripts +## table headers +pendingExpandCode=Code +pendingLanguage=Language +contextUser=Requester +contextItem=Context +requestApprovalTime=Request date +requestApprovalTime_tooltip=Inform about when the approval request was made. + +## table content +noRequesterSinceMetadata=N/A +noRequesterSinceMetadata_tooltip=The requester was not recorded. \ + It could be because the request was created before the metadata was introduced or the code calling this script did not provide user information. +noRequestApprovalTimeSinceMetadata=N/A +noRequestApprovalTimeSinceMetadata_tooltip=It was requested before the introduction of metadata. +noContextItemSinceMetadata=N/A +noContextItemSinceMetadata_tooltip=The context item was not provided by the code asking for the approval. + +approvePending=Approve selected +approvePending_tooltip=Approving a pending script will allow any user to execute it. You need to be careful to only approve non-dangerous scripts. +approvePending_confirm=Really approve selected scripts? Have you checked that none of them are dangerous? +denyPending=Deny selected +denyPending_tooltip=Denying a pending script will just remove it from the pending list. If there are new script execution attempts, it will reappear in this list. +denyPending_confirm=Really deny selected scripts? If they are triggered again, they will reappear in this list. +pendingNoSelectedItemError=Please select at least one line. + +pendingExpand=expand code +pendingExpand_tooltip=Expand the source code visualization +pendingMinimize=minimize code +pendingMinimize_tooltip=Minimize the source code visualization + +noPendingScripts=No scripts pending approval. +noApprovedScripts=No approved scripts. diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/signature_approved.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/signature_approved.jelly new file mode 100644 index 000000000..2f824653c --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/signature_approved.jelly @@ -0,0 +1,70 @@ + + +
+
Signatures already approved:
+ + +
Signatures already approved assuming permission check:
+ + + + +
Signatures already approved which may have introduced security vulnerabilities (recommend clearing):
+ +
+

+ You can also remove all previous signature approvals: + +

+ + Or you can just remove the dangerous ones: + + +
+ + +
diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/signature_pending.jelly b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/signature_pending.jelly new file mode 100644 index 000000000..f813a6a18 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/tabs/signature_pending.jelly @@ -0,0 +1,70 @@ + + +
+ + +

+ No pending signature approvals. +

+
+ + +
+

+ / + + / + + signature + : + ${s.signature} + + Approving this signature may introduce a security vulnerability! You are advised to deny it. + +

+
+
+
+
+
+ + +
diff --git a/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/taglib b/src/main/resources/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApproval/taglib new file mode 100644 index 000000000..e69de29bb diff --git a/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/AbstractApprovalTest.java b/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/AbstractApprovalTest.java index 6f417fb1f..1b96bbf03 100644 --- a/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/AbstractApprovalTest.java +++ b/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/AbstractApprovalTest.java @@ -56,7 +56,7 @@ String getClearAllApprovedId() { return null; } - private Approvable[] createFiveEntries() throws Exception { + Approvable[] createFiveEntries() throws Exception { final Approvable[] entries = new Approvable[5]; for (int i = 0; i < entries.length; i++) { entries[i] = create().assertPending(); @@ -80,35 +80,4 @@ private Approvable[] createFiveEntries() throws Exception { } entries[4].assertPending(); } - - - @Test public void approveExternal() throws Exception { - configureSecurity(); - final Approvable[] entries = createFiveEntries(); - - final Manager manager = new Manager(r); - - for (int i = 0; i < entries.length; i++) { - entries[i].pending(manager); - } - - entries[0].pending(manager).approve().approved(manager); - entries[1].pending(manager).approve().approved(manager); - entries[2].pending(manager).deny().assertDeleted(); - entries[3].pending(manager).approve().approved(manager); - if (entries[3].canDelete()) { - entries[3].approved(manager).delete().assertDeleted(manager); - } - - // clear all classpaths - final String clearId = getClearAllApprovedId(); - if (clearId != null) { - manager.click(clearId); - for (int i = 0; i < 4; i++) { - entries[i].assertDeleted(manager); - } - } - // The last one remains pending - entries[4].pending(manager); - } } diff --git a/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/EntryApprovalTest.java b/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/EntryApprovalTest.java index 2598c4011..2b6c843a6 100644 --- a/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/EntryApprovalTest.java +++ b/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/EntryApprovalTest.java @@ -92,6 +92,43 @@ private static Entry entry(File f) throws Exception { return new Entry(new ClasspathEntry(f.toURI().toURL().toExternalForm())); } + @Test public void approveExternal() throws Exception { + configureSecurity(); + final Approvable[] entries = createFiveEntries(); + + final Manager managerPending = new Manager(r, "classPathPending"); + + for (int i = 0; i < entries.length; i++) { + entries[i].pending(managerPending); + } + + entries[0].pending(managerPending).approve(); + entries[1].pending(managerPending).approve(); + entries[2].pending(managerPending).deny().assertDeleted(); + entries[3].pending(managerPending).approve(); + + final Manager managerApproved = new Manager(r, "classPathApproval"); + entries[0].approved(managerApproved); + entries[1].approved(managerApproved); + entries[3].approved(managerApproved); + + if (entries[3].canDelete()) { + entries[3].approved(managerApproved).delete().assertDeleted(managerApproved); + } + + // clear all classpaths + final String clearId = getClearAllApprovedId(); + if (clearId != null) { + managerApproved.click(clearId); + for (int i = 0; i < 4; i++) { + entries[i].assertDeleted(managerApproved); + } + } + final Manager managerPending2 = new Manager(r, "classPathPending"); + // The last one remains pending + entries[4].pending(managerPending2); + } + static final class Entry extends Approvable { private final ClasspathEntry entry; private final String hash; diff --git a/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/Manager.java b/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/Manager.java index 5bfa9a959..9a1c2959d 100644 --- a/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/Manager.java +++ b/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/Manager.java @@ -44,7 +44,7 @@ final class Manager { private final JenkinsRule.WebClient wc; private final HtmlPage page; - Manager(JenkinsRule rule) throws Exception { + Manager(JenkinsRule rule, String tabName) throws Exception { this.wc = rule.createWebClient(); // click "OK" for all confirms. wc.setConfirmHandler(new ConfirmHandler() { @@ -52,7 +52,11 @@ public boolean handleConfirm(Page page, String message) { return true; } }); - this.page = wc.goTo(ScriptApproval.get().getUrlName()); + if (tabName == null) { + this.page = wc.goTo(ScriptApproval.get().getUrlName()); + } else { + this.page = wc.goTo(ScriptApproval.get().getUrlName() + "?tab=" + tabName); + } } private void clickAndWait(HtmlElement e) throws IOException { diff --git a/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApprovalTest.java b/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApprovalTest.java index e92afda99..e035966f4 100644 --- a/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApprovalTest.java +++ b/src/test/java/org/jenkinsci/plugins/scriptsecurity/scripts/ScriptApprovalTest.java @@ -27,7 +27,9 @@ import com.gargoylesoftware.htmlunit.html.HtmlPage; import com.gargoylesoftware.htmlunit.html.HtmlTextArea; import hudson.model.FreeStyleProject; +import hudson.model.Job; import hudson.model.Result; +import hudson.security.Permission; import hudson.util.VersionNumber; import jenkins.model.Jenkins; import org.hamcrest.Matchers; @@ -35,22 +37,32 @@ import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.TestGroovyRecorder; import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage; +import org.jenkinsci.plugins.scriptsecurity.scripts.metadata.HashAndFullScriptMetadata; import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; import org.jvnet.hudson.test.recipes.LocalData; import org.xml.sax.SAXException; import java.io.IOException; import java.util.List; +import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Level; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.arrayContainingInAnyOrder; +import static org.hamcrest.Matchers.arrayWithSize; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertThat; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class ScriptApprovalTest extends AbstractApprovalTest { @@ -79,7 +91,7 @@ public void malformedScriptApproval() throws Exception { } catch (Exception e) { // ignore - we want to make sure we're logging this properly. } - assertThat(logging.getRecords(), Matchers.hasSize(Matchers.equalTo(1))); + assertThat(logging.getRecords(), hasSize(equalTo(1))); assertEquals("Malformed signature entry in scriptApproval.xml: ' new java.lang.Exception java.lang.String'", logging.getRecords().get(0).getMessage()); } @@ -101,14 +113,15 @@ public void malformedScriptApproval() throws Exception { assertEquals(expectedLinkCount, scriptApprovalLinks.size()); // the icon link and the textual link String managePageBodyText = managePage.getBody().getTextContent(); - assertThat(managePageBodyText, Matchers.containsString("1 dangerous signatures previously approved which ought not have been.")); + assertThat(managePageBodyText, containsString("1 dangerous signatures previously approved which ought not have been.")); - HtmlPage scriptApprovalPage = managePage.getAnchorByHref("scriptApproval").click(); + String approvedSignatureUrl = managePage.getAnchorByHref("scriptApproval").getHrefAttribute() + "/?tab=signatureApproved"; + HtmlPage scriptApprovalPage = wc.goTo(approvedSignatureUrl); HtmlTextArea approvedTextArea = scriptApprovalPage.getHtmlElementById("approvedSignatures"); HtmlTextArea dangerousTextArea = scriptApprovalPage.getHtmlElementById("dangerousApprovedSignatures"); - assertThat(approvedTextArea.getTextContent(), Matchers.containsString(DANGEROUS_SIGNATURE)); - assertThat(dangerousTextArea.getTextContent(), Matchers.containsString(DANGEROUS_SIGNATURE)); + assertThat(approvedTextArea.getTextContent(), containsString(DANGEROUS_SIGNATURE)); + assertThat(dangerousTextArea.getTextContent(), containsString(DANGEROUS_SIGNATURE)); } @Test public void nothingHappening() throws Exception { @@ -193,6 +206,75 @@ String getClearAllApprovedId() { return CLEAR_ALL_ID; } + @Test + @Issue("JENKINS-62448") + @LocalData("legacyApproval") + public void legacyAreStillRecognized() { + List approvedFullScriptMetadata = ScriptApproval.get().getApprovedFullScriptMetadata(); + assertThat(approvedFullScriptMetadata, hasSize(6)); + Optional helloScript = approvedFullScriptMetadata.stream().filter(m -> m.hash.equals("ca57380cdd93d5cbff29daf6951e425d05908ea1")).findFirst(); + assertTrue("No hash present for the echo Hello", helloScript.isPresent()); + + assertThat(ScriptApproval.get().getApprovedSignatures(), arrayWithSize(2)); + assertThat(ScriptApproval.get().getApprovedSignatures(), arrayContainingInAnyOrder( + "staticMethod org.codehaus.groovy.runtime.DefaultGroovyMethods println java.lang.Object java.lang.Object", + "new java.io.File java.lang.String java.lang.String" + )); + + assertThat(ScriptApproval.get().getDangerousApprovedSignatures(), arrayWithSize(1)); + assertThat(ScriptApproval.get().getDangerousApprovedSignatures()[0], equalTo("new java.io.File java.lang.String java.lang.String")); + + assertThat(ScriptApproval.get().getAclApprovedSignatures(), arrayWithSize(1)); + assertThat(ScriptApproval.get().getAclApprovedSignatures()[0], equalTo("new java.io.File java.lang.String")); + + assertThat(ScriptApproval.get().getPendingSignatures(), hasSize(1)); + assertThat(ScriptApproval.get().getPendingSignatures().iterator().next().signature, equalTo("method java.io.File length")); + + assertThat(ScriptApproval.get().getApprovedClasspathEntries(), hasSize(1)); + assertThat(ScriptApproval.get().getApprovedClasspathEntries().get(0).getHash(), equalTo("c8a65bd626dd7b34fc329434c9c1e728a4abe828")); + assertThat(ScriptApproval.get().getApprovedClasspathEntries().get(0).getURL().toString(), equalTo("https://repo.jenkins-ci.org/javanet2-cache/org/jvnet/hudson/main/maven-plugin/1.301/maven-plugin-1.301.hpi")); + + assertThat(ScriptApproval.get().getPendingClasspathEntries(), hasSize(1)); + assertThat(ScriptApproval.get().getPendingClasspathEntries().get(0).getHash(), equalTo("7f014e0dab147d3ae431efa1b8b1305112711b18")); + assertThat(ScriptApproval.get().getPendingClasspathEntries().get(0).getURL().toString(), equalTo("https://repo.jenkins-ci.org/javanet2-cache/org/jvnet/hudson/main/maven-plugin/1.302/maven-plugin-1.302.hpi")); + assertThat(ScriptApproval.get().getPendingClasspathEntries().get(0).getContext().getUser(), equalTo("config")); + + assertThat(ScriptApproval.get().getPendingScriptsSorted(), hasSize(2)); + } + + @Test + @Issue("JENKINS-62448") + @LocalData("legacyApproval") + public void legacyIsEnhancedWithMetadataAfterUse() throws Exception { + r.jenkins.setSecurityRealm(r.createDummySecurityRealm()); + r.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER).everywhere().to("admin") + .grant(Permission.READ, Job.CREATE).everywhere().to("config")); + + JenkinsRule.WebClient wc = r.createWebClient(); + wc.login("config", "config"); + + Optional executeScriptBefore = ScriptApproval.get().getApprovedFullScriptMetadata().stream() + .filter(m -> m.hash.equals("d224435330553e6054e66fbe050cfafafeadc732")) + .findFirst(); + assertTrue(executeScriptBefore.isPresent()); + assertTrue(executeScriptBefore.get().metadata.isEmpty()); + assertThat(ScriptApproval.get().metadataStorage.readScript("d224435330553e6054e66fbe050cfafafeadc732"), nullValue()); + + FreeStyleProject p = r.createFreeStyleProject(); + p.getPublishersList().add(new TestGroovyRecorder( + new SecureGroovyScript("jenkins.model.Jenkins.instance", false, null))); + + p.scheduleBuild2(0).get(); + + Optional executeScriptAfter = ScriptApproval.get().getApprovedFullScriptMetadata().stream() + .filter(m -> m.hash.equals("d224435330553e6054e66fbe050cfafafeadc732")) + .findFirst(); + assertTrue(executeScriptAfter.isPresent()); + assertFalse(executeScriptAfter.get().metadata.isEmpty()); + assertThat(ScriptApproval.get().metadataStorage.readScript("d224435330553e6054e66fbe050cfafafeadc732"), equalTo("jenkins.model.Jenkins.instance")); + } + static final class Script extends Approvable + groovy + + + + groovy + + + + + + method java.io.File length + false + + + + + + config + free-for-classpath + + 7f014e0dab147d3ae431efa1b8b1305112711b18 + https://repo.jenkins-ci.org/javanet2-cache/org/jvnet/hudson/main/maven-plugin/1.302/maven-plugin-1.302.hpi + + +