From ddaf9ba5bc9006231bf0edfb14fed2f1f8b42421 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Tue, 20 Oct 2026 00:01:02 +0800 Subject: [PATCH] Stop re-asking for plugin permissions granted in an earlier session --- src/slic3r/plugin/PluginAuditManager.cpp | 473 ++++++++--- src/slic3r/plugin/PluginAuditManager.hpp | 47 +- src/slic3r/plugin/PluginFsUtils.cpp | 2 + src/slic3r/plugin/PluginFsUtils.hpp | 2 + src/slic3r/plugin/PyPluginTrampoline.hpp | 2 +- ...PrinterAgentPluginCapabilityTrampoline.hpp | 2 +- tests/slic3rutils/test_plugin_audit.cpp | 770 ++++++++++++++++++ 7 files changed, 1150 insertions(+), 148 deletions(-) diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index e913763891..9cf010d803 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -130,6 +131,20 @@ static AuditEventCategory event_category(const std::string& event_name) return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; } +// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole +// operation, so the raising frame bounds everything the operation does. Every other mapped event +// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly +// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2 +// are raised in iglob(), which returns its lazy generator before any directory is listed. +static const std::unordered_set operation_root_events{ + "urllib.Request", "http.client.connect", "os.walk", "os.fwalk", + "glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob", + "subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp", + "shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree", + "shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive", + "shutil.unpack_archive", +}; + // True for the categories whose targets are filesystem paths, as opposed to a network // address or a process command line -- the deny-path and allowed-root checks only make sense // against a path. @@ -146,6 +161,24 @@ static bool is_fs_category(AuditEventCategory category) } } +// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open". +static const char* category_name(AuditEventCategory category) +{ + switch (category) { + case AuditEventCategory::FsRead: return "FsRead"; + case AuditEventCategory::FsReadWrite: return "FsReadWrite"; + case AuditEventCategory::FsCreate: return "FsCreate"; + case AuditEventCategory::FsDelete: return "FsDelete"; + case AuditEventCategory::Http: return "Http"; + case AuditEventCategory::Socket: return "Socket"; + case AuditEventCategory::ProcessCreate: return "ProcessCreate"; + case AuditEventCategory::ProcessReplace: return "ProcessReplace"; + case AuditEventCategory::Threading: return "Threading"; + case AuditEventCategory::None: break; + } + return "None"; +} + // --------------------------------------------------------------------------- // Path safety // --------------------------------------------------------------------------- @@ -215,26 +248,50 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local std::string PluginAuditManager::m_current_capability_name = ""; thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; +thread_local std::vector PluginAuditManager::m_root_frames; +thread_local int PluginAuditManager::m_releasing_frames = 0; thread_local bool PluginAuditManager::m_audit_denial_pending = false; thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local AuditViolation PluginAuditManager::m_last_violation; ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, const std::string& capability_name) - : m_previous_id(PluginAuditManager::instance().current_plugin()) + : m_plugin_key(plugin_key) + , m_capability(capability_name) + , m_previous_id(PluginAuditManager::instance().current_plugin()) , m_previous_capability(PluginAuditManager::instance().current_capability()) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) + , m_previous_root_frames(std::move(PluginAuditManager::m_root_frames)) { PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::m_scoped_allowed_roots.clear(); + PluginAuditManager::m_root_frames.clear(); } ScopedPluginAuditContext::~ScopedPluginAuditContext() { - PluginAuditManager::instance().set_current_plugin(m_previous_id); - PluginAuditManager::instance().set_current_capability(m_previous_capability); + PluginAuditManager& mgr = PluginAuditManager::instance(); + + // Let this scope's root frames go while it is still the current context, and as its own + // plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer + // must never be judged as the outer plugin, or bypass the audit when there is none. A + // finalizer may record a new root frame, so repeat until none is held. + if (!PluginAuditManager::m_root_frames.empty()) { + assert(PyGILState_Check()); + mgr.set_current_plugin(m_plugin_key); + mgr.set_current_capability(m_capability); + while (!PluginAuditManager::m_root_frames.empty()) { + std::vector frames = std::move(PluginAuditManager::m_root_frames); + PluginAuditManager::m_root_frames.clear(); + mgr.release_frames(frames); + } + } + + mgr.set_current_plugin(m_previous_id); + mgr.set_current_capability(m_previous_capability); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); + PluginAuditManager::m_root_frames = std::move(m_previous_root_frames); } // --------------------------------------------------------------------------- @@ -575,34 +632,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const return true; } -bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key, - const std::vector& call_site_ids) const -{ - if (plugin_key.empty() || call_site_ids.empty()) - return false; - - std::lock_guard lock(m_mutex); - auto it = m_approved_call_sites.find(plugin_key); - if (it == m_approved_call_sites.end()) - return false; - - for (const auto& id : call_site_ids) - if (it->second.count(id)) - return true; - return false; -} - -void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key, - const std::vector& call_site_ids) -{ - if (plugin_key.empty() || call_site_ids.empty()) - return; - - std::lock_guard lock(m_mutex); - auto& approved = m_approved_call_sites[plugin_key]; - approved.insert(call_site_ids.begin(), call_site_ids.end()); -} - // --------------------------------------------------------------------------- // The C-level audit hook // --------------------------------------------------------------------------- @@ -663,40 +692,88 @@ std::string python_str(PyObject* object) return result; } -std::vector call_site_identities(const std::string& plugin_root) -{ - std::vector ids; - if (plugin_root.empty()) - return ids; +// The interpreter's stdlib folders, and the site-packages folders inside them that hold +// third-party code, both resolved. Set by install_hook(); like the verdict cache below, only +// touched while the GIL is held. +static std::vector s_stdlib_dirs; +static std::vector s_site_package_dirs; +static std::unordered_map s_stdlib_file_verdicts; // co_filename -> is stdlib +void collect_stdlib_dirs() +{ + s_stdlib_dirs.clear(); + s_site_package_dirs.clear(); + s_stdlib_file_verdicts.clear(); + + PyObject* sysconfig = PyImport_ImportModule("sysconfig"); + PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr; + if (paths && PyDict_Check(paths)) { + const auto collect = [paths](const char* key, std::vector& out) { + const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed + if (dir.empty()) + return; + boost::system::error_code ec; + const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec); + out.push_back(ec ? boost::filesystem::path(dir) : resolved); + }; + collect("stdlib", s_stdlib_dirs); + collect("platstdlib", s_stdlib_dirs); + collect("purelib", s_site_package_dirs); + collect("platlib", s_site_package_dirs); + } + Py_XDECREF(paths); + Py_XDECREF(sysconfig); + if (PyErr_Occurred()) { + PyErr_Clear(); + BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig"; + } +} + +bool is_stdlib_frame(PyObject* frame) +{ + PyCodeObject* code = PyFrame_GetCode(reinterpret_cast(frame)); // new reference + const std::string filename = python_unicode(code->co_filename); + Py_DECREF(code); + return PluginAuditManager::is_stdlib_file(filename); +} + +// The frames on this thread's Python stack, innermost first. The pointers stay valid while the +// caller runs, because a running activation keeps its frame object alive. +std::vector stack_frames() +{ + std::vector frames; PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference Py_XINCREF(frame); // normalize to an owned reference for the loop below - while (frame) { - PyCodeObject* code = PyFrame_GetCode(frame); // new reference - const std::string filename = python_unicode(reinterpret_cast(code->co_filename)); - const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root); - - std::string id; - if (!is_plugin_frame && !filename.empty()) { - const std::string funcname = python_unicode(reinterpret_cast(code->co_name)); - id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno); - } - Py_DECREF(code); - + frames.push_back(reinterpret_cast(frame)); PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack Py_DECREF(frame); frame = back; - - if (is_plugin_frame) - break; - if (!id.empty()) - ids.push_back(std::move(id)); } + return frames; +} - Py_XDECREF(frame); // only holds a reference here if the loop exited via break +// Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended +// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so +// it stays held until the scope ends. +void take_returned_frames(std::vector& held, std::vector& dropped) +{ + if (held.empty()) + return; - return ids; + const std::vector stack = stack_frames(); + const auto running = [&stack](PyObject* frame) { + if (std::find(stack.begin(), stack.end(), frame) != stack.end()) + return true; + // NULL once the generator has finished or been deleted: its frame then owns its own data. + PyObject* generator = PyFrame_GetGenerator(reinterpret_cast(frame)); // new reference + const bool suspended = generator != nullptr; + Py_XDECREF(generator); + return suspended; + }; + const auto returned = std::stable_partition(held.begin(), held.end(), running); + dropped.insert(dropped.end(), returned, held.end()); + held.erase(returned, held.end()); } static const std::unordered_set two_path_fs_events{ @@ -717,12 +794,15 @@ static const std::unordered_map> audit_targ {"socket.getnameinfo", {0}}, {"socket.getservbyname", {0}}, {"socket.getservbyport", {0}}, + {"socket.sendto", {1}}, + {"socket.sendmsg", {1}}, {"os.system", {0}}, {"subprocess.Popen", {1, 0}}, {"os.posix_spawn", {1, 0}}, {"os.spawn", {2, 1}}, {"os.startfile", {0}}, + {"os.startfile/2", {0}}, {"pty.spawn", {0}}, {"_winapi.CreateProcess", {1, 0}}, {"_posixsubprocess.fork_exec", {0}}, @@ -767,7 +847,11 @@ std::vector audit_targets(const std::string& event_name, AuditEvent const auto it = audit_target_arg_indices.find(event_name); if (it != audit_target_arg_indices.end()) { for (const Py_ssize_t index : it->second) { - std::string value = python_str(tuple_item(args, index)); + // None is an argument left out (sendmsg without an address), not a target named "None". + PyObject* const item = tuple_item(args, index); + if (item == Py_None) + continue; + std::string value = python_str(item); if (!value.empty()) { targets.push_back(std::move(value)); break; @@ -778,8 +862,14 @@ std::vector audit_targets(const std::string& event_name, AuditEvent return targets; } -std::vector* permission_list_for(AuditEventCategory category, PluginPermissions& permissions) +// The saved list that grants an event: its category's target list, or the event-grant list for +// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an +// os.fork grant is not an os.exec of a command named "os.fork"). +std::vector* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions) { + if (by_event) + return &permissions.events; + switch (category) { case AuditEventCategory::FsRead: return &permissions.fs_read; case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; @@ -792,6 +882,13 @@ std::vector* permission_list_for(AuditEventCategory category, Plugi } } +// An event with no target is granted by its effective category and name, e.g. "FsRead:open" +// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open"). +std::string event_grant_key(AuditEventCategory category, const std::string& event_name) +{ + return std::string(category_name(category)) + ":" + event_name; +} + bool has_permission(const std::vector& granted, const std::string& target) { return std::find(granted.begin(), granted.end(), target) != granted.end(); @@ -902,22 +999,25 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n // Records a grant in the plugin's sidecar so it is not asked again. Reads the install state // freshly because the async prompt outlives the caller's stack copy of it. -void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector& targets) +void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector& keys) { PluginInstallState state; if (!PluginManager::instance().get_install_state(plugin_key, state)) return; - std::vector* permission_list = permission_list_for(category, state.permissions); + std::vector* permission_list = permission_list_for(category, by_event, state.permissions); if (!permission_list) return; - for (const auto& target : targets) - persist_permission(plugin_key, state, *permission_list, target); + for (const auto& key : keys) + persist_permission(plugin_key, state, *permission_list, key); } -// Requests permission for an audited event, returning true when it is already granted or the user -// approves an inline prompt. +static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing; + +// Asks the user to grant an audited event, returning true when they approve an inline prompt. +// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has +// no target (by_event), which the prompt does not show as a target. // // An audited event can fire on a thread the UI thread may itself be blocked waiting on: the // SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and @@ -929,56 +1029,205 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, c // prompt); the grant is persisted once the user accepts, so a later attempt succeeds without // re-prompting. bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name, - const std::string& event_name, const std::vector& unresolved) + const std::string& event_name, bool by_event, const std::vector& keys) { + const std::vector shown = by_event ? std::vector{} : keys; + + if (s_permission_prompt_for_testing) + return s_permission_prompt_for_testing(event_name, shown); + if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) return false; if (wxIsMainThread()) - return prompt_for_targets(category, plugin_name, event_name, unresolved); + return prompt_for_targets(category, plugin_name, event_name, shown); - GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() { + GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() { if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) return; - if (prompt_for_targets(category, plugin_name, event_name, unresolved)) - persist_grant(plugin_key, category, unresolved); + if (prompt_for_targets(category, plugin_name, event_name, shown)) + persist_grant(plugin_key, category, by_event, keys); }); return false; } +// Decides an event that no allowed root or running operation covers: a saved grant allows it, +// otherwise the user is asked and a Yes is saved. int decide_audited_event(PluginAuditManager& mgr, PluginInstallState& state, const std::string& plugin_key, const std::string& plugin_name, const std::string& event_name, AuditEventCategory category, - const std::vector& targets, - std::vector* permission_list, - const std::vector& call_site_ids) + const std::vector& targets) { - std::vector unresolved = targets; + const bool by_event = targets.empty(); + const std::vector keys = by_event ? std::vector{event_grant_key(category, event_name)} : targets; + std::vector* permission_list = permission_list_for(category, by_event, state.permissions); + + std::vector unresolved = keys; if (permission_list) { unresolved.clear(); - for (const auto& target : targets) - if (!has_permission(*permission_list, target)) - unresolved.push_back(target); - if (!targets.empty() && unresolved.empty()) + for (const auto& key : keys) + if (!has_permission(*permission_list, key)) + unresolved.push_back(key); + if (unresolved.empty()) return 0; } - if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved)) + // Letting a root frame go runs finalizers inside a scope destructor or another event's hook, + // where no dialog may open: what they do must already be granted. + if (mgr.releasing_frames()) + return report_denied(mgr, event_name, {false, "audit permission required"}); + + if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved)) return report_denied(mgr, event_name, {false, "audit permission required"}); if (permission_list) - for (const auto& target : unresolved) - persist_permission(plugin_key, state, *permission_list, target); - - mgr.record_approved_call_sites(plugin_key, call_site_ids); + for (const auto& key : unresolved) + persist_permission(plugin_key, state, *permission_list, key); return 0; } +// Judges an audited event raised while a plugin context is active. +int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args) +{ + const bool fs_category = is_fs_category(event_type); + const std::vector targets = audit_targets(event_name, event_type, args); + + // A denied path (secrets, certificates, config files -- see is_denied_path) is an + // unconditional block: checked before the running-operation check below, so no granted + // operation can launder access to one, and before the allowed-root shortcut further down, + // so an allowed root (e.g. the read-only resources folder) cannot make a denied path + // underneath it reachable. + if (fs_category) { + for (const auto& target : targets) { + if (mgr.is_denied_path(boost::filesystem::path(target))) + return report_denied(mgr, event_name, {false, "denied path"}, target); + } + } + + // A nested event of an operation already granted, e.g. socket.connect inside an approved + // urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a + // redirect or a nested urlopen() inside a running request is not covered by it. + if (event_name != "urllib.Request" && mgr.covered_by_root_frame()) + return 0; + + // A filesystem target that resolves entirely inside a pre-determined allowed root -- the + // plugin system's own data_dir() tree (which holds each plugin's storage folder and the + // installed/system profile cache), the read-only bundled resources folder, or a per-call + // scoped root such as the current G-code folder -- is part of the plugin system's normal + // workflow and does not need a prompt. + if (fs_category && !targets.empty()) { + const bool is_write = event_type != AuditEventCategory::FsRead; + const bool all_inside_allowed_root = + std::all_of(targets.begin(), targets.end(), [&](const std::string& target) { + return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed; + }); + if (all_inside_allowed_root) + return 0; + } + + PluginInstallState state; + const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state); + if (!have_install_state) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin(); + } + + const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name; + + const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets); + + // A granted operation covers its nested events while it runs. A targetless grant names no + // operation, so it covers nothing else. + if (result == 0 && !targets.empty() && operation_root_events.count(event_name)) + mgr.record_root_frame(); + return result; +} + } // namespace PluginAuditDetail +void PluginAuditManager::record_root_frame() +{ + PyObject* frame = reinterpret_cast(PyEval_GetFrame()); // borrowed reference + if (!frame || !PluginAuditDetail::is_stdlib_frame(frame)) + return; + if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end()) + return; // glob.glob and glob.glob/2 share a frame + Py_INCREF(frame); + m_root_frames.push_back(frame); +} + +bool PluginAuditManager::covered_by_root_frame() const +{ + if (m_root_frames.empty()) + return false; + + // Only stdlib frames may sit between the event and the operation: plugin or library code + // the operation calls back into (an os.walk onerror, a urllib handler) acts on its own. + for (PyObject* frame : PluginAuditDetail::stack_frames()) { + if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end()) + return true; + if (!PluginAuditDetail::is_stdlib_frame(frame)) + return false; + } + return false; +} + +bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; } + +bool PluginAuditManager::is_stdlib_file(const std::string& filename) +{ + // Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename. + if (boost::algorithm::starts_with(filename, "& dirs) { + return std::any_of(dirs.begin(), dirs.end(), + [&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); }); + }; + it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs); + } + return it->second; +} + +void PluginAuditManager::release_frames(std::vector& frames) +{ + if (frames.empty()) + return; + + // A finalizer's own denial must neither replace the PermissionError of the event being + // decided nor leave a stale denial behind for the next call. + PyObject* const error = PyErr_GetRaisedException(); + const bool denial_pending = m_audit_denial_pending; + const bool has_violation = m_has_last_violation; + const AuditViolation violation = m_last_violation; + + std::vector released; + released.swap(frames); + ++m_releasing_frames; + for (PyObject* frame : released) + Py_DECREF(frame); + --m_releasing_frames; + + m_audit_denial_pending = denial_pending; + m_has_last_violation = has_violation; + m_last_violation = violation; + PyErr_SetRaisedException(error); +} + +void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt) +{ + PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt); +} + int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) { auto* mgr = static_cast(user_data); @@ -1006,62 +1255,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user if (event_type == AuditEventCategory::None) return 0; - const bool fs_category = is_fs_category(event_type); - const std::vector targets = PluginAuditDetail::audit_targets(event_name, event_type, args); - - // A denied path (secrets, certificates, config files -- see is_denied_path) is an - // unconditional block: checked before the ancestor-cascade check below, so a cascade - // approval recorded for an unrelated action can never launder access to one, and before - // the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources - // folder) cannot make a denied path underneath it reachable. - if (fs_category) { - for (const auto& target : targets) { - if (mgr->is_denied_path(boost::filesystem::path(target))) - return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target); - } - } - - PluginDescriptor plugin_descriptor; - PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor); - - // Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request - // was already approved, socket.connect won't trigger another permission dialog. - const std::vector call_site_ids = - PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root); - if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids)) - return 0; - - // A filesystem target that resolves entirely inside a pre-determined allowed root -- the - // plugin system's own data_dir() tree (which holds each plugin's storage folder and the - // installed/system profile cache), the read-only bundled resources folder, or a per-call - // scoped root such as the current G-code folder -- is part of the plugin system's normal - // workflow and does not need a prompt. - if (fs_category && !targets.empty()) { - const bool is_write = event_type != AuditEventCategory::FsRead; - const bool all_inside_allowed_root = - std::all_of(targets.begin(), targets.end(), [&](const std::string& target) { - return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed; - }); - if (all_inside_allowed_root) - return 0; - } - - PluginInstallState state; - const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state); - if (!have_install_state) { - BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr->current_plugin(); - } - - const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name; - - std::vector* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions); - - return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name, - event_type, targets, permission_list, call_site_ids); + // Root frames whose operation has returned are let go once this event is decided, so that a + // finalizer they run re-enters a hook whose held frames are already consistent. + std::vector returned_frames; + PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames); + const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args); + mgr->release_frames(returned_frames); + return result; } void PluginAuditManager::install_hook() { + // Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame). + PluginAuditDetail::collect_stdlib_dirs(); + // data_dir() is the primary globally-allowed root during plugin execution: read+write. It // covers the plugin system's own workflow needs -- each plugin's storage folder // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) -- diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 41d3dca1e8..ac6d99a4da 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -4,10 +4,9 @@ // Via pybind11 so this file requests the same python3xx.lib as everything else. #include #include +#include #include #include -#include -#include #include #include @@ -144,15 +143,26 @@ public: void clear_last_violation(); bool last_violation(AuditViolation& violation) const; - // --- call-site cascade cache --- - // A single plugin action often fires several nested CPython audit events as it passes - // through stdlib layers (urllib.request calling http.client calling socket, for example). - // Once the user approves one event, every stdlib frame still on the stack for that call - // is recorded here by (filename, function, first line) identity. A later event whose own - // ancestor chain still contains one of those frames is the same logical action seen from - // a deeper layer, so it is auto-approved instead of prompting again. - bool has_approved_ancestor(const std::string& plugin_key, const std::vector& call_site_ids) const; - void record_approved_call_sites(const std::string& plugin_key, const std::vector& call_site_ids); + // --- operation-root frames --- + // One stdlib operation fires several nested audit events as it passes through lower layers: + // urlopen() raises urllib.Request, then http.client and socket events. When an operation's + // own root event is granted (by a saved grant or the user), the stdlib frame that raised it + // is held, and a later event whose stack reaches that frame through stdlib frames only is + // part of the same operation, so it is allowed without a prompt of its own. Plugin or + // library code the operation calls back into is judged on its own. A frame is let go once it + // returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may + // run finalizers of objects its locals held; they are judged as the plugin and never prompt. + void record_root_frame(); + bool covered_by_root_frame() const; + bool releasing_frames() const; + + // True when code from `filename` (a code object's co_filename) is the interpreter's own + // stdlib: the only code a root frame's coverage passes through. Valid after install_hook(). + static bool is_stdlib_file(const std::string& filename); + + // Answers permission prompts in place of the dialog, for tests. Production never sets it. + using PermissionPrompt = std::function& targets)>; + static void set_permission_prompt_for_testing(PermissionPrompt prompt); bool verbose_events = true; @@ -163,24 +173,32 @@ private: static int audit_hook(const char* event, PyObject* args, void* user_data); + // Drops the references in `frames`, keeping the caller's error and denial state intact. + void release_frames(std::vector& frames); + static thread_local std::string m_current_plugin_key; static thread_local std::string m_current_capability_name; static thread_local std::vector m_scoped_allowed_roots; + static thread_local std::vector m_root_frames; // strong references + static thread_local int m_releasing_frames; static thread_local bool m_audit_denial_pending; static thread_local bool m_has_last_violation; static thread_local AuditViolation m_last_violation; - // mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock. + // mutable: is_denied_filename() is a const query that must lock. mutable std::mutex m_mutex; std::vector m_global_allowed_roots; std::vector m_denied_filenames; std::vector m_denied_path_keywords; - std::unordered_map> m_approved_call_sites; // plugin_key -> call-site ids }; // RAII guard that sets the current plugin key and capability name, restoring the previous // pair on scope exit. `capability_name` may be empty for calls that are not scoped to a // single capability. +// +// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before +// restoring the outer context. That needs the GIL whenever a frame is still held, so construct +// the scope after the GIL guard, to be destroyed before it. class ScopedPluginAuditContext { public: @@ -194,9 +212,12 @@ public: ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete; private: + std::string m_plugin_key; + std::string m_capability; std::string m_previous_id; std::string m_previous_capability; std::vector m_previous_scoped_roots; + std::vector m_previous_root_frames; }; } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 1dd8391ad7..00077d3f65 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall read_string_list("network_socket", parsed.permissions.network_socket); read_string_list("process", parsed.permissions.process); read_string_list("threading", parsed.permissions.threading); + read_string_list("events", parsed.permissions.events); } if (state.contains("enabled") && state["enabled"].is_boolean()) @@ -852,6 +853,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin {"network_socket", state.permissions.network_socket}, {"process", state.permissions.process}, {"threading", state.permissions.threading}, + {"events", state.permissions.events}, }; nlohmann::json capabilities = nlohmann::json::array(); diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index 9c864ed73a..ee61d8c957 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -104,6 +104,8 @@ struct PluginPermissions std::vector network_socket; std::vector process; std::vector threading; + // Grants for audit events that expose no target, keyed ":" (e.g. "Socket:socket.__new__"). + std::vector events; }; struct PluginInstallState { diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index 915c578353..6d7ca9135c 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -102,9 +102,9 @@ public: // mistake), both fall back to the base's empty object rather than writing `"cap_config": null`. nlohmann::json get_default_config() const override { + pybind11::gil_scoped_acquire gil; ORCA_PY_AUDIT_SCOPE(); try { - pybind11::gil_scoped_acquire gil; pybind11::function override = pybind11::get_override(static_cast(this), "get_default_config"); if (!override) return Base::get_default_config(); diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp index 790f6e3ac6..812d69c072 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp @@ -282,11 +282,11 @@ public: int request_bind_ticket(std::string* ticket) override { try { - ORCA_PY_AUDIT_SCOPE(); ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); ::Slic3r::PythonGILState gil; if (!gil) throw std::runtime_error("Python interpreter is shutting down"); + ORCA_PY_AUDIT_SCOPE(); pybind11::function override = pybind11::get_override(static_cast(this), "request_bind_ticket"); if (!override) diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index 267cdb4099..debf5784b1 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -1,22 +1,48 @@ #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 { @@ -349,3 +375,747 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit 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("")); +}