diff --git a/docs/Home.md b/docs/Home.md index aa0bdba4d..40356e3a7 100755 --- a/docs/Home.md +++ b/docs/Home.md @@ -29,6 +29,8 @@ Browse the Jenkins issue tracker to see any [open issues](https://issues.jenkins ## Release Notes * 1.67 (unreleased) + * Allow import of Groovy code from the workspace when script security sandbox is enabled + ([#1078](https://github.com/jenkinsci/job-dsl-plugin/pull/1078)) * Enhanced support for the [Groovy Plugin](https://wiki.jenkins-ci.org/display/JENKINS/Groovy+plugin) ([JENKINS-44256](https://issues.jenkins-ci.org/browse/JENKINS-44256)) * Support for the older versions of the [Groovy Plugin](https://wiki.jenkins-ci.org/display/JENKINS/Groovy+plugin) is diff --git a/docs/Real-World-Examples.md b/docs/Real-World-Examples.md index b6251b03a..80a03c67b 100644 --- a/docs/Real-World-Examples.md +++ b/docs/Real-World-Examples.md @@ -66,12 +66,6 @@ REST API calls Import other files (i.e. with class definitions) into your script ----------------------------------------------------------------- -> Importing Groovy classes from the workspace is not possible when script security is enabled since that would undermine -> the script approval process. As an alternative it is possible to package the classes into a JAR file and add that JAR -> to the classpath through the _Additional classpath_ option. Classpath entries are subject to the approval process. See -> [Job DSL Gradle Example](https://github.com/sheehan/job-dsl-gradle-example) or -> [Job DSL Sample](https://github.com/unguiculus/job-dsl-sample) as starting point for building and packaging classes. - Make a directory at the same level as the DSL called `utilities` and create a file called `MyUtilities.groovy` in the `utilities` directory with the following contents: @@ -92,4 +86,4 @@ Then from the DSL, add something like this: def myJob = job('example') MyUtilities.addMyFeature(myJob) -Note that importing other files is not possible when [[Script Security]] is enabled. \ No newline at end of file +Note that importing other files is not possible when [[Script Security]] is enabled and not using Groovy Sandbox. diff --git a/job-dsl-core/src/main/groovy/javaposse/jobdsl/dsl/AbstractDslScriptLoader.groovy b/job-dsl-core/src/main/groovy/javaposse/jobdsl/dsl/AbstractDslScriptLoader.groovy index 6f61a4b22..1876a6617 100644 --- a/job-dsl-core/src/main/groovy/javaposse/jobdsl/dsl/AbstractDslScriptLoader.groovy +++ b/job-dsl-core/src/main/groovy/javaposse/jobdsl/dsl/AbstractDslScriptLoader.groovy @@ -48,9 +48,8 @@ abstract class AbstractDslScriptLoader - groovyShell.classLoader.close() - groovyShell.classLoader.parent.close() + ClassLoader classLoader = groovyShell.classLoader + while (classLoader != AbstractDslScriptLoader.classLoader) { + if (classLoader instanceof Closeable) { + ((Closeable) classLoader).close() + } + classLoader = classLoader.parent + } } } generatedItems @@ -112,8 +116,11 @@ abstract class AbstractDslScriptLoader createSecureScriptRequests(Collection scriptRequests) { + scriptRequests.collect { + // it is not safe to use additional classpath entries + new ScriptRequest(it.body, it.urlRoots[0..0] as URL[], it.ignoreExisting, it.scriptPath) + } } @Override @@ -46,4 +54,36 @@ class SandboxDslScriptLoader extends SecureDslScriptLoader { throw new DslException(e.message, e) } } + + private static class WorkspaceClassLoader extends URLClassLoader { + private final Item seedJob + + WorkspaceClassLoader(URL workspaceUrl, ClassLoader parent, Item seedJob) { + super([workspaceUrl] as URL[], parent) + this.seedJob = seedJob + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + throw new ClassNotFoundException(name) + } + + @Override + URL findResource(String name) { + if (!seedJob.hasPermission(Item.WORKSPACE)) { + return null + } + + super.findResource(name) + } + + @Override + Enumeration findResources(String name) throws IOException { + if (!seedJob.hasPermission(Item.WORKSPACE)) { + return Collections.emptyEnumeration() + } + + super.findResources(name) + } + } } diff --git a/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/ScriptApprovalDslScriptLoader.groovy b/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/ScriptApprovalDslScriptLoader.groovy index bcb7d8d9f..147b3c808 100644 --- a/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/ScriptApprovalDslScriptLoader.groovy +++ b/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/ScriptApprovalDslScriptLoader.groovy @@ -33,7 +33,7 @@ class ScriptApprovalDslScriptLoader extends SecureDslScriptLoader { } protected Collection createSecureScriptRequests(Collection scriptRequests) { - super.createSecureScriptRequests(scriptRequests).each { + scriptRequests.collect { if (it.body) { ScriptApproval.get().configuring( it.body, @@ -41,6 +41,9 @@ class ScriptApprovalDslScriptLoader extends SecureDslScriptLoader { ApprovalContext.create().withItem(seedJob) ) } + + // it is not safe to use additional classpath entries + new ScriptRequest(it.body, new URL[0], it.ignoreExisting, it.scriptPath) } } } diff --git a/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/SecureDslScriptLoader.groovy b/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/SecureDslScriptLoader.groovy index 4ee82e0a0..71928fbb8 100644 --- a/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/SecureDslScriptLoader.groovy +++ b/job-dsl-plugin/src/main/groovy/javaposse/jobdsl/plugin/SecureDslScriptLoader.groovy @@ -14,10 +14,10 @@ abstract class SecureDslScriptLoader extends JenkinsDslScriptLoader { super.runScripts(createSecureScriptRequests(scriptRequests)) } - protected Collection createSecureScriptRequests(Collection scriptRequests) { - scriptRequests.collect { - // it is not safe to use additional classpath entries - new ScriptRequest(it.body, new URL[0], it.ignoreExisting, it.scriptPath) - } + @Override + protected ClassLoader prepareClassLoader(URL[] urlRoots, ClassLoader classLoader) { + new URLClassLoader([] as URL[], classLoader) } + + protected abstract Collection createSecureScriptRequests(Collection scriptRequests) } diff --git a/job-dsl-plugin/src/test/groovy/ScriptHelper.groovy b/job-dsl-plugin/src/test/groovy/ScriptHelper.groovy new file mode 100644 index 000000000..9300cf2f5 --- /dev/null +++ b/job-dsl-plugin/src/test/groovy/ScriptHelper.groovy @@ -0,0 +1,5 @@ +class ScriptHelper { + static foo() { + 'foo' + } +} diff --git a/job-dsl-plugin/src/test/groovy/javaposse/jobdsl/plugin/ExecuteDslScriptsSpec.groovy b/job-dsl-plugin/src/test/groovy/javaposse/jobdsl/plugin/ExecuteDslScriptsSpec.groovy index 2700c02ee..53f112d7b 100644 --- a/job-dsl-plugin/src/test/groovy/javaposse/jobdsl/plugin/ExecuteDslScriptsSpec.groovy +++ b/job-dsl-plugin/src/test/groovy/javaposse/jobdsl/plugin/ExecuteDslScriptsSpec.groovy @@ -1442,6 +1442,123 @@ class ExecuteDslScriptsSpec extends Specification { ScriptApproval.get().pendingSignatures*.signature == ['staticMethod java.lang.System exit int'] } + def 'run script in sandbox with import from workspace'() { + setup: + String script = 'import Helper\njob(Helper.computeName()) { description("foo") }' + + jenkinsRule.instance.securityRealm = jenkinsRule.createDummySecurityRealm() + jenkinsRule.instance.authorizationStrategy = new MockAuthorizationStrategy() + .grant(Jenkins.READ, Item.READ, Item.CONFIGURE, Item.CREATE, Computer.BUILD, Item.WORKSPACE) + .everywhere().to('dev') + + FreeStyleProject job = jenkinsRule.createFreeStyleProject('seed') + FreeStyleBuild build = job.scheduleBuild2(0).get() + build.workspace.child('Helper.groovy').write('class Helper { static computeName() { "foo" } }', 'UTF-8') + job.buildersList.add(new ExecuteDslScripts(scriptText: script, sandbox: true)) + setupQIA('dev', job) + + when: + jenkinsRule.submit(jenkinsRule.createWebClient().login('dev').getPage(job, 'configure').getFormByName('config')) + + then: + assert ScriptApproval.get().pendingScripts*.script == [] + + when: + build = job.scheduleBuild2(0).get() + + then: + build.result == SUCCESS + assert ScriptApproval.get().pendingScripts*.script == [] + } + + def 'cannot run script in sandbox with import from workspace without WORKSPACE permission'() { + setup: + String script = 'import Helper\njob(Helper.computeName()) { description("foo") }' + + jenkinsRule.instance.securityRealm = jenkinsRule.createDummySecurityRealm() + jenkinsRule.instance.authorizationStrategy = new MockAuthorizationStrategy() + .grant(Jenkins.READ, Item.READ, Item.CONFIGURE, Item.CREATE, Computer.BUILD).everywhere().to('dev') + + FreeStyleProject job = jenkinsRule.createFreeStyleProject('seed') + FreeStyleBuild build = job.scheduleBuild2(0).get() + build.workspace.child('Helper.groovy').write('class Helper { static computeName() { "foo" } }', 'UTF-8') + job.buildersList.add(new ExecuteDslScripts(scriptText: script, sandbox: true)) + setupQIA('dev', job) + + when: + jenkinsRule.submit(jenkinsRule.createWebClient().login('dev').getPage(job, 'configure').getFormByName('config')) + + then: + assert ScriptApproval.get().pendingScripts*.script == [] + + when: + build = job.scheduleBuild2(0).get() + + then: + build.result == FAILURE + build.log.contains('unable to resolve class Helper') + ScriptApproval.get().pendingSignatures.isEmpty() + } + + def 'run script in sandbox with import from workspace with unapproved signature'() { + setup: + String script = 'import Helper\njob(Helper.boom()) { description("foo") }' + + jenkinsRule.instance.securityRealm = jenkinsRule.createDummySecurityRealm() + jenkinsRule.instance.authorizationStrategy = new MockAuthorizationStrategy() + .grant(Jenkins.READ, Item.READ, Item.CONFIGURE, Item.CREATE, Computer.BUILD, Item.WORKSPACE) + .everywhere().to('dev') + + FreeStyleProject job = jenkinsRule.createFreeStyleProject('seed') + FreeStyleBuild build = job.scheduleBuild2(0).get() + build.workspace.child('Helper.groovy').write('class Helper { static boom() { System.exit(0) } }', 'UTF-8') + job.buildersList.add(new ExecuteDslScripts(scriptText: script, sandbox: true)) + setupQIA('dev', job) + + when: + jenkinsRule.submit(jenkinsRule.createWebClient().login('dev').getPage(job, 'configure').getFormByName('config')) + + then: + assert ScriptApproval.get().pendingScripts*.script == [] + + when: + build = job.scheduleBuild2(0).get() + + then: + build.result == FAILURE + ScriptApproval.get().pendingSignatures*.signature == ['staticMethod java.lang.System exit int'] + } + + def 'cannot import compiled class from workspace'() { + setup: + String script = 'import ScriptHelper\njob(ScriptHelper.foo()) { description("foo") }' + + jenkinsRule.instance.securityRealm = jenkinsRule.createDummySecurityRealm() + jenkinsRule.instance.authorizationStrategy = new MockAuthorizationStrategy() + .grant(Jenkins.READ, Item.READ, Item.CONFIGURE, Item.CREATE, Computer.BUILD, Item.WORKSPACE) + .everywhere().to('dev') + + FreeStyleProject job = jenkinsRule.createFreeStyleProject('seed') + FreeStyleBuild build = job.scheduleBuild2(0).get() + build.workspace.child('ScriptHelper.class').copyFrom(getClass().getResourceAsStream('/ScriptHelper.class')) + job.buildersList.add(new ExecuteDslScripts(scriptText: script, sandbox: true)) + setupQIA('dev', job) + + when: + jenkinsRule.submit(jenkinsRule.createWebClient().login('dev').getPage(job, 'configure').getFormByName('config')) + + then: + assert ScriptApproval.get().pendingScripts*.script == [] + + when: + build = job.scheduleBuild2(0).get() + + then: + build.result == FAILURE + build.log.contains('Scripts not permitted to use staticMethod ScriptHelper foo') + ScriptApproval.get().pendingSignatures*.signature == ['staticMethod ScriptHelper foo'] + } + def 'cannot run script in sandbox without queue item authentication'() { setup: String script = 'job("test") { description("foo") }'