Compare commits

..
Author SHA1 Message Date
Ian Chua b0c15879de fix: flatpak audit denial because of config in path 2026-10-20 14:51:28 +08:00
6 changed files with 153 additions and 185 deletions

No files matched your search

+3 -25
View File
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
u ) u )
export BUILD_TARGET="universal" export BUILD_TARGET="universal"
;; ;;
h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]" h ) echo "Usage: ./build_release_macos.sh [-d]"
echo " -d: Build deps only" echo " -d: Build deps only"
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)" echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
echo " -s: Build slicer only" echo " -s: Build slicer only"
@@ -60,33 +60,13 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew" echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)" echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)" echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds,"
echo " e.g. -DDESTDIR=<dir> for the deps, -DCMAKE_PREFIX_PATH=<dir>/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh."
echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory."
exit 0 exit 0
;; ;;
: )
echo "Option -${OPTARG} requires an argument, aborting."
exit 1
;;
* ) * )
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
exit 1
;; ;;
esac esac
done done
# getopts stops at '--'; whatever follows is handed to cmake unchanged.
if [[ ${OPTIND} -le $# ]] ; then
separator_index=$((OPTIND-1))
if [[ "${!separator_index}" != "--" ]] ; then
echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting."
exit 1
fi
fi
shift $((OPTIND-1))
EXTRA_CMAKE_ARGS=("$@")
# Set defaults # Set defaults
if [ -z "$ARCH" ]; then if [ -z "$ARCH" ]; then
@@ -231,8 +211,7 @@ function build_deps() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \ "${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \ ${CMAKE_POLICY_COMPAT}
"${EXTRA_CMAKE_ARGS[@]}"
fi fi
cmake --build . --config "$BUILD_CONFIG" --target deps cmake --build . --config "$BUILD_CONFIG" --target deps
) )
@@ -335,8 +314,7 @@ function build_slicer() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \ "${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \ ${CMAKE_POLICY_COMPAT}
"${EXTRA_CMAKE_ARGS[@]}"
fi fi
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET" cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
) )
+2 -6
View File
@@ -59,13 +59,9 @@ else ()
set(_cross_compile_arg --host=${TOOLCHAIN_PREFIX}) set(_cross_compile_arg --host=${TOOLCHAIN_PREFIX})
endif () endif ()
# 6.3.0 is the first release whose arm64 assembly avoids x18, which macOS
# reserves and zeroes on every return to user space; 6.2.1 crashes on Apple
# silicon whenever CGAL falls back to exact arithmetic.
ExternalProject_Add(dep_GMP ExternalProject_Add(dep_GMP
URL https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz URL https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2
https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.3.0/gmp-6.3.0.tar.xz URL_HASH SHA256=eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c
URL_HASH SHA256=a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/GMP DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/GMP
PATCH_COMMAND git apply ${GMP_DIRECTORY_FLAG} --verbose ${CMAKE_CURRENT_LIST_DIR}/0001-GMP_GCC15.patch PATCH_COMMAND git apply ${GMP_DIRECTORY_FLAG} --verbose ${CMAKE_CURRENT_LIST_DIR}/0001-GMP_GCC15.patch
BUILD_IN_SOURCE ON BUILD_IN_SOURCE ON
@@ -226,10 +226,10 @@ modules:
sha256: f353e7b99bd0cbfc27ac9082de51acf32a8bc0b3e21ff9661ecca6f205ec1d81 sha256: f353e7b99bd0cbfc27ac9082de51acf32a8bc0b3e21ff9661ecca6f205ec1d81
dest: external-packages/OpenVDB dest: external-packages/OpenVDB
# GMP 6.3.0 # GMP 6.2.1
- type: file - type: file
url: https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz url: https://github.com/SoftFever/OrcaSlicer_deps/releases/download/gmp-6.2.1/gmp-6.2.1.tar.bz2
sha256: a3c2b80201b89e68616f4ad30bc66aee4927c3ce50e33929ca819d5c43538898 sha256: eae9326beb4158c386e39a356818031bd28f3124cf915f8c5b1dc4c7a36b4d7c
dest: external-packages/GMP dest: external-packages/GMP
# MPFR 4.2.2 # MPFR 4.2.2
+74 -93
View File
@@ -183,47 +183,52 @@ static const char* category_name(AuditEventCategory category)
// Path safety // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root) namespace {
// Canonicalize a path for containment comparison. weakly_canonical resolves symlinks but does
// NOT require the path to exist — it canonicalizes the prefix that exists and appends the
// non-existing tail lexically.
boost::filesystem::path canonical_for_comparison(const boost::filesystem::path& path)
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
boost::system::error_code ec; boost::system::error_code ec;
fs::path canon = fs::weakly_canonical(path, ec);
// Canonicalize both paths. weakly_canonical resolves symlinks but does
// NOT require the path to exist — it canonicalizes the prefix that exists
// and appends the non-existing tail lexically.
fs::path canon_candidate = fs::weakly_canonical(candidate, ec);
if (ec) { if (ec) {
// Fall back to lexically_normal + absolute // Fall back to lexically_normal + absolute
canon_candidate = fs::absolute(candidate, ec).lexically_normal(); canon = fs::absolute(path, ec).lexically_normal();
if (ec) if (ec)
canon_candidate = candidate; canon = path;
} }
return canon;
}
fs::path canon_root = fs::weakly_canonical(allowed_root, ec); bool same_path_component(const boost::filesystem::path& lhs, const boost::filesystem::path& rhs)
if (ec) { {
canon_root = fs::absolute(allowed_root, ec).lexically_normal();
if (ec)
canon_root = allowed_root;
}
// Component-wise comparison: the root must be a prefix of candidate,
// and the next component must not be ".." or missing.
auto cand_it = canon_candidate.begin();
auto cand_end = canon_candidate.end();
auto root_it = canon_root.begin();
auto root_end = canon_root.end();
const auto same_component = [](const fs::path& lhs, const fs::path& rhs) {
#ifdef _WIN32 #ifdef _WIN32
return boost::algorithm::iequals(lhs.native(), rhs.native()); return boost::algorithm::iequals(lhs.native(), rhs.native());
#else #else
return lhs == rhs; return lhs == rhs;
#endif #endif
}; }
// Component-wise containment: the whole of canonical_root must be a prefix of
// canonical_candidate, and the remaining components must not traverse upward. Both arguments are
// expected to come from canonical_for_comparison(). On a match, suffix receives the candidate's
// components below the root (empty when the two paths are equal).
bool suffix_inside_root(const boost::filesystem::path& canonical_candidate,
const boost::filesystem::path& canonical_root,
boost::filesystem::path& suffix)
{
suffix.clear();
auto cand_it = canonical_candidate.begin();
auto cand_end = canonical_candidate.end();
auto root_it = canonical_root.begin();
auto root_end = canonical_root.end();
// Consume matching components // Consume matching components
while (root_it != root_end && cand_it != cand_end && same_component(*root_it, *cand_it)) { while (root_it != root_end && cand_it != cand_end && same_path_component(*root_it, *cand_it)) {
++root_it; ++root_it;
++cand_it; ++cand_it;
} }
@@ -238,9 +243,19 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
return false; return false;
} }
for (; cand_it != cand_end; ++cand_it)
suffix /= *cand_it;
return true; return true;
} }
} // namespace
bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boost::filesystem::path& allowed_root)
{
boost::filesystem::path suffix;
return suffix_inside_root(canonical_for_comparison(candidate), canonical_for_comparison(allowed_root), suffix);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ScopedPluginAuditContext // ScopedPluginAuditContext
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -353,18 +368,16 @@ void PluginAuditManager::add_denied_filename(const std::string& filename)
std::vector<std::string> PluginAuditManager::default_denied_filenames() std::vector<std::string> PluginAuditManager::default_denied_filenames()
{ {
// AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key // AppConfig::config_path() picks .conf vs .ini on the USE_JSON_CONFIG ifdef and the app key
// on its mode, and is a non-static member we cannot call without an instance. Denying all // on its mode, and is a non-static member we cannot call without an instance. Denying all
// four names is cheaper and more robust than replicating that; the two unused names cost one // four names is cheaper and more robust than replicating that; the two unused names cost one
// string comparison each. Keep the plugin install-state sidecar here too: it controls which // string comparison each. Single-sourced here so install_hook() and the tests seed from the
// packages load on startup and must remain host-managed. Single-sourced so install_hook() and // same list and cannot drift apart.
// the tests seed from the same list and cannot drift apart.
return { return {
SLIC3R_APP_KEY ".conf", SLIC3R_APP_KEY ".conf",
GCODEVIEWER_APP_KEY ".conf", GCODEVIEWER_APP_KEY ".conf",
SLIC3R_APP_KEY ".ini", SLIC3R_APP_KEY ".ini",
GCODEVIEWER_APP_KEY ".ini", GCODEVIEWER_APP_KEY ".ini",
secret_constants::USER_SECRET_FILENAME, secret_constants::USER_SECRET_FILENAME,
".install_state.json",
}; };
} }
@@ -412,7 +425,8 @@ std::vector<std::string> PluginAuditManager::default_denied_path_keywords()
// it happens to live inside an otherwise-allowed root (e.g. the bundled TLS client cert at // 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 // resources_dir()/cert/..., which would become reachable the moment resources_dir() is
// granted as a read-only allowed root). Match as whole path components, not substrings, so // 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. // imports such as numpy/__config__.py and stdlib configparser.py remain usable. Inside
// data_dir() they match below that root only; see is_denied_path_keyword().
return {"secret", "secrets", "cert", "certs", "certificate", "certificates", "conf", "config"}; return {"secret", "secrets", "cert", "certs", "certificate", "certificates", "conf", "config"};
} }
@@ -431,19 +445,29 @@ bool PluginAuditManager::is_denied_path_keyword(const boost::filesystem::path& c
{ {
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
boost::system::error_code ec; const fs::path canon = canonical_for_comparison(candidate);
fs::path canon = fs::weakly_canonical(candidate, ec);
if (ec) { // The app's data folder can itself sit below a component named like one of the keywords
canon = fs::absolute(candidate, ec).lexically_normal(); // without being one: Flatpak keeps it under <home>/.var/app/<app-id>/config/OrcaSlicer, and
if (ec) // the sandbox's home layout is not ours to change. A keyword above data_dir() therefore says
canon = candidate; // nothing about what a plugin may reach inside it, so inside data_dir() the keywords apply to
} // the suffix below data_dir() only. Everywhere else -- resources_dir() with the bundled
// certificates, a plugin's own folders, arbitrary files -- the whole path is still checked,
// and so is a keyword under data_dir() itself.
fs::path canonical_data_dir;
if (!data_dir().empty())
canonical_data_dir = canonical_for_comparison(data_dir());
fs::path scope = canon;
fs::path suffix;
if (!canonical_data_dir.empty() && suffix_inside_root(canon, canonical_data_dir, suffix))
scope = suffix;
std::lock_guard<std::mutex> lock(m_mutex); std::lock_guard<std::mutex> lock(m_mutex);
if (m_denied_path_keywords.empty()) if (m_denied_path_keywords.empty())
return false; return false;
for (const auto& component : canon) { for (const auto& component : scope) {
std::string name = component.string(); std::string name = component.string();
if (name.empty()) if (name.empty())
continue; continue;
@@ -500,12 +524,6 @@ AuditDecision PluginAuditManager::check_path_access(const boost::filesystem::pat
candidate = absolute_candidate; candidate = absolute_candidate;
} }
// Importing Python modules reads their source and may inspect package directories. These
// reads are normal interpreter work, not plugin data access; keep the exemption read-only
// and exclude third-party packages from the stdlib roots.
if (!is_write && is_stdlib_path(candidate))
return {true, ""};
// A root that doesn't allow writes only matches a read-shaped request; a write/create/ // 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 // delete-shaped one falls through to "outside allowed root" for that root even though the
// path is physically inside it. // path is physically inside it.
@@ -1023,22 +1041,6 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, b
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing; static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
static const char* audit_category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
default: return "None";
}
}
// Asks the user to grant an audited event, returning true when they approve an inline prompt. // Asks the user to grant an audited event, returning true when they approve an inline prompt.
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has // `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
// no target (by_event), which the prompt does not show as a target. // no target (by_event), which the prompt does not show as a target.
@@ -1056,16 +1058,6 @@ bool request_permission(AuditEventCategory category, const std::string& plugin_k
const std::string& event_name, bool by_event, const std::vector<std::string>& keys) const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
{ {
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys; const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
std::string targets_text = by_event ? "<none>" : "";
for (const auto& target : shown) {
if (!targets_text.empty())
targets_text += ", ";
targets_text += target;
}
BOOST_LOG_TRIVIAL(info) << "[AUDIT PROMPT] plugin=" << plugin_key << " event=" << event_name
<< " category=" << audit_category_name(category) << " targets=[" << targets_text << "]"
<< (by_event && !keys.empty() ? " grant=" + keys.front() : std::string{});
if (s_permission_prompt_for_testing) if (s_permission_prompt_for_testing)
return s_permission_prompt_for_testing(event_name, shown); return s_permission_prompt_for_testing(event_name, shown);
@@ -1232,18 +1224,6 @@ bool PluginAuditManager::is_stdlib_file(const std::string& filename)
return it->second; return it->second;
} }
bool PluginAuditManager::is_stdlib_path(const boost::filesystem::path& path)
{
if (path.empty())
return false;
const auto inside_any = [&path](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&path](const boost::filesystem::path& dir) { return is_inside_allowed_root(path, dir); });
};
return inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames) void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
{ {
if (frames.empty()) if (frames.empty())
@@ -1312,7 +1292,7 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
void PluginAuditManager::install_hook() void PluginAuditManager::install_hook()
{ {
// Identifies stdlib frames for operation roots and stdlib paths for read-only access. // Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame).
PluginAuditDetail::collect_stdlib_dirs(); PluginAuditDetail::collect_stdlib_dirs();
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It // data_dir() is the primary globally-allowed root during plugin execution: read+write. It
@@ -1329,9 +1309,8 @@ void PluginAuditManager::install_hook()
// "cert" denied-path keyword seeded below. // "cert" denied-path keyword seeded below.
add_global_allowed_root(resources_dir(), /*allow_write=*/false); add_global_allowed_root(resources_dir(), /*allow_write=*/false);
// The user's app config and cloud credentials live directly inside data_dir(), and plugin // The user's app config and cloud credentials live directly inside data_dir(), so the
// install state lives beneath it, so the root just granted would otherwise expose them to // root just granted would otherwise expose them to any plugin. Deny them by name.
// plugins. Deny them by name.
// //
// Seeded here rather than by each secret's owner because install_hook() runs during lazy // Seeded here rather than by each secret's owner because install_hook() runs during lazy
// interpreter init and therefore provably precedes any plugin bytecode, whereas // interpreter init and therefore provably precedes any plugin bytecode, whereas
@@ -1342,10 +1321,12 @@ void PluginAuditManager::install_hook()
for (const auto& name : default_denied_filenames()) for (const auto& name : default_denied_filenames())
add_denied_filename(name); add_denied_filename(name);
// Categorical denies on top of the exact-name list above: no path a plugin can reach may // Categorical denies on top of the exact-name list above: a path a plugin reaches must not
// contain a "secret", "cert"(ificate), or "conf"(ig) path component, regardless of which // have a "secret", "cert"(ificate), or "conf"(ig) component below data_dir() -- or anywhere
// allowed root it happens to sit inside. default_denied_path_keywords() is the single // in the path outside data_dir(), whose own location may legitimately contain one (Flatpak
// source of this list; the tests seed from it too. // keeps it under .../config/OrcaSlicer). This holds regardless of which allowed root the
// path 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()) for (const auto& keyword : default_denied_path_keywords())
add_denied_path_keyword(keyword); add_denied_path_keyword(keyword);
+9 -10
View File
@@ -102,12 +102,13 @@ public:
bool is_denied_filename(const boost::filesystem::path& candidate) const; bool is_denied_filename(const boost::filesystem::path& candidate) const;
// --- denied-path-keyword registry --- // --- denied-path-keyword registry ---
// Keywords that categorically deny a path if ANY component matches one case-insensitively -- // Keywords that categorically deny a path when a component matches one case-insensitively --
// e.g. a "secrets" subfolder, a "certificates" folder, a "conf"/"config" directory, or a // 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. // .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 // The components above the app's data_dir() are not considered (see below). This is broader
// match: importable modules such as numpy/__config__.py, configparser.py, sysconfig.py, or // than the exact-name is_denied_filename registry, but it is not a substring match:
// user folders such as "Conference" and "Concert" are unrelated names and must stay promptable. // 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); void add_denied_path_keyword(const std::string& keyword);
// The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact // The list install_hook() seeds into the keyword registry. Exposed so tests seed the exact
@@ -115,7 +116,10 @@ public:
static std::vector<std::string> default_denied_path_keywords(); static std::vector<std::string> default_denied_path_keywords();
// True when any component of candidate's (canonicalized) path matches a registered keyword, // 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. // case-insensitively. Inside data_dir(), only the components below data_dir() are checked:
// the app's own data folder may itself sit under a component named like a keyword (Flatpak
// keeps it under .../config/OrcaSlicer), which must not deny everything inside it. A
// registered "conf" keyword also blocks .conf/.ini file components.
bool is_denied_path_keyword(const boost::filesystem::path& candidate) const; bool is_denied_path_keyword(const boost::filesystem::path& candidate) const;
// is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for // is_denied_filename(candidate) || is_denied_path_keyword(candidate). Convenience for
@@ -173,11 +177,6 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data); static int audit_hook(const char* event, PyObject* args, void* user_data);
// True for paths inside the interpreter's stdlib, excluding site-packages. Unlike
// is_stdlib_file(), this query is not cached because filesystem paths may resolve through
// symlinks that can change.
static bool is_stdlib_path(const boost::filesystem::path& path);
// Drops the references in `frames`, keeping the caller's error and denial state intact. // Drops the references in `frames`, keeping the caller's error and denial state intact.
void release_frames(std::vector<PyObject*>& frames); void release_frames(std::vector<PyObject*>& frames);
+62 -48
View File
@@ -69,7 +69,7 @@ void seed_denied_keywords()
} // namespace } // namespace
TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]") TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]")
{ {
seed_denied_names(); seed_denied_names();
const PluginAuditManager& mgr = PluginAuditManager::instance(); const PluginAuditManager& mgr = PluginAuditManager::instance();
@@ -81,7 +81,6 @@ TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini")));
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini"))); CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini")));
CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME))); CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME)));
CHECK(mgr.is_denied_filename(fs::path(".install_state.json")));
} }
SECTION("companions holding the same secrets are denied by the prefix rule") SECTION("companions holding the same secrets are denied by the prefix rule")
@@ -137,7 +136,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf"); const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME; const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
const fs::path install_state = fs::path(data_dir()) / "plugins" / "example" / ".install_state.json";
SECTION("a non-denied file inside the allowed root is writable (root really grants writes)") SECTION("a non-denied file inside the allowed root is writable (root really grants writes)")
{ {
@@ -178,17 +176,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
CHECK_FALSE(decision.allowed); CHECK_FALSE(decision.allowed);
} }
SECTION("plugin install state is blocked for reads and writes despite the allowed root")
{
AuditDecision read_decision = mgr.check_open(install_state.string(), "r");
CHECK_FALSE(read_decision.allowed);
CHECK(read_decision.reason == "denied filename");
AuditDecision write_decision = mgr.check_open(install_state.string(), "w");
CHECK_FALSE(write_decision.allowed);
CHECK(write_decision.reason == "denied filename");
}
SECTION("a traversal path resolving to the config is blocked") SECTION("a traversal path resolving to the config is blocked")
{ {
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf"); const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
@@ -301,6 +288,67 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
} }
} }
TEST_CASE("Plugin audit checks denied keywords below data_dir, not in the directories above it", "[PluginAudit]")
{
// Flatpak keeps the app's data folder under <home>/.var/app/<app-id>/config/OrcaSlicer, so
// data_dir() itself has a "config" component. That ancestor describes the sandbox's layout,
// not what the folder holds, and must not hard-deny every path inside it.
ScopedDataDir data_dir_guard("plugin-audit-data-dir-keyword");
const fs::path flatpak_data_dir = data_dir_guard.dir / "config" / "OrcaSlicer";
set_data_dir(flatpak_data_dir.string());
seed_denied_names();
seed_denied_keywords();
PluginAuditManager& mgr = PluginAuditManager::instance();
// Reproduce install_hook()'s grant: data_dir() is a global allowed root.
mgr.add_global_allowed_root(data_dir());
ScopedPluginAuditContext ctx("test_plugin", "");
SECTION("the root and the paths below it that carry no keyword are allowed")
{
CHECK_FALSE(mgr.is_denied_path_keyword(flatpak_data_dir / "orca_plugins" / "state.json"));
CHECK(mgr.check_open(data_dir(), "r").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "orca_plugins" / "test_plugin" / "state.json").string(), "r").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "orca_plugins" / "test_plugin" / "state.json").string(), "w").allowed);
CHECK(mgr.check_open((flatpak_data_dir / "system" / "profiles" / "vendor.json").string(), "r").allowed);
}
SECTION("a 'secrets' component below data_dir() is still denied")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / "secrets" / "token.json").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
SECTION("a 'config' component below data_dir() is still denied")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / "config" / "settings.json").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
SECTION("the app config below data_dir() is still denied by name")
{
AuditDecision decision = mgr.check_open((flatpak_data_dir / (SLIC3R_APP_KEY ".conf")).string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied filename");
}
SECTION("an unrelated resources root keeps the full-path keyword check")
{
ScopedResourcesDir resources_dir_guard("plugin-audit-data-dir-keyword-resources");
mgr.add_global_allowed_root(resources_dir(), /*allow_write=*/false);
CHECK(mgr.check_open((fs::path(resources_dir()) / "profiles" / "vendor.json").string(), "r").allowed);
AuditDecision decision = mgr.check_open((fs::path(resources_dir()) / "cert" / "slicer_base64.cer").string(), "r");
CHECK_FALSE(decision.allowed);
CHECK(decision.reason == "denied path keyword");
}
}
TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]") TEST_CASE("Plugin audit is_denied_path combines the filename and keyword registries", "[audit]")
{ {
seed_denied_names(); seed_denied_names();
@@ -1132,37 +1180,3 @@ TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string())); CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>")); CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
} }
TEST_CASE("A plugin may read the CPython stdlib without a prompt, but not site-packages or for writing", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
// os.py ships with every CPython installation. A site-packages path is third-party code even
// when the directory is empty: the prompt must come before the missing file is noticed.
const fs::path stdlib_file = stdlib / "os.py";
REQUIRE(fs::exists(stdlib_file));
python.globals["STDLIB_FILE"] = stdlib_file.string();
python.globals["SITE_PACKAGE_FILE"] = (purelib / "regression_probe.py").string();
python.run_as(PLUGIN, R"PY(
read_outcome = attempt(lambda: open(STDLIB_FILE).read())
write_outcome = attempt(lambda: open(STDLIB_FILE, "r+").close())
site_packages_outcome = attempt(lambda: open(SITE_PACKAGE_FILE).close())
)PY");
CHECK(python.str("read_outcome") == "ok");
CHECK(python.str("write_outcome") == "denied");
CHECK(python.str("site_packages_outcome") == "denied");
// Only the write and the site-packages read are asked about; the stdlib read is implicit.
const std::string write_prompt = "open: " + python.str("STDLIB_FILE") + "\n";
const std::string site_packages_prompt = "open: " + python.str("SITE_PACKAGE_FILE") + "\n";
CHECK(describe(harness.prompts) == write_prompt + site_packages_prompt);
}