Skip to content

Commit

Permalink
Python as Scripting language for inline task
Browse files Browse the repository at this point in the history
  • Loading branch information
saksham2105 committed Jul 11, 2024
1 parent 3a43cc6 commit ad58439
Show file tree
Hide file tree
Showing 6 changed files with 428 additions and 0 deletions.
2 changes: 2 additions & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ dependencies {
implementation "com.github.ben-manes.caffeine:caffeine"

implementation "org.openjdk.nashorn:nashorn-core:15.4"
implementation "org.python:jython-slim:${revJython}"


// JAXB is not bundled with Java 11, dependencies added explicitly
// These are needed by Apache BVAL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@
*/
package com.netflix.conductor.core.events;

import java.util.Map;

import javax.script.Bindings;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;

import org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory;
import org.python.core.PyObject;
import org.python.util.PythonInterpreter;

public class ScriptEvaluator {

Expand Down Expand Up @@ -48,12 +52,36 @@ public static Boolean evalBool(String script, Object input) throws ScriptExcepti
* @return Generic object, the result of the evaluated expression.
*/
public static Object eval(String script, Object input) throws ScriptException {
if (input instanceof Map<?, ?>) {
Map<String, Object> inputs = (Map<String, Object>) input;
if (inputs.containsKey("evaluatorType")
&& inputs.get("evaluatorType").toString().equals("python")) {
return evalPython(script, input);
}
}
initEngine(false);
Bindings bindings = engine.createBindings();
bindings.put("$", input);
return engine.eval(script, bindings);
}

/**
* Evaluates the script with the help of input provided. Set environment variable using Jython
*
* @param script Script to be evaluated.
* @param input Input parameters.
* @throws ScriptException
* @return Generic object, the result of the evaluated expression.
*/
private static Object evalPython(String script, Object input) throws ScriptException {
Map<String, Object> inputs = (Map<String, Object>) input;
String outputIdentifier = inputs.get("outputIdentifier").toString();
PythonInterpreter interpreter = new PythonInterpreter();
interpreter.exec(script);
PyObject result = interpreter.get(outputIdentifier);
return result.toString();
}

// to mock in a test
public static String getEnv(String name) {
return System.getenv(name);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright 2024 Conductor Authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/
package com.netflix.conductor.core.execution.evaluators;

import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.script.ScriptException;

import org.python.core.PyObject;
import org.python.util.PythonInterpreter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import com.netflix.conductor.core.events.ScriptEvaluator;
import com.netflix.conductor.core.exception.TerminateWorkflowException;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;

@Component(PythonEvaluator.NAME)
public class PythonEvaluator implements Evaluator {

public static final String NAME = "python";
private static final Logger LOGGER = LoggerFactory.getLogger(PythonEvaluator.class);
private static final PythonInterpreter pythonInterpreter = new PythonInterpreter();
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final Pattern pattern =
Pattern.compile("\\$\\.([a-zA-Z0-9_\\.]+)"); // Regex Pattern to find all occurrences of

// $.[variable] or $.[nested.property] in script

@Override
public Object evaluate(String script, Object input) {
LOGGER.debug("Python evaluator -- script: {}", script);
try {
script = script.trim();
Map<String, Object> inputs = (Map<String, Object>) input;
script = replaceVariablesInScript(script, inputs);
boolean scriptTrusted = isScriptTrusted(script);
if (!scriptTrusted) {
throw new ScriptException(
"Script execution is restricted due to policy violations.");
}
Object result = ScriptEvaluator.eval(script, input);
LOGGER.debug("Python evaluator -- result: {}", result);
return result;
} catch (Exception e) {
LOGGER.error("Error while evaluating script: {}", script, e);
throw new TerminateWorkflowException(e.getMessage());
}
}

private static boolean isScriptTrusted(String script) {
try (InputStream inputStream =
PythonEvaluator.class
.getClassLoader()
.getResourceAsStream("python/untrusted_code_validator.py");
InputStreamReader isr = new InputStreamReader(inputStream, "UTF-8");
BufferedReader br = new BufferedReader(isr)) {
if (inputStream == null) {
throw new FileNotFoundException(
String.format(
"Resource file %s not found.", "untrusted_code_validator.py"));
}
StringBuilder stringBuilder = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
stringBuilder.append(line).append("\n");
}
String untrustedCode = "'''" + script + "'''";
String pythonScript = stringBuilder.toString().replace("${code}", untrustedCode);
pythonInterpreter.exec(pythonScript);
PyObject result = pythonInterpreter.get("codeTrusted");
return result.toString().equals("True");
} catch (Exception e) {
LOGGER.error("Some error encountered validating python script : {} as : {}", script, e);
return false;
}
}

public String replaceVariablesInScript(String script, Map<String, Object> inputs)
throws IOException {
String inputJsonString = objectMapper.writeValueAsString(inputs);
DocumentContext jsonContext = JsonPath.parse(inputJsonString);

// Use a Matcher to process all matches in the script
Matcher matcher = pattern.matcher(script);
StringBuffer updatedScript = new StringBuffer();

while (matcher.find()) {
String jsonPath = matcher.group(1);
try {
Object value = jsonContext.read("$." + jsonPath);
// Create the replacement string for the variable
String replacement = value != null ? value.toString() : "";
// Escape $ to avoid issues in replacement string
String safeReplacement = replacement.replace("$", "\\$");
// Append the new script with the replaced variable
matcher.appendReplacement(updatedScript, safeReplacement);
} catch (Exception e) {
// In case of an invalid JsonPath expression, keep the original placeholder
matcher.appendReplacement(updatedScript, "\\$." + jsonPath);
}
}
// Append the remaining part of the script after the last match
matcher.appendTail(updatedScript);
return updatedScript.toString();
}
}
44 changes: 44 additions & 0 deletions core/src/main/resources/python/untrusted_code_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
### This script contains code to check if the code to run in inline_task
### is safe and not vulnerable
### This is being executed by @PythonEvaluator.java
### Trusted code must not have import statements and some restricted built_in functions

import ast

class ConductorBuiltInRestrictor(ast.NodeVisitor):
restricted_builtins = {
'eval', 'exec', 'compile', 'execfile', 'del' ,'open', 'close', 'read', 'write', 'close', 'readlines', 'input', 'raw_input', 'open', '__import__', '__file__','__package__'
,'__path__','__spec__','__doc__','__module__','__loader__','__annotations__','__builtins__','__cached__','__build_class__', 'getattr',
'setattr', 'delattr', 'globals', 'locals', 'vars', 'dir', 'type', 'id',
'help', 'super', 'object', 'staticmethod', 'classmethod', 'property',
'basestring', 'bytearray', 'bytes', 'callable', 'classmethod', 'complex',
'delattr', 'dict', 'enumerate', 'eval', 'filter', 'frozenset', 'getattr',
'globals', 'hasattr', 'hash', 'help', 'id', 'input', 'isinstance',
'issubclass', 'iter', 'locals', 'map', 'memoryview',
'next', 'object', 'property', 'repr', 'reversed',
'setattr', 'sorted', 'staticmethod', 'vars', 'zip', 'reload', 'exit', 'quit'
}

def visit_Import(self, node):
raise ImportError("Import statements are not allowed.")

def visit_ImportFrom(self, node):
raise ImportError("Import statements are not allowed.")

def visit_Call(self, node):
if isinstance(node.func, ast.Name) and node.func.id in self.restricted_builtins:
raise ImportError("Usage of '%s' is not allowed." % node.func.id)
self.generic_visit(node)

def isCodeTrusted(self, code):
try:
tree = ast.parse(code)
self.visit(tree)
return True
except ImportError as e:
return False

# Example usage
restrictor = ConductorBuiltInRestrictor()
codeTrusted = restrictor.isCodeTrusted(${code}) # Should raise an ImportError
codeTrusted
Loading

0 comments on commit ad58439

Please sign in to comment.