From 3e20a37282f82dff2470a8d5af12151a29a4e4fa Mon Sep 17 00:00:00 2001 From: Kevin-CB Date: Mon, 4 Nov 2024 16:00:07 +0100 Subject: [PATCH] SECURITY-3362 --- .../workflow/cps/replay/ReplayAction.java | 44 +++++++- .../cps/replay/ReplayPipelineCommand.java | 25 +++++ .../cps/replay/ReplayAction/index.jelly | 2 +- .../workflow/cps/replay/ReplayActionTest.java | 60 +++++++++++ .../cps/replay/ReplayPipelineCommandTest.java | 100 ++++++++++++++++++ 5 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommandTest.java diff --git a/plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction.java b/plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction.java index a92696abe..d42ebf7f1 100644 --- a/plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction.java +++ b/plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction.java @@ -68,12 +68,17 @@ import net.sf.json.JSON; import net.sf.json.JSONObject; import org.acegisecurity.AccessDeniedException; +import org.jenkinsci.plugins.scriptsecurity.scripts.ApprovalContext; +import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval; +import org.jenkinsci.plugins.scriptsecurity.scripts.UnapprovedUsageException; +import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage; import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; import org.jenkinsci.plugins.workflow.cps.CpsFlowExecution; import org.jenkinsci.plugins.workflow.flow.FlowExecution; import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.DoNotUse; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.AncestorInPath; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.StaplerRequest; @@ -119,7 +124,8 @@ private ReplayAction(Run run) { } /** Fetches execution, blocking if needed while we wait for some of the loading process. */ - private @CheckForNull CpsFlowExecution getExecutionBlocking() { + @Restricted(NoExternalUse.class) + public @CheckForNull CpsFlowExecution getExecutionBlocking() { FlowExecutionOwner owner = ((FlowExecutionOwner.Executable) run).asFlowExecutionOwner(); if (owner == null) { return null; @@ -163,6 +169,14 @@ private ReplayAction(Run run) { } } + private boolean isSandboxed() { + CpsFlowExecution exec = getExecutionLazy(); + if (exec != null) { + return exec.isSandbox(); + } + return false; + } + /** Runs the extra tests for replayability beyond {@link #isEnabled()} that require a blocking load of the execution. */ /* accessible to Jelly */ public boolean isReplayableSandboxTest() { CpsFlowExecution exec = getExecutionBlocking(); @@ -261,6 +275,16 @@ public void doRebuild(StaplerRequest req, StaplerResponse rsp) throws ServletExc if (execution == null) { return null; } + + if (!execution.isSandbox()) { + ScriptApproval.get().configuring(replacementMainScript,GroovyLanguage.get(), ApprovalContext.create(), true); + try { + ScriptApproval.get().using(replacementMainScript, GroovyLanguage.get()); + } catch (UnapprovedUsageException e) { + throw new Failure("The script is not approved."); + } + } + actions.add(new ReplayFlowFactoryAction(replacementMainScript, replacementLoadedScripts, execution.isSandbox())); actions.add(new CauseAction(new Cause.UserIdCause(), new ReplayCause(run))); @@ -357,12 +381,26 @@ private static String diff(String script, String oldText, String nueText) throws return hunks.isEmpty() ? "" : hunks.toUnifiedDiff("old/" + script, "new/" + script, new StringReader(oldText), new StringReader(nueText), 3); } - // Stub, we do not need to do anything here. + /** + * Loaded scripts do not need to be approved. + */ @RequirePOST - public FormValidation doCheckScript() { + public FormValidation doCheckLoadedScript() { return FormValidation.ok(); } + /** + * Form validation for the main script + * Jelly only + * @param value the script being checked + * @return a message indicating that the script needs to be approved; nothing if the script is empty; + * a corresponding message if the script is approved + */ + @RequirePOST + public FormValidation doCheckScript(@QueryParameter String value) { + return Jenkins.get().getDescriptorByType(CpsFlowDefinition.DescriptorImpl.class).doCheckScript(value, "", isSandboxed()); + } + @RequirePOST public JSON doCheckScriptCompile(@AncestorInPath Item job, @QueryParameter String value) { return Jenkins.get().getDescriptorByType(CpsFlowDefinition.DescriptorImpl.class).doCheckScriptCompile(job, value); diff --git a/plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommand.java b/plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommand.java index 564846ba4..8e16004a1 100644 --- a/plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommand.java +++ b/plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommand.java @@ -32,7 +32,11 @@ import hudson.model.Run; import java.util.HashMap; import java.util.Map; +import jenkins.model.Jenkins; import org.apache.commons.io.IOUtils; +import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval; +import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage; +import org.jenkinsci.plugins.workflow.cps.CpsFlowExecution; import org.jenkinsci.plugins.workflow.cps.replay.Messages; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.CmdLineParser; @@ -51,6 +55,9 @@ @Option(name="-s", aliases="--script", metaVar="SCRIPT", usage="Name of script to edit, such as Script3, if not the main Jenkinsfile.") public String script; + @Option(name = "-a", aliases="--approve", metaVar="APPROVE", usage ="Approve the main Jenkinsfile if the build is unsandboxed.") + public boolean approve = false; + @Override public String getShortDescription() { return Messages.ReplayCommand_shortDescription(); } @@ -77,13 +84,31 @@ throw new AbortException("Unrecognized script name among " + replacementLoadedScripts.keySet()); } replacementLoadedScripts.put(script, text); + if (approve) { + approveScript(action.getOriginalScript(), action); + } action.run(action.getOriginalScript(), replacementLoadedScripts); } else { + approveScript(text, action); action.run(text, action.getOriginalLoadedScripts()); } return 0; } + public void approveScript(String script, ReplayAction action) { + CpsFlowExecution exec = action.getExecutionBlocking(); + if (exec == null) { + return; + } + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER) || exec.isSandbox()) { + return; + } + if (!ScriptApproval.get().isScriptApproved(script, GroovyLanguage.get())) { + ScriptApproval.get().preapprove(script, GroovyLanguage.get()); + ScriptApproval.get().save(); + } + } + @SuppressWarnings("rawtypes") public static class JobHandler extends GenericItemOptionHandler { diff --git a/plugin/src/main/resources/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction/index.jelly b/plugin/src/main/resources/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction/index.jelly index f64d08cd6..c4730f327 100644 --- a/plugin/src/main/resources/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction/index.jelly +++ b/plugin/src/main/resources/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction/index.jelly @@ -17,7 +17,7 @@ - + diff --git a/plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayActionTest.java b/plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayActionTest.java index 44f42ec68..280ede37b 100644 --- a/plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayActionTest.java +++ b/plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayActionTest.java @@ -24,6 +24,7 @@ package org.jenkinsci.plugins.workflow.cps.replay; +import org.htmlunit.FailingHttpStatusCodeException; import org.htmlunit.WebAssert; import org.htmlunit.html.HtmlForm; import org.htmlunit.html.HtmlPage; @@ -56,6 +57,8 @@ import jenkins.model.Jenkins; import org.apache.commons.io.IOUtils; import org.hamcrest.Matchers; +import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval; +import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage; import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; import org.jenkinsci.plugins.workflow.job.WorkflowJob; import org.jenkinsci.plugins.workflow.job.WorkflowRun; @@ -77,6 +80,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -397,4 +401,60 @@ public void rebuild() throws Exception { }); } + @Issue("SECURITY-3362") + @Test + public void rebuildNeedScriptApproval() throws Exception { + story.addStep(new Statement() { + @Override public void evaluate() throws Throwable { + story.j.jenkins.setSecurityRealm(story.j.createDummySecurityRealm()); + story.j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy(). + grant(Jenkins.READ, Item.BUILD, Item.READ).everywhere().to("dev1")); + + WorkflowJob p = story.j.jenkins.createProject(WorkflowJob.class, "SECURITY-3362"); + String script = "pipeline {\n" + + " agent any\n" + + " stages {\n" + + " stage('List Jobs') {\n" + + " steps {\n" + + " script {\n" + + " println \"Jobs: ${jenkins.model.Jenkins.instance.getItemByFullName(env.JOB_NAME)?.parent?.items*.fullName.join(', ')}!\"" + + " }\n" + + " }\n" + + " }\n" + + " }\n" + + "}\n"; + p.setDefinition(new CpsFlowDefinition(script, false)); + + ScriptApproval.get().preapprove(script, GroovyLanguage.get()); + + WorkflowRun b1 = story.j.assertBuildStatusSuccess(p.scheduleBuild2(0)); + story.j.assertBuildStatusSuccess(story.j.waitForCompletion(b1)); + + ScriptApproval.get().clearApprovedScripts(); + + { // First time around, verify that UI elements are present and functional. + ReplayAction a = b1.getAction(ReplayAction.class); + assertNotNull(a); + assertFalse(canReplay(b1, "dev1")); + assertTrue(canRebuild(b1, "dev1")); + JenkinsRule.WebClient wc = story.j.createWebClient(); + wc.login("dev1"); + + HtmlPage page = wc.getPage(b1, a.getUrlName()); + WebAssert.assertFormNotPresent(page, "config"); + HtmlForm form = page.getFormByName("rebuild"); + + try { + story.j.submit(form); + } catch (FailingHttpStatusCodeException e) { + String responseBody = e.getResponse().getContentAsString(); + assertTrue(responseBody.contains("The script is not approved.")); + } + story.j.waitUntilNoActivity(); + WorkflowRun b2 = p.getBuildByNumber(2); + assertNull(b2); + } + } + }); + } } diff --git a/plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommandTest.java b/plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommandTest.java new file mode 100644 index 000000000..b025595b7 --- /dev/null +++ b/plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommandTest.java @@ -0,0 +1,100 @@ +/* + * The MIT License + * + * Copyright 2024 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.workflow.cps.replay; + +import hudson.cli.CLICommandInvoker; +import jenkins.model.Jenkins; +import org.apache.tools.ant.filters.StringInputStream; +import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval; +import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +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.MockAuthorizationStrategy; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class ReplayPipelineCommandTest { + + @Rule public JenkinsRule j = new JenkinsRule(); + + @Issue("SECURITY-3362") + @Test + public void rebuildNeedScriptApprovalCLIEdition() throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy(). + grant(Jenkins.ADMINISTER).everywhere().toEveryone()); + + WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "SECURITY-3362"); + j.jenkins.getWorkspaceFor(p).child("a.groovy").write("echo 'Hello LoadedWorld'", null); + String script = + "node() {\n" + + " a = load('a.groovy')\n" + + "}\n"; + p.setDefinition(new CpsFlowDefinition(script, false)); + + ScriptApproval.get().preapprove(script, GroovyLanguage.get()); + + WorkflowRun b1 = j.assertBuildStatusSuccess(p.scheduleBuild2(0)); + j.assertBuildStatusSuccess(j.waitForCompletion(b1)); + String viaCliScript = "echo 'HelloWorld'"; + + assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript)) + .invokeWithArgs(p.getName(), "-n", "1"), + CLICommandInvoker.Matcher.succeededSilently()); + j.waitUntilNoActivity(); + j.assertBuildStatusSuccess(p.getBuildByNumber(2)); + + assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript)) + .invokeWithArgs(p.getName(), "-n", "1", "-s", "Script1"), + CLICommandInvoker.Matcher.succeededSilently()); + j.waitUntilNoActivity(); + j.assertBuildStatusSuccess(p.getBuildByNumber(3)); + + ScriptApproval.get().clearApprovedScripts(); + + assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript)) + .invokeWithArgs(p.getName(), "-n", "1"), + CLICommandInvoker.Matcher.succeededSilently()); + j.waitUntilNoActivity(); + j.assertBuildStatusSuccess(p.getBuildByNumber(4)); + + ScriptApproval.get().clearApprovedScripts(); + + assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript)) + .invokeWithArgs(p.getName(), "-n", "1", "-s", "Script1"), + CLICommandInvoker.Matcher.failedWith(1)); + + assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript)) + .invokeWithArgs(p.getName(), "-n", "1", "-s", "Script1", "-a"), + CLICommandInvoker.Matcher.succeededSilently()); + j.waitUntilNoActivity(); + j.assertBuildStatusSuccess(p.getBuildByNumber(5)); + } +}