#include #include #include #include #include #include // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h) #include #include #include #include #include #include #include #include // secret_constants::USER_SECRET_FILENAME #include #include #include #include #include "libslic3r_version.h" #include "plugin_test_utils.hpp" #include "test_utils.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace Slic3r; namespace fs = boost::filesystem; namespace py = pybind11; namespace { // Seed the deny registry with the same list install_hook() uses. Both draw from // PluginAuditManager::default_denied_filenames(), so the test and production seeding cannot // drift apart. The registry is a process singleton, so repeated seeding only appends harmless // duplicates; matching is unaffected. void seed_denied_names() { PluginAuditManager& mgr = PluginAuditManager::instance(); for (const auto& name : PluginAuditManager::default_denied_filenames()) mgr.add_denied_filename(name); } // Seed the keyword registry with the same list install_hook() uses. Same rationale as // seed_denied_names(): a process-singleton registry, seeded from the single shared source so // production and tests cannot drift apart. void seed_denied_keywords() { PluginAuditManager& mgr = PluginAuditManager::instance(); for (const auto& keyword : PluginAuditManager::default_denied_path_keywords()) mgr.add_denied_path_keyword(keyword); } } // namespace TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]") { seed_denied_names(); const PluginAuditManager& mgr = PluginAuditManager::instance(); SECTION("the seeded names are denied by their base name") { CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf"))); CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".conf"))); CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME))); CHECK(mgr.is_denied_filename(fs::path(".install_state.json"))); } SECTION("companions holding the same secrets are denied by the prefix rule") { CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf.bak"))); CHECK(mgr.is_denied_filename(fs::path(std::string(secret_constants::USER_SECRET_FILENAME) + ".tmp"))); // Windows alternate data streams share the same base name. CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf:stream"))); } SECTION("the denial ignores the directory the file lives in") { CHECK(mgr.is_denied_filename(fs::path("/tmp") / (SLIC3R_APP_KEY ".conf"))); CHECK(mgr.is_denied_filename(fs::path("/some/plugin/dir") / (SLIC3R_APP_KEY ".conf"))); // Traversal is handled for free: filename() of the path below is already the denied name. CHECK(mgr.is_denied_filename(fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf"))); } SECTION("matching is case-insensitive on every platform") { CHECK(mgr.is_denied_filename(fs::path("orcaslicer.conf"))); CHECK(mgr.is_denied_filename(fs::path("ORCASLICER.CONF"))); CHECK(mgr.is_denied_filename(fs::path("ORCA_REFRESH_TOKEN.SEC"))); } SECTION("an unrelated name that merely shares a stem is not denied") { // The prefix is the full registered name ("OrcaSlicer.conf"), not the stem "OrcaSlicer", // so a sibling file with a different extension/suffix stays allowed. CHECK_FALSE(mgr.is_denied_filename(fs::path(data_dir()) / (SLIC3R_APP_KEY "_other.txt"))); CHECK_FALSE(mgr.is_denied_filename(fs::path(data_dir()) / (SLIC3R_APP_KEY ".json"))); CHECK_FALSE(mgr.is_denied_filename(fs::path("orca_refresh_token.txt"))); } SECTION("an empty path is not denied") { CHECK_FALSE(mgr.is_denied_filename(fs::path())); } } TEST_CASE("Plugin audit deny beats allowed roots", "[audit]") { ScopedDataDir data_dir_guard("plugin-audit-deny"); seed_denied_names(); PluginAuditManager& mgr = PluginAuditManager::instance(); // Reproduce install_hook()'s grant: data_dir() is a global allowed root, so both the app // config and the token would otherwise be reachable simply by living inside it. mgr.add_global_allowed_root(data_dir()); // Enter a plugin context. The deny must hold even inside a globally allowed root. ScopedPluginAuditContext ctx("test_plugin", ""); const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME; const fs::path install_state = fs::path(data_dir()) / "plugins" / "example" / ".install_state.json"; SECTION("a non-denied file inside the allowed root is writable (root really grants writes)") { AuditDecision decision = mgr.check_open((fs::path(data_dir()) / "plugin_data.txt").string(), "w"); CHECK(decision.allowed); } SECTION("a file outside the allowed root is blocked for reads as well as writes") { AuditDecision decision = mgr.check_open((fs::path(data_dir()).parent_path() / "outside.txt").string(), "r"); CHECK_FALSE(decision.allowed); CHECK(decision.reason == "outside allowed root"); } SECTION("writing the app config is blocked despite data_dir() being allowed") { AuditDecision decision = mgr.check_open(conf.string(), "w"); CHECK_FALSE(decision.allowed); CHECK(decision.reason == "denied filename"); } SECTION("reading the app config is blocked despite the allowed root") { AuditDecision decision = mgr.check_open(conf.string(), "r"); CHECK_FALSE(decision.allowed); CHECK(decision.reason == "denied filename"); } SECTION("reading the cloud refresh token is blocked") { AuditDecision decision = mgr.check_open(token.string(), "r"); CHECK_FALSE(decision.allowed); } SECTION("the token staging companion (.tmp) is blocked too") { AuditDecision decision = mgr.check_open((token.string() + ".tmp"), "w"); CHECK_FALSE(decision.allowed); } SECTION("plugin install state is blocked for reads and writes despite the allowed root") { AuditDecision read_decision = mgr.check_open(install_state.string(), "r"); CHECK_FALSE(read_decision.allowed); CHECK(read_decision.reason == "denied filename"); AuditDecision write_decision = mgr.check_open(install_state.string(), "w"); CHECK_FALSE(write_decision.allowed); CHECK(write_decision.reason == "denied filename"); } SECTION("a traversal path resolving to the config is blocked") { const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf"); AuditDecision decision = mgr.check_open(traversal.string(), "r"); CHECK_FALSE(decision.allowed); } } TEST_CASE("Plugin audit deny beats a plugin's own scoped root", "[audit]") { ScopedDataDir data_dir_guard("plugin-audit-scoped"); seed_denied_names(); PluginAuditManager& mgr = PluginAuditManager::instance(); // A plugin's private directory, granted as a scoped root while it runs. const fs::path plugin_dir = fs::path(data_dir()) / "plugins" / "test_plugin"; fs::create_directories(plugin_dir); ScopedPluginAuditContext ctx("test_plugin", ""); mgr.add_scoped_allowed_root(plugin_dir); SECTION("the plugin's own non-denied file opens for read and write") { const std::string own_file = (plugin_dir / "state.json").string(); CHECK(mgr.check_open(own_file, "r").allowed); CHECK(mgr.check_open(own_file, "w").allowed); } SECTION("a denied name stashed inside the plugin's own root is still blocked") { const std::string smuggled = (plugin_dir / (SLIC3R_APP_KEY ".conf")).string(); AuditDecision decision = mgr.check_open(smuggled, "w"); CHECK_FALSE(decision.allowed); CHECK(decision.reason == "denied filename"); } } TEST_CASE("Plugin audit does not constrain non-plugin code", "[audit]") { ScopedDataDir data_dir_guard("plugin-audit-noplugin"); seed_denied_names(); PluginAuditManager& mgr = PluginAuditManager::instance(); mgr.clear_current_plugin(); // no plugin context: this is OrcaSlicer's own C++/internal Python const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); // The name is still recognised as denied... CHECK(mgr.is_denied_filename(conf)); // ...but with no current plugin the access check allows it: denies constrain plugin code only. CHECK(mgr.check_open(conf.string(), "w").allowed); CHECK(mgr.check_open(conf.string(), "r").allowed); } TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", "[audit]") { seed_denied_keywords(); const PluginAuditManager& mgr = PluginAuditManager::instance(); SECTION("a 'secrets' directory component is denied") { CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secrets/api_key.json"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/secret/token.txt"))); } SECTION("a 'certificate(s)' directory component is denied") { CHECK(mgr.is_denied_path_keyword(fs::path("/resources/cert/slicer_base64.cer"))); CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem"))); } SECTION("a 'conf'/'config' directory or config file component is denied") { CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.ini"))); } SECTION("matching is case-insensitive") { CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt"))); CHECK(mgr.is_denied_path_keyword(fs::path("/resources/Certificates/ca.pem"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/PLUGIN.CONF"))); } SECTION("matching is not limited to the base name -- any ancestor component counts") { CHECK(mgr.is_denied_path_keyword(fs::path("/data/secrets/nested/deep/file.txt"))); } SECTION("an unrelated path is not denied") { CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/__config__.py"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/_core/_ufunc_config.py"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/configparser.py"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/sysconfig.py"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/logging/config.py"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/certifi/cacert.pem"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Conference/output.txt"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Concert/output.txt"))); } SECTION("an empty path is not denied") { CHECK_FALSE(mgr.is_denied_path_keyword(fs::path())); } } TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]") { seed_denied_names(); seed_denied_keywords(); const PluginAuditManager& mgr = PluginAuditManager::instance(); SECTION("a filename-registry match is denied") { CHECK(mgr.is_denied_path(fs::path(SLIC3R_APP_KEY ".conf"))); } SECTION("a keyword-registry match is denied") { CHECK(mgr.is_denied_path(fs::path("/plugin/secrets/token.txt"))); } SECTION("a path matching neither registry is not denied") { CHECK_FALSE(mgr.is_denied_path(fs::path("/plugin/output/model.gcode"))); } } TEST_CASE("Plugin audit a read-only allowed root blocks writes but not reads", "[audit]") { ScopedDataDir data_dir_guard("plugin-audit-readonly"); ScopedResourcesDir resources_dir_guard("plugin-audit-readonly-resources"); seed_denied_names(); seed_denied_keywords(); PluginAuditManager& mgr = PluginAuditManager::instance(); mgr.add_global_allowed_root(resources_dir(), /*allow_write=*/false); ScopedPluginAuditContext ctx("test_plugin", ""); const fs::path readonly_file = fs::path(resources_dir()) / "profiles" / "vendor.json"; SECTION("a read inside the read-only root is allowed") { CHECK(mgr.check_open(readonly_file.string(), "r").allowed); } SECTION("a write inside the read-only root is blocked") { AuditDecision decision = mgr.check_open(readonly_file.string(), "w"); CHECK_FALSE(decision.allowed); CHECK(decision.reason == "outside allowed root"); } SECTION("a create inside the read-only root is blocked") { AuditDecision decision = mgr.check_path_access(readonly_file, /*is_write=*/true); CHECK_FALSE(decision.allowed); } SECTION("the bundled cert underneath the read-only root is denied even for reads") { const fs::path cert = fs::path(resources_dir()) / "cert" / "slicer_base64.cer"; AuditDecision decision = mgr.check_open(cert.string(), "r"); CHECK_FALSE(decision.allowed); CHECK(decision.reason == "denied path keyword"); } } TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit]") { ScopedDataDir data_dir_guard("plugin-audit-scoped-readonly"); seed_denied_names(); seed_denied_keywords(); PluginAuditManager& mgr = PluginAuditManager::instance(); const fs::path readonly_dir = fs::path(data_dir()) / "readonly_scope"; fs::create_directories(readonly_dir); ScopedPluginAuditContext ctx("test_plugin", ""); mgr.add_scoped_allowed_root(readonly_dir, /*allow_write=*/false); SECTION("a read inside the scoped read-only root is allowed") { CHECK(mgr.check_open((readonly_dir / "vendor.json").string(), "r").allowed); } SECTION("a write inside the scoped read-only root is blocked") { CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed); } } // --------------------------------------------------------------------------- // The audit hook, run in the embedded interpreter // --------------------------------------------------------------------------- // // Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system // up with the audit hook installed, once, and shuts it down at the end. The tests loop over their // cases instead of using SECTIONs, which would restart the interpreter for every section. namespace { // Discovered, never loaded: the hook only needs the plugin's root and its sidecar. const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script # requires-python = ">=3.12" # # [tool.orcaslicer.plugin] # name = "Audit Plugin" # description = "Plugin audit hook fixture" # author = "OrcaSlicer" # version = "1.0" # type = "script" # /// )PY"; // A third-party library, installed outside the stdlib and outside the plugin. const char* const LIBRARY_SOURCE = R"PY( import socket import urllib.request def read_all(paths): for path in paths: with open(path) as f: f.read() def connect_from_host(port): socket.gethostname() socket.create_connection(("127.0.0.1", port)).close() def fetch(url): with urllib.request.urlopen(url) as response: return response.read() def fetch_each(urls): for url in urls: yield fetch(url) class ReadingHandler(urllib.request.BaseHandler): """Reads a file while each response is processed.""" def __init__(self, path): self.path = path self.outcome = None def http_response(self, request, response): try: open(self.path).close() self.outcome = "ok" except PermissionError: self.outcome = "denied" return response )PY"; // Test-side Python, run outside any plugin context: files outside every allowed root, a local // HTTP server, the library, and every module the tests use imported up front, so that no import // happens inside a plugin context. Code from exec() runs as "", which is not stdlib, so // attempt() and fetch() stand in for plugin code. const char* const AUDIT_SETUP = R"PY( import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading import urllib.request, zipfile if os.name != "nt": import pty APPROVED = os.path.join(OUTSIDE, "approved.txt") PRIVATE = os.path.join(OUTSIDE, "private.txt") MISSING = os.path.join(OUTSIDE, "missing") TREE = os.path.join(OUTSIDE, "tree") TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"] for path in (APPROVED, PRIVATE): with open(path, "w") as f: f.write("x") for sub in ("one", "two"): os.makedirs(os.path.join(TREE, sub)) with open(os.path.join(TREE, sub, "f.txt"), "w") as f: f.write("x") LIBRARY = os.path.join(OUTSIDE, "library") os.makedirs(LIBRARY) with open(os.path.join(LIBRARY, "netlib.py"), "w") as f: f.write(LIBRARY_SOURCE) sys.path.insert(0, LIBRARY) import netlib class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path == "/redirect": self.send_response(302) self.send_header("Location", URL_B) body = b"" else: self.send_response(200) body = b"ok" self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) threading.Thread(target=server.serve_forever, daemon=True).start() PORT = server.server_address[1] BASE = "http://127.0.0.1:%d" % PORT URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect" urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({}))) def fetch(url): with urllib.request.urlopen(url) as response: return response.read() fetch(URL_A) # urllib imports some modules on its first request def attempt(action): try: action() return "ok" except PermissionError: return "denied" def spawn_checked(argv): parent = os.getpid() try: return pty.spawn(argv) finally: if os.getpid() != parent: os._exit(1) # a child whose exec was refused must not go on running the tests finalizer_outcomes = [] class TrackedRequest(urllib.request.Request): def __del__(self): finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close())) )PY"; struct Prompt { std::string plugin; std::string event; std::vector targets; }; // One line per prompt, "event: target ...", so a failed check shows every prompt. std::string describe(const std::vector& prompts) { std::string text; for (const Prompt& prompt : prompts) { text += prompt.event + ":"; for (const std::string& target : prompt.targets) text += " " + target; text += "\n"; } return text; } // The plugin system with the audit hook installed, and the permission dialog replaced by a // recorder that answers with `approve`. Declare it first in a test, so it is destroyed last. struct AuditHarness { // Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log. // data_dir() is a global allowed root from then on, so the test files live in `outside`. ScopedDataDir data{"plugin-audit-hook"}; bool initialized = PluginManager::instance().initialize(); ScopedTemporaryDir outside{"orca-plugin-audit-outside"}; std::vector prompts; std::function approve = [](const Prompt&) { return false; }; AuditHarness() { PluginAuditManager::set_permission_prompt_for_testing( [this](const std::string& event, const std::vector& targets) { prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets}); return approve(prompts.back()); }); } ~AuditHarness() { PluginAuditManager::set_permission_prompt_for_testing(nullptr); PluginManager::instance().shutdown(); PythonInterpreter::instance().shutdown(); } // Call before taking the GIL: discovery is not Python work. void add_plugin(const std::string& key) { const fs::path root = data.plugins_dir() / key; fs::create_directories(root); std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE; save_permissions(key, {}); PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true); } // Grants as if saved in an earlier session. void save_permissions(const std::string& key, const PluginPermissions& permissions) { PluginInstallState state; state.installed_from = "local"; state.installed_version = "1.0"; state.plugin_name = key; state.permissions = permissions; REQUIRE(write_install_state(data.plugins_dir() / key, state)); } PluginPermissions saved_permissions(const std::string& key) const { PluginInstallState state; read_install_state(data.plugins_dir() / key, state); return state.permissions; } }; // The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP // server before the interpreter shuts down. struct PythonSide { py::dict globals; explicit PythonSide(const AuditHarness& harness) { globals["OUTSIDE"] = harness.outside.path().string(); globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE; exec(AUDIT_SETUP); } // A Python error fails the test here, as text: an error_already_set left to propagate would // outlive the interpreter the harness shuts down. void exec(const char* code) { std::string error; try { py::exec(code, globals); } catch (py::error_already_set& raised) { error = raised.what(); } if (!error.empty()) FAIL(error); } ~PythonSide() { try { globals["server"].attr("shutdown")(); globals["server"].attr("server_close")(); } catch (...) { } } std::string str(const char* name) const { return py::str(globals[name]).cast(); } std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast(); } std::vector strings(const char* name) const { return globals[name].cast>(); } // Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an // audit scope of its own. void run_as(const std::string& plugin_key, const char* code) { ScopedPluginAuditContext audit(plugin_key); exec(code); } }; PluginPermissions http_grants(std::vector urls) { PluginPermissions permissions; permissions.network_http = std::move(urls); return permissions; } const char* const PLUGIN = "AuditPlugin"; } // namespace TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin(PLUGIN); py::gil_scoped_acquire gil; PythonSide python(harness); harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")})); python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))"); CHECK(python.str("outcome") == "ok"); CHECK(describe(harness.prompts).empty()); } TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin(PLUGIN); py::gil_scoped_acquire gil; PythonSide python(harness); harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")})); python.run_as(PLUGIN, R"PY( outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)] outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)] outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B])))) )PY"); // The redirect target is asked for inside the approved redirecting request, and a library's // wrapper or generator asks for the second URL as plain code does. CHECK(python.strings("outcomes") == std::vector{"ok", "denied", "denied", "ok", "denied", "denied"}); const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n"; CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b); } TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin(PLUGIN); py::gil_scoped_acquire gil; PythonSide python(harness); python.exec(R"PY( CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy") handler = netlib.ReadingHandler(PRIVATE) handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler) walk_outcomes = [] copy_outcomes = [] def on_walk_error(error): walk_outcomes.append(attempt(lambda: open(PRIVATE).close())) def copy_file(source, destination): copy_outcomes.append(attempt(lambda: open(PRIVATE).close())) )PY"); PluginPermissions permissions = http_grants({python.str("URL_A")}); permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")}; permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")}; harness.save_permissions(PLUGIN, permissions); // A library handler that urllib calls while the approved request runs, the plugin's own // os.walk error callback and copytree copy function (called once per file), and a library // loop over a saved file and an unsaved one. python.run_as(PLUGIN, R"PY( request_outcome = attempt(lambda: handler_opener.open(URL_A).read()) for _ in os.walk(MISSING, onerror=on_walk_error): pass shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file) loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE])) )PY"); CHECK(python.str("request_outcome") == "ok"); CHECK(python.eval("handler.outcome") == "denied"); CHECK(python.strings("walk_outcomes") == std::vector{"denied"}); CHECK(python.strings("copy_outcomes") == std::vector{"denied", "denied"}); CHECK(python.str("loop_outcome") == "denied"); const std::string private_file = "open: " + python.str("PRIVATE") + "\n"; CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file); } TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin(PLUGIN); py::gil_scoped_acquire gil; PythonSide python(harness); PluginPermissions permissions; permissions.fs_read = {python.str("TREE"), python.str("APPROVED")}; harness.save_permissions(PLUGIN, permissions); // The walk lists the tree's two subdirectories on later resumes, after the plugin's own // audited reads in between. Abandoned walks leave frames whose generator is gone, which the // next audited event lets go. python.run_as(PLUGIN, R"PY( walked = [] for root, dirs, files in os.walk(TREE): walked.append(root) open(APPROVED).close() globbed = list(pathlib.Path(TREE).glob("**/*")) abandoned = os.walk(TREE) next(abandoned) del abandoned open(APPROVED).close() for _ in pathlib.Path(TREE).glob("*"): break open(APPROVED).close() )PY"); CHECK(py::len(python.globals["walked"]) == 3); CHECK(py::len(python.globals["globbed"]) == 4); CHECK(describe(harness.prompts).empty()); } TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin(PLUGIN); py::gil_scoped_acquire gil; PythonSide python(harness); struct Case { const char* prompts; const char* code; }; // Each operation names targets no earlier one saved, so each is asked once. FsCreate and // FsDelete grants are never saved, so those operations are asked every time. Only the // expected prompts are approved, so a nested event left uncovered fails the operation. std::vector cases = { {"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"}, {"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"}, {"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"}, {"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"}, {"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"}, {"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"}, {"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"}, {"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"}, {"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"}, {"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"}, {"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"}, // send() runs after connect() has returned, so its own grant is asked once. {"http.client.connect\nhttp.client.send", "connection = http.client.HTTPConnection('127.0.0.1', PORT)\n" "connection.request('GET', '/a')\n" "connection.getresponse().read()\n" "connection.close()"}, }; #ifndef _WIN32 cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"}); // The forked child's exec is covered by the spawn too: had it prompted, the child's own // denial would end it with status 1. cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"}); #endif for (const Case& operation : cases) { INFO(operation.code); const std::string expected = std::string(operation.prompts) + "\n"; harness.approve = [&expected](const Prompt& prompt) { return expected.find(prompt.event + "\n") != std::string::npos; }; harness.prompts.clear(); python.run_as(PLUGIN, operation.code); std::string asked; for (const Prompt& prompt : harness.prompts) asked += (asked.empty() ? "" : "\n") + prompt.event; CHECK(asked == operation.prompts); } #ifndef _WIN32 CHECK(python.eval("spawn_status") == "0"); #endif } TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin(PLUGIN); harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; }; py::gil_scoped_acquire gil; PythonSide python(harness); python.run_as(PLUGIN, R"PY( socket.socket().close() udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9))) udp.close() )PY"); // The socket is asked for once, without a target to show; the datagram's address is a target. CHECK(python.str("sendto_outcome") == "denied"); CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n"); CHECK(harness.saved_permissions(PLUGIN).events == std::vector{"Socket:socket.__new__"}); CHECK(harness.saved_permissions(PLUGIN).network_socket.empty()); harness.prompts.clear(); python.run_as(PLUGIN, "socket.socket().close()"); CHECK(describe(harness.prompts).empty()); // A grant for an event with no target covers nothing else: a library's approved // gethostname() does not carry the connection it makes next. harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; }; python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))"); CHECK(python.str("connect_outcome") == "denied"); CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n"); harness.prompts.clear(); harness.approve = [](const Prompt&) { return true; }; #ifndef _WIN32 // sendmsg() without an address names no target either. python.run_as(PLUGIN, R"PY( left, right = socket.socketpair() left.sendmsg([b"x"]) left.close() right.close() )PY"); CHECK(describe(harness.prompts) == "socket.sendmsg:\n"); CHECK(harness.saved_permissions(PLUGIN).events == std::vector{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"}); #endif } TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin(PLUGIN); harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; }; py::gil_scoped_acquire gil; PythonSide python(harness); python.exec(R"PY( READ_FD = os.open(APPROVED, os.O_RDONLY) WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT) )PY"); // A file descriptor has no path to show or save: a granted read of one is not a write. python.run_as(PLUGIN, R"PY( fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()), attempt(lambda: open(WRITE_FD, "w", closefd=False).close())] )PY"); CHECK(python.strings("fd_outcomes") == std::vector{"ok", "denied"}); CHECK(describe(harness.prompts) == "open:\nopen:\n"); CHECK(harness.saved_permissions(PLUGIN).events == std::vector{"FsRead:open"}); // A saved os.fork event grant is not an os.system command named "os.fork". PluginPermissions permissions; permissions.events = {"FsRead:open", "ProcessCreate:os.fork"}; harness.save_permissions(PLUGIN, permissions); harness.prompts.clear(); python.run_as(PLUGIN, R"PY( reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close()) system_outcome = attempt(lambda: os.system("os.fork")) os.close(READ_FD) os.close(WRITE_FD) )PY"); CHECK(python.str("reread_outcome") == "ok"); CHECK(python.str("system_outcome") == "denied"); REQUIRE(harness.prompts.size() == 1); CHECK(harness.prompts.front().event == "os.system"); CHECK(harness.saved_permissions(PLUGIN).process.empty()); } TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin(PLUGIN); py::gil_scoped_acquire gil; PythonSide python(harness); PluginPermissions permissions = http_grants({python.str("URL_A")}); permissions.fs_read = {python.str("APPROVED")}; harness.save_permissions(PLUGIN, permissions); PluginAuditManager& mgr = PluginAuditManager::instance(); // Once the request returns, only its held frame keeps the request object alive, and the // request's finalizer reads a file the plugin was never granted. python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))"); CHECK(python.strings("finalizer_outcomes") == std::vector{"denied"}); // at scope end CHECK(describe(harness.prompts).empty()); CHECK_FALSE(mgr.audit_denial_pending()); python.exec("finalizer_outcomes.clear()"); python.run_as(PLUGIN, R"PY( fetch(TrackedRequest(URL_A)) open(APPROVED).close() # the next event lets the returned request's frame go outcomes_before_scope_end = list(finalizer_outcomes) )PY"); CHECK(python.strings("outcomes_before_scope_end") == std::vector{"denied"}); CHECK(describe(harness.prompts).empty()); // The finalizer's denial during another event's decision leaves that event's own denial intact. python.exec("finalizer_outcomes.clear()"); python.run_as(PLUGIN, R"PY( fetch(TrackedRequest(URL_A)) try: os.listdir(OUTSIDE) outer_error = "" except PermissionError as error: outer_error = str(error) )PY"); CHECK(python.strings("finalizer_outcomes") == std::vector{"denied"}); CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir")); CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n"); AuditViolation violation; REQUIRE(mgr.last_violation(violation)); CHECK(violation.event_name == "os.listdir"); CHECK(mgr.audit_denial_pending()); } TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.add_plugin("PluginA"); harness.add_plugin("PluginB"); py::gil_scoped_acquire gil; PythonSide python(harness); PluginPermissions permissions; permissions.fs_read = {python.str("TREE")}; harness.save_permissions("PluginA", permissions); { // Two walks plugin A started and suspended after listing the tree itself. ScopedPluginAuditContext plugin_a("PluginA"); python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)"); { // Plugin B resuming one of them is asked for each subdirectory, as plugin B. ScopedPluginAuditContext plugin_b("PluginB"); python.exec("listed_by_b = len(list(first))"); } // Back in plugin A's scope, its walks are covered again. python.exec("listed_by_a = len(list(second))"); } CHECK(python.globals["listed_by_b"].cast() == 0); CHECK(python.globals["listed_by_a"].cast() == 2); REQUIRE(harness.prompts.size() == 2); for (const Prompt& prompt : harness.prompts) { CHECK(prompt.plugin == "PluginB"); CHECK(prompt.event == "os.scandir"); } } TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; }; py::gil_scoped_acquire gil; PythonSide python(harness); (void) PythonPluginBridge::instance(); // force the embedded module registration into the binary python.globals["orca"] = py::module_::import("orca"); // walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker // in its locals; the frame is let go when the capability call ends, which runs the marker's // finalizer. python.exec(R"PY( class Marker: def __init__(self, action=None): self.action = action def __del__(self): finalized.append(self.action() if self.action else "ran") finalized = [] def walk_and_drop(marker): walker = os.walk(TREE, onerror=marker) next(walker) del walker class ConfigCapability(orca.PythonPluginBase): def get_name(self): return "config_cap" def get_default_config(self): walk_and_drop(Marker()) return {"finalized_during_call": len(finalized)} def has_config_ui(self): walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close()))) next(walker) attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key del walker # the walk's frame is now held by the audit scope alone raise RuntimeError("the capability fails after a denial") class TicketAgent(orca.printer_agent.PrinterAgentBase): def get_name(self): return "ticket_agent" def request_bind_ticket(self): walk_and_drop(Marker()) return (0, "ticket") )PY"); py::object config_instance = python.globals["ConfigCapability"](); auto config_cap = config_instance.cast>(); config_cap->set_audit_plugin_key("GilPlugin"); config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script); py::object agent_instance = python.globals["TicketAgent"](); auto agent_cap = agent_instance.cast>(); agent_cap->set_audit_plugin_key("GilPlugin"); agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection); auto agent = std::dynamic_pointer_cast(agent_cap); REQUIRE(agent); // A host call arrives without the GIL; both calls take it before their audit scope. nlohmann::json config; int ticket_result = -1; std::string ticket; { py::gil_scoped_release released; config = config_cap->get_default_config(); ticket_result = agent->request_bind_ticket(&ticket); } CHECK(config == nlohmann::json{{"finalized_during_call", 0}}); CHECK(ticket_result == 0); CHECK(ticket == "ticket"); CHECK(python.strings("finalized") == std::vector{"ran", "ran"}); // On the trampoline's denial path the frame is let go as the plugin, though the ambient key // was cleared: the finalizer's read is audited and denied without a prompt. python.exec("finalized.clear()"); harness.prompts.clear(); CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set); CHECK(python.strings("finalized") == std::vector{"denied"}); CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n"); } TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]") { AuditHarness harness; if (!harness.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); py::gil_scoped_acquire gil; const py::dict paths = py::module_::import("sysconfig").attr("get_paths")(); const fs::path stdlib = py::str(paths["stdlib"]).cast(); const fs::path purelib = py::str(paths["purelib"]).cast(); const fs::path link = harness.outside.path() / "linked-stdlib"; boost::system::error_code ec; fs::create_directory_symlink(stdlib, link, ec); if (ec) SKIP("Cannot create a directory symlink here: " + ec.message()); CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string())); CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string())); CHECK(PluginAuditManager::is_stdlib_file("")); CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string())); CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string())); CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string())); CHECK_FALSE(PluginAuditManager::is_stdlib_file("")); }