Skip to content

Commit 478dd9e

Browse files
committed
SECURITY-3362
1 parent 55dd42a commit 478dd9e

File tree

5 files changed

+227
-4
lines changed

5 files changed

+227
-4
lines changed

plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction.java

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,17 @@
6868
import net.sf.json.JSON;
6969
import net.sf.json.JSONObject;
7070
import org.acegisecurity.AccessDeniedException;
71+
import org.jenkinsci.plugins.scriptsecurity.scripts.ApprovalContext;
72+
import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval;
73+
import org.jenkinsci.plugins.scriptsecurity.scripts.UnapprovedUsageException;
74+
import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage;
7175
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
7276
import org.jenkinsci.plugins.workflow.cps.CpsFlowExecution;
7377
import org.jenkinsci.plugins.workflow.flow.FlowExecution;
7478
import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
7579
import org.kohsuke.accmod.Restricted;
7680
import org.kohsuke.accmod.restrictions.DoNotUse;
81+
import org.kohsuke.accmod.restrictions.NoExternalUse;
7782
import org.kohsuke.stapler.AncestorInPath;
7883
import org.kohsuke.stapler.QueryParameter;
7984
import org.kohsuke.stapler.StaplerRequest;
@@ -119,7 +124,8 @@ private ReplayAction(Run run) {
119124
}
120125

121126
/** Fetches execution, blocking if needed while we wait for some of the loading process. */
122-
private @CheckForNull CpsFlowExecution getExecutionBlocking() {
127+
@Restricted(NoExternalUse.class)
128+
public @CheckForNull CpsFlowExecution getExecutionBlocking() {
123129
FlowExecutionOwner owner = ((FlowExecutionOwner.Executable) run).asFlowExecutionOwner();
124130
if (owner == null) {
125131
return null;
@@ -163,6 +169,14 @@ private ReplayAction(Run run) {
163169
}
164170
}
165171

172+
private boolean isSandboxed() {
173+
CpsFlowExecution exec = getExecutionLazy();
174+
if (exec != null) {
175+
return exec.isSandbox();
176+
}
177+
return false;
178+
}
179+
166180
/** Runs the extra tests for replayability beyond {@link #isEnabled()} that require a blocking load of the execution. */
167181
/* accessible to Jelly */ public boolean isReplayableSandboxTest() {
168182
CpsFlowExecution exec = getExecutionBlocking();
@@ -261,6 +275,16 @@ public void doRebuild(StaplerRequest req, StaplerResponse rsp) throws ServletExc
261275
if (execution == null) {
262276
return null;
263277
}
278+
279+
if (!execution.isSandbox()) {
280+
ScriptApproval.get().configuring(replacementMainScript,GroovyLanguage.get(), ApprovalContext.create(), true);
281+
try {
282+
ScriptApproval.get().using(replacementMainScript, GroovyLanguage.get());
283+
} catch (UnapprovedUsageException e) {
284+
throw new Failure("The script is not approved.");
285+
}
286+
}
287+
264288
actions.add(new ReplayFlowFactoryAction(replacementMainScript, replacementLoadedScripts, execution.isSandbox()));
265289
actions.add(new CauseAction(new Cause.UserIdCause(), new ReplayCause(run)));
266290

@@ -357,12 +381,26 @@ private static String diff(String script, String oldText, String nueText) throws
357381
return hunks.isEmpty() ? "" : hunks.toUnifiedDiff("old/" + script, "new/" + script, new StringReader(oldText), new StringReader(nueText), 3);
358382
}
359383

360-
// Stub, we do not need to do anything here.
384+
/**
385+
* Loaded scripts do not need to be approved.
386+
*/
361387
@RequirePOST
362-
public FormValidation doCheckScript() {
388+
public FormValidation doCheckLoadedScript() {
363389
return FormValidation.ok();
364390
}
365391

392+
/**
393+
* Form validation for the main script
394+
* Jelly only
395+
* @param value the script being checked
396+
* @return a message indicating that the script needs to be approved; nothing if the script is empty;
397+
* a corresponding message if the script is approved
398+
*/
399+
@RequirePOST
400+
public FormValidation doCheckScript(@QueryParameter String value) {
401+
return Jenkins.get().getDescriptorByType(CpsFlowDefinition.DescriptorImpl.class).doCheckScript(value, "", isSandboxed());
402+
}
403+
366404
@RequirePOST
367405
public JSON doCheckScriptCompile(@AncestorInPath Item job, @QueryParameter String value) {
368406
return Jenkins.get().getDescriptorByType(CpsFlowDefinition.DescriptorImpl.class).doCheckScriptCompile(job, value);

plugin/src/main/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayPipelineCommand.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
import hudson.model.Run;
3333
import java.util.HashMap;
3434
import java.util.Map;
35+
import jenkins.model.Jenkins;
3536
import org.apache.commons.io.IOUtils;
37+
import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval;
38+
import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage;
39+
import org.jenkinsci.plugins.workflow.cps.CpsFlowExecution;
3640
import org.jenkinsci.plugins.workflow.cps.replay.Messages;
3741
import org.kohsuke.args4j.Argument;
3842
import org.kohsuke.args4j.CmdLineParser;
@@ -51,6 +55,9 @@
5155
@Option(name="-s", aliases="--script", metaVar="SCRIPT", usage="Name of script to edit, such as Script3, if not the main Jenkinsfile.")
5256
public String script;
5357

58+
@Option(name = "-a", aliases="--approve", metaVar="APPROVE", usage ="Approve the main Jenkinsfile if the build is unsandboxed.")
59+
public boolean approve = false;
60+
5461
@Override public String getShortDescription() {
5562
return Messages.ReplayCommand_shortDescription();
5663
}
@@ -77,13 +84,31 @@
7784
throw new AbortException("Unrecognized script name among " + replacementLoadedScripts.keySet());
7885
}
7986
replacementLoadedScripts.put(script, text);
87+
if (approve) {
88+
approveScript(action.getOriginalScript(), action);
89+
}
8090
action.run(action.getOriginalScript(), replacementLoadedScripts);
8191
} else {
92+
approveScript(text, action);
8293
action.run(text, action.getOriginalLoadedScripts());
8394
}
8495
return 0;
8596
}
8697

98+
public void approveScript(String script, ReplayAction action) {
99+
CpsFlowExecution exec = action.getExecutionBlocking();
100+
if (exec == null) {
101+
return;
102+
}
103+
if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER) || exec.isSandbox()) {
104+
return;
105+
}
106+
if (!ScriptApproval.get().isScriptApproved(script, GroovyLanguage.get())) {
107+
ScriptApproval.get().preapprove(script, GroovyLanguage.get());
108+
ScriptApproval.get().save();
109+
}
110+
}
111+
87112
@SuppressWarnings("rawtypes")
88113
public static class JobHandler extends GenericItemOptionHandler<Job> {
89114

plugin/src/main/resources/org/jenkinsci/plugins/workflow/cps/replay/ReplayAction/index.jelly

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
</f:entry>
1818
<j:forEach var="loadedScript" items="${it.originalLoadedScripts.entrySet()}">
1919
<f:entry field="${loadedScript.key.replace('.', '_')}" title="${loadedScript.key}">
20-
<wfe:workflow-editor script="${loadedScript.value}" theme="${it.theme}" checkUrl="${rootURL}/${it.owner.url}${it.urlName}/checkScript" checkDependsOn=""/>
20+
<wfe:workflow-editor script="${loadedScript.value}" theme="${it.theme}" checkUrl="${rootURL}/${it.owner.url}${it.urlName}/checkLoadedScript" checkDependsOn=""/>
2121
</f:entry>
2222
</j:forEach>
2323
<f:block>

plugin/src/test/java/org/jenkinsci/plugins/workflow/cps/replay/ReplayActionTest.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
package org.jenkinsci.plugins.workflow.cps.replay;
2626

27+
import org.htmlunit.FailingHttpStatusCodeException;
2728
import org.htmlunit.WebAssert;
2829
import org.htmlunit.html.HtmlForm;
2930
import org.htmlunit.html.HtmlPage;
@@ -56,6 +57,8 @@
5657
import jenkins.model.Jenkins;
5758
import org.apache.commons.io.IOUtils;
5859
import org.hamcrest.Matchers;
60+
import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval;
61+
import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage;
5962
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
6063
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
6164
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
@@ -77,6 +80,7 @@
7780
import static org.junit.Assert.assertEquals;
7881
import static org.junit.Assert.assertFalse;
7982
import static org.junit.Assert.assertNotNull;
83+
import static org.junit.Assert.assertNull;
8084
import static org.junit.Assert.assertThrows;
8185
import static org.junit.Assert.assertTrue;
8286

@@ -397,4 +401,60 @@ public void rebuild() throws Exception {
397401
});
398402
}
399403

404+
@Issue("SECURITY-3362")
405+
@Test
406+
public void rebuildNeedScriptApproval() throws Exception {
407+
story.addStep(new Statement() {
408+
@Override public void evaluate() throws Throwable {
409+
story.j.jenkins.setSecurityRealm(story.j.createDummySecurityRealm());
410+
story.j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().
411+
grant(Jenkins.READ, Item.BUILD, Item.READ).everywhere().to("dev1"));
412+
413+
WorkflowJob p = story.j.jenkins.createProject(WorkflowJob.class, "SECURITY-3362");
414+
String script = "pipeline {\n" +
415+
" agent any\n" +
416+
" stages {\n" +
417+
" stage('List Jobs') {\n" +
418+
" steps {\n" +
419+
" script {\n" +
420+
" println \"Jobs: ${jenkins.model.Jenkins.instance.getItemByFullName(env.JOB_NAME)?.parent?.items*.fullName.join(', ')}!\"" +
421+
" }\n" +
422+
" }\n" +
423+
" }\n" +
424+
" }\n" +
425+
"}\n";
426+
p.setDefinition(new CpsFlowDefinition(script, false));
427+
428+
ScriptApproval.get().preapprove(script, GroovyLanguage.get());
429+
430+
WorkflowRun b1 = story.j.assertBuildStatusSuccess(p.scheduleBuild2(0));
431+
story.j.assertBuildStatusSuccess(story.j.waitForCompletion(b1));
432+
433+
ScriptApproval.get().clearApprovedScripts();
434+
435+
{ // First time around, verify that UI elements are present and functional.
436+
ReplayAction a = b1.getAction(ReplayAction.class);
437+
assertNotNull(a);
438+
assertFalse(canReplay(b1, "dev1"));
439+
assertTrue(canRebuild(b1, "dev1"));
440+
JenkinsRule.WebClient wc = story.j.createWebClient();
441+
wc.login("dev1");
442+
443+
HtmlPage page = wc.getPage(b1, a.getUrlName());
444+
WebAssert.assertFormNotPresent(page, "config");
445+
HtmlForm form = page.getFormByName("rebuild");
446+
447+
try {
448+
story.j.submit(form);
449+
} catch (FailingHttpStatusCodeException e) {
450+
String responseBody = e.getResponse().getContentAsString();
451+
assertTrue(responseBody.contains("The script is not approved."));
452+
}
453+
story.j.waitUntilNoActivity();
454+
WorkflowRun b2 = p.getBuildByNumber(2);
455+
assertNull(b2);
456+
}
457+
}
458+
});
459+
}
400460
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright 2024 CloudBees, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
25+
package org.jenkinsci.plugins.workflow.cps.replay;
26+
27+
import hudson.cli.CLICommandInvoker;
28+
import jenkins.model.Jenkins;
29+
import org.apache.tools.ant.filters.StringInputStream;
30+
import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval;
31+
import org.jenkinsci.plugins.scriptsecurity.scripts.languages.GroovyLanguage;
32+
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
33+
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
34+
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
35+
import org.junit.Rule;
36+
import org.junit.Test;
37+
import org.jvnet.hudson.test.Issue;
38+
import org.jvnet.hudson.test.JenkinsRule;
39+
import org.jvnet.hudson.test.MockAuthorizationStrategy;
40+
41+
import static org.hamcrest.MatcherAssert.assertThat;
42+
43+
public class ReplayPipelineCommandTest {
44+
45+
@Rule public JenkinsRule j = new JenkinsRule();
46+
47+
@Issue("SECURITY-3362")
48+
@Test
49+
public void rebuildNeedScriptApprovalCLIEdition() throws Exception {
50+
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
51+
j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().
52+
grant(Jenkins.ADMINISTER).everywhere().toEveryone());
53+
54+
WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "SECURITY-3362");
55+
j.jenkins.getWorkspaceFor(p).child("a.groovy").write("echo 'Hello LoadedWorld'", null);
56+
String script =
57+
"node() {\n" +
58+
" a = load('a.groovy')\n" +
59+
"}\n";
60+
p.setDefinition(new CpsFlowDefinition(script, false));
61+
62+
ScriptApproval.get().preapprove(script, GroovyLanguage.get());
63+
64+
WorkflowRun b1 = j.assertBuildStatusSuccess(p.scheduleBuild2(0));
65+
j.assertBuildStatusSuccess(j.waitForCompletion(b1));
66+
String viaCliScript = "echo 'HelloWorld'";
67+
68+
assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript))
69+
.invokeWithArgs(p.getName(), "-n", "1"),
70+
CLICommandInvoker.Matcher.succeededSilently());
71+
j.waitUntilNoActivity();
72+
j.assertBuildStatusSuccess(p.getBuildByNumber(2));
73+
74+
assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript))
75+
.invokeWithArgs(p.getName(), "-n", "1", "-s", "Script1"),
76+
CLICommandInvoker.Matcher.succeededSilently());
77+
j.waitUntilNoActivity();
78+
j.assertBuildStatusSuccess(p.getBuildByNumber(3));
79+
80+
ScriptApproval.get().clearApprovedScripts();
81+
82+
assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript))
83+
.invokeWithArgs(p.getName(), "-n", "1"),
84+
CLICommandInvoker.Matcher.succeededSilently());
85+
j.waitUntilNoActivity();
86+
j.assertBuildStatusSuccess(p.getBuildByNumber(4));
87+
88+
ScriptApproval.get().clearApprovedScripts();
89+
90+
assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript))
91+
.invokeWithArgs(p.getName(), "-n", "1", "-s", "Script1"),
92+
CLICommandInvoker.Matcher.failedWith(1));
93+
94+
assertThat(new CLICommandInvoker(j, new ReplayPipelineCommand()).withStdin(new StringInputStream(viaCliScript))
95+
.invokeWithArgs(p.getName(), "-n", "1", "-s", "Script1", "-a"),
96+
CLICommandInvoker.Matcher.succeededSilently());
97+
j.waitUntilNoActivity();
98+
j.assertBuildStatusSuccess(p.getBuildByNumber(5));
99+
}
100+
}

0 commit comments

Comments
 (0)