Fix CI by adding missing sandbox class and regression tests

This commit is contained in:
pancake committed 2026-10-16 01:36:01 +02:00
1 parent 0aef416048
commit 74e6a85dec
4 files changed
+475

No files matched your search

@@ -0,0 +1,134 @@
package r4ghidra.repl.sandbox;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
/** Capability policy for REPL operations; grains name allowed resources, as in radare2. */
public final class R2Sandbox {
public static final int NONE = 0;
public static final int SOCKET = 1;
public static final int DISK = 2;
public static final int FILES = 4;
public static final int EXEC = 8;
public static final int ENVIRON = 16;
public static final int NETWORK = 32;
public static final int HIDDEN = 64;
public static final int ALL = SOCKET | DISK | FILES | EXEC | ENVIRON | NETWORK | HIDDEN;
private static final Map<String, Integer> GRAINS = new LinkedHashMap<>();
static {
GRAINS.put("socket", SOCKET);
GRAINS.put("disk", DISK);
GRAINS.put("files", FILES);
GRAINS.put("exec", EXEC);
GRAINS.put("environ", ENVIRON);
GRAINS.put("network", NETWORK);
GRAINS.put("hidden", HIDDEN);
}
private boolean enabled = true;
private int grains = NONE;
private boolean locked;
public synchronized boolean isEnabled() {
return enabled;
}
public synchronized int getGrains() {
return grains;
}
public synchronized void setEnabled(boolean value) {
if (locked && !value) {
throw new IllegalArgumentException("Cannot disable a locked sandbox");
}
enabled = value;
}
public synchronized void setGrains(int value) {
if ((value & ~ALL) != 0) {
throw new IllegalArgumentException("Unknown sandbox grain bits");
}
if (locked && (value & grains) != value) {
throw new IllegalArgumentException("Cannot increase locked sandbox permissions");
}
grains = value;
}
/** Freeze the current permissions as a ceiling for untrusted commands, such as HTTP requests. */
public synchronized void lock() {
enabled = true;
locked = true;
}
public synchronized boolean isRestricted(int resources) {
return enabled && (grains & resources) != resources;
}
/** Parse comma-separated permissions, with a leading ! starting from all permissions. */
public static int parseGrains(String expression) {
String expr = expression.trim().toLowerCase(Locale.ROOT);
int result = expr.startsWith("!") ? ALL : NONE;
for (String part : expr.split(",", -1)) {
String name = part.trim();
boolean remove = name.startsWith("!");
if (remove) {
name = name.substring(1).trim();
}
Integer grain =
switch (name) {
case "all" -> ALL;
case "none" -> NONE;
case "file" -> FILES;
default -> GRAINS.get(name);
};
if (grain == null) {
throw new IllegalArgumentException("Invalid sandbox grain: " + part.trim());
}
if (remove) {
result &= ~grain;
} else if (grain == NONE) {
result = NONE;
} else {
result |= grain;
}
}
return result;
}
public static String formatGrains(int grains) {
if (grains == NONE) {
return "none";
}
if (grains == ALL) {
return "all";
}
StringBuilder result = new StringBuilder();
GRAINS.forEach(
(name, grain) -> {
if ((grains & grain) != 0) {
if (result.length() > 0) {
result.append(',');
}
result.append(name);
}
});
return result.toString();
}
public static String getHelp() {
return "Usage: e cfg.sandbox.grain=permission[,permission...]\n"
+ " all / none allow all / no resources\n"
+ " files read files, list directories and inspect file existence\n"
+ " disk write, append and delete files (also requires files)\n"
+ " exec execute shell commands, external pipes and process exit\n"
+ " environ read or modify environment variables\n"
+ " hidden access dotfiles and hidden directories (also requires files)\n"
+ " socket/network reserved for socket operations / non-local network access\n"
+ " !name remove a permission; leading ! starts from all\n"
+ " Example: e cfg.sandbox.grain=files,disk\n"
+ " JavaScript requires all permissions because it has unrestricted JVM access.\n";
}
}
@@ -0,0 +1,90 @@
package r4ghidra.repl.handlers;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONTokener;
import r4ghidra.repl.R2Command;
import r4ghidra.repl.R2Context;
/** Regression tests for JSON output from JavaScript values. */
public final class R2JsCommandHandlerTest {
public static void run(R2Context context) throws Exception {
int sandboxFlags = context.getSandboxFlags();
context.setSandboxFlags(R2Context.R_SANDBOX_GRAIN_NONE);
try {
R2JsCommandHandler handler = new R2JsCommandHandler();
for (String value :
List.of("", "plain", "\"quoted\"", "C:\\new\\test\\", "\\n", "λ 😀", "\u2028\u2029")) {
checkString(handler, context, JSONObject.quote(value), value);
}
for (int i = 0; i < 32; i++) {
checkString(handler, context, "String.fromCharCode(" + i + ")", String.valueOf((char) i));
}
checkString(
handler,
context,
"String.fromCharCode(92,34,44,34,105,110,106,101,99,116,101,100,34,58,116,114,117,101)",
"\\\",\"injected\":true");
check(json(handler, context, "42").equals("42\n"), "number output");
check(json(handler, context, "true").equals("true\n"), "boolean output");
String nested = json(handler, context, "({text: 'a\\nb\\tc\\\\d', values: [1, true, null]})");
JSONObject object = new JSONObject(nested);
check(object.getString("text").equals("a\nb\tc\\d"), "nested string");
check(object.getJSONArray("values").similar(new JSONArray("[1,true,null]")), "nested array");
check(
new JSONArray(json(handler, context, "['a\\nb', 'c\\\\d']"))
.toList()
.equals(List.of("a\nb", "c\\d")),
"array output");
// Serializing an object must never evaluate its textual representation as source code.
json(handler, context, "var serializedTextExecuted = false");
json(handler, context, "new java.lang.StringBuilder('(serializedTextExecuted = true)')");
check(
json(handler, context, "serializedTextExecuted").equals("false\n"),
"object text was evaluated");
// Circular values retain the handler's string fallback.
checkString(
handler, context, "var cyclic = {}; cyclic.self = cyclic; cyclic", "[object Object]");
// Force the fallback with a host value whose textual representation needs escaping.
checkString(
handler,
context,
"JSON.stringify = function() { throw new Error('forced'); };"
+ "new java.lang.StringBuilder('a\\nb\\tc\\\\d\\\"')",
"a\nb\tc\\d\"");
System.out.println("JavaScript JSON regression tests passed");
} finally {
context.setSandboxFlags(sandboxFlags);
}
}
private static String json(R2JsCommandHandler handler, R2Context context, String script)
throws Exception {
return handler.execute(new R2Command("js", "j", List.of(script), null), context);
}
private static void checkString(
R2JsCommandHandler handler, R2Context context, String script, String expected)
throws Exception {
String output = json(handler, context, script);
check(output.endsWith("\n"), "missing output newline");
for (int i = 0; i < output.length() - 1; i++) {
check(output.charAt(i) >= 32, "unescaped control character in JSON output");
}
JSONTokener tokener = new JSONTokener(output);
check(expected.equals(tokener.nextValue()), "JSON string did not round-trip: " + output);
check(tokener.nextClean() == 0, "extra data after JSON string: " + output);
}
private static void check(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
}
@@ -0,0 +1,193 @@
package r4ghidra.repl.sandbox;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import r4ghidra.repl.R2Context;
import r4ghidra.repl.R2REPLImpl;
import r4ghidra.repl.config.R2EvalConfig;
import r4ghidra.repl.filesystem.R2FileSystem;
import r4ghidra.repl.filesystem.R2FileSystemException;
import r4ghidra.repl.handlers.R2JsCommandHandler;
/** Exercises the real parser and filesystem against temporary files and a Ghidra context. */
public final class R2SandboxIntegrationTest {
public static void run(R2REPLImpl repl, Path directory) throws Exception {
Files.createDirectories(directory);
Path plain = directory.resolve("plain.txt");
Files.writeString(plain, "fixture");
Path script = directory.resolve("script with spaces.r2");
Files.writeString(script, "echo from-script\n");
Path js = directory.resolve("script.r2.js");
Files.writeString(js, "1+1");
R2Context context = repl.getContext();
R2EvalConfig config = context.getEvalConfig();
R2FileSystem fs = context.getFileSystem();
repl.registerCommand("js", new R2JsCommandHandler());
check(config.get("cfg.sandbox.grain").equals("none"), "default grain");
for (String command :
List.of(
"!echo denied",
"!!echo denied",
".!echo denied",
"echo denied | cat",
"%",
"%*",
"%j",
"%PATH",
"%R4GHIDRA_SANDBOX_TEST=denied",
". '" + script + "'",
". '" + js + "'",
"js 1+1",
"q 23",
"q!!")) {
denied(repl, command);
}
check(repl.executeCommand("!").contains("echo denied"), "history stays available");
check(!fs.fileExists(plain.toString()), "blocked metadata");
blocked(() -> fs.readFile(plain.toString()));
blocked(() -> fs.readBytes(plain.toString()));
blocked(() -> fs.listFiles(directory.toString()));
// Memory files remain usable with no host permissions, including script loading.
fs.writeFile("$sandbox", "echo memory-script\n");
check(repl.executeCommand(". $sandbox").equals("memory-script\n"), "memory script");
repl.executeCommand("echo memory > $output");
check(fs.readFile("$output").equals("memory\n"), "memory redirection");
fs.writeBytes("$binary", new byte[] {0, (byte) 255});
check(fs.readBytes("$binary")[1] == (byte) 255, "memory bytes");
check(repl.executeCommand("e cfg.sandbox.grain=?").contains("!name"), "grain help");
check(config.get("cfg.sandbox.grain").equals("none"), "help does not change policy");
repl.executeCommand("e sandbox.grain=files");
check(config.get("sandbox.grain").equals("files"), "short alias");
check(config.get("cfg.sandbox.grain").equals("files"), "canonical grain");
check(fs.readFile(plain.toString()).equals("fixture"), "files permits text reads");
check(fs.readBytes(plain.toString()).length == 7, "files permits binary reads");
check(fs.fileExists(plain.toString()), "files permits metadata");
check(fs.listFiles(directory.toString()).contains(plain.toString()), "files permits listing");
check(repl.executeCommand(". '" + script + "'").equals("from-script\n"), "disk script");
blocked(() -> fs.writeFile(plain.toString(), "changed"));
blocked(() -> fs.writeBytes(plain.toString(), new byte[] {1}));
blocked(() -> fs.appendFile(plain.toString(), "changed"));
blocked(() -> fs.deleteFile(plain.toString()));
denied(repl, "echo changed > " + plain);
denied(repl, "echo changed >> " + plain);
check(Files.readString(plain).equals("fixture"), "blocked writes leave file intact");
denied(repl, "js 1+1");
// Reject invalid values before they can change either config or the active policy.
for (String invalid : List.of("rw", "files,garbage", "files,", "")) {
String result = repl.executeCommand("e cfg.sandbox.grain=" + invalid);
check(result.startsWith("Error:"), "invalid grain rejected: " + result);
check(config.get("cfg.sandbox.grain").equals("files"), "invalid grain preserves config");
blocked(() -> fs.writeFile(plain.toString(), "changed"));
}
check(repl.executeCommand("e cfg.sandbox=typo").startsWith("Error:"), "invalid boolean");
check(config.getBoolean("cfg.sandbox"), "invalid boolean preserves sandbox");
repl.executeCommand("e cfg.sandbox.grain = files, disk");
fs.writeFile(plain.toString(), "one");
fs.appendFile(plain.toString(), "two");
check(fs.readFile(plain.toString()).equals("onetwo"), "disk writes and append");
fs.writeBytes(plain.toString(), new byte[] {0, (byte) 255});
check(fs.readBytes(plain.toString())[1] == (byte) 255, "disk bytes");
fs.deleteFile(plain.toString());
check(!Files.exists(plain), "disk deletion");
repl.executeCommand("e cfg.sandbox.grain=disk");
blocked(() -> fs.writeFile(plain.toString(), "changed"));
check(!Files.exists(plain), "disk without files cannot create files");
testHiddenFiles(repl, directory);
repl.executeCommand("e cfg.sandbox.grain=exec");
check(
repl.executeCommand("!!printf '%s' 'hello world'").equals("hello world\n"),
"shell arguments");
check(repl.executeCommand("!true").contains("code: 0"), "interactive shell");
check(repl.executeCommand(".!echo echo from-shell").equals("from-shell\n"), "shell script");
check(repl.executeCommand("echo piped | cat").equals("piped\n"), "external pipe");
denied(repl, "%PATH");
denied(repl, "js 1+1");
// Recheck the permission after the left command, before spawning the pipe process.
denied(repl, "e cfg.sandbox.grain=none | cat");
check(config.get("cfg.sandbox.grain").equals("none"), "pipe left side narrowed permissions");
denied(repl, "e cfg.sandbox=false | cat");
check(config.getBoolean("cfg.sandbox"), "denied pipe never executes its left side");
repl.executeCommand("e cfg.sandbox.grain=environ");
String environment = repl.executeCommand("%j");
check(new org.json.JSONObject(environment).length() > 0, "environment grain");
denied(repl, "!!echo denied");
// Turning the local sandbox off and on preserves the chosen grain.
repl.executeCommand("e cfg.sandbox=off");
check(!context.isSandboxed(R2Context.R_SANDBOX_GRAIN_ALL), "sandbox off");
repl.executeCommand("e cfg.sandbox=ON");
check(config.get("cfg.sandbox.grain").equals("environ"), "grain survives toggle");
denied(repl, "!!echo denied");
repl.executeCommand("e cfg.sandbox.grain=all");
check(repl.executeCommand("js 1+1").equals("2\n"), "explicit unrestricted JavaScript");
check(repl.executeCommand(". '" + js + "'").equals("2\n"), "JavaScript file");
context.setSandboxFlags(R2Context.R_SANDBOX_GRAIN_ALL & ~R2Context.R_SANDBOX_GRAIN_FILES);
check(config.get("cfg.sandbox.grain").equals("files"), "restriction API synchronizes config");
// Suppressed listeners must not bypass sandbox validation or enforcement.
config.set("cfg.sandbox.grain", "none", false);
blocked(() -> fs.readFile(script.toString()));
System.out.println("Sandbox command integration tests passed");
}
private static void testHiddenFiles(R2REPLImpl repl, Path directory) throws Exception {
R2FileSystem fs = repl.getContext().getFileSystem();
Path hidden = Files.createDirectories(directory.resolve(".hidden"));
Path secret = hidden.resolve("secret");
Files.writeString(secret, "secret");
Path alias = directory.resolve("alias");
Files.createSymbolicLink(alias, hidden);
repl.executeCommand("e cfg.sandbox.grain=files,disk");
for (Path path : List.of(secret, alias.resolve("secret"))) {
blocked(() -> fs.readFile(path.toString()));
blocked(() -> fs.readBytes(path.toString()));
blocked(() -> fs.writeFile(path.toString(), "changed"));
blocked(() -> fs.deleteFile(path.toString()));
check(!fs.fileExists(path.toString()), "hidden metadata blocked");
}
blocked(() -> fs.writeBytes(alias.resolve("new").toString(), new byte[] {1}));
blocked(() -> fs.listFiles(alias.toString()));
List<String> visible = fs.listFiles(directory.toString());
check(
!visible.contains(hidden.toString()) && !visible.contains(alias.toString()),
"hidden listing filtered");
check(Files.readString(secret).equals("secret"), "hidden file untouched");
check(!Files.exists(hidden.resolve("new")), "hidden target not created");
repl.executeCommand("e cfg.sandbox.grain=files,disk,hidden");
check(fs.readFile(secret.toString()).equals("secret"), "hidden grant");
check(fs.readFile(alias.resolve("secret").toString()).equals("secret"), "hidden symlink grant");
}
private static void denied(R2REPLImpl repl, String command) {
String result = repl.executeCommand(command);
check(result.startsWith("Error:") && result.contains("sandbox"), command + ": " + result);
}
private static void blocked(Operation operation) throws Exception {
try {
operation.run();
throw new AssertionError("Sandbox operation allowed");
} catch (R2FileSystemException expected) {
// Expected.
}
}
private interface Operation {
void run() throws Exception;
}
private static void check(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
}
@@ -0,0 +1,58 @@
package r4ghidra.repl.sandbox;
import java.util.List;
/** Runs without Ghidra or a test framework. */
public final class R2SandboxTest {
public static void main(String[] args) {
check(R2Sandbox.parseGrains("none") == 0, "none");
check(R2Sandbox.parseGrains("all") == R2Sandbox.ALL, "all");
check(R2Sandbox.parseGrains("file,disk") == (R2Sandbox.FILES | R2Sandbox.DISK), "file alias");
check(
R2Sandbox.parseGrains("!disk,!exec") == (R2Sandbox.ALL & ~R2Sandbox.DISK & ~R2Sandbox.EXEC),
"leading negation");
check(R2Sandbox.parseGrains("all,none,environ") == R2Sandbox.ENVIRON, "none resets");
check(R2Sandbox.parseGrains("all,!all,files") == R2Sandbox.FILES, "remove all");
check(R2Sandbox.parseGrains(" FILES , !disk ") == R2Sandbox.FILES, "spaces and case");
for (int mask = 0; mask <= R2Sandbox.ALL; mask++) {
check(R2Sandbox.parseGrains(R2Sandbox.formatGrains(mask)) == mask, "round trip " + mask);
}
for (String invalid :
List.of("", "rw", "unknown", "files,", ",exec", "files,,exec", "!!disk")) {
rejects(() -> R2Sandbox.parseGrains(invalid));
}
R2Sandbox policy = new R2Sandbox();
check(policy.isRestricted(R2Sandbox.ALL), "default denies host resources");
policy.setGrains(R2Sandbox.FILES);
check(!policy.isRestricted(R2Sandbox.FILES), "allow reads");
check(policy.isRestricted(R2Sandbox.FILES | R2Sandbox.DISK), "write requires both grains");
rejects(() -> policy.setGrains(128));
check(policy.getGrains() == R2Sandbox.FILES, "invalid bits preserve policy");
policy.setEnabled(false);
check(!policy.isRestricted(R2Sandbox.ALL), "disabled policy");
policy.setEnabled(true);
check(policy.isRestricted(R2Sandbox.DISK), "re-enabling preserves grain");
policy.lock();
rejects(() -> policy.setEnabled(false));
rejects(() -> policy.setGrains(R2Sandbox.ALL));
policy.setGrains(R2Sandbox.NONE);
rejects(() -> policy.setGrains(R2Sandbox.FILES));
check(policy.isEnabled() && policy.getGrains() == 0, "locked policy can only narrow");
System.out.println("Sandbox policy regression tests passed");
}
private static void rejects(Runnable action) {
try {
action.run();
throw new AssertionError("Invalid policy accepted");
} catch (IllegalArgumentException expected) {
// Expected.
}
}
private static void check(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
}