diff --git a/src/slic3r/plugin/PluginAuditManager.cpp b/src/slic3r/plugin/PluginAuditManager.cpp index 1fe8837c5c..291ae8a419 100644 --- a/src/slic3r/plugin/PluginAuditManager.cpp +++ b/src/slic3r/plugin/PluginAuditManager.cpp @@ -346,8 +346,20 @@ std::vector PluginAuditManager::default_denied_path_keywords() // 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"}; + // granted as a read-only allowed root). Match as whole path components, not substrings, so + // imports such as numpy/__config__.py and stdlib configparser.py remain usable. + return {"secret", "secrets", "cert", "certs", "certificate", "certificates", "conf", "config"}; +} + +static bool has_denied_config_extension(std::string name) +{ + const size_t stream_pos = name.find(':'); + if (stream_pos != std::string::npos) + name.erase(stream_pos); + + const boost::filesystem::path path(name); + const std::string extension = path.extension().string(); + return extension == ".conf" || extension == ".ini"; } bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& candidate) const @@ -372,7 +384,7 @@ bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& c 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) + if (name == keyword || (keyword == "conf" && has_denied_config_extension(name))) return true; } } @@ -796,7 +808,8 @@ bool persist_permission(const std::string& plugin_key, int report_denied(PluginAuditManager& mgr, const std::string& event_name, - const AuditDecision& decision) + const AuditDecision& decision, + const std::string& target = {}) { AuditViolation violation; violation.plugin_key = mgr.current_plugin(); @@ -804,7 +817,13 @@ int report_denied(PluginAuditManager& mgr, violation.reason = decision.reason; mgr.report_violation(violation); - PyErr_SetString(PyExc_PermissionError, "Plugin attempted an audited operation without permission"); + std::string message = "Plugin attempted audited operation \"" + event_name + "\" without permission"; + if (!decision.reason.empty()) + message += ": " + decision.reason; + if (!target.empty()) + message += ": " + target; + + PyErr_SetString(PyExc_PermissionError, message.c_str()); return -1; } @@ -916,7 +935,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user 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"}); + return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target); } } diff --git a/src/slic3r/plugin/PluginAuditManager.hpp b/src/slic3r/plugin/PluginAuditManager.hpp index 76ac5cdd18..18d6b75a01 100644 --- a/src/slic3r/plugin/PluginAuditManager.hpp +++ b/src/slic3r/plugin/PluginAuditManager.hpp @@ -4,7 +4,6 @@ // Via pybind11 so this file requests the same python3xx.lib as everything else. #include #include -#include #include #include #include @@ -103,22 +102,20 @@ public: bool is_denied_filename(const boost::filesystem::path& candidate) const; // --- 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. + // Keywords that categorically deny a path if ANY component matches one case-insensitively -- + // e.g. a "secrets" subfolder, a "certificates" folder, a "conf"/"config" directory, or a + // .conf/.ini file anywhere the plugin can otherwise reach, including inside an allowed root. + // This is broader than the exact-name is_denied_filename registry, but it is not a substring + // match: importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or + // user folders such as "Conference" and "Concert" are unrelated names and must stay promptable. void add_denied_path_keyword(const std::string& keyword); // 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(); - // True when any component of candidate's (canonicalized) path contains a registered - // keyword, case-insensitively. + // True when any component of candidate's (canonicalized) path matches a registered keyword, + // case-insensitively. A registered "conf" keyword also blocks .conf/.ini file components. bool is_denied_path_keyword(const boost::filesystem::path& candidate) const; // is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for diff --git a/tests/slic3rutils/test_plugin_audit.cpp b/tests/slic3rutils/test_plugin_audit.cpp index ca8c46fabd..267cdb4099 100644 --- a/tests/slic3rutils/test_plugin_audit.cpp +++ b/tests/slic3rutils/test_plugin_audit.cpp @@ -222,18 +222,19 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem"))); } - SECTION("a 'conf'/'config' directory or file component is denied") + SECTION("a 'conf'/'config' directory or config file component is denied") { CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json"))); CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.ini"))); } SECTION("matching is case-insensitive") { CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt"))); - CHECK(mgr.is_denied_path_keyword(fs::path("/resources/CertBundle/ca.pem"))); - CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/resources/Certificates/ca.pem"))); + CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/PLUGIN.CONF"))); } SECTION("matching is not limited to the base name -- any ancestor component counts") @@ -245,6 +246,14 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword", { CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode"))); CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/__config__.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/_core/_ufunc_config.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/configparser.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/sysconfig.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/logging/config.py"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/certifi/cacert.pem"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Conference/output.txt"))); + CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Concert/output.txt"))); } SECTION("an empty path is not denied")