diff --git a/src/slic3r/GUI/DeviceCore/DevConfigUtil.cpp b/src/slic3r/GUI/DeviceCore/DevConfigUtil.cpp index b86d3cde98..25008cad29 100644 --- a/src/slic3r/GUI/DeviceCore/DevConfigUtil.cpp +++ b/src/slic3r/GUI/DeviceCore/DevConfigUtil.cpp @@ -16,24 +16,27 @@ namespace Slic3r // This block is never executed at runtime. static void _toolhead_translation_markers() { - // Dynamic toolhead display names from JSON config — xgettext cannot scan these - L("Main Extruder"); L("Main extruder"); L("main extruder"); - L("Auxiliary Extruder"); L("Auxiliary extruder"); L("auxiliary extruder"); - L("Left Extruder"); L("Left extruder"); L("left extruder"); - L("Right Extruder"); L("Right extruder"); L("right extruder"); - L("Main Nozzle"); L("Main nozzle"); L("main nozzle"); - L("Auxiliary Nozzle"); L("Auxiliary nozzle"); L("auxiliary nozzle"); - L("Left Nozzle"); L("Left nozzle"); L("left nozzle"); - L("Right Nozzle"); L("Right nozzle"); L("right nozzle"); - L("Main Hotend"); L("Main hotend"); L("main hotend"); - L("Auxiliary Hotend"); L("Auxiliary hotend"); L("auxiliary hotend"); - L("Left Hotend"); L("Left hotend"); L("left hotend"); - L("Right Hotend"); L("Right hotend"); L("right hotend"); - // standalone position words (short_name=true runtime results) - L("main"); L("auxiliary"); - L("Main"); L("Auxiliary"); - L("left"); L("right"); - L("Left"); L("Right"); + // Possible runtime values of tool_head_display_names, marked for extraction. + static const char *const markers[] = { + L("Main Extruder"), L("Main extruder"), L("main extruder"), + L("Auxiliary Extruder"), L("Auxiliary extruder"), L("auxiliary extruder"), + L("Left Extruder"), L("Left extruder"), L("left extruder"), + L("Right Extruder"), L("Right extruder"), L("right extruder"), + L("Main Nozzle"), L("Main nozzle"), L("main nozzle"), + L("Auxiliary Nozzle"), L("Auxiliary nozzle"), L("auxiliary nozzle"), + L("Left Nozzle"), L("Left nozzle"), L("left nozzle"), + L("Right Nozzle"), L("Right nozzle"), L("right nozzle"), + L("Main Hotend"), L("Main hotend"), L("main hotend"), + L("Auxiliary Hotend"), L("Auxiliary hotend"), L("auxiliary hotend"), + L("Left Hotend"), L("Left hotend"), L("left hotend"), + L("Right Hotend"), L("Right hotend"), L("right hotend"), + // standalone position words (short_name=true runtime results) + L("main"), L("auxiliary"), + L("Main"), L("Auxiliary"), + L("left"), L("right"), + L("Left"), L("Right"), + }; + (void) markers; } std::string DevPrinterConfigUtil::m_resource_file_path = ""; diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp index c383815f8c..4dc072d721 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp @@ -266,7 +266,7 @@ void wgtDeviceNozzleRackSelect::OnNozzleItemSelected(wxCommandEvent &evt) } auto *item = dynamic_cast(evt.GetEventObject()); - if (item; auto ptr = m_nozzle_rack.lock()) { + if (auto ptr = m_nozzle_rack.lock(); item && ptr) { int to_select_pos_id = sGetNozzlePosId(item, m_toolhead_nozzle_l, m_toolhead_nozzle_r); if (to_select_pos_id > -1 && to_select_pos_id != GetSelectedNozzlePosID()) { SetSelectedNozzle(ptr->GetNozzleSystem()->GetNozzleByPosId(to_select_pos_id)); diff --git a/src/slic3r/GUI/UpgradePanel.cpp b/src/slic3r/GUI/UpgradePanel.cpp index 96b9c1b481..d31fde8613 100644 --- a/src/slic3r/GUI/UpgradePanel.cpp +++ b/src/slic3r/GUI/UpgradePanel.cpp @@ -862,7 +862,6 @@ void MachineInfoPanel::update_ams_ext(MachineObject *obj) if (new_extra_ams_ver != obj->new_ver_list.end()) has_new_version = true; - extra_ams_it->second.sw_new_ver; if (has_new_version) { m_extra_ams_panel->m_ams_new_version_img->Show(); ver_text = new_extra_ams_ver->second.sw_ver; diff --git a/src/slic3r/GUI/Widgets/AMSItem.cpp b/src/slic3r/GUI/Widgets/AMSItem.cpp index f99e5f49fd..9d5ec9c1c1 100644 --- a/src/slic3r/GUI/Widgets/AMSItem.cpp +++ b/src/slic3r/GUI/Widgets/AMSItem.cpp @@ -114,7 +114,6 @@ bool AMSinfo::parse_ams_info(MachineObject *obj, DevAms *ams, bool remain_flag, info.ctype = 0; info.material_colour = AMS_TRAY_DEFAULT_COL; info.material_state = AMSCanType::AMS_CAN_TYPE_THIRDBRAND; - wxColour(255, 255, 255); } if (it->second->is_tray_info_ready() && obj->cali_version >= 0) { @@ -171,7 +170,6 @@ void AMSinfo::parse_ext_info(MachineObject* obj, DevAmsTray tray) { info.filament_id = ""; info.ctype = 0; info.material_colour = AMS_TRAY_DEFAULT_COL; - wxColour(255, 255, 255); } info.material_state = AMSCanType::AMS_CAN_TYPE_VIRTUAL; if (tray.is_tray_info_ready() && obj->cali_version >= 0) { diff --git a/src/slic3r/GUI/Widgets/FanControl.cpp b/src/slic3r/GUI/Widgets/FanControl.cpp index 057baca430..f10553814e 100644 --- a/src/slic3r/GUI/Widgets/FanControl.cpp +++ b/src/slic3r/GUI/Widgets/FanControl.cpp @@ -1014,8 +1014,12 @@ void FanControlPopupNew::init_names(MachineObject* obj) { if (obj) { const std::string& special_cooling_text = DevPrinterConfigUtil::get_fan_text(obj->printer_type, "special_cooling_text"); if (!special_cooling_text.empty()) { - L("Cooling mode is suitable for printing PLA/PETG/TPU materials."); //some potential text, add i18n flags - L("Cooling mode is suitable for printing PLA/PETG/TPU materials and filters the chamber air."); + // Possible runtime values of special_cooling_text, marked for extraction. + static const char *const markers[] = { + L("Cooling mode is suitable for printing PLA/PETG/TPU materials."), + L("Cooling mode is suitable for printing PLA/PETG/TPU materials and filters the chamber air."), + }; + (void) markers; label_text[AIR_DUCT::AIR_DUCT_COOLING_FILT] = _L(special_cooling_text); } } @@ -1028,9 +1032,13 @@ wxString FanControlPopupNew::get_fan_func_name(int mode, int submode, AIR_FUN fu const std::string& func_text = DevPrinterConfigUtil::get_fan_text(m_obj->printer_type, mode, (int)func, submode); if (!func_text.empty()) { - L_CONTEXT("Right(Aux)", "air_duct"); - L_CONTEXT("Right(Filter)", "air_duct"); - L_CONTEXT("Left(Aux)", "air_duct"); + // Possible runtime values of func_text, marked for extraction. + static const char *const markers[] = { + L_CONTEXT("Right(Aux)", "air_duct"), + L_CONTEXT("Right(Filter)", "air_duct"), + L_CONTEXT("Left(Aux)", "air_duct"), + }; + (void) markers; return _L_CONTEXT(func_text, "air_duct"); } } diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 29aa59b253..4d69a4f761 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -7,11 +7,127 @@ #include #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include namespace Slic3r { +// extensive list of audit events can be found at https://docs.python.org/3/library/audit_events.html +static const std::unordered_map audit_event_categories{ + // fsread + {"glob.glob", AuditEventCategory::FsRead}, + {"glob.glob/2", AuditEventCategory::FsRead}, + {"os.fwalk", AuditEventCategory::FsRead}, + {"os.getxattr", AuditEventCategory::FsRead}, + {"os.listdir", AuditEventCategory::FsRead}, + {"os.listdrives", AuditEventCategory::FsRead}, + {"os.listmounts", AuditEventCategory::FsRead}, + {"os.listvolumes", AuditEventCategory::FsRead}, + {"os.listxattr", AuditEventCategory::FsRead}, + {"os.scandir", AuditEventCategory::FsRead}, + {"os.walk", AuditEventCategory::FsRead}, + {"pathlib.Path.glob", AuditEventCategory::FsRead}, + {"pathlib.Path.rglob", AuditEventCategory::FsRead}, + + // fsreadwrite + {"os.chflags", AuditEventCategory::FsReadWrite}, + {"os.chmod", AuditEventCategory::FsReadWrite}, + {"os.chown", AuditEventCategory::FsReadWrite}, + {"os.removexattr", AuditEventCategory::FsReadWrite}, + {"os.rename", AuditEventCategory::FsReadWrite}, + {"os.setxattr", AuditEventCategory::FsReadWrite}, + {"os.truncate", AuditEventCategory::FsReadWrite}, + {"os.utime", AuditEventCategory::FsReadWrite}, + {"shutil.chown", AuditEventCategory::FsReadWrite}, + {"shutil.copymode", AuditEventCategory::FsReadWrite}, + {"shutil.copystat", AuditEventCategory::FsReadWrite}, + {"shutil.copyfile", AuditEventCategory::FsReadWrite}, + {"shutil.copytree", AuditEventCategory::FsReadWrite}, + {"shutil.make_archive", AuditEventCategory::FsReadWrite}, + {"shutil.move", AuditEventCategory::FsReadWrite}, + {"shutil.unpack_archive", AuditEventCategory::FsReadWrite}, + + // fscreate + {"os.link", AuditEventCategory::FsCreate}, + {"os.mkdir", AuditEventCategory::FsCreate}, + {"os.symlink", AuditEventCategory::FsCreate}, + {"tempfile.mkdtemp", AuditEventCategory::FsCreate}, + {"tempfile.mkstemp", AuditEventCategory::FsCreate}, + {"_winapi.CreateJunction", AuditEventCategory::FsCreate}, + + // fsdelete + {"os.remove", AuditEventCategory::FsDelete}, + {"os.rmdir", AuditEventCategory::FsDelete}, + {"shutil.rmtree", AuditEventCategory::FsDelete}, + + // http + {"http.client.connect", AuditEventCategory::Http}, + {"http.client.send", AuditEventCategory::Http}, + {"urllib.Request", AuditEventCategory::Http}, + + // socket + {"socket.__new__", AuditEventCategory::Socket}, + {"socket.bind", AuditEventCategory::Socket}, + {"socket.connect", AuditEventCategory::Socket}, + {"socket.getaddrinfo", AuditEventCategory::Socket}, + {"socket.gethostbyaddr", AuditEventCategory::Socket}, + {"socket.gethostbyname", AuditEventCategory::Socket}, + {"socket.gethostname", AuditEventCategory::Socket}, + {"socket.getnameinfo", AuditEventCategory::Socket}, + {"socket.getservbyname", AuditEventCategory::Socket}, + {"socket.getservbyport", AuditEventCategory::Socket}, + {"socket.sendmsg", AuditEventCategory::Socket}, + {"socket.sendto", AuditEventCategory::Socket}, + + // processcreate + {"os.fork", AuditEventCategory::ProcessCreate}, + {"os.forkpty", AuditEventCategory::ProcessCreate}, + {"os.posix_spawn", AuditEventCategory::ProcessCreate}, + {"os.spawn", AuditEventCategory::ProcessCreate}, + {"os.system", AuditEventCategory::ProcessCreate}, + {"os.startfile", AuditEventCategory::ProcessCreate}, + {"os.startfile/2", AuditEventCategory::ProcessCreate}, + {"pty.spawn", AuditEventCategory::ProcessCreate}, + {"subprocess.Popen", AuditEventCategory::ProcessCreate}, + {"_winapi.CreateProcess", AuditEventCategory::ProcessCreate}, + {"_posixsubprocess.fork_exec", AuditEventCategory::ProcessCreate}, +}; + +// Returns the category event_name belongs to, or AuditEventCategory::None when it isn't audited. +static AuditEventCategory event_category(const std::string& event_name) +{ + const auto it = audit_event_categories.find(event_name); + return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; +} + +// 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. +static bool is_fs_category(AuditEventCategory category) +{ + switch (category) { + case AuditEventCategory::FsRead: + case AuditEventCategory::FsReadWrite: + case AuditEventCategory::FsCreate: + case AuditEventCategory::FsDelete: + return true; + default: + return false; + } +} + // --------------------------------------------------------------------------- // Path safety // --------------------------------------------------------------------------- @@ -78,24 +194,21 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos // ScopedPluginAuditContext // --------------------------------------------------------------------------- -thread_local std::string PluginAuditManager::m_current_plugin_key = ""; -thread_local std::string PluginAuditManager::m_current_capability_name = ""; -thread_local PluginAuditManager::AuditMode PluginAuditManager::m_audit_mode = PluginAuditManager::AuditMode::Loading; -thread_local std::vector PluginAuditManager::m_scoped_allowed_roots; +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 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, - PluginAuditManager::AuditMode mode) + const std::string& capability_name) : m_previous_id(PluginAuditManager::instance().current_plugin()) , m_previous_capability(PluginAuditManager::instance().current_capability()) - , m_previous_mode(PluginAuditManager::instance().audit_mode()) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) { PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_capability(capability_name); - PluginAuditManager::instance().set_audit_mode(mode); PluginAuditManager::m_scoped_allowed_roots.clear(); } @@ -103,7 +216,6 @@ ScopedPluginAuditContext::~ScopedPluginAuditContext() { PluginAuditManager::instance().set_current_plugin(m_previous_id); PluginAuditManager::instance().set_current_capability(m_previous_capability); - PluginAuditManager::instance().set_audit_mode(m_previous_mode); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); } @@ -129,23 +241,24 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); } -void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root) +void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write) { if (root.empty()) return; std::lock_guard lock(m_mutex); - m_global_allowed_roots.push_back(root); - BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string(); + m_global_allowed_roots.push_back({root, allow_write}); + BOOST_LOG_TRIVIAL(info) << "[AUDIT] Global allowed root: " << root.string() << " allow_write=" << allow_write; } -void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root) +void PluginAuditManager::add_scoped_allowed_root(const boost::filesystem::path& root, bool allow_write) { if (root.empty()) return; - m_scoped_allowed_roots.push_back(root); - BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string(); + m_scoped_allowed_roots.push_back({root, allow_write}); + BOOST_LOG_TRIVIAL(info) << "[AUDIT] Scoped allowed root for plugin " << current_plugin() << ": " << root.string() + << " allow_write=" << allow_write; } // --------------------------------------------------------------------------- @@ -199,12 +312,65 @@ bool PluginAuditManager::is_denied_filename(const boost::filesystem::path& candi } // --------------------------------------------------------------------------- -// Audit mode +// Denied path keywords // --------------------------------------------------------------------------- -void PluginAuditManager::set_audit_mode(AuditMode mode) { m_audit_mode = mode; } +void PluginAuditManager::add_denied_path_keyword(const std::string& keyword) +{ + if (keyword.empty()) + return; -PluginAuditManager::AuditMode PluginAuditManager::audit_mode() const { return m_audit_mode; } + std::string lower = keyword; + std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return std::tolower(c); }); + + std::lock_guard lock(m_mutex); + m_denied_path_keywords.push_back(lower); + BOOST_LOG_TRIVIAL(info) << "[AUDIT] Denied path keyword: " << lower; +} + +std::vector PluginAuditManager::default_denied_path_keywords() +{ + // Broad, categorical rules on top of the exact-name is_denied_filename registry: a plugin + // must never be able to reach a secret, a certificate, or a configuration file just because + // it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at + // resources_dir()/cert/..., which would become reachable the moment resources_dir() is + // granted as a read-only allowed root). + return {"secret", "cert", "conf"}; +} + +bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& candidate) const +{ + namespace fs = boost::filesystem; + + boost::system::error_code ec; + fs::path canon = fs::weakly_canonical(candidate, ec); + if (ec) { + canon = fs::absolute(candidate, ec).lexically_normal(); + if (ec) + canon = candidate; + } + + std::lock_guard lock(m_mutex); + if (m_denied_path_keywords.empty()) + return false; + + for (const auto& component : canon) { + std::string name = component.string(); + if (name.empty()) + continue; + std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); }); + for (const auto& keyword : m_denied_path_keywords) { + if (name.find(keyword) != std::string::npos) + return true; + } + } + return false; +} + +bool PluginAuditManager::is_denied_path(const boost::filesystem::path& candidate) const +{ + return is_denied_filename(candidate) || is_denied_path_keyword(candidate); +} // --------------------------------------------------------------------------- // Policy checks @@ -219,20 +385,20 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat if (plugin_key.empty()) return {true, ""}; // not running inside a plugin context - // Denied filenames are checked first, above both the Loading exemption below and the - // allowed roots. The app config and the cloud refresh token live directly inside - // data_dir(), which is a global allowed root, and no scope ever sets Enforcing — so a - // deny placed any lower would be unreachable for reads. + // Denied filenames/keywords are checked before the allowed roots. The app config and the + // cloud refresh token live directly inside data_dir(), which is a global allowed root, and + // the bundled TLS client cert lives inside resources_dir(), a read-only global allowed + // root, so a deny placed any lower would be unreachable. if (is_denied_filename(path)) { BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write << " plugin=" << plugin_key << " reason=denied filename"; return {false, "denied filename"}; } - - // During import/loading, only block writes. Python must be able to read - // stdlib modules and the plugin file itself during import. - if (m_audit_mode == AuditMode::Loading && !is_write) - return {true, ""}; + if (is_denied_path_keyword(path)) { + BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << path.string() << " is_write=" << is_write + << " plugin=" << plugin_key << " reason=denied path keyword"; + return {false, "denied path keyword"}; + } namespace fs = boost::filesystem; fs::path candidate = path; @@ -245,8 +411,11 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat candidate = absolute_candidate; } + // A root that doesn't allow writes only matches a read-shaped request; a write/create/ + // delete-shaped one falls through to "outside allowed root" for that root even though the + // path is physically inside it. for (const auto& root : m_scoped_allowed_roots) { - if (is_inside_allowed_root(candidate, root)) { + if ((!is_write || root.allow_write) && is_inside_allowed_root(candidate, root.path)) { return {true, ""}; } } @@ -254,14 +423,13 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat { std::lock_guard lock(m_mutex); for (const auto& root : m_global_allowed_roots) { - if (is_inside_allowed_root(candidate, root)) { + if ((!is_write || root.allow_write) && is_inside_allowed_root(candidate, root.path)) { return {true, ""}; } } } BOOST_LOG_TRIVIAL(warning) << "[AUDIT] block path=" << candidate.string() << " is_write=" << is_write - << " audit_mode=" << (m_audit_mode == AuditMode::Loading ? "Loading" : "Enforcing") << " plugin=" << plugin_key; return {false, "outside allowed root"}; } @@ -269,19 +437,99 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat AuditDecision PluginAuditManager::check_open(const std::string& path_str, const std::string& mode) { const bool is_write = mode.find('w') != std::string::npos || mode.find('a') != std::string::npos || - mode.find('+') != std::string::npos; + mode.find('+') != std::string::npos || mode.find('x') != std::string::npos; return check_path_access(boost::filesystem::path(path_str), is_write); } +bool PluginAuditManager::request_filesystem_read_permissions(const std::string& plugin_key, + const std::vector& paths) +{ + if (plugin_key.empty() || paths.empty()) + return true; + + PluginDescriptor descriptor; + if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) + return false; + + PluginInstallState state; + read_install_state(boost::filesystem::path(descriptor.plugin_root), state); + + std::vector missing; + for (const std::string& path : paths) { + if (std::find(state.permissions.fs_read.begin(), state.permissions.fs_read.end(), path) == state.permissions.fs_read.end()) + missing.push_back(path); + } + if (missing.empty()) + return true; + + if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) + return false; + + auto show_dialog = [&descriptor, &missing]() { + wxString requested_paths; + for (const std::string& path : missing) + requested_paths += wxString::FromUTF8(path.c_str()) + "\n"; + + wxMessageDialog dialog( + nullptr, + wxString::Format("Plugin \"%s\" requests filesystem read access to:\n%s", + wxString::FromUTF8(descriptor.name.c_str()), requested_paths), + "Plugin permissions", + wxYES_NO | wxICON_WARNING); + return dialog.ShowModal() == wxID_YES; + }; + + bool granted = false; + if (wxIsMainThread()) { + granted = show_dialog(); + } else { + auto result = std::make_shared>(); + auto future = result->get_future(); + GUI::wxGetApp().CallAfter([result, descriptor_name = descriptor.name, missing]() { + wxString requested_paths; + for (const std::string& path : missing) + requested_paths += wxString::FromUTF8(path.c_str()) + "\n"; + + wxMessageDialog dialog( + nullptr, + wxString::Format("Plugin \"%s\" requests filesystem read access to:\n%s", + wxString::FromUTF8(descriptor_name.c_str()), requested_paths), + "Plugin permissions", + wxYES_NO | wxICON_WARNING); + result->set_value(dialog.ShowModal() == wxID_YES); + }); + granted = future.get(); + } + + if (!granted) + return false; + + if (state.plugin_name.empty()) { + state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local"; + state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version; + state.plugin_name = descriptor.name; + state.cloud_uuid = descriptor.cloud_uuid(); + state.enabled = true; + } + + state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end()); + return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); +} + void PluginAuditManager::report_violation(const AuditViolation& violation) { m_last_violation = violation; m_has_last_violation = true; + m_audit_denial_pending = true; BOOST_LOG_TRIVIAL(warning) << "[AUDIT BLOCKED] plugin=" << violation.plugin_key << " event=" << violation.event_name - << " path=" << violation.path.string() << " reason=" << violation.reason; + << " reason=" << violation.reason; } +bool PluginAuditManager::audit_denial_pending() const { return m_audit_denial_pending; } + +void PluginAuditManager::clear_audit_denial() { m_audit_denial_pending = false; } + void PluginAuditManager::clear_last_violation() { m_has_last_violation = false; @@ -297,31 +545,326 @@ 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 // --------------------------------------------------------------------------- -namespace { +namespace PluginAuditDetail { + +PyObject* tuple_item(PyObject* args, Py_ssize_t index) +{ + if (!args || !PyTuple_Check(args) || index < 0 || index >= PyTuple_GET_SIZE(args)) + return nullptr; + return PyTuple_GET_ITEM(args, index); +} + +std::string python_path(PyObject* object) +{ + if (!object) + return {}; + + PyObject* path_object = PyOS_FSPath(object); + if (!path_object) { + PyErr_Clear(); + return {}; + } + + const char* path = PyUnicode_Check(path_object) ? PyUnicode_AsUTF8(path_object) : PyBytes_AsString(path_object); + std::string result = path ? path : ""; + Py_DECREF(path_object); + if (!path) + PyErr_Clear(); + return result; +} + +std::string python_unicode(PyObject* object) +{ + if (!object || !PyUnicode_Check(object)) + return {}; + const char* text = PyUnicode_AsUTF8(object); + if (!text) { + PyErr_Clear(); + return {}; + } + return text; +} + +std::string python_str(PyObject* object) +{ + if (!object) + return {}; + + PyObject* str_object = PyObject_Str(object); + if (!str_object) { + PyErr_Clear(); + return {}; + } + + const std::string result = python_unicode(str_object); + Py_DECREF(str_object); + return result; +} + +std::vector call_site_identities(const std::string& plugin_root) +{ + std::vector ids; + if (plugin_root.empty()) + return ids; + + 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); + + 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)); + } + + Py_XDECREF(frame); // only holds a reference here if the loop exited via break + + return ids; +} + +static const std::unordered_set two_path_fs_events{ + "os.rename", "os.link", "os.symlink", "shutil.copyfile", + "shutil.copytree", "shutil.copymode", "shutil.copystat", "shutil.move", + "shutil.unpack_archive", "_winapi.CreateJunction", +}; + +static const std::unordered_map> audit_target_arg_indices{ + {"http.client.connect", {1}}, + {"urllib.Request", {0}}, + + {"socket.connect", {1}}, + {"socket.bind", {1}}, + {"socket.getaddrinfo", {0}}, + {"socket.gethostbyname", {0}}, + {"socket.gethostbyaddr", {0}}, + {"socket.getnameinfo", {0}}, + {"socket.getservbyname", {0}}, + {"socket.getservbyport", {0}}, + + {"os.system", {0}}, + {"subprocess.Popen", {1, 0}}, + {"os.posix_spawn", {1, 0}}, + {"os.spawn", {2, 1}}, + {"os.startfile", {0}}, + {"pty.spawn", {0}}, + {"_winapi.CreateProcess", {1, 0}}, + {"_posixsubprocess.fork_exec", {0}}, +}; + +AuditEventCategory open_category(PyObject* args) +{ + const std::string mode = python_unicode(tuple_item(args, 1)); + if (!mode.empty() && mode.find_first_of("wax+") == std::string::npos) + return AuditEventCategory::FsRead; + return AuditEventCategory::FsReadWrite; +} + +std::vector audit_targets(const std::string& event_name, AuditEventCategory category, PyObject* args) +{ + std::vector targets; + + switch (category) { + case AuditEventCategory::FsRead: + case AuditEventCategory::FsReadWrite: + case AuditEventCategory::FsCreate: + case AuditEventCategory::FsDelete: { + const Py_ssize_t path_count = two_path_fs_events.count(event_name) ? 2 : 1; + for (Py_ssize_t index = 0; index < path_count; ++index) { + const std::string path = python_path(tuple_item(args, index)); + if (!path.empty()) + targets.push_back(path); + } + return targets; + } + default: + break; + } + + 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)); + if (!value.empty()) { + targets.push_back(std::move(value)); + break; + } + } + } + + return targets; +} + +std::vector* permission_list_for(AuditEventCategory category, PluginPermissions& permissions) +{ + switch (category) { + case AuditEventCategory::FsRead: return &permissions.fs_read; + case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; + case AuditEventCategory::Http: return &permissions.network_http; + case AuditEventCategory::Socket: return &permissions.network_socket; + case AuditEventCategory::ProcessCreate: return &permissions.process; + default: return nullptr; + } +} + +bool has_permission(const std::vector& granted, const std::string& target) +{ + return std::find(granted.begin(), granted.end(), target) != granted.end(); +} + +bool persist_permission(const std::string& plugin_key, + PluginInstallState& state, + std::vector& permission_list, + const std::string& target) +{ + PluginDescriptor descriptor; + if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) + return false; + + // A sandbox plugin may not have a sidecar yet. Seed the small amount of install metadata + // needed by the writer so clicking Yes still creates the JSON file. + if (state.plugin_name.empty()) { + state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local"; + state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version; + state.plugin_name = descriptor.name; + state.cloud_uuid = descriptor.cloud_uuid(); + state.enabled = true; + } + + if (!has_permission(permission_list, target)) + permission_list.push_back(target); + return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); +} -// Records a blocked event and raises PermissionError in the calling interpreter. Returns -1 -// so an event branch can `return report_denied(...)` directly. int report_denied(PluginAuditManager& mgr, const std::string& event_name, - const boost::filesystem::path& path, const AuditDecision& decision) { AuditViolation violation; violation.plugin_key = mgr.current_plugin(); violation.event_name = event_name; - violation.path = path; violation.reason = decision.reason; mgr.report_violation(violation); - PyErr_SetString(PyExc_PermissionError, "Plugin attempted to access a blocked file path"); + PyErr_SetString(PyExc_PermissionError, "Plugin attempted an audited operation without permission"); return -1; } -} // namespace +wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name, + const wxString& target_list) +{ + if (target_list.IsEmpty()) + return wxString::Format( + _L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\".\n\n" + "This operation does not expose a target the audit hook can display."), + plugin_name, event_name); + + switch (category) { + case AuditEventCategory::FsRead: + return wxString::Format(_L("Plugin \"%s\" is requesting to read the following file(s):\n%s"), plugin_name, target_list); + case AuditEventCategory::FsReadWrite: + return wxString::Format(_L("Plugin \"%s\" is requesting to read/write the following file(s):\n%s"), plugin_name, target_list); + case AuditEventCategory::FsCreate: + return wxString::Format(_L("Plugin \"%s\" is requesting to create the following file(s):\n%s"), plugin_name, target_list); + case AuditEventCategory::FsDelete: + return wxString::Format(_L("Plugin \"%s\" is requesting to delete the following file(s):\n%s"), plugin_name, target_list); + case AuditEventCategory::Http: + return wxString::Format(_L("Plugin \"%s\" is requesting to make an HTTP request to:\n%s"), plugin_name, target_list); + case AuditEventCategory::Socket: + return wxString::Format(_L("Plugin \"%s\" is requesting to open a network connection to:\n%s"), plugin_name, target_list); + case AuditEventCategory::ProcessCreate: + return wxString::Format(_L("Plugin \"%s\" is requesting to run the following command(s):\n%s"), plugin_name, target_list); + default: + return wxString::Format(_L("Plugin \"%s\" is requesting permission for the Python audit event \"%s\"."), plugin_name, event_name); + } +} + +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) +{ + std::vector unresolved = targets; + 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()) + return 0; + } + + wxString target_list; + for (const auto& target : unresolved) + target_list += wxString::FromUTF8(target.c_str()) + "\n"; + + wxMessageDialog dialog(nullptr, + audit_message(category, wxString::FromUTF8(plugin_name.c_str()), + wxString::FromUTF8(event_name.c_str()), target_list), + _L("Plugin permission request"), wxYES_NO | wxICON_WARNING); + if (dialog.ShowModal() != wxID_YES) + 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); + return 0; +} + +} // namespace PluginAuditDetail int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) { @@ -331,94 +874,95 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user std::string event_name(event ? event : ""); + if (event_name.empty()) + return 0; + // Verbose logging of every audit event (can be noisy) if (mgr->verbose_events) { BOOST_LOG_TRIVIAL(debug) << "[AUDIT EVENT] " << event_name; } - // extensive list of audit events can be found at https://docs.python.org/3/library/audit_events.html - - // --- open event --- - if (event_name == "open") { - const char* path_cstr = nullptr; - const char* mode_cstr = nullptr; - int flags = 0; - - // open(path, mode, flags) — path may be str, bytes, or int fd - if (!PyArg_ParseTuple(args, "s|si", &path_cstr, &mode_cstr, &flags)) { - PyErr_Clear(); // couldn't parse; allow - return 0; - } - - std::string path_str(path_cstr ? path_cstr : ""); - std::string mode_str(mode_cstr ? mode_cstr : "r"); - - AuditDecision decision = mgr->check_open(path_str, mode_str); - if (!decision.allowed) - return report_denied(*mgr, event_name, path_str, decision); + if (mgr->current_plugin().empty()) { + BOOST_LOG_TRIVIAL(trace) << "[AUDIT] event=" << event_name << " bypassed (no plugin context)"; return 0; } - // --- os.rename event (raised by os.rename and os.replace) --- - if (event_name == "os.rename") { - const char* src_cstr = nullptr; - const char* dst_cstr = nullptr; - PyObject* src_dir_fd = nullptr; - PyObject* dst_dir_fd = nullptr; - - // os.rename(src, dst, src_dir_fd, dst_dir_fd) — paths may be str, bytes, or int fd. - // The dir_fd arguments are unused, but must be accepted for the tuple to parse. - if (!PyArg_ParseTuple(args, "ss|OO", &src_cstr, &dst_cstr, &src_dir_fd, &dst_dir_fd)) { - PyErr_Clear(); // couldn't parse; allow - return 0; - } - - // A rename writes at both ends, so either end being denied blocks the call. - for (const char* path_cstr : {src_cstr, dst_cstr}) { - std::string path_str(path_cstr ? path_cstr : ""); - AuditDecision decision = mgr->check_path_access(path_str, /* is_write */ true); - if (!decision.allowed) - return report_denied(*mgr, event_name, path_str, decision); - } + // the open function can take in different flags that determine if it is a read or readwrite. + const AuditEventCategory event_type = + event_name == "open" ? PluginAuditDetail::open_category(args) : event_category(event_name); + 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"}); + } } - // --- os.remove event (raised by os.remove and os.unlink) --- - if (event_name == "os.remove") { - const char* path_cstr = nullptr; - PyObject* dir_fd = nullptr; + PluginDescriptor plugin_descriptor; + PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor); - // os.remove(path, dir_fd) — path may be str, bytes, or int fd - if (!PyArg_ParseTuple(args, "s|O", &path_cstr, &dir_fd)) { - PyErr_Clear(); // couldn't parse; allow - return 0; - } - - std::string path_str(path_cstr ? path_cstr : ""); - AuditDecision decision = mgr->check_path_access(path_str, /* is_write */ true); - if (!decision.allowed) - return report_denied(*mgr, event_name, path_str, decision); + // 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; } - // Unknown event — allow by default - 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); } void PluginAuditManager::install_hook() { - if (PySys_AddAuditHook(audit_hook, this) < 0) { - BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install CPython audit hook"; - return; - } - BOOST_LOG_TRIVIAL(info) << "[AUDIT] CPython audit hook installed successfully"; - - // data_dir() is the only globally-allowed root during enforced plugin execution. - // The executable directory and resources directory are intentionally NOT allowed - // here: plugins must not write outside data_dir() (G-code plugins additionally get - // the temp G-code folder via a scoped root). Reads remain permissive in Loading mode. + // 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) -- + // without a separate, narrower grant for either (G-code plugins additionally get the temp + // G-code folder via a scoped root, see SlicingPipelinePluginCapabilityTrampoline). add_global_allowed_root(data_dir()); + // resources_dir() holds the app's bundled, shared assets (installed system profiles, the + // bundled TLS client cert, web assets). Plugins may read from it -- e.g. inspecting bundled + // profiles -- but must never write into the shared, potentially multi-user app install, so + // it is granted read-only. The bundled cert itself stays unreachable regardless, via the + // "cert" denied-path keyword seeded below. + add_global_allowed_root(resources_dir(), /*allow_write=*/false); + // The user's app config and cloud credentials live directly inside data_dir(), so the // root just granted would otherwise expose them to any plugin. Deny them by name. // @@ -430,6 +974,19 @@ void PluginAuditManager::install_hook() // (see its comment for why all four config names are denied); the tests seed from it too. for (const auto& name : default_denied_filenames()) add_denied_filename(name); + + // Categorical denies on top of the exact-name list above: no path a plugin can reach may + // contain a "secret", "cert"(ificate), or "conf"(ig) path component, regardless of which + // allowed root it happens to sit inside. default_denied_path_keywords() is the single + // source of this list; the tests seed from it too. + for (const auto& keyword : default_denied_path_keywords()) + add_denied_path_keyword(keyword); + + if (PySys_AddAuditHook(audit_hook, this) < 0) { + BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install CPython audit hook"; + return; + } + BOOST_LOG_TRIVIAL(info) << "[AUDIT] CPython audit hook installed successfully"; } } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 1b24357e47..d6751a57fd 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -2,8 +2,11 @@ #define slic3r_PluginAuditManager_hpp_ #include +#include #include #include +#include +#include #include #include @@ -18,10 +21,31 @@ struct AuditDecision { struct AuditViolation { std::string plugin_key; std::string event_name; - boost::filesystem::path path; std::string reason; }; +// A filesystem root a plugin may access while an audit context is active. allow_write is +// false for a root that only grants reads (e.g. the bundled, shared resources folder) -- +// a write-shaped event never matches such a root, even though a read-shaped one does. +struct AllowedRoot { + boost::filesystem::path path; + bool allow_write = true; +}; + +// The set of CPython audit events PluginAuditManager recognizes, grouped by the kind of +// operation they represent. None means the event isn't one audit_hook() acts on at all. +enum class AuditEventCategory { + None, + FsRead, + FsReadWrite, + FsCreate, + FsDelete, + Http, + Socket, + ProcessCreate, + Threading, +}; + // Returns true if candidate resolves to a path inside allowed_root. // Uses weakly_canonical and component-wise comparison to reject traversal attacks. bool is_inside_allowed_root(const boost::filesystem::path& candidate, @@ -49,12 +73,15 @@ public: void clear_current_capability(); // --- allowed-roots registry --- - void add_global_allowed_root(const boost::filesystem::path& root); - void add_scoped_allowed_root(const boost::filesystem::path& root); + // allow_write = false registers a read-only root: a read-shaped event inside it is allowed, + // but a write/create/delete-shaped event is not, so it falls through to the normal + // prompt-or-deny path instead. + void add_global_allowed_root(const boost::filesystem::path& root, bool allow_write = true); + void add_scoped_allowed_root(const boost::filesystem::path& root, bool allow_write = true); // --- denied-filenames registry --- - // Filenames a plugin may never touch, in any directory, regardless of audit mode or - // enclosing allowed root. A candidate is denied when its filename starts with a + // Filenames a plugin may never touch, in any directory, regardless of the enclosing allowed + // root. A candidate is denied when its filename starts with a // registered name, so .bak/.tmp companions are covered by the same entry. // // The comparison is case-insensitive on every platform, unlike the _WIN32-only iequals @@ -73,33 +100,60 @@ public: // 8.3 short name is out of scope (see the design doc). This blocks direct access only. bool is_denied_filename(const boost::filesystem::path& candidate) const; - // --- enforcement mode --- - enum class AuditMode { - // Import/loading phase: allow reads anywhere, only block writes - // outside allowed roots. Python needs to read stdlib modules - // during import and those are not inside plugin directories. - Loading, + // --- denied-path-keyword registry --- + // Keywords that categorically deny a path if ANY of its components (directory or file + // name), not just the base name, contains one case-insensitively -- e.g. a "secrets" + // subfolder, a "certificates" folder, or a "conf"/"config" file anywhere the plugin can + // otherwise reach, including inside an allowed root. This is intentionally broader and + // fuzzier than the exact-name is_denied_filename registry: it exists to categorically rule + // out whole classes of sensitive paths (secrets, certificates, config) rather than name + // specific known files, at the cost of over-blocking an unrelated name that happens to + // contain the keyword -- the fail-safe direction, same rationale as is_denied_filename. + void add_denied_path_keyword(const std::string& keyword); - // Execution phase: block both reads and writes outside allowed - // roots, plus subprocess/socket/ctypes. - Enforcing, - }; + // The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact + // same set without a live interpreter. + static std::vector default_denied_path_keywords(); - void set_audit_mode(AuditMode mode); - AuditMode audit_mode() const; + // True when any component of candidate's (canonicalized) path contains a registered + // keyword, case-insensitively. + bool is_denied_path_keyword(const boost::filesystem::path& candidate) const; + + // is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for + // call sites that only need to know whether a path is categorically off-limits, not which + // specific rule fired. + bool is_denied_path(const boost::filesystem::path& candidate) const; // --- policy checks --- - // Shared core for every audited filesystem event. The deny list is consulted above the - // Loading-mode read exemption and above the allowed roots, so a denied filename is - // blocked even though every scope currently runs in Loading and the files in question - // sit inside data_dir(), which is itself a global allowed root. + // Shared core for every audited filesystem event. The deny checks are consulted above the + // allowed roots, so a denied path is blocked even when it sits inside an allowed root (e.g. + // data_dir(), which is a global allowed root). AuditDecision check_path_access(const boost::filesystem::path& candidate, bool is_write); AuditDecision check_open(const std::string& path, const std::string& mode); + // Ask the user to grant the requested filesystem-read paths. The request may originate on a + // plugin load worker, so the implementation marshals the modal dialog to the wx main thread. + // Returns true only when every missing path was granted and persisted; denial aborts the plugin + // load without adding a permission. + bool request_filesystem_read_permissions(const std::string& plugin_key, + const std::vector& paths); + void report_violation(const AuditViolation& violation); + bool audit_denial_pending() const; + void clear_audit_denial(); 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); + bool verbose_events = true; private: @@ -109,17 +163,19 @@ private: static int audit_hook(const char* event, PyObject* args, void* user_data); - static thread_local std::string m_current_plugin_key; - static thread_local std::string m_current_capability_name; - static thread_local AuditMode m_audit_mode; - static thread_local std::vector m_scoped_allowed_roots; - static thread_local bool m_has_last_violation; - static thread_local AuditViolation m_last_violation; + 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 bool m_audit_denial_pending; + static thread_local bool m_has_last_violation; + static thread_local AuditViolation m_last_violation; - // mutable: is_denied_filename() is a const query that must lock. + // mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock. mutable std::mutex m_mutex; - std::vector m_global_allowed_roots; + 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 @@ -130,8 +186,7 @@ class ScopedPluginAuditContext public: explicit ScopedPluginAuditContext( const std::string& plugin_key, - const std::string& capability_name = {}, - PluginAuditManager::AuditMode mode = PluginAuditManager::AuditMode::Loading); + const std::string& capability_name = {}); ~ScopedPluginAuditContext(); @@ -141,8 +196,7 @@ public: private: std::string m_previous_id; std::string m_previous_capability; - PluginAuditManager::AuditMode m_previous_mode; - std::vector m_previous_scoped_roots; + std::vector m_previous_scoped_roots; }; } // namespace Slic3r diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 4d3758508e..4d6aabe3d3 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -784,6 +784,23 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall parsed.plugin_name = state["plugin_name"].get(); if (state.contains("cloud_uuid") && state["cloud_uuid"].is_string()) parsed.cloud_uuid = state["cloud_uuid"].get(); + + if (state.contains("permissions") && state["permissions"].is_object()) { + const auto& permissions = state["permissions"]; + auto read_string_list = [&permissions](const char* key, std::vector& out) { + if (!permissions.contains(key) || !permissions[key].is_array()) + return; + for (const auto& entry : permissions[key]) + if (entry.is_string()) + out.push_back(entry.get()); + }; + read_string_list("fs_read", parsed.permissions.fs_read); + read_string_list("fs_readwrite", parsed.permissions.fs_readwrite); + read_string_list("network_http", parsed.permissions.network_http); + read_string_list("network_socket", parsed.permissions.network_socket); + read_string_list("process", parsed.permissions.process); + } + if (state.contains("enabled") && state["enabled"].is_boolean()) parsed.enabled = state["enabled"].get(); @@ -819,6 +836,14 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin if (!state.cloud_uuid.empty()) json["cloud_uuid"] = state.cloud_uuid; + json["permissions"] = { + {"fs_read", state.permissions.fs_read}, + {"fs_readwrite", state.permissions.fs_readwrite}, + {"network_http", state.permissions.network_http}, + {"network_socket", state.permissions.network_socket}, + {"process", state.permissions.process}, + }; + nlohmann::json capabilities = nlohmann::json::array(); for (const auto& [name, enabled] : state.capabilities) capabilities.push_back(nlohmann::json{{name, enabled}}); @@ -836,6 +861,9 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin const std::vector>& capabilities) { PluginInstallState state; + // Loading a plugin updates its lifecycle/capability state, but must retain permissions granted + // during register_capabilities() or by a previous runtime audit prompt. + read_install_state(plugin_dir, state); state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; // Prefer the descriptor's recorded installed_version (the version fetched from the cloud // at install time, preserved across sidecar re-writes) so a stale manifest/PEP723 header @@ -852,10 +880,16 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginDescriptor& entry) { // Install-time writer: the package is not loaded, so its capabilities are not known yet and the - // sidecar is (re)initialized to "auto-load, nothing disabled". PluginManager writes the real - // per-capability flags once the package is loaded, via the (dir, entry, enabled, capabilities) - // overload. - return write_install_state(plugin_dir, entry, true, {}); + // sidecar is (re)initialized to "auto-load, nothing disabled". This intentionally resets + // permissions on a fresh install/reinstall. PluginManager writes the real per-capability flags + // while preserving permissions once the package is loaded, via the overload above. + PluginInstallState state; + state.installed_from = entry.is_cloud_plugin() ? "cloud" : "local"; + state.installed_version = !entry.installed_version.empty() ? entry.installed_version : entry.version; + state.plugin_name = entry.name; + state.cloud_uuid = entry.cloud_uuid(); + state.enabled = true; + return write_install_state(plugin_dir, state); } bool read_python_plugin_metadata(const boost::filesystem::path& py_path, PluginDescriptor& descriptor, std::string& error) diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index 5f57dbf807..5b62e9f7f1 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -82,11 +82,23 @@ inline nlohmann::json py_to_json(const pybind11::handle& o) return py::str(o).cast(); // fallback: str() } +struct PluginPermissions +{ + std::vector fs_read; + std::vector fs_readwrite; + std::vector network_http; + std::vector network_socket; + std::vector process; +}; + struct PluginInstallState { std::string installed_from; // "local" | "cloud" std::string installed_version; std::string plugin_name; std::string cloud_uuid; // empty for local + + PluginPermissions permissions; + bool enabled = true; std::vector> capabilities; // name -> enabled, ordered }; diff --git a/src/slic3r/plugin/PluginLoader.cpp b/src/slic3r/plugin/PluginLoader.cpp index 92fe92068f..0c45564f1b 100644 --- a/src/slic3r/plugin/PluginLoader.cpp +++ b/src/slic3r/plugin/PluginLoader.cpp @@ -280,7 +280,7 @@ bool load(const PluginDescriptor& descriptor, // (while the active plugin key is set), then instantiates each registered capability and caches // its get_name(). Returns one entry per capability. std::string bridge_error; - auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, bridge_error); + auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error); if (!bridge_error.empty()) { capabilities_found.clear(); error = "Plugin registration failed: " + bridge_error; diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 2bae29611f..f9a6dc60ac 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -622,6 +623,20 @@ std::shared_ptr PluginManager::get_plugin_capability( return nullptr; } +bool PluginManager::get_install_state(const std::string& plugin_key, PluginInstallState& install_state) +{ + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) { + return false; + } + + if (!read_install_state(boost::filesystem::path(descriptor.plugin_root), install_state)) { + return false; + } + + return true; +} + // ── Lifecycle ─────────────────────────────────────────────────────────────────────────────── bool PluginManager::is_plugin_loaded(const std::string& plugin_key) const @@ -910,6 +925,8 @@ void PluginManager::load_plugin_impl(const std::string& plugin_key, bool skip_de if (!plugin_loader::load(descriptor, skip_deps, capabilities_to_enable, registry_precheck, plugin, error)) { if (error == LOAD_CANCELLED) return; // cancelled: nothing materialized survives, and no error is recorded + if (error.rfind("Plugin registration failed:", 0) == 0) + mark_plugin_install_state_disabled(plugin_key); fail(std::move(error)); return; } @@ -1135,6 +1152,51 @@ void PluginManager::write_loaded_plugin_install_state(const std::string& plugin_ write_install_state(boost::filesystem::path(descriptor.plugin_root), descriptor, /*enabled=*/true, capabilities); } +void PluginManager::mark_plugin_install_state_disabled(const std::string& plugin_key) +{ + std::lock_guard state_lock(m_install_state_mutex); + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) + return; + + const boost::filesystem::path root(descriptor.plugin_root); + PluginInstallState state; + if (!read_install_state(root, state)) { + state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local"; + state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version; + state.plugin_name = descriptor.name; + state.cloud_uuid = descriptor.cloud_uuid(); + } + state.enabled = false; + if (!write_install_state(root, state)) + return; + + std::lock_guard lock(m_mutex); + if (Plugin* plugin = find_plugin_locked(plugin_key)) + plugin->descriptor.enabled = false; +} + +void PluginManager::revoke_plugin_permissions(const std::string& plugin_key) +{ + std::lock_guard state_lock(m_install_state_mutex); + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) + return; + + const boost::filesystem::path root(descriptor.plugin_root); + PluginInstallState state; + if (!read_install_state(root, state)) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Failed to read install state for " << plugin_key; + return; + } + + state.permissions = {}; + if (!write_install_state(root, state)) + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": Failed to revoke permissions for " << plugin_key; +} + // ── Callbacks ─────────────────────────────────────────────────────────────────────────────── void PluginManager::subscribe_on_load_callback(PluginLifecycleCompleteFn fn) @@ -1354,6 +1416,11 @@ bool PluginManager::install_plugin(const boost::filesystem::path& filepath, Plug return false; } + // Every successful install may have replaced executable plugin code. Revoke any permissions + // associated with the previous package so the newly installed version must request them again. + if (!plugin_descriptor.plugin_key.empty()) + revoke_plugin_permissions(plugin_descriptor.plugin_key); + if (!plugin_descriptor.plugin_key.empty()) clear_plugin_error(plugin_descriptor.plugin_key); @@ -1741,6 +1808,7 @@ bool PluginManager::update_cloud_plugin(const std::string& plugin_key, std::stri } clear_plugin_error(plugin_key); + return true; } diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index b5044d6dba..e1fedaff94 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -159,6 +159,8 @@ public: PluginCapabilityType type = PluginCapabilityType::Unknown, bool only_enabled = true) const; + bool get_install_state(const std::string& plugin_key, PluginInstallState& install_state); + void load_plugin(const std::string& plugin_key, bool skip_deps = false, std::vector capabilities_to_enable = {}); bool unload_plugin(const std::string& plugin_key); void unload_all_plugins(); @@ -255,6 +257,9 @@ private: // Writes the sidecar for a loaded plugin (enabled=true plus the current per-capability flags). void write_loaded_plugin_install_state(const std::string& plugin_key); + void mark_plugin_install_state_disabled(const std::string& plugin_key); + // Revoke permissions after a package replacement so the new package must request them again. + void revoke_plugin_permissions(const std::string& plugin_key); bool finalize_cloud_plugin_removal(const PluginDescriptor& plugin, bool keep_local, std::string& error); bool delete_installed_plugin_package(const PluginDescriptor& plugin, std::string& error); diff --git a/src/slic3r/plugin/PyPluginTrampoline.hpp b/src/slic3r/plugin/PyPluginTrampoline.hpp index e36c0b375c..ad7fe25f10 100644 --- a/src/slic3r/plugin/PyPluginTrampoline.hpp +++ b/src/slic3r/plugin/PyPluginTrampoline.hpp @@ -26,25 +26,30 @@ try { \ override_call; \ } catch (pybind11::error_already_set & err) { \ + const bool _orca_audit_denial = ::Slic3r::PluginAuditManager::instance().audit_denial_pending(); \ + if (_orca_audit_denial) \ + ::Slic3r::PluginAuditManager::instance().clear_current_plugin(); \ ::Slic3r::log_python_exception_keep(err); \ + if (_orca_audit_denial) \ + ::Slic3r::PluginAuditManager::instance().clear_audit_denial(); \ throw; \ } // Opens the plugin's filesystem audit scope for the duration of a C++ -> Python call, and publishes // the calling capability's cached name so host APIs invoked from Python can tell which capability // they are serving. No-op without an audit plugin key. Declares a local `_orca_audit_scope`. -#define ORCA_PY_AUDIT_SCOPE(mode) \ +#define ORCA_PY_AUDIT_SCOPE() \ std::optional<::Slic3r::ScopedPluginAuditContext> _orca_audit_scope; \ if (const std::string& _orca_audit_key = this->audit_plugin_key(); !_orca_audit_key.empty()) \ - _orca_audit_scope.emplace(_orca_audit_key, this->name(), mode) + _orca_audit_scope.emplace(_orca_audit_key, this->name()) -#define ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, ...) \ +#define ORCA_PY_OVERRIDE_AUDITED(audit_setup, override_macro, ret, base, name, ...) \ do { \ ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); \ ::Slic3r::PythonGILState _orca_python_gil; \ if (!_orca_python_gil) \ throw std::runtime_error("Python interpreter is shutting down"); \ - ORCA_PY_AUDIT_SCOPE(mode); \ + ORCA_PY_AUDIT_SCOPE(); \ if (_orca_audit_scope) \ audit_setup(); \ ORCA_PY_LOGGED_OVERRIDE_BODY(override_macro(ret, base, name, ##__VA_ARGS__)); \ @@ -58,7 +63,7 @@ public: std::string get_name() const override { - ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, Base, get_name); + ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE_PURE, std::string, Base, get_name); } // Config UI hooks. Available on every capability type, so they live here rather than in @@ -66,7 +71,6 @@ public: bool has_config_ui() const override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, bool, @@ -77,7 +81,6 @@ public: std::string get_config_ui() const override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, std::string, @@ -94,7 +97,7 @@ public: // mistake), both fall back to the base's empty object rather than writing `"cap_config": null`. nlohmann::json get_default_config() const override { - ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); + ORCA_PY_AUDIT_SCOPE(); try { pybind11::gil_scoped_acquire gil; pybind11::function override = pybind11::get_override(static_cast(this), "get_default_config"); @@ -117,17 +120,17 @@ public: void on_load() override { - ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_load); + ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE, void, Base, on_load); } void on_unload() override { - ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_unload); + ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE, void, Base, on_unload); } void on_cancelled() override { - ORCA_PY_OVERRIDE_AUDITED(::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, void, Base, on_cancelled); + ORCA_PY_OVERRIDE_AUDITED([] {}, PYBIND11_OVERRIDE, void, Base, on_cancelled); } }; @@ -139,7 +142,7 @@ public: PluginCapabilityType get_type() const override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, PluginCapabilityType, PluginCapabilityInterface, + [] {}, PYBIND11_OVERRIDE, PluginCapabilityType, PluginCapabilityInterface, get_type); } }; diff --git a/src/slic3r/plugin/PythonInterpreter.cpp b/src/slic3r/plugin/PythonInterpreter.cpp index e46ee58f80..3d180c2f2a 100644 --- a/src/slic3r/plugin/PythonInterpreter.cpp +++ b/src/slic3r/plugin/PythonInterpreter.cpp @@ -87,6 +87,12 @@ void log_python_exception_keep(pybind11::error_already_set& err) if (!gil) return; + // Traceback output is host-owned work. In particular, the stderr tee opens the Python log + // file on every write. Keep that open outside the plugin audit context, otherwise an ordinary + // exception raised by a plugin can recursively trigger the filesystem permission dialog while + // its original exception is being reported. + ScopedPluginAuditContext audit_suppression(""); + // Non-destructive: print the traceback to sys.stderr (tee'd to the session log) // WITHOUT consuming err, so the caller can rethrow it intact. For example, downstream C++ // catchers can still read err.what() for the user-facing dialog. We must NOT use @@ -622,10 +628,6 @@ bool PythonInterpreter::initialize() else BOOST_LOG_TRIVIAL(info) << "Bundled uv executable not found"; - // Install the CPython audit hook for plugin policy enforcement. - // This is defense-in-depth: today it only inspects the `open` audit event - // and blocks writes outside the allowed roots; subprocess/socket/ctypes and - // other events are not yet handled. It is NOT a full security sandbox. PluginAuditManager::instance().install_hook(); // Persist Python stderr (plugin tracebacks, including uncaught diff --git a/src/slic3r/plugin/PythonPluginBridge.cpp b/src/slic3r/plugin/PythonPluginBridge.cpp index 40f317c016..11bb6159cc 100644 --- a/src/slic3r/plugin/PythonPluginBridge.cpp +++ b/src/slic3r/plugin/PythonPluginBridge.cpp @@ -1,5 +1,6 @@ #include "PythonPluginBridge.hpp" +#include #include #include #include @@ -40,6 +41,7 @@ thread_local std::string g_active_plugin_key; std::mutex g_registry_mutex; std::unordered_map> g_pending_capabilities; std::unordered_map g_pending_package; +std::unordered_map> g_pending_fs_read_permissions; struct PluginInstanceHandle { // The C++ plugin interface points into a Python object. Keep both alive through one @@ -77,6 +79,8 @@ void discard_pending_capture_without_python(const std::string& plugin_key) (void) package->second.release(); g_pending_package.erase(package); } + + g_pending_fs_read_permissions.erase(plugin_key); } } // namespace @@ -101,34 +105,38 @@ void PythonPluginBridge::begin_plugin_capture(const std::string& plugin_key) // for this same entry path. g_pending_capabilities.erase(plugin_key); g_pending_package.erase(plugin_key); + g_pending_fs_read_permissions.erase(plugin_key); } // From now until finalize/cancel, @orca.plugin and register_capability() calls made by // Python code on this thread are attributed to this plugin. g_active_plugin_key = plugin_key; } -std::vector PythonPluginBridge::finalize_plugin_capture(const std::string& plugin_key, std::string& error) +std::vector PythonPluginBridge::finalize_plugin_capture(const std::string& capture_key, + const std::string& plugin_key, + std::string& error) { PythonGILState gil; if (!gil) { error = "Python interpreter is shutting down"; return {}; } - BOOST_LOG_TRIVIAL(info) << "Finalizing Python plugin capture for key " << plugin_key; + BOOST_LOG_TRIVIAL(info) << "Finalizing Python plugin capture for key " << capture_key; // Phase 1: run the package class's register_capabilities() while the active key is // still set. That method is expected to call orca.register_capability() once per // capability class, and register_capability() needs g_active_plugin_key to know which // pending bucket to append to. { - auto clear_active_key = [&plugin_key]() { - if (g_active_plugin_key == plugin_key) + auto clear_active_key = [&capture_key]() { + if (g_active_plugin_key == capture_key) g_active_plugin_key.clear(); }; - auto discard_pending_for_key = [&plugin_key]() { + auto discard_pending_for_key = [&capture_key]() { std::lock_guard lock(g_registry_mutex); - g_pending_capabilities.erase(plugin_key); - g_pending_package.erase(plugin_key); + g_pending_capabilities.erase(capture_key); + g_pending_package.erase(capture_key); + g_pending_fs_read_permissions.erase(capture_key); }; try { @@ -138,7 +146,7 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons py::object package_cls; { std::lock_guard lock(g_registry_mutex); - auto it = g_pending_package.find(plugin_key); + auto it = g_pending_package.find(capture_key); if (it != g_pending_package.end()) { package_cls = it->second; g_pending_package.erase(it); @@ -157,6 +165,33 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons // are kept. py::object package = package_cls(); package.attr("register_capabilities")(); + + // Permission declarations are collected while register_capabilities() runs so the + // plugin can describe its needs without touching wx from the Python load worker. Ask + // only after registration returns, before capability instances are materialized. + std::vector fs_read_permissions; + { + std::lock_guard lock(g_registry_mutex); + auto it = g_pending_fs_read_permissions.find(capture_key); + if (it != g_pending_fs_read_permissions.end()) { + fs_read_permissions = std::move(it->second); + g_pending_fs_read_permissions.erase(it); + } + } + if (!fs_read_permissions.empty()) { + bool granted = false; + { + py::gil_scoped_release release; + granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions); + } + if (!granted) { + error = "Plugin filesystem read permission request denied"; + BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key; + discard_pending_for_key(); + clear_active_key(); + return {}; + } + } } catch (py::error_already_set& err) { log_python_exception_keep(err); error = err.what(); @@ -180,7 +215,7 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons std::vector classes; { std::lock_guard lock(g_registry_mutex); - auto it = g_pending_capabilities.find(plugin_key); + auto it = g_pending_capabilities.find(capture_key); if (it != g_pending_capabilities.end()) { classes = std::move(it->second); g_pending_capabilities.erase(it); @@ -189,10 +224,10 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons // Registration is complete. Later register_capability() calls should fail instead of // accidentally attaching themselves to this plugin. - if (g_active_plugin_key == plugin_key) + if (g_active_plugin_key == capture_key) g_active_plugin_key.clear(); - BOOST_LOG_TRIVIAL(info) << "Collected " << classes.size() << " registered capability class(es) for key " << plugin_key; + BOOST_LOG_TRIVIAL(info) << "Collected " << classes.size() << " registered capability class(es) for key " << capture_key; std::vector capabilities; capabilities.reserve(classes.size()); @@ -204,7 +239,7 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons py::object instance = cls(); if (!py::isinstance(instance)) { error = "Registered capability must inherit from a PluginCapability base"; - BOOST_LOG_TRIVIAL(error) << "Python plugin capture failed type check for key " << plugin_key + BOOST_LOG_TRIVIAL(error) << "Python plugin capture failed type check for key " << capture_key << " error=" << error; return {}; } @@ -212,7 +247,7 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons auto capability_iface = instance.cast>(); if (!capability_iface) { error = "Failed to cast Python capability to PluginCapabilityInterface"; - BOOST_LOG_TRIVIAL(error) << "Python plugin capture failed cast for key " << plugin_key + BOOST_LOG_TRIVIAL(error) << "Python plugin capture failed cast for key " << capture_key << " error=" << error; return {}; } @@ -226,7 +261,7 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons // here is a hard error that rejects the whole plugin capture. if (name.find(';') != std::string::npos) { error = "Capability name must not contain ';': " + name; - BOOST_LOG_TRIVIAL(error) << "Python plugin capture rejected capability for key " << plugin_key + BOOST_LOG_TRIVIAL(error) << "Python plugin capture rejected capability for key " << capture_key << " error=" << error; return {}; } @@ -246,12 +281,12 @@ std::vector PythonPluginBridge::finalize_plugin_capture(cons // log the traceback here. GIL is held for the duration of finalize_plugin_capture. log_python_exception_keep(err); error = err.what(); - BOOST_LOG_TRIVIAL(error) << "Python plugin capture raised Python exception for key " << plugin_key + BOOST_LOG_TRIVIAL(error) << "Python plugin capture raised Python exception for key " << capture_key << " error=" << error; return {}; } catch (const std::exception& ex) { error = ex.what(); - BOOST_LOG_TRIVIAL(error) << "Python plugin capture raised exception for key " << plugin_key + BOOST_LOG_TRIVIAL(error) << "Python plugin capture raised exception for key " << capture_key << " error=" << error; return {}; } @@ -279,6 +314,7 @@ void PythonPluginBridge::cancel_plugin_capture(const std::string& plugin_key) // may already have registered under this key. g_pending_capabilities.erase(plugin_key); g_pending_package.erase(plugin_key); + g_pending_fs_read_permissions.erase(plugin_key); } if (g_active_plugin_key == plugin_key) @@ -305,6 +341,7 @@ void PythonPluginBridge::clear_pending_captures() (void) pkg.release(); } g_pending_package.clear(); + g_pending_fs_read_permissions.clear(); g_active_plugin_key.clear(); return; } @@ -313,6 +350,7 @@ void PythonPluginBridge::clear_pending_captures() BOOST_LOG_TRIVIAL(info) << "Clearing " << g_pending_capabilities.size() << " pending Python plugin capture(s)"; g_pending_capabilities.clear(); g_pending_package.clear(); + g_pending_fs_read_permissions.clear(); g_active_plugin_key.clear(); } @@ -450,6 +488,27 @@ void bind_python_api(pybind11::module_& m) }, R"pbdoc(Register a PluginCapability subclass while OrcaSlicer loads your module.)pbdoc"); + m.def( + "request_permissions", + [](const std::vector& fs_read) { + if (g_active_plugin_key.empty()) + throw py::value_error("request_permissions() called outside plugin discovery context"); + + std::lock_guard lock(g_registry_mutex); + auto& requested = g_pending_fs_read_permissions[g_active_plugin_key]; + for (const std::string& path : fs_read) { + if (path.empty()) + throw py::value_error("request_permissions(fs_read=...) does not accept empty paths"); + if (std::find(requested.begin(), requested.end(), path) == requested.end()) + requested.push_back(path); + } + }, + py::arg("fs_read") = std::vector{}, + R"pbdoc(Request filesystem read permissions while OrcaSlicer loads your module. + +The host presents the request to the user after register_capabilities() returns. Denying the +request aborts the plugin load.)pbdoc"); + m.def("plugin", [](py::object cls) { if (g_active_plugin_key.empty()) throw py::value_error("@orca.plugin used outside plugin discovery context"); diff --git a/src/slic3r/plugin/PythonPluginBridge.hpp b/src/slic3r/plugin/PythonPluginBridge.hpp index 6ccadabd00..036c8f1941 100644 --- a/src/slic3r/plugin/PythonPluginBridge.hpp +++ b/src/slic3r/plugin/PythonPluginBridge.hpp @@ -31,7 +31,7 @@ public: // Returns one CapturedCapability per capability, or an empty vector on failure // (error message populated). std::vector finalize_plugin_capture( - const std::string& plugin_key, std::string& error); + const std::string& capture_key, const std::string& plugin_key, std::string& error); // Clear any pending registrations for the key. Safe to call when import fails. void cancel_plugin_capture(const std::string& plugin_key); diff --git a/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp index 3fb476c228..f6e13c981b 100644 --- a/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp @@ -16,7 +16,6 @@ public: std::string get_icon() override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE, std::string, @@ -27,7 +26,6 @@ public: std::string get_ui() override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, @@ -42,7 +40,7 @@ public: if (!gil) throw std::runtime_error("Python interpreter is shutting down"); - ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); + ORCA_PY_AUDIT_SCOPE(); pybind11::function override = pybind11::get_override(static_cast(this), "on_message"); if (!override) diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp index 226ea04650..5491e1cef6 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp @@ -16,206 +16,206 @@ public: AgentInfo get_agent_info() override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, AgentInfo, PrinterAgentPluginCapability, + [] {}, PYBIND11_OVERRIDE_PURE, AgentInfo, PrinterAgentPluginCapability, get_agent_info); } int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, connect_printer, dev_id, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, connect_printer, dev_id, dev_ip, username, password, use_ssl); } int disconnect_printer() override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, disconnect_printer); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, disconnect_printer); } int send_message(std::string dev_id, std::string json_str, int qos, int flag) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message, dev_id, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message, dev_id, json_str, qos, flag); } int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message_to_printer, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, send_message_to_printer, dev_id, json_str, qos, flag); } bool start_discovery(bool start, bool sending) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, start_discovery, start, + [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, start_discovery, start, sending); } int bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind_detect, dev_ip, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind_detect, dev_ip, sec_link, detect); } std::string get_user_selected_machine() override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, PrinterAgentPluginCapability, + [] {}, PYBIND11_OVERRIDE_PURE, std::string, PrinterAgentPluginCapability, get_user_selected_machine); } int set_user_selected_machine(std::string dev_id) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_user_selected_machine, dev_id); } int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_send_gcode_to_sdcard, params, update_fn, cancel_fn, wait_fn); } int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_local_print, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_local_print, params, update_fn, cancel_fn); } FilamentSyncMode get_filament_sync_mode() const override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, FilamentSyncMode, PrinterAgentPluginCapability, + [] {}, PYBIND11_OVERRIDE_PURE, FilamentSyncMode, PrinterAgentPluginCapability, get_filament_sync_mode); } bool fetch_filament_info(std::string dev_id) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, fetch_filament_info, dev_id); + [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, fetch_filament_info, dev_id); } int check_cert() override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, check_cert); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, check_cert); } void install_device_cert(std::string dev_id, bool lan_only) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, void, PrinterAgentPluginCapability, install_device_cert, dev_id, + [] {}, PYBIND11_OVERRIDE_PURE, void, PrinterAgentPluginCapability, install_device_cert, dev_id, lan_only); } int ping_bind(std::string ping_code) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, ping_bind, ping_code); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, ping_bind, ping_code); } int bind(std::string dev_ip, std::string dev_id, std::string dev_model, std::string sec_link, std::string timezone, bool improved, OnUpdateStatusFn update_fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind, dev_ip, dev_id, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, bind, dev_ip, dev_id, dev_model, sec_link, timezone, improved, update_fn); } int unbind(std::string dev_id) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, unbind, dev_id); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, unbind, dev_id); } int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_print, params, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_print, params, update_fn, cancel_fn, wait_fn); } int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_local_print_with_record, params, update_fn, cancel_fn, wait_fn); } int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_sdcard_print, params, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, start_sdcard_print, params, update_fn, cancel_fn); } int get_hms_snapshot(std::string dev_id, std::string file_name, std::function callback) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, get_hms_snapshot, dev_id, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, get_hms_snapshot, dev_id, file_name, callback); } int set_server_callback(OnServerErrFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_server_callback, fn); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_server_callback, fn); } int set_on_ssdp_msg_fn(OnMsgArrivedFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_ssdp_msg_fn, fn); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_ssdp_msg_fn, fn); } int set_on_printer_connected_fn(OnPrinterConnectedFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_printer_connected_fn, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_printer_connected_fn, fn); } int set_on_subscribe_failure_fn(GetSubscribeFailureFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_subscribe_failure_fn, + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_subscribe_failure_fn, fn); } int set_on_message_fn(OnMessageFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_message_fn, fn); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_message_fn, fn); } int set_on_user_message_fn(OnMessageFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_user_message_fn, fn); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_user_message_fn, fn); } int set_on_local_connect_fn(OnLocalConnectedFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_connect_fn, fn); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_connect_fn, fn); } int set_on_local_message_fn(OnMessageFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_message_fn, fn); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_on_local_message_fn, fn); } int set_queue_on_main_fn(QueueOnMainFn fn) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_queue_on_main_fn, fn); + [] {}, PYBIND11_OVERRIDE_PURE, int, PrinterAgentPluginCapability, set_queue_on_main_fn, fn); } // request_bind_ticket returns its ticket through a std::string* out-param, which pybind11 @@ -223,7 +223,7 @@ public: // returns a (result, ticket) tuple, which we unpack into the int result and the out-param. int request_bind_ticket(std::string* ticket) override { - ORCA_PY_AUDIT_SCOPE(::Slic3r::PluginAuditManager::AuditMode::Loading); + ORCA_PY_AUDIT_SCOPE(); ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); ::Slic3r::PythonGILState gil; if (!gil) diff --git a/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp index f77d7ce597..62958a89d9 100644 --- a/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp @@ -13,7 +13,6 @@ public: ExecutionResult execute() override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, ExecutionResult, diff --git a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp index 8605532c12..5c9d527e42 100644 --- a/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapabilityTrampoline.hpp @@ -10,7 +10,6 @@ public: using PyPluginCommonTrampoline::PyPluginCommonTrampoline; ExecutionResult execute(SlicingPipelineContext& ctx) override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [&]{ // At Step.psGCodePostProcess the plugin edits the exported G-code file, which lives // outside data_dir() (a temp/output folder), so writing to it would otherwise be diff --git a/tests/slic3rutils/plugin_test_utils.hpp b/tests/slic3rutils/plugin_test_utils.hpp index d60b3441c8..cbdcf1ff95 100644 --- a/tests/slic3rutils/plugin_test_utils.hpp +++ b/tests/slic3rutils/plugin_test_utils.hpp @@ -34,4 +34,24 @@ struct ScopedDataDir ScopedDataDir& operator=(const ScopedDataDir&) = delete; }; +// Point resources_dir() at a throwaway directory for the lifetime of a test and restore the +// previous value afterwards, mirroring ScopedDataDir. +struct ScopedResourcesDir +{ + ScopedTemporaryDir tmp; + boost::filesystem::path dir; + std::string previous; + + explicit ScopedResourcesDir(const std::string& tag) + : tmp("orca-" + tag), dir(tmp.path()), previous(resources_dir()) + { + set_resources_dir(dir.string()); + } + + ~ScopedResourcesDir() { set_resources_dir(previous); } + + ScopedResourcesDir(const ScopedResourcesDir&) = delete; + ScopedResourcesDir& operator=(const ScopedResourcesDir&) = delete; +}; + } // namespace Slic3r diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index 49eedbd1fb..71ce692671 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -27,6 +27,16 @@ void seed_denied_names() 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 app config and token filenames anywhere", "[audit]") @@ -81,7 +91,7 @@ TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit } } -TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption", "[audit]") +TEST_CASE("Plugin audit deny beats allowed roots", "[audit]") { ScopedDataDir data_dir_guard("plugin-audit-deny"); seed_denied_names(); @@ -91,8 +101,8 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption" // 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 in Loading mode, which every scope runs in. - ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading); + // 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; @@ -103,6 +113,13 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption" 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"); @@ -110,16 +127,14 @@ TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption" CHECK(decision.reason == "denied filename"); } - SECTION("reading the app config is blocked even though Loading exempts reads") + SECTION("reading the app config is blocked despite the allowed root") { - // Without the deny, a read in Loading mode short-circuits to allow. The deny sits above - // that exemption, so this must still be blocked. 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 in Loading mode") + SECTION("reading the cloud refresh token is blocked") { AuditDecision decision = mgr.check_open(token.string(), "r"); CHECK_FALSE(decision.allowed); @@ -150,7 +165,7 @@ TEST_CASE("Plugin audit deny beats a plugin's own scoped root", "[audit]") const fs::path plugin_dir = fs::path(data_dir()) / "plugins" / "test_plugin"; fs::create_directories(plugin_dir); - ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading); + ScopedPluginAuditContext ctx("test_plugin", ""); mgr.add_scoped_allowed_root(plugin_dir); SECTION("the plugin's own non-denied file opens for read and write") @@ -185,3 +200,139 @@ TEST_CASE("Plugin audit does not constrain non-plugin code", "[audit]") 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 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"))); + } + + 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/CertBundle/ca.pem"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON"))); + } + + 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"))); + } + + 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); + } +} diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index fceeaf1f83..ae16703fa6 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -112,6 +112,22 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins REQUIRE(read_install_state(plugin_dir, state)); CHECK(state.installed_version == "1.2.0"); + state.permissions.fs_read = {"/path/to/read"}; + state.permissions.fs_readwrite = {"/path/to/readwrite"}; + state.permissions.network_http = {"https://api.example.com"}; + state.permissions.network_socket = {"192.168.45.6:443"}; + state.permissions.process = {"/usr/bin/curl"}; + REQUIRE(write_install_state(plugin_dir, state)); + + // Permission data is persisted in the same sidecar as the installation metadata. + PluginInstallState persisted; + REQUIRE(read_install_state(plugin_dir, persisted)); + CHECK(persisted.permissions.fs_read == state.permissions.fs_read); + CHECK(persisted.permissions.fs_readwrite == state.permissions.fs_readwrite); + CHECK(persisted.permissions.network_http == state.permissions.network_http); + CHECK(persisted.permissions.network_socket == state.permissions.network_socket); + CHECK(persisted.permissions.process == state.permissions.process); + // Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still // 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare // the cloud's latest version against the installed version instead of the stale header, so an @@ -120,4 +136,4 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins scanned.version = "1.0.0"; // as parsed from the unchanged PEP723 header read_install_state(plugin_dir, scanned); CHECK(scanned.installed_version == "1.2.0"); -} \ No newline at end of file +}