Compare commits

...
Author SHA1 Message Date
peachismomo 3dff91b9ff fix: clang-tidy 2026-10-20 18:48:25 +08:00
Ian Chua 8f10d48a76 Merge branch 'main' into feat/headless-plugin-probe 2026-10-20 18:43:50 +08:00
peachismomo 0aa1b5fb69 feat: Add headless Python plugin permission probe 2026-10-20 18:42:07 +08:00
Ian Chua e7d6cc32a7 fix: deny plugin from modifying plugin sidecar (#16634) 2026-10-20 18:04:42 +08:00
SoftFever 069bd402d9 Forward CMake arguments after -- in the macOS build script (#16683)
Unknown options and missing option values now stop the script instead of being ignored.
2026-10-20 16:06:33 +08:00
SoftFever 6626377abc Stop re-asking for plugin permissions granted in an earlier session (#16658) 2026-10-20 13:38:44 +08:00
HanifKoh 74d4392dae Keep a Disabled Filament Override Off After a Restart (Key List Variant) (#16650)
Keep a Disabled Filament Override Off After a Restart

A child preset stores "nil" both for an override the user switched off and
for a per-variant column it inherits from its parent, and the loader filled
every nil from the parent. Whenever the parent preset sets Retract Before
Wipe or another override, switching it off lasted only until the next
restart, project open or newer cloud copy.

Treat the keys in is_filament_extruder_override_key, now including the
ironing overrides, as "nil means off": Preset::save and the project-embedded
preset diff write such an option with all its columns, and the loader keeps
its nil columns while still mapping them onto the parent's variants by
name. Every other nullable option keeps nil as "inherit this column". The
cloud diff only force-emits a disabled override whose default is a real
value.
2026-10-20 13:38:20 +08:00
HanifKoh 0d5b3a79d7 Keep a Scalar Config Option's Value When Deserializing Fails (#16641)
A failed stream extraction writes 0 into its target, so Float, Int, Percent and FloatOrPercent options were overwritten before deserialize reported the error, and Point/Point3 kept a partially scanned value. Parse into a temporary and assign only on success; FloatOrPercent sets its percent flag only then too.
2026-10-20 13:02:15 +08:00
31 changed files with 2958 additions and 478 deletions

No files matched your search

+1
View File
@@ -123,6 +123,7 @@ option(SLIC3R_ASAN "Enable ASan on Clang and GCC" 0)
# Python stubgen module # Python stubgen module
option(ORCA_BUILD_PYTHON_STUBGEN_MODULE "Build importable Python module for pybind11-stubgen" ON) option(ORCA_BUILD_PYTHON_STUBGEN_MODULE "Build importable Python module for pybind11-stubgen" ON)
option(ORCA_BUILD_PLUGIN_PERMISSION_PROBE "Build the headless Python plugin permission probe (developer tool)" OFF)
set(ORCA_BUNDLED_UV_EXECUTABLE "" CACHE FILEPATH "Path to a uv executable to bundle for Python package installation. Leave empty to auto-download.") set(ORCA_BUNDLED_UV_EXECUTABLE "" CACHE FILEPATH "Path to a uv executable to bundle for Python package installation. Leave empty to auto-download.")
# Auto-download uv if not provided by the user. # Auto-download uv if not provided by the user.
+25 -3
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]" h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]"
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,13 +60,33 @@ 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
@@ -211,7 +231,8 @@ 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
) )
@@ -314,7 +335,8 @@ 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"
) )
+1
View File
@@ -366,6 +366,7 @@ src/slic3r/Utils/MoonrakerPrinterAgent.cpp
src/slic3r/Utils/BBLNetworkPlugin.cpp src/slic3r/Utils/BBLNetworkPlugin.cpp
src/slic3r/Utils/OrcaCloudServiceAgent.cpp src/slic3r/Utils/OrcaCloudServiceAgent.cpp
src/slic3r/plugin/PluginAuditManager.cpp src/slic3r/plugin/PluginAuditManager.cpp
src/slic3r/plugin/PluginAuditManagerGui.cpp
src/slic3r/plugin/PluginConfig.cpp src/slic3r/plugin/PluginConfig.cpp
src/slic3r/plugin/PluginResolver.cpp src/slic3r/plugin/PluginResolver.cpp
src/slic3r/plugin/CloudPluginService.cpp src/slic3r/plugin/CloudPluginService.cpp
+1 -1
View File
@@ -23,7 +23,7 @@ if %FULL_MODE%==1 (
call :prepareGettextList "%list_file%" "%filtered_list%" "%missing_list%" call :prepareGettextList "%list_file%" "%filtered_list%" "%missing_list%"
if "!has_sources!"=="1" ( if "!has_sources!"=="1" (
if not exist "%generated_i18n%" mkdir "%generated_i18n%" if not exist "%generated_i18n%" mkdir "%generated_i18n%"
.\tools\xgettext.exe --keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "%filtered_list%" -o "%generated_pot%" .\tools\xgettext.exe --keyword=L --keyword=_L --keyword=_u8L --keyword=PLUGIN_U8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "%filtered_list%" -o "%generated_pot%"
if errorlevel 1 ( if errorlevel 1 (
set "script_exit_code=1" set "script_exit_code=1"
) else ( ) else (
+1 -1
View File
@@ -85,7 +85,7 @@ if $FULL_MODE; then
generated_pot_file="${generated_i18n_dir}/OrcaSlicer.pot" generated_pot_file="${generated_i18n_dir}/OrcaSlicer.pot"
mkdir -p "$generated_i18n_dir" mkdir -p "$generated_i18n_dir"
xgettext --keyword=L --keyword=_L --keyword=_u8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "$filtered_list" -o "$generated_pot_file" xgettext --keyword=L --keyword=_L --keyword=_u8L --keyword=PLUGIN_U8L --keyword=L_CONTEXT:1,2c --keyword=_L_CONTEXT:1,2c --keyword=_u8L_CONTEXT:1,2c --keyword=_L_PLURAL:1,2 --add-comments=TRN --from-code=UTF-8 --no-location --debug --boost --no-wrap -f "$filtered_list" -o "$generated_pot_file"
python3 scripts/HintsToPot.py ./resources "$generated_i18n_dir" python3 scripts/HintsToPot.py ./resources "$generated_i18n_dir"
if [ -f "$pot_file" ] && files_equal_ignoring_pot_date "$pot_file" "$generated_pot_file"; then if [ -f "$pot_file" ] && files_equal_ignoring_pot_date "$pot_file" "$generated_pot_file"; then
+41 -16
View File
@@ -377,7 +377,8 @@ public:
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0; virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0;
virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0; virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0;
virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0; virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) = 0; // keep_nil: copy a nil column from rhs as well, for options whose nil means "disabled" rather than "inherit".
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil = false) = 0;
virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0; virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0;
virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0; virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0;
// Resize the vector of values, copy the newly added values from opt_default if provided. // Resize the vector of values, copy the newly added values from opt_default if provided.
@@ -580,7 +581,7 @@ public:
//set a item related with extruder variants when loading user config, only set the different value of some extruder //set a item related with extruder variants when loading user config, only set the different value of some extruder
//rhs: item from user config //rhs: item from user config
//diff_index: which index in this vector need to be set //diff_index: which index in this vector need to be set
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) override virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil) override
{ {
if (rhs->type() == this->type()) { if (rhs->type() == this->type()) {
// Assign the first value of the rhs vector. // Assign the first value of the rhs vector.
@@ -592,7 +593,7 @@ public:
for (size_t i = 0; i < diff_index.size(); i++) { for (size_t i = 0; i < diff_index.size(); i++) {
if (diff_index[i] != -1) { if (diff_index[i] != -1) {
for (size_t j = 0; j < stride; j++) { for (size_t j = 0; j < stride; j++) {
if (!other->is_nil(diff_index[i] * stride)) if (keep_nil || !other->is_nil(diff_index[i] * stride))
this->values[i * stride + j] = other->values[diff_index[i] * stride + j]; this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
} }
} }
@@ -841,8 +842,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
bool operator==(const ConfigOption &rhs) const override bool operator==(const ConfigOption &rhs) const override
@@ -1033,8 +1038,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; int value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
ConfigOptionInt& operator=(const ConfigOption *opt) ConfigOptionInt& operator=(const ConfigOption *opt)
@@ -1252,8 +1261,12 @@ public:
UNUSED(append); UNUSED(append);
// don't try to parse the trailing % since it's optional // don't try to parse the trailing % since it's optional
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
private: private:
@@ -1365,10 +1378,14 @@ public:
bool deserialize(const std::string &str, bool append = false) override bool deserialize(const std::string &str, bool append = false) override
{ {
UNUSED(append); UNUSED(append);
this->percent = str.find_first_of("%") != std::string::npos;
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
this->percent = str.find_first_of("%") != std::string::npos;
return true;
} }
private: private:
@@ -1538,8 +1555,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
char dummy; char dummy;
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 || Vec2d value;
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2; if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 &&
sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2)
return false;
this->value = value;
return true;
} }
private: private:
@@ -1658,8 +1679,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
char dummy; char dummy;
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 || Vec3d value;
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3; if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 &&
sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3)
return false;
this->value = value;
return true;
} }
private: private:
+8 -5
View File
@@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config)
{ {
ConfigOption *opt_src = config.option(option); ConfigOption *opt_src = config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true); ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable())) if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
opt_dst->set(opt_src); opt_dst->set(opt_src);
else { else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src); ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
@@ -2132,7 +2132,7 @@ Preset* PresetCollection::get_preset_differed_for_save(Preset& preset)
{ {
ConfigOption *opt_src = preset.config.option(option); ConfigOption *opt_src = preset.config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true); ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable())) if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
opt_dst->set(opt_src); opt_dst->set(opt_src);
else { else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src); ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
@@ -2199,9 +2199,12 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map<std
// option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently // option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe. // resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
if (m_type == Preset::TYPE_FILAMENT) { if (m_type == Preset::TYPE_FILAMENT) {
for (const std::string& opt_key : filament_extruder_override_keys) { for (const std::string& opt_key : preset.config.keys()) {
if (key_values.count(opt_key)) if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
continue; // already carried by the diff continue; // not an override, or already carried by the diff
const ConfigOptionDef *def = print_config_def.get(opt_key);
if (def == nullptr || !def->default_value || def->default_value->is_nil())
continue; // the option's default is nil too, so an absent key cannot come back as a value
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key)); const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
if (opt_vec == nullptr) if (opt_vec == nullptr)
continue; continue;
+11 -4
View File
@@ -119,12 +119,18 @@ const std::vector<std::string> filament_extruder_override_keys = {
}; };
// Some filament override parameters are generated from filament_extruder_override_keys, // Some filament override parameters are generated from filament_extruder_override_keys,
// while filament_retract_length_nc is defined separately. Keep the generator list // while filament_retract_length_nc and the ironing overrides are defined separately.
// unchanged and use this helper for behavior checks that need the full override set. // Keep the generator list unchanged and use this helper for behavior checks that need
// the full override set. A nil in one of these keys means the override is disabled, so
// child presets store and load it verbatim instead of treating nil as "inherit".
bool is_filament_extruder_override_key(const std::string &opt_key) bool is_filament_extruder_override_key(const std::string &opt_key)
{ {
return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() || return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() ||
opt_key == "filament_retract_length_nc"; opt_key == "filament_retract_length_nc" ||
opt_key == "filament_ironing_flow" ||
opt_key == "filament_ironing_spacing" ||
opt_key == "filament_ironing_inset" ||
opt_key == "filament_ironing_speed";
} }
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id) size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
@@ -12272,7 +12278,8 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
opt_src->set(opt_target); opt_src->set(opt_target);
} }
else else
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride); // An override's nil means "disabled" and is the child's value; every other nil column inherits.
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt));
} }
} }
} }
+62
View File
@@ -707,6 +707,10 @@ set(SLIC3R_GUI_SOURCES
plugin/PluginManager.hpp plugin/PluginManager.hpp
plugin/PluginAuditManager.cpp plugin/PluginAuditManager.cpp
plugin/PluginAuditManager.hpp plugin/PluginAuditManager.hpp
plugin/PluginAuditManagerGui.cpp
plugin/PluginAuditManagerInternal.hpp
plugin/PluginModule.cpp
plugin/PluginText.hpp
plugin/PluginResolver.cpp plugin/PluginResolver.cpp
plugin/PluginResolver.hpp plugin/PluginResolver.hpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp
@@ -1092,3 +1096,61 @@ if (ORCA_BUILD_PYTHON_STUBGEN_MODULE)
PREFIX "" PREFIX ""
) )
endif() endif()
# Headless plugin permission probe. A distinct executable so normal GUI loading and prompt
# behavior stay untouched. It compiles the GUI-free plugin sources directly instead of linking
# libslic3r_gui, so neither the GUI library nor wx is pulled into the probe. The permission
# front end is selected by source composition: PluginAuditManagerGui.cpp for the GUI app and
# probe/PluginAuditManagerHeadless.cpp here.
#
# Residual cost: the probe still links libslic3r (the large core library) for Utils,
# LifecycleEvents, the model/print types used by the GUI-free host bindings and libslic3r's own
# transitive dependencies. It does not link libslic3r_gui, wx or the GUI-only host/plugin sources.
# Developer/security-analysis tool, so it is off by default.
if (ORCA_BUILD_PLUGIN_PERMISSION_PROBE)
add_executable(orca-plugin-probe
plugin/PluginPermissionProbe.cpp
plugin/PluginProbeCollector.hpp
plugin/probe/NanoSvgImplementation.cpp
plugin/PluginAuditManager.cpp
plugin/PluginAuditManagerInternal.hpp
plugin/PluginModule.cpp
plugin/PluginText.hpp
plugin/probe/PluginAuditManagerHeadless.cpp
plugin/PluginFsUtils.cpp
plugin/PluginLoader.cpp
plugin/PythonInterpreter.cpp
plugin/PythonPluginBridge.cpp
plugin/probe/PluginHostProbe.cpp
plugin/probe/PluginProbeConfig.cpp
plugin/host/PluginHostGeometry.cpp
plugin/host/PluginHostMesh.cpp
plugin/host/PluginHostModel.cpp
plugin/host/PluginHostSlicing.cpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp
plugin/pluginTypes/pages/PagesPluginCapability.cpp
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp
plugin/pluginTypes/script/ScriptPluginCapability.cpp
plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.cpp
)
target_compile_definitions(orca-plugin-probe PRIVATE ORCA_PLUGIN_PROBE_BUILD)
target_include_directories(orca-plugin-probe PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_SOURCE_DIR}/src
Utils
)
target_link_libraries(orca-plugin-probe PRIVATE
libslic3r
nanosvg
pybind11::embed
)
set_target_properties(orca-plugin-probe PROPERTIES
OUTPUT_NAME orca-plugin-probe
FOLDER "tools"
)
endif()
+377 -376
View File
@@ -1,4 +1,5 @@
#include "PluginAuditManager.hpp" #include "PluginAuditManager.hpp"
#include "PluginAuditManagerInternal.hpp"
#include "../Utils/OrcaCloudServiceAgent.hpp" #include "../Utils/OrcaCloudServiceAgent.hpp"
#include "libslic3r/Utils.hpp" #include "libslic3r/Utils.hpp"
@@ -11,28 +12,16 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <algorithm> #include <algorithm>
#include <cassert>
#include <cctype> #include <cctype>
#include <cstddef> #include <cstddef>
#include <cstdlib> #include <cstdlib>
#include <future>
#include <mutex> #include <mutex>
#include <memory>
#include <slic3r/GUI/BindDialog.hpp>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <unordered_set> #include <unordered_set>
#include <utility> #include <utility>
#include <vector> #include <vector>
#include <wx/app.h>
#include <wx/event.h>
#include <wx/msgdlg.h>
#include <wx/string.h>
#include <wx/thread.h>
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/plugin/PluginDescriptor.hpp"
#include <Python.h> #include <Python.h>
namespace Slic3r { namespace Slic3r {
@@ -130,6 +119,20 @@ static AuditEventCategory event_category(const std::string& event_name)
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
} }
// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole
// operation, so the raising frame bounds everything the operation does. Every other mapped event
// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly
// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2
// are raised in iglob(), which returns its lazy generator before any directory is listed.
static const std::unordered_set<std::string> operation_root_events{
"urllib.Request", "http.client.connect", "os.walk", "os.fwalk",
"glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob",
"subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp",
"shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree",
"shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive",
"shutil.unpack_archive",
};
// True for the categories whose targets are filesystem paths, as opposed to a network // 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 // address or a process command line -- the deny-path and allowed-root checks only make sense
// against a path. // against a path.
@@ -146,6 +149,49 @@ static bool is_fs_category(AuditEventCategory category)
} }
} }
namespace plugin_audit {
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
const char* category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
case AuditEventCategory::None: break;
}
return "None";
}
int report_denied(PluginAuditManager& mgr,
const std::string& event_name,
const AuditDecision& decision,
const std::string& target)
{
AuditViolation violation;
violation.plugin_key = mgr.current_plugin();
violation.event_name = event_name;
violation.reason = decision.reason;
mgr.report_violation(violation);
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;
}
} // namespace plugin_audit
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path safety // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -214,27 +260,52 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local std::string PluginAuditManager::m_current_plugin_key = "";
thread_local std::string PluginAuditManager::m_current_capability_name = ""; thread_local std::string PluginAuditManager::m_current_capability_name = "";
thread_local std::string PluginAuditManager::m_current_audit_phase = "";
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots; thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
thread_local int PluginAuditManager::m_releasing_frames = 0;
thread_local bool PluginAuditManager::m_audit_denial_pending = false; thread_local bool PluginAuditManager::m_audit_denial_pending = false;
thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation; thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name) const std::string& capability_name)
: m_previous_id(PluginAuditManager::instance().current_plugin()) : m_plugin_key(plugin_key)
, m_capability(capability_name)
, m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability()) , m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
, m_previous_root_frames(std::move(PluginAuditManager::m_root_frames))
{ {
PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::m_scoped_allowed_roots.clear(); PluginAuditManager::m_scoped_allowed_roots.clear();
PluginAuditManager::m_root_frames.clear();
} }
ScopedPluginAuditContext::~ScopedPluginAuditContext() ScopedPluginAuditContext::~ScopedPluginAuditContext()
{ {
PluginAuditManager::instance().set_current_plugin(m_previous_id); PluginAuditManager& mgr = PluginAuditManager::instance();
PluginAuditManager::instance().set_current_capability(m_previous_capability);
// Let this scope's root frames go while it is still the current context, and as its own
// plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer
// must never be judged as the outer plugin, or bypass the audit when there is none. A
// finalizer may record a new root frame, so repeat until none is held.
if (!PluginAuditManager::m_root_frames.empty()) {
assert(PyGILState_Check());
mgr.set_current_plugin(m_plugin_key);
mgr.set_current_capability(m_capability);
while (!PluginAuditManager::m_root_frames.empty()) {
std::vector<PyObject*> frames = std::move(PluginAuditManager::m_root_frames);
PluginAuditManager::m_root_frames.clear();
mgr.release_frames(frames);
}
}
mgr.set_current_plugin(m_previous_id);
mgr.set_current_capability(m_previous_capability);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
PluginAuditManager::m_root_frames = std::move(m_previous_root_frames);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -259,6 +330,76 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca
void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); } void PluginAuditManager::clear_current_capability() { m_current_capability_name.clear(); }
void PluginAuditManager::set_current_audit_phase(const std::string& phase) { m_current_audit_phase = phase; }
const std::string& PluginAuditManager::current_audit_phase() const { return m_current_audit_phase; }
void PluginAuditManager::clear_current_audit_phase() { m_current_audit_phase.clear(); }
void PluginAuditManager::set_observation_observer(AuditObserver observer)
{
std::lock_guard<std::mutex> lock(m_observer_mutex);
m_observation_observer = std::move(observer);
}
void PluginAuditManager::clear_observation_observer()
{
std::lock_guard<std::mutex> lock(m_observer_mutex);
m_observation_observer = nullptr;
}
bool PluginAuditManager::observation_mode() const
{
std::lock_guard<std::mutex> lock(m_observer_mutex);
return static_cast<bool>(m_observation_observer);
}
// The audit hook holds the GIL here. Copy the observer out before invoking it so a callback that
// reads the manager cannot deadlock on m_observer_mutex.
bool PluginAuditManager::record_observation(const std::string& event_name,
const std::string& category,
const std::vector<std::string>& targets)
{
AuditObserver observer;
{
std::lock_guard<std::mutex> lock(m_observer_mutex);
observer = m_observation_observer;
}
if (!observer)
return false;
// The observer is called from a CPython C callback; a C++ exception must not unwind through
// the audit hook. The probe's collector only allocates, so a throw here is a lost observation,
// not a reason to abort the audited operation.
try {
AuditObservation observation;
observation.plugin_key = m_current_plugin_key;
observation.capability = m_current_capability_name;
observation.phase = m_current_audit_phase;
observation.event_name = event_name;
observation.category = category;
observation.targets = targets;
// Probe mode never denies: the hook returns 0 after recording. A later failure of the
// surrounding Python operation is reported by the probe's callback/load result, not here.
observation.continued = true;
observer(observation);
} catch (...) {
}
return true;
}
ScopedPluginAuditPhase::ScopedPluginAuditPhase(std::string phase)
: m_phase(std::move(phase))
, m_previous_phase(PluginAuditManager::instance().current_audit_phase())
{
PluginAuditManager::instance().set_current_audit_phase(m_phase);
}
ScopedPluginAuditPhase::~ScopedPluginAuditPhase()
{
PluginAuditManager::instance().set_current_audit_phase(m_previous_phase);
}
void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write) void PluginAuditManager::add_global_allowed_root(const boost::filesystem::path& root, bool allow_write)
{ {
if (root.empty()) if (root.empty())
@@ -298,14 +439,16 @@ 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. Single-sourced here so install_hook() and the tests seed from the // string comparison each. Keep the plugin install-state sidecar here too: it controls which
// same list and cannot drift apart. // packages load on startup and must remain host-managed. Single-sourced so install_hook() and
// 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",
}; };
} }
@@ -477,73 +620,14 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
if (plugin_key.empty() || paths.empty()) if (plugin_key.empty() || paths.empty())
return true; return true;
PluginDescriptor descriptor; // Probe mode records the declaration and proceeds. It must not consult the install state,
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.empty()) // open the permission dialog, or persist a grant.
return false; if (observation_mode()) {
record_observation("orca.request_permissions", "FsRead", paths);
PluginInstallState state;
read_install_state(boost::filesystem::path(descriptor.plugin_root), state);
std::vector<std::string> 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; 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(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
_L("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<std::promise<bool>>();
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(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
_L("Plugin permissions"),
wxYES_NO | wxICON_WARNING);
result->set_value(dialog.ShowModal() == wxID_YES);
});
granted = future.get();
} }
if (!granted) return plugin_audit::request_filesystem_read_permissions(plugin_key, paths);
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) void PluginAuditManager::report_violation(const AuditViolation& violation)
@@ -575,34 +659,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
return true; return true;
} }
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const
{
if (plugin_key.empty() || call_site_ids.empty())
return false;
std::lock_guard<std::mutex> 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<std::string>& call_site_ids)
{
if (plugin_key.empty() || call_site_ids.empty())
return;
std::lock_guard<std::mutex> 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 // The C-level audit hook
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -663,40 +719,88 @@ std::string python_str(PyObject* object)
return result; return result;
} }
std::vector<std::string> call_site_identities(const std::string& plugin_root) // The interpreter's stdlib folders, and the site-packages folders inside them that hold
{ // third-party code, both resolved. Set by install_hook(); like the verdict cache below, only
std::vector<std::string> ids; // touched while the GIL is held.
if (plugin_root.empty()) static std::vector<boost::filesystem::path> s_stdlib_dirs;
return ids; static std::vector<boost::filesystem::path> s_site_package_dirs;
static std::unordered_map<std::string, bool> s_stdlib_file_verdicts; // co_filename -> is stdlib
void collect_stdlib_dirs()
{
s_stdlib_dirs.clear();
s_site_package_dirs.clear();
s_stdlib_file_verdicts.clear();
PyObject* sysconfig = PyImport_ImportModule("sysconfig");
PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr;
if (paths && PyDict_Check(paths)) {
const auto collect = [paths](const char* key, std::vector<boost::filesystem::path>& out) {
const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed
if (dir.empty())
return;
boost::system::error_code ec;
const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec);
out.push_back(ec ? boost::filesystem::path(dir) : resolved);
};
collect("stdlib", s_stdlib_dirs);
collect("platstdlib", s_stdlib_dirs);
collect("purelib", s_site_package_dirs);
collect("platlib", s_site_package_dirs);
}
Py_XDECREF(paths);
Py_XDECREF(sysconfig);
if (PyErr_Occurred()) {
PyErr_Clear();
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig";
}
}
bool is_stdlib_frame(PyObject* frame)
{
PyCodeObject* code = PyFrame_GetCode(reinterpret_cast<PyFrameObject*>(frame)); // new reference
const std::string filename = python_unicode(code->co_filename);
Py_DECREF(code);
return PluginAuditManager::is_stdlib_file(filename);
}
// The frames on this thread's Python stack, innermost first. The pointers stay valid while the
// caller runs, because a running activation keeps its frame object alive.
std::vector<PyObject*> stack_frames()
{
std::vector<PyObject*> frames;
PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
Py_XINCREF(frame); // normalize to an owned reference for the loop below Py_XINCREF(frame); // normalize to an owned reference for the loop below
while (frame) { while (frame) {
PyCodeObject* code = PyFrame_GetCode(frame); // new reference frames.push_back(reinterpret_cast<PyObject*>(frame));
const std::string filename = python_unicode(reinterpret_cast<PyObject*>(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<PyObject*>(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 PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
Py_DECREF(frame); Py_DECREF(frame);
frame = back; frame = back;
if (is_plugin_frame)
break;
if (!id.empty())
ids.push_back(std::move(id));
} }
return frames;
}
Py_XDECREF(frame); // only holds a reference here if the loop exited via break // Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended
// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so
// it stays held until the scope ends.
void take_returned_frames(std::vector<PyObject*>& held, std::vector<PyObject*>& dropped)
{
if (held.empty())
return;
return ids; const std::vector<PyObject*> stack = stack_frames();
const auto running = [&stack](PyObject* frame) {
if (std::find(stack.begin(), stack.end(), frame) != stack.end())
return true;
// NULL once the generator has finished or been deleted: its frame then owns its own data.
PyObject* generator = PyFrame_GetGenerator(reinterpret_cast<PyFrameObject*>(frame)); // new reference
const bool suspended = generator != nullptr;
Py_XDECREF(generator);
return suspended;
};
const auto returned = std::stable_partition(held.begin(), held.end(), running);
dropped.insert(dropped.end(), returned, held.end());
held.erase(returned, held.end());
} }
static const std::unordered_set<std::string> two_path_fs_events{ static const std::unordered_set<std::string> two_path_fs_events{
@@ -717,12 +821,15 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
{"socket.getnameinfo", {0}}, {"socket.getnameinfo", {0}},
{"socket.getservbyname", {0}}, {"socket.getservbyname", {0}},
{"socket.getservbyport", {0}}, {"socket.getservbyport", {0}},
{"socket.sendto", {1}},
{"socket.sendmsg", {1}},
{"os.system", {0}}, {"os.system", {0}},
{"subprocess.Popen", {1, 0}}, {"subprocess.Popen", {1, 0}},
{"os.posix_spawn", {1, 0}}, {"os.posix_spawn", {1, 0}},
{"os.spawn", {2, 1}}, {"os.spawn", {2, 1}},
{"os.startfile", {0}}, {"os.startfile", {0}},
{"os.startfile/2", {0}},
{"pty.spawn", {0}}, {"pty.spawn", {0}},
{"_winapi.CreateProcess", {1, 0}}, {"_winapi.CreateProcess", {1, 0}},
{"_posixsubprocess.fork_exec", {0}}, {"_posixsubprocess.fork_exec", {0}},
@@ -767,7 +874,11 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
const auto it = audit_target_arg_indices.find(event_name); const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) { if (it != audit_target_arg_indices.end()) {
for (const Py_ssize_t index : it->second) { for (const Py_ssize_t index : it->second) {
std::string value = python_str(tuple_item(args, index)); // None is an argument left out (sendmsg without an address), not a target named "None".
PyObject* const item = tuple_item(args, index);
if (item == Py_None)
continue;
std::string value = python_str(item);
if (!value.empty()) { if (!value.empty()) {
targets.push_back(std::move(value)); targets.push_back(std::move(value));
break; break;
@@ -778,207 +889,138 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
return targets; return targets;
} }
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions) // Judges an audited event raised while a plugin context is active.
int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args)
{ {
switch (category) { const bool fs_category = is_fs_category(event_type);
case AuditEventCategory::FsRead: return &permissions.fs_read; const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
case AuditEventCategory::Http: return &permissions.network_http; // Probe mode: report the classified event and let it through. Deliberately before the deny
case AuditEventCategory::Socket: return &permissions.network_socket; // registry and allowed-root checks, so the report covers operations that production would
case AuditEventCategory::ProcessCreate: return &permissions.process; // block as well as those it would allow. No grant is read or written.
case AuditEventCategory::Threading: return &permissions.threading; if (mgr.record_observation(event_name, plugin_audit::category_name(event_type), targets))
case AuditEventCategory::ProcessReplace: return &permissions.process; return 0;
default: return nullptr;
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
// unconditional block: checked before the running-operation check below, so no granted
// operation can launder access to one, and before the allowed-root shortcut further down,
// so an allowed root (e.g. the read-only resources folder) cannot make a denied path
// underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr.is_denied_path(boost::filesystem::path(target)))
return plugin_audit::report_denied(mgr, event_name, {false, "denied path"}, target);
} }
}
bool has_permission(const std::vector<std::string>& 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<std::string>& 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)) // A nested event of an operation already granted, e.g. socket.connect inside an approved
permission_list.push_back(target); // urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state); // redirect or a nested urlopen() inside a running request is not covered by it.
} if (event_name != "urllib.Request" && mgr.covered_by_root_frame())
return 0;
int report_denied(PluginAuditManager& mgr, // A filesystem target that resolves entirely inside a pre-determined allowed root -- the
const std::string& event_name, // plugin system's own data_dir() tree (which holds each plugin's storage folder and the
const AuditDecision& decision, // installed/system profile cache), the read-only bundled resources folder, or a per-call
const std::string& target = {}) // scoped root such as the current G-code folder -- is part of the plugin system's normal
{ // workflow and does not need a prompt.
AuditViolation violation; if (fs_category && !targets.empty()) {
violation.plugin_key = mgr.current_plugin(); const bool is_write = event_type != AuditEventCategory::FsRead;
violation.event_name = event_name; const bool all_inside_allowed_root =
violation.reason = decision.reason; std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
mgr.report_violation(violation); return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed;
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;
}
wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name,
const wxString& target_list, size_t target_count)
{
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_PLURAL("Plugin \"%s\" is requesting to read the following file:\n%s",
"Plugin \"%s\" is requesting to read the following files:\n%s", target_count),
plugin_name, target_list);
case AuditEventCategory::FsReadWrite:
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read/write the following file:\n%s",
"Plugin \"%s\" is requesting to read/write the following files:\n%s", target_count),
plugin_name, target_list);
case AuditEventCategory::FsCreate:
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to create the following file:\n%s",
"Plugin \"%s\" is requesting to create the following files:\n%s", target_count),
plugin_name, target_list);
case AuditEventCategory::FsDelete:
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to delete the following file:\n%s",
"Plugin \"%s\" is requesting to delete the following files:\n%s", target_count),
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_PLURAL("Plugin \"%s\" is requesting to run the following command:\n%s",
"Plugin \"%s\" is requesting to run the following commands:\n%s", target_count),
plugin_name, target_list);
case AuditEventCategory::Threading:
return wxString::Format(_L("Plugin \"%s\" is requesting permission to create a thread."), plugin_name);
case AuditEventCategory::ProcessReplace:
return wxString::Format(_L("Plugin \"%s\" is requesting to replace the running application with:\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);
}
}
// Builds and shows the modal permission prompt. Must run on the GUI thread.
bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_name, const std::string& event_name,
const std::vector<std::string>& unresolved)
{
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, unresolved.size()),
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
}
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
// freshly because the async prompt outlives the caller's stack copy of it.
void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector<std::string>& targets)
{
PluginInstallState state;
if (!PluginManager::instance().get_install_state(plugin_key, state))
return;
std::vector<std::string>* permission_list = permission_list_for(category, state.permissions);
if (!permission_list)
return;
for (const auto& target : targets)
persist_permission(plugin_key, state, *permission_list, target);
}
// Requests permission for an audited event, returning true when it is already granted or the user
// approves an inline prompt.
//
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
// re-prompting.
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
const std::string& event_name, const std::vector<std::string>& unresolved)
{
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
if (wxIsMainThread())
return prompt_for_targets(category, plugin_name, event_name, unresolved);
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return;
if (prompt_for_targets(category, plugin_name, event_name, unresolved))
persist_grant(plugin_key, category, unresolved);
}); });
return false; if (all_inside_allowed_root)
}
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<std::string>& targets,
std::vector<std::string>* permission_list,
const std::vector<std::string>& call_site_ids)
{
std::vector<std::string> 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; return 0;
} }
if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved)) const int result = plugin_audit::decide_event(mgr, event_name, event_type, targets);
return report_denied(mgr, event_name, {false, "audit permission required"});
if (permission_list) // A granted operation covers its nested events while it runs. A targetless grant names no
for (const auto& target : unresolved) // operation, so it covers nothing else.
persist_permission(plugin_key, state, *permission_list, target); if (result == 0 && !targets.empty() && operation_root_events.count(event_name))
mgr.record_root_frame();
mgr.record_approved_call_sites(plugin_key, call_site_ids); return result;
return 0;
} }
} // namespace PluginAuditDetail } // namespace PluginAuditDetail
void PluginAuditManager::record_root_frame()
{
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
if (!frame || !PluginAuditDetail::is_stdlib_frame(frame))
return;
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
return; // glob.glob and glob.glob/2 share a frame
Py_INCREF(frame);
m_root_frames.push_back(frame);
}
bool PluginAuditManager::covered_by_root_frame() const
{
if (m_root_frames.empty())
return false;
// Only stdlib frames may sit between the event and the operation: plugin or library code
// the operation calls back into (an os.walk onerror, a urllib handler) acts on its own.
for (PyObject* frame : PluginAuditDetail::stack_frames()) {
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
return true;
if (!PluginAuditDetail::is_stdlib_frame(frame))
return false;
}
return false;
}
bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; }
bool PluginAuditManager::is_stdlib_file(const std::string& filename)
{
// Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename.
if (boost::algorithm::starts_with(filename, "<frozen "))
return true;
if (filename.empty() || filename.front() == '<')
return false;
// Both sides are resolved: co_filename follows the Python home as it was given, which may
// be a symlink.
const auto [it, inserted] = PluginAuditDetail::s_stdlib_file_verdicts.try_emplace(filename, false);
if (inserted) {
const boost::filesystem::path file(filename);
const auto inside_any = [&file](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); });
};
it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
return it->second;
}
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
{
if (frames.empty())
return;
// A finalizer's own denial must neither replace the PermissionError of the event being
// decided nor leave a stale denial behind for the next call.
PyObject* const error = PyErr_GetRaisedException();
const bool denial_pending = m_audit_denial_pending;
const bool has_violation = m_has_last_violation;
const AuditViolation violation = m_last_violation;
std::vector<PyObject*> released;
released.swap(frames);
++m_releasing_frames;
for (PyObject* frame : released)
Py_DECREF(frame);
--m_releasing_frames;
m_audit_denial_pending = denial_pending;
m_has_last_violation = has_violation;
m_last_violation = violation;
PyErr_SetRaisedException(error);
}
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
{ {
auto* mgr = static_cast<PluginAuditManager*>(user_data); auto* mgr = static_cast<PluginAuditManager*>(user_data);
@@ -1006,62 +1048,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
if (event_type == AuditEventCategory::None) if (event_type == AuditEventCategory::None)
return 0; return 0;
const bool fs_category = is_fs_category(event_type); // Root frames whose operation has returned are let go once this event is decided, so that a
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args); // finalizer they run re-enters a hook whose held frames are already consistent.
std::vector<PyObject*> returned_frames;
// A denied path (secrets, certificates, config files -- see is_denied_path) is an PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
// unconditional block: checked before the ancestor-cascade check below, so a cascade const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
// approval recorded for an unrelated action can never launder access to one, and before mgr->release_frames(returned_frames);
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources return result;
// folder) cannot make a denied path underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr->is_denied_path(boost::filesystem::path(target)))
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target);
}
}
PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
// was already approved, socket.connect won't trigger another permission dialog.
const std::vector<std::string> call_site_ids =
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
}
PluginInstallState state;
const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state);
if (!have_install_state) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr->current_plugin();
}
const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name;
std::vector<std::string>* 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() void PluginAuditManager::install_hook()
{ {
// Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame).
PluginAuditDetail::collect_stdlib_dirs();
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It // 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 // 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) -- // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
@@ -1076,8 +1076,9 @@ 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(), so the // The user's app config and cloud credentials live directly inside data_dir(), and plugin
// root just granted would otherwise expose them to any plugin. Deny them by name. // install state lives beneath it, so the root just granted would otherwise expose them to
// 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
+93 -13
View File
@@ -4,10 +4,9 @@
// Via pybind11 so this file requests the same python3xx.lib as everything else. // Via pybind11 so this file requests the same python3xx.lib as everything else.
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <pybind11/conduit/wrap_include_python_h.h> #include <pybind11/conduit/wrap_include_python_h.h>
#include <functional>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
@@ -25,6 +24,19 @@ struct AuditViolation {
std::string reason; std::string reason;
}; };
// One classified CPython audit event observed while the probe is in record-only mode. This is
// evidence about the code path that ran, not a grant request: the hook allowed the operation to
// continue and did not consult the permission dialog or the persisted install state.
struct AuditObservation {
std::string plugin_key;
std::string capability;
std::string phase;
std::string event_name;
std::string category;
std::vector<std::string> targets;
bool continued = true;
};
// A filesystem root a plugin may access while an audit context is active. allow_write is // 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) -- // 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. // a write-shaped event never matches such a root, even though a read-shaped one does.
@@ -138,21 +150,56 @@ public:
bool request_filesystem_read_permissions(const std::string& plugin_key, bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths); const std::vector<std::string>& paths);
// --- probe observation mode (thread-safe, never used by the GUI) ---
// When an observer is installed, every classified event raised by plugin-authored code is
// reported to it and then allowed through (the hook returns 0). The permission dialogs, saved
// grants and .install_state.json are never consulted, so this is not a containment boundary.
// The observer must not touch Python objects: it runs inside the audit hook with the GIL held.
using AuditObserver = std::function<void(const AuditObservation&)>;
void set_observation_observer(AuditObserver observer);
void clear_observation_observer();
bool observation_mode() const;
// Stop after classification/target extraction; true means an observer consumed the event.
// Exposed for the probe and its tests, not for production callers.
bool record_observation(const std::string& event_name,
const std::string& category,
const std::vector<std::string>& targets);
// --- probe phase attribution (thread_local) ---
// Labels events for the probe's report. Production leaves it empty. A Python thread spawned by
// plugin code inherits no phase, so its events are reported as unattributed rather than being
// guessed at.
void set_current_audit_phase(const std::string& phase);
const std::string& current_audit_phase() const;
void clear_current_audit_phase();
void report_violation(const AuditViolation& violation); void report_violation(const AuditViolation& violation);
bool audit_denial_pending() const; bool audit_denial_pending() const;
void clear_audit_denial(); void clear_audit_denial();
void clear_last_violation(); void clear_last_violation();
bool last_violation(AuditViolation& violation) const; bool last_violation(AuditViolation& violation) const;
// --- call-site cascade cache --- // --- operation-root frames ---
// A single plugin action often fires several nested CPython audit events as it passes // One stdlib operation fires several nested audit events as it passes through lower layers:
// through stdlib layers (urllib.request calling http.client calling socket, for example). // urlopen() raises urllib.Request, then http.client and socket events. When an operation's
// Once the user approves one event, every stdlib frame still on the stack for that call // own root event is granted (by a saved grant or the user), the stdlib frame that raised it
// is recorded here by (filename, function, first line) identity. A later event whose own // is held, and a later event whose stack reaches that frame through stdlib frames only is
// ancestor chain still contains one of those frames is the same logical action seen from // part of the same operation, so it is allowed without a prompt of its own. Plugin or
// a deeper layer, so it is auto-approved instead of prompting again. // library code the operation calls back into is judged on its own. A frame is let go once it
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const; // returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids); // run finalizers of objects its locals held; they are judged as the plugin and never prompt.
void record_root_frame();
bool covered_by_root_frame() const;
bool releasing_frames() const;
// True when code from `filename` (a code object's co_filename) is the interpreter's own
// stdlib: the only code a root frame's coverage passes through. Valid after install_hook().
static bool is_stdlib_file(const std::string& filename);
// Answers permission prompts in place of the dialog, for tests. Production never sets it.
using PermissionPrompt = std::function<bool(const std::string& event_name, const std::vector<std::string>& targets)>;
static void set_permission_prompt_for_testing(PermissionPrompt prompt);
bool verbose_events = true; bool verbose_events = true;
@@ -163,24 +210,38 @@ 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);
// Drops the references in `frames`, keeping the caller's error and denial state intact.
void release_frames(std::vector<PyObject*>& frames);
static thread_local std::string m_current_plugin_key; static thread_local std::string m_current_plugin_key;
static thread_local std::string m_current_capability_name; static thread_local std::string m_current_capability_name;
static thread_local std::string m_current_audit_phase;
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots; static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
static thread_local std::vector<PyObject*> m_root_frames; // strong references
static thread_local int m_releasing_frames;
static thread_local bool m_audit_denial_pending; static thread_local bool m_audit_denial_pending;
static thread_local bool m_has_last_violation; static thread_local bool m_has_last_violation;
static thread_local AuditViolation m_last_violation; static thread_local AuditViolation m_last_violation;
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock. // mutable: is_denied_filename() is a const query that must lock.
mutable std::mutex m_mutex; mutable std::mutex m_mutex;
std::vector<AllowedRoot> m_global_allowed_roots; std::vector<AllowedRoot> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames; std::vector<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords; std::vector<std::string> m_denied_path_keywords;
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
// Kept apart from m_mutex: observation_mode() is queried from the audit hook while the hook
// already holds the GIL, and the setter runs before plugin code starts.
mutable std::mutex m_observer_mutex;
AuditObserver m_observation_observer;
}; };
// RAII guard that sets the current plugin key and capability name, restoring the previous // RAII guard that sets the current plugin key and capability name, restoring the previous
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a // pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
// single capability. // single capability.
//
// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before
// restoring the outer context. That needs the GIL whenever a frame is still held, so construct
// the scope after the GIL guard, to be destroyed before it.
class ScopedPluginAuditContext class ScopedPluginAuditContext
{ {
public: public:
@@ -194,9 +255,28 @@ public:
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete; ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
private: private:
std::string m_plugin_key;
std::string m_capability;
std::string m_previous_id; std::string m_previous_id;
std::string m_previous_capability; std::string m_previous_capability;
std::vector<AllowedRoot> m_previous_scoped_roots; std::vector<AllowedRoot> m_previous_scoped_roots;
std::vector<PyObject*> m_previous_root_frames;
};
// Sets the probe's phase label on the current thread and restores the previous one on scope exit.
// Empty in production, where no phase is ever set.
class ScopedPluginAuditPhase
{
public:
explicit ScopedPluginAuditPhase(std::string phase);
~ScopedPluginAuditPhase();
ScopedPluginAuditPhase(const ScopedPluginAuditPhase&) = delete;
ScopedPluginAuditPhase& operator=(const ScopedPluginAuditPhase&) = delete;
private:
std::string m_phase;
std::string m_previous_phase;
}; };
} // namespace Slic3r } // namespace Slic3r
+332
View File
@@ -0,0 +1,332 @@
#include "PluginAuditManagerInternal.hpp"
#include "PluginDescriptor.hpp"
#include "PluginFsUtils.hpp"
#include "PluginManager.hpp"
#include <boost/filesystem.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/log/trivial.hpp>
#include <algorithm>
#include <cstddef>
#include <future>
#include <memory>
#include <slic3r/plugin/PluginAuditManager.hpp>
#include <string>
#include <utility>
#include <vector>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/I18N.hpp>
#include <wx/app.h>
#include <wx/msgdlg.h>
#include <wx/string.h>
#include <wx/thread.h>
namespace Slic3r {
namespace {
// The saved list that grants an event: its category's target list, or the event-grant list for
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
// os.fork grant is not an os.exec of a command named "os.fork").
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
{
if (by_event)
return &permissions.events;
switch (category) {
case AuditEventCategory::FsRead: return &permissions.fs_read;
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
case AuditEventCategory::Http: return &permissions.network_http;
case AuditEventCategory::Socket: return &permissions.network_socket;
case AuditEventCategory::ProcessCreate: return &permissions.process;
case AuditEventCategory::Threading: return &permissions.threading;
case AuditEventCategory::ProcessReplace: return &permissions.process;
default: return nullptr;
}
}
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
{
return std::string(plugin_audit::category_name(category)) + ":" + event_name;
}
bool has_permission(const std::vector<std::string>& 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<std::string>& 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);
}
wxString audit_message(AuditEventCategory category, const wxString& plugin_name, const wxString& event_name,
const wxString& target_list, size_t target_count)
{
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_PLURAL("Plugin \"%s\" is requesting to read the following file:\n%s",
"Plugin \"%s\" is requesting to read the following files:\n%s", target_count),
plugin_name, target_list);
case AuditEventCategory::FsReadWrite:
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to read/write the following file:\n%s",
"Plugin \"%s\" is requesting to read/write the following files:\n%s", target_count),
plugin_name, target_list);
case AuditEventCategory::FsCreate:
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to create the following file:\n%s",
"Plugin \"%s\" is requesting to create the following files:\n%s", target_count),
plugin_name, target_list);
case AuditEventCategory::FsDelete:
return wxString::Format(_L_PLURAL("Plugin \"%s\" is requesting to delete the following file:\n%s",
"Plugin \"%s\" is requesting to delete the following files:\n%s", target_count),
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_PLURAL("Plugin \"%s\" is requesting to run the following command:\n%s",
"Plugin \"%s\" is requesting to run the following commands:\n%s", target_count),
plugin_name, target_list);
case AuditEventCategory::Threading:
return wxString::Format(_L("Plugin \"%s\" is requesting permission to create a thread."), plugin_name);
case AuditEventCategory::ProcessReplace:
return wxString::Format(_L("Plugin \"%s\" is requesting to replace the running application with:\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);
}
}
// Builds and shows the modal permission prompt. Must run on the GUI thread.
bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_name, const std::string& event_name,
const std::vector<std::string>& unresolved)
{
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, unresolved.size()),
_L("Plugin permission request"), wxYES_NO | wxICON_WARNING);
return dialog.ShowModal() == wxID_YES;
}
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
// freshly because the async prompt outlives the caller's stack copy of it.
void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
{
PluginInstallState state;
if (!PluginManager::instance().get_install_state(plugin_key, state))
return;
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
if (!permission_list)
return;
for (const auto& key : keys)
persist_permission(plugin_key, state, *permission_list, key);
}
PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
// no target (by_event), which the prompt does not show as a target.
//
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
// BackgroundSlicingProcess::stop()/stop_internal() park the UI thread until that worker stops.
// Blocking the worker on a marshaled modal -- which is safe for the plugin-load worker that
// request_filesystem_read_permissions runs on -- would therefore deadlock the application (the
// invariant PluginHostUi.cpp documents for slicing-hook UI calls). Off the main thread the prompt
// is therefore posted asynchronously and the current event denied (fail closed, like an unanswered
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
// re-prompting.
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
{
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
if (s_permission_prompt_for_testing)
return s_permission_prompt_for_testing(event_name, shown);
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false;
if (wxIsMainThread())
return prompt_for_targets(category, plugin_name, event_name, shown);
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return;
if (prompt_for_targets(category, plugin_name, event_name, shown))
persist_grant(plugin_key, category, by_event, keys);
});
return false;
}
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
// otherwise the user is asked and a Yes is saved.
int decide_audited_event(PluginAuditManager& mgr,
PluginInstallState& state,
const std::string& plugin_key,
const std::string& plugin_name,
const std::string& event_name,
AuditEventCategory category,
const std::vector<std::string>& targets)
{
const bool by_event = targets.empty();
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
std::vector<std::string> unresolved = keys;
if (permission_list) {
unresolved.clear();
for (const auto& key : keys)
if (!has_permission(*permission_list, key))
unresolved.push_back(key);
if (unresolved.empty())
return 0;
}
// Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
// where no dialog may open: what they do must already be granted.
if (mgr.releasing_frames())
return plugin_audit::report_denied(mgr, event_name, {false, "audit permission required"});
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
return plugin_audit::report_denied(mgr, event_name, {false, "audit permission required"});
if (permission_list)
for (const auto& key : unresolved)
persist_permission(plugin_key, state, *permission_list, key);
return 0;
}
} // namespace
bool plugin_audit::request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths)
{
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<std::string> 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(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor.name.c_str()), requested_paths),
_L("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<std::promise<bool>>();
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(_L("Plugin \"%s\" requests filesystem read access to:\n%s"),
wxString::FromUTF8(descriptor_name.c_str()), requested_paths),
_L("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);
}
int plugin_audit::decide_event(PluginAuditManager& mgr,
const std::string& event_name,
AuditEventCategory category,
const std::vector<std::string>& targets)
{
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;
return decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, category, targets);
}
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
{
s_permission_prompt_for_testing = std::move(prompt);
}
} // namespace Slic3r
@@ -0,0 +1,38 @@
#ifndef slic3r_PluginAuditManagerInternal_hpp_
#define slic3r_PluginAuditManagerInternal_hpp_
// Internal seam between the GUI-free audit core and the permission front end. The GUI library
// supplies the dialogs, prompts and .install_state.json persistence; the headless probe supplies
// a fail-closed implementation that never links wx. Production behavior lives in the GUI
// implementation, which is the code that used to be part of PluginAuditManager.cpp.
#include "PluginAuditManager.hpp"
#include <string>
#include <vector>
namespace Slic3r::plugin_audit {
const char* category_name(AuditEventCategory category);
int report_denied(PluginAuditManager& mgr,
const std::string& event_name,
const AuditDecision& decision,
const std::string& target = {});
// Used only when the manager is not in observation mode. The GUI implementation consults the
// install state, prompts and persists; the probe implementation fails closed.
bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths);
// Decides an event that no allowed root or running operation covered. The GUI implementation
// reads the install state and prompts; the probe implementation fails closed. In observation
// mode the audit hook records and returns before reaching this seam.
int decide_event(PluginAuditManager& mgr,
const std::string& event_name,
AuditEventCategory category,
const std::vector<std::string>& targets);
} // namespace Slic3r::plugin_audit
#endif // slic3r_PluginAuditManagerInternal_hpp_
+18 -17
View File
@@ -14,8 +14,7 @@
#include "PluginAuditManager.hpp" #include "PluginAuditManager.hpp"
#include "PythonInterpreter.hpp" #include "PythonInterpreter.hpp"
#include "slic3r/GUI/I18N.hpp" #include "PluginText.hpp"
#include "slic3r/GUI/format.hpp"
#include <exception> #include <exception>
#include <miniz.h> #include <miniz.h>
@@ -92,7 +91,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
const fs::path plugin_root = resolve_plugin_root_from_descriptor(descriptor); const fs::path plugin_root = resolve_plugin_root_from_descriptor(descriptor);
if (plugin_root.empty()) { if (plugin_root.empty()) {
error = _u8L("Plugin folder could not be determined."); error = PLUGIN_U8L("Plugin folder could not be determined.");
return false; return false;
} }
@@ -103,7 +102,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
resolved_root = fs::absolute(plugin_root, ec); resolved_root = fs::absolute(plugin_root, ec);
} }
if (ec || resolved_root.empty()) { if (ec || resolved_root.empty()) {
error = GUI::format(_u8L("Failed to resolve plugin folder: %1%"), plugin_root.string()); error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
return false; return false;
} }
@@ -123,7 +122,7 @@ bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std:
const auto removed_count = fs::remove_all(resolved_root, ec); const auto removed_count = fs::remove_all(resolved_root, ec);
if (ec) { if (ec) {
// TRN %1% is a folder path, %2% the system error message. // TRN %1% is a folder path, %2% the system error message.
error = GUI::format(_u8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message()); error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
return false; return false;
} }
@@ -808,6 +807,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
read_string_list("network_socket", parsed.permissions.network_socket); read_string_list("network_socket", parsed.permissions.network_socket);
read_string_list("process", parsed.permissions.process); read_string_list("process", parsed.permissions.process);
read_string_list("threading", parsed.permissions.threading); read_string_list("threading", parsed.permissions.threading);
read_string_list("events", parsed.permissions.events);
} }
if (state.contains("enabled") && state["enabled"].is_boolean()) if (state.contains("enabled") && state["enabled"].is_boolean())
@@ -852,6 +852,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
{"network_socket", state.permissions.network_socket}, {"network_socket", state.permissions.network_socket},
{"process", state.permissions.process}, {"process", state.permissions.process},
{"threading", state.permissions.threading}, {"threading", state.permissions.threading},
{"events", state.permissions.events},
}; };
nlohmann::json capabilities = nlohmann::json::array(); nlohmann::json capabilities = nlohmann::json::array();
@@ -1197,7 +1198,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) { if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) {
error = GUI::format(_u8L("Plugin directory does not exist: %1%"), plugin_dir.string()); error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory does not exist: %1%"), plugin_dir.string());
return {}; return {};
} }
@@ -1214,7 +1215,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
if (ext == ".py") { if (ext == ".py") {
if (!py_entry.empty()) { if (!py_entry.empty()) {
// TRN %1% and %2% are file names. // TRN %1% and %2% are file names.
error = GUI::format(_u8L("Plugin directory contains multiple .py files: %1% and %2%"), error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .py files: %1% and %2%"),
py_entry.filename().string(), it->path().filename().string()); py_entry.filename().string(), it->path().filename().string());
return {}; return {};
} }
@@ -1222,7 +1223,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
} else if (ext == ".whl") { } else if (ext == ".whl") {
if (!whl_entry.empty()) { if (!whl_entry.empty()) {
// TRN %1% and %2% are file names. // TRN %1% and %2% are file names.
error = GUI::format(_u8L("Plugin directory contains multiple .whl files: %1% and %2%"), error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .whl files: %1% and %2%"),
whl_entry.filename().string(), it->path().filename().string()); whl_entry.filename().string(), it->path().filename().string());
return {}; return {};
} }
@@ -1231,7 +1232,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
} }
if (!py_entry.empty() && !whl_entry.empty()) { if (!py_entry.empty() && !whl_entry.empty()) {
error = _u8L("Plugin directory contains both .py and .whl entry files"); error = PLUGIN_U8L("Plugin directory contains both .py and .whl entry files");
return {}; return {};
} }
@@ -1241,7 +1242,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
if (!whl_entry.empty()) if (!whl_entry.empty())
return whl_entry; return whl_entry;
error = _u8L("Plugin directory does not contain a .py or .whl entry file"); error = PLUGIN_U8L("Plugin directory does not contain a .py or .whl entry file");
return {}; return {};
} }
@@ -1258,34 +1259,34 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
error.clear(); error.clear();
if (dir.empty()) { if (dir.empty()) {
error = _u8L("Resource texture directory is not set"); error = PLUGIN_U8L("Resource texture directory is not set");
return files; return files;
} }
if (plugin_root.empty()) { if (plugin_root.empty()) {
error = _u8L("Plugin folder could not be determined."); error = PLUGIN_U8L("Plugin folder could not be determined.");
return files; return files;
} }
boost::system::error_code ec; boost::system::error_code ec;
const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec); const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec);
if (ec) { if (ec) {
error = _u8L("Resource texture directory could not be resolved"); error = PLUGIN_U8L("Resource texture directory could not be resolved");
return files; return files;
} }
if (!fs::is_directory(resolved_dir, ec) || ec) { if (!fs::is_directory(resolved_dir, ec) || ec) {
error = _u8L("Resource texture path is not a directory"); error = PLUGIN_U8L("Resource texture path is not a directory");
return files; return files;
} }
const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec); const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec);
if (ec) { if (ec) {
error = _u8L("Plugin folder could not be resolved"); error = PLUGIN_U8L("Plugin folder could not be resolved");
return files; return files;
} }
if (!is_inside_allowed_root(resolved_dir, resolved_root)) { if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
error = _u8L("Resource texture directory is outside the plugin folder"); error = PLUGIN_U8L("Resource texture directory is outside the plugin folder");
return files; return files;
} }
@@ -1310,7 +1311,7 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
} }
if (ec) { if (ec) {
error = _u8L("Resource texture directory could not be read"); error = PLUGIN_U8L("Resource texture directory could not be read");
return {}; return {};
} }
+2
View File
@@ -104,6 +104,8 @@ struct PluginPermissions
std::vector<std::string> network_socket; std::vector<std::string> network_socket;
std::vector<std::string> process; std::vector<std::string> process;
std::vector<std::string> threading; std::vector<std::string> threading;
// Grants for audit events that expose no target, keyed "<category>:<event>" (e.g. "Socket:socket.__new__").
std::vector<std::string> events;
}; };
struct PluginInstallState { struct PluginInstallState {
+24 -25
View File
@@ -11,8 +11,7 @@
#include "PythonPluginBridge.hpp" #include "PythonPluginBridge.hpp"
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp" #include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include "libslic3r/Utils.hpp" #include "libslic3r/Utils.hpp"
#include "slic3r/GUI/I18N.hpp" #include "PluginText.hpp"
#include "slic3r/GUI/format.hpp"
#include <boost/algorithm/string.hpp> #include <boost/algorithm/string.hpp>
#include <boost/algorithm/string/case_conv.hpp> #include <boost/algorithm/string/case_conv.hpp>
@@ -102,7 +101,7 @@ bool read_local_plugin_package_metadata(const boost::filesystem::path& source_pa
const std::string ext = plugin_package_extension(source_path); const std::string ext = plugin_package_extension(source_path);
if (ext != ".py" && ext != ".whl") { if (ext != ".py" && ext != ".whl") {
error = GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext); error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
return false; return false;
} }
@@ -179,14 +178,14 @@ bool add_wheel_dependencies_to_sys_path(const PluginDescriptor& descriptor,
std::string extract_error; std::string extract_error;
if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) { if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) {
// TRN %1% is the dependency file, %2% the error detail. // TRN %1% is the dependency file, %2% the error detail.
error = GUI::format(_u8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error); error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
return false; return false;
} }
} }
std::string syspath_error; std::string syspath_error;
if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) { if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) {
error = GUI::format(_u8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error); error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
return false; return false;
} }
plugin_paths.push_back(dep_dir.string()); plugin_paths.push_back(dep_dir.string());
@@ -248,12 +247,12 @@ bool load(const PluginDescriptor& descriptor,
PythonInterpreter& interpreter = PythonInterpreter::instance(); PythonInterpreter& interpreter = PythonInterpreter::instance();
if (!interpreter.is_initialized()) { if (!interpreter.is_initialized()) {
error = GUI::format(_u8L("Python interpreter not initialized: %1%"), interpreter.last_error()); error = PLUGIN_FORMAT(PLUGIN_U8L("Python interpreter not initialized: %1%"), interpreter.last_error());
return false; return false;
} }
if (!descriptor.is_metadata_valid()) { if (!descriptor.is_metadata_valid()) {
error = GUI::format(_u8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key); error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
if (descriptor.has_error()) if (descriptor.has_error())
error += " - " + descriptor.normalized_error(); error += " - " + descriptor.normalized_error();
return false; return false;
@@ -274,7 +273,7 @@ bool load(const PluginDescriptor& descriptor,
if (!skip_deps) { if (!skip_deps) {
std::string pkg_install_error; std::string pkg_install_error;
if (!install_packages(descriptor.dependencies, pkg_install_error)) { if (!install_packages(descriptor.dependencies, pkg_install_error)) {
error = GUI::format(_u8L("Failed to install plugin dependencies: %1%"), pkg_install_error); error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
return false; return false;
} }
} }
@@ -293,7 +292,7 @@ bool load(const PluginDescriptor& descriptor,
PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error); PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error);
if (module == nullptr) { if (module == nullptr) {
bridge.cancel_plugin_capture(descriptor.entry_path); bridge.cancel_plugin_capture(descriptor.entry_path);
error = GUI::format(_u8L("Failed to load plugin module: %1%"), load_error); error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to load plugin module: %1%"), load_error);
return false; return false;
} }
@@ -308,13 +307,13 @@ bool load(const PluginDescriptor& descriptor,
auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error); auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error);
if (!bridge_error.empty()) { if (!bridge_error.empty()) {
capabilities_found.clear(); capabilities_found.clear();
error = GUI::format(_u8L("Plugin registration failed: %1%"), bridge_error); error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin registration failed: %1%"), bridge_error);
if (registration_failed != nullptr) if (registration_failed != nullptr)
*registration_failed = true; *registration_failed = true;
return false; return false;
} }
if (capabilities_found.empty()) { if (capabilities_found.empty()) {
error = _u8L("Plugin module did not register any capabilities"); error = PLUGIN_U8L("Plugin module did not register any capabilities");
return false; return false;
} }
@@ -366,7 +365,7 @@ bool load(const PluginDescriptor& descriptor,
if (!seen_capabilities[type].insert(found.name).second) { if (!seen_capabilities[type].insert(found.name).second) {
// TRN %1% is the capability name, %2% the capability type (e.g. Script). // TRN %1% is the capability name, %2% the capability type (e.g. Script).
materialization_error = GUI::format(_u8L("Plugin declares duplicate capability '%1%' for type %2%"), materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin declares duplicate capability '%1%' for type %2%"),
found.name, PluginConfig::capability_type_label(type)); found.name, PluginConfig::capability_type_label(type));
break; break;
} }
@@ -422,9 +421,9 @@ bool load(const PluginDescriptor& descriptor,
capabilities.push_back(std::move(instance)); capabilities.push_back(std::move(instance));
} }
} catch (const std::exception& ex) { } catch (const std::exception& ex) {
materialization_error = GUI::format(_u8L("Plugin capability materialization failed: %1%"), ex.what()); materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin capability materialization failed: %1%"), ex.what());
} catch (...) { } catch (...) {
materialization_error = _u8L("Plugin capability materialization failed"); materialization_error = PLUGIN_U8L("Plugin capability materialization failed");
} }
} }
@@ -466,11 +465,11 @@ bool load(const PluginDescriptor& descriptor,
} catch (const std::exception& ex) { } catch (const std::exception& ex) {
teardown_capabilities(plugin.capabilities, lifecycle_count); teardown_capabilities(plugin.capabilities, lifecycle_count);
// TRN on_load is the name of the plugin's startup function; keep it untranslated. // TRN on_load is the name of the plugin's startup function; keep it untranslated.
error = GUI::format(_u8L("Plugin on_load failed: %1%"), ex.what()); error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin on_load failed: %1%"), ex.what());
return false; return false;
} catch (...) { } catch (...) {
teardown_capabilities(plugin.capabilities, lifecycle_count); teardown_capabilities(plugin.capabilities, lifecycle_count);
error = _u8L("Plugin on_load failed"); error = PLUGIN_U8L("Plugin on_load failed");
return false; return false;
} }
@@ -651,7 +650,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
bool backup_created = false; bool backup_created = false;
auto fail = [&error](const std::string& message) { auto fail = [&error](const std::string& message) {
error = GUI::format(_u8L("Plugin installation failed: %1%"), message); error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin installation failed: %1%"), message);
return false; return false;
}; };
@@ -659,23 +658,23 @@ bool install_plugin(const boost::filesystem::path& filepath,
const fs::path source_path = canonical_or_absolute(filepath); const fs::path source_path = canonical_or_absolute(filepath);
if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec)) if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec))
return fail(GUI::format(_u8L("Plugin package is not a file: %1%"), filepath.string())); return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package is not a file: %1%"), filepath.string()));
const std::string ext = plugin_package_extension(source_path); const std::string ext = plugin_package_extension(source_path);
if (ext != ".py" && ext != ".whl") if (ext != ".py" && ext != ".whl")
return fail(GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext)); return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext));
// The cloud UUID is concatenated into the install path (final_dir = plugin_root / cloud_uuid). // The cloud UUID is concatenated into the install path (final_dir = plugin_root / cloud_uuid).
// It comes verbatim from the server's catalog JSON, so reject anything that could escape the // It comes verbatim from the server's catalog JSON, so reject anything that could escape the
// per-user plugin root (traversal, leading dots, separators) before touching the filesystem. // per-user plugin root (traversal, leading dots, separators) before touching the filesystem.
if (is_cloud_install && !is_valid_plugin_id(cloud_uuid)) if (is_cloud_install && !is_valid_plugin_id(cloud_uuid))
return fail(GUI::format(_u8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid)); return fail(PLUGIN_FORMAT(PLUGIN_U8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid));
const fs::path plugin_root = is_cloud_install && !cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(cloud_user_id)) : const fs::path plugin_root = is_cloud_install && !cloud_user_id.empty() ? fs::path(get_cloud_plugin_dir(cloud_user_id)) :
local_plugin_root(); local_plugin_root();
fs::create_directories(plugin_root, ec); fs::create_directories(plugin_root, ec);
if (ec) if (ec)
// TRN %1% is a folder path, %2% the system error message. // TRN %1% is a folder path, %2% the system error message.
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message())); return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
std::string meta_error; std::string meta_error;
if (is_cloud_install) { if (is_cloud_install) {
@@ -718,7 +717,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
fs::rename(final_dir, backup_dir, ec); fs::rename(final_dir, backup_dir, ec);
if (ec) if (ec)
// TRN %1% is a folder path, %2% the system error message. // TRN %1% is a folder path, %2% the system error message.
return fail(GUI::format(_u8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message())); return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
backup_created = true; backup_created = true;
} }
@@ -729,7 +728,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
boost::system::error_code restore_ec; boost::system::error_code restore_ec;
fs::rename(backup_dir, final_dir, restore_ec); fs::rename(backup_dir, final_dir, restore_ec);
} }
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message())); return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
} }
fs::copy_file(source_path, dest_file, ec); fs::copy_file(source_path, dest_file, ec);
@@ -740,7 +739,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
fs::rename(backup_dir, final_dir, restore_ec); fs::rename(backup_dir, final_dir, restore_ec);
} }
// TRN %1% is a file path, %2% the system error message. // TRN %1% is a file path, %2% the system error message.
return fail(GUI::format(_u8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message())); return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
} }
// Update entry_path to the installed location. // Update entry_path to the installed location.
@@ -760,7 +759,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
fs::remove_all(final_dir, rollback_ec); fs::remove_all(final_dir, rollback_ec);
if (backup_created) if (backup_created)
fs::rename(backup_dir, final_dir, rollback_ec); fs::rename(backup_dir, final_dir, rollback_ec);
return fail(GUI::format(_u8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string())); return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
} }
} }
-12
View File
@@ -53,18 +53,6 @@ const char* const LOAD_CANCELLED = "__plugin_load_cancelled__";
} // namespace } // namespace
void Plugin::release_module()
{
if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty())
return;
PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules);
module = nullptr;
module_name.clear();
plugin_sys_paths.clear();
plugin_modules.clear();
}
PluginManager& PluginManager::instance() PluginManager& PluginManager::instance()
{ {
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to // Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
+22
View File
@@ -0,0 +1,22 @@
// Plugin module ownership is part of the loader's contract, not of the registry's GUI-facing
// surface. Kept in its own translation unit so the headless probe can link the loader without
// pulling PluginManager.cpp (and therefore wx/GUI) into the probe.
#include "PluginManager.hpp"
#include "PythonInterpreter.hpp"
namespace Slic3r {
void Plugin::release_module()
{
if (module == nullptr && plugin_sys_paths.empty() && plugin_modules.empty())
return;
PythonInterpreter::instance().unload_module(module, module_name, plugin_sys_paths, plugin_modules);
module = nullptr;
module_name.clear();
plugin_sys_paths.clear();
plugin_modules.clear();
}
} // namespace Slic3r
+565
View File
@@ -0,0 +1,565 @@
// Headless, record-only plugin permission probe.
//
// This is a developer/security-analysis tool, not a second production loader. It reuses the
// embedded interpreter, the production plugin_loader sequence and the GUI-free `orca` bindings
// (capability types plus the geometry/model/slicing host domains), so the observations come from
// the same audit classification and target extraction as the GUI. It then lets every audited
// operation continue instead of consulting permission grants or dialogs. Host domains that need
// the live GUI (app, presets, ui) and plugin.storage are registered as explicit unsupported
// errors; the config API reports an empty config and refuses writes.
//
// It is NOT a sandbox. Plugin code runs with the probe's filesystem, network and process
// privileges. The temporary data directory only keeps OrcaSlicer's own state out of the user's
// tree; it does not confine plugin-authored code. Use OS-level restrictions (container, user,
// seccomp/AppArmor, firewall) when the package is not trusted.
//
// The report describes only the paths this run exercised. CPython audit events are observations of
// particular operations, not a notification for every Python call, so absence of a category in the
// report is not evidence that the plugin cannot perform it.
#include "PluginAuditManager.hpp"
#include "PluginDescriptor.hpp"
#include "PluginFsUtils.hpp"
#include "PluginLoader.hpp"
#include "PluginManager.hpp"
#include "PluginProbeCollector.hpp"
#include "PythonInterpreter.hpp"
#include "PythonPluginInterface.hpp"
#include "libslic3r/LifecycleEvents.hpp"
#include "libslic3r/Utils.hpp"
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/filesystem.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/system/error_code.hpp>
#include <nlohmann/json.hpp>
#include <pybind11/embed.h>
#include <pybind11/gil.h>
#include <pybind11/pytypes.h>
#include <cstdlib>
#include <exception>
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
namespace fs = boost::filesystem;
namespace py = pybind11;
using namespace Slic3r;
namespace {
constexpr const char* kLoadPhase = "load:import+registration+on_load";
struct Options
{
fs::path plugin_path;
bool json = false;
};
void print_usage(std::ostream& out)
{
out << "Usage: orca-plugin-probe [--json] <plugin.py|plugin.whl>\n"
"\n"
"Runs a plugin in a temporary data/working directory and reports the CPython audit\n"
"events observed while its import, registration, on_load and discoverable callbacks\n"
"execute. The probe does not prompt for permissions, write grants, install dependencies,\n"
"or start the OrcaSlicer GUI.\n"
"\n"
" --json Emit the report as JSON (default: human-readable text)\n"
" --help Show this message\n"
"\n"
"WARNING: this is not a sandbox. Plugin Python code runs with this process's privileges.\n"
"Results are observational and non-exhaustive. Use OS-level restrictions for untrusted\n"
"packages.\n"
"\n"
"Exit status: 0 plugin loaded, 1 initialization/load failure, 2 usage error.\n";
}
bool parse_options(int argc, char** argv, Options& options, std::string& error)
{
for (int index = 1; index < argc; ++index) {
const std::string argument = argv[index];
if (argument == "--help" || argument == "-h") {
print_usage(std::cout);
std::exit(0);
}
if (argument == "--json") {
options.json = true;
continue;
}
if (!argument.empty() && argument.front() == '-') {
error = "Unknown option: " + argument;
return false;
}
if (!options.plugin_path.empty()) {
error = "Only one plugin package may be probed at a time.";
return false;
}
options.plugin_path = fs::path(argument);
}
if (options.plugin_path.empty()) {
error = "No plugin package given.";
return false;
}
return true;
}
// Keep every write OrcaSlicer makes during the run inside a throwaway tree, and keep relative
// paths opened by plugin code out of the caller's working directory. This is hygiene, not
// confinement: an absolute path supplied by the plugin is still opened normally.
class ScopedProbeWorkspace
{
public:
ScopedProbeWorkspace()
: m_root(fs::temp_directory_path() / fs::unique_path("orca-plugin-probe-%%%%-%%%%-%%%%"))
, m_previous_data_dir(data_dir())
, m_previous_working_dir(fs::current_path())
{
fs::create_directories(m_root);
set_data_dir(m_root.string());
fs::current_path(m_root);
m_moved = true;
}
~ScopedProbeWorkspace()
{
boost::system::error_code ec;
set_data_dir(m_previous_data_dir);
if (m_moved)
fs::current_path(m_previous_working_dir, ec);
fs::remove_all(m_root, ec);
}
const fs::path& root() const { return m_root; }
private:
fs::path m_root;
std::string m_previous_data_dir;
fs::path m_previous_working_dir;
bool m_moved = false;
};
class ScopedObservationObserver
{
public:
explicit ScopedObservationObserver(PluginProbeCollector& collector) : m_collector(collector)
{
PluginAuditManager::instance().set_observation_observer(
[this](const AuditObservation& observation) { m_collector.record(observation); });
}
~ScopedObservationObserver() { PluginAuditManager::instance().clear_observation_observer(); }
ScopedObservationObserver(const ScopedObservationObserver&) = delete;
ScopedObservationObserver& operator=(const ScopedObservationObserver&) = delete;
private:
PluginProbeCollector& m_collector;
};
bool prepare_descriptor(const fs::path& plugin_path, PluginDescriptor& descriptor, std::string& error)
{
boost::system::error_code ec;
const fs::path canonical = fs::weakly_canonical(plugin_path, ec);
if (ec || !fs::exists(canonical) || !fs::is_regular_file(canonical)) {
error = "Plugin package is not a readable file: " + plugin_path.string();
return false;
}
std::string extension = canonical.extension().string();
boost::algorithm::to_lower(extension);
if (extension != ".py" && extension != ".whl") {
error = "Plugin package must be a .py or .whl file, got: " + extension;
return false;
}
if (extension == ".whl") {
if (!read_wheel_plugin_metadata(canonical, descriptor, error))
return false;
} else {
if (!read_python_plugin_metadata(canonical, descriptor, error))
return false;
}
descriptor.entry_path = canonical.string();
descriptor.plugin_key = make_local_plugin_key(canonical.stem().string());
descriptor.metadata_valid = true;
// Leaving plugin_root empty keeps the probe independent of any .install_state.json next to
// the package and avoids the production sidecar path.
descriptor.plugin_root.clear();
if (descriptor.name.empty())
descriptor.name = descriptor.plugin_key;
return true;
}
void redirect_python_stdout()
{
py::gil_scoped_acquire gil;
py::object sys = py::module_::import("sys");
// Keep plugin prints from corrupting the C++ report on stdout. sys.stderr is the interpreter's
// tee to the temporary log, so plugin output remains available there.
sys.attr("stdout") = sys.attr("stderr");
}
template<class Function>
void invoke_callback(const std::shared_ptr<PluginCapabilityInterface>& capability,
const std::string& phase,
const std::string& callback,
PluginProbeCollector& collector,
Function&& function)
{
ScopedPluginAuditPhase phase_scope(phase);
try {
std::forward<Function>(function)();
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "invoked", {}});
} catch (const std::exception& error) {
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "failed", error.what()});
} catch (...) {
collector.record_callback({capability ? capability->name() : std::string{}, phase, callback, "failed", "non-standard exception"});
}
}
struct ProbeCapability
{
std::string name;
std::string type;
bool enabled = true;
};
std::vector<ProbeCapability> describe_capabilities(const Plugin& plugin)
{
std::vector<ProbeCapability> capabilities;
capabilities.reserve(plugin.capabilities.size());
for (const auto& capability : plugin.capabilities) {
if (!capability)
continue;
capabilities.push_back({capability->name(), plugin_capability_type_to_string(capability->type()), capability->is_enabled()});
}
return capabilities;
}
void exercise_callbacks(const Plugin& plugin, PluginProbeCollector& collector)
{
// on_load() already ran inside plugin_loader::load. The callbacks below are the additional
// entry points a loaded capability exposes to the host. Synthetic inputs are used; a callback
// may behave differently with a real application state, which the report cannot capture.
static constexpr LifecycleEvent lifecycle_events[] = {
LifecycleEvent::NewProject, LifecycleEvent::ProjectOpened, LifecycleEvent::ProjectBeforeSave,
LifecycleEvent::ProjectAfterSave, LifecycleEvent::ProjectClosed, LifecycleEvent::ProjectDirtyChanged,
LifecycleEvent::SliceStarted, LifecycleEvent::SliceGeometryFinished, LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished, LifecycleEvent::SlicingJobComplete, LifecycleEvent::ObjectAdded,
LifecycleEvent::ObjectDeleted, LifecycleEvent::ObjectTransformed, LifecycleEvent::ObjectChanged,
LifecycleEvent::ObjectRenamed, LifecycleEvent::PlateCreated, LifecycleEvent::PlateDeleted,
LifecycleEvent::PlateSelected, LifecycleEvent::PlateRenamed, LifecycleEvent::PresetSelected,
LifecycleEvent::PresetSaved, LifecycleEvent::PrintStateChanged, LifecycleEvent::DeviceOnline,
LifecycleEvent::DeviceOffline, LifecycleEvent::DeviceDiscovered, LifecycleEvent::DeviceSelected,
LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished, LifecycleEvent::PrintJobStarted,
LifecycleEvent::PrintJobFinished, LifecycleEvent::SendJobStarted, LifecycleEvent::SendJobFinished,
};
for (const auto& capability : plugin.capabilities) {
if (!capability)
continue;
invoke_callback(capability, "callback:get_default_config", "get_default_config", collector,
[&capability]() { (void) capability->get_default_config(); });
if (capability->config_ui_available()) {
invoke_callback(capability, "callback:get_config_ui", "get_config_ui", collector,
[&capability]() { (void) capability->get_config_ui(); });
} else {
collector.record_callback(
{capability->name(), "callback:get_config_ui", "get_config_ui", "skipped", "has_config_ui() is false"});
}
for (const LifecycleEvent event : lifecycle_events) {
const std::string event_name = lifecycle_event_to_string(event);
const std::string phase = "callback:on_lifecycle_event(" + event_name + ")";
LifecycleEventContext context;
context.source = "orca-plugin-probe";
context.msg = "synthetic event generated by the permission probe";
invoke_callback(capability, phase, "on_lifecycle_event(" + event_name + ")", collector,
[&capability, event, &context]() { capability->on_lifecycle_event(event, context); });
}
// Cancellation is a load-time path; calling it after a successful load would misrepresent
// the capability's real lifecycle. Report it as skipped instead.
collector.record_callback(
{capability->name(), "callback:on_cancelled", "on_cancelled", "skipped", "not invoked after a successful load"});
}
}
nlohmann::json observation_json(const AuditObservation& observation)
{
return {
{"plugin", observation.plugin_key},
{"capability", observation.capability},
{"phase", observation.phase.empty() ? "unattributed" : observation.phase},
{"event", observation.event_name},
{"category", observation.category},
{"targets", observation.targets},
{"continued", observation.continued},
};
}
nlohmann::json callback_json(const ProbeCallbackRecord& callback)
{
return {
{"capability", callback.capability}, {"phase", callback.phase}, {"callback", callback.callback},
{"status", callback.status}, {"detail", callback.detail},
};
}
nlohmann::json capability_json(const ProbeCapability& capability)
{
return {
{"name", capability.name},
{"type", capability.type},
{"enabled", capability.enabled},
};
}
nlohmann::json build_report(const Options& options,
const PluginProbeCollector& collector,
const std::vector<ProbeCapability>& capabilities,
const std::string& plugin_key,
bool python_initialized,
const std::string& python_error,
bool loaded,
const std::string& load_error,
const std::string& data_dir)
{
nlohmann::json observations = nlohmann::json::array();
for (const AuditObservation& observation : collector.observations())
observations.push_back(observation_json(observation));
nlohmann::json callbacks = nlohmann::json::array();
for (const ProbeCallbackRecord& callback : collector.callbacks())
callbacks.push_back(callback_json(callback));
nlohmann::json capability_list = nlohmann::json::array();
for (const ProbeCapability& capability : capabilities)
capability_list.push_back(capability_json(capability));
return {
{"tool", "orca-plugin-permission-probe"},
{"mode", "record-only"},
{"observed_by_this_run_only", true},
{"plugin",
{
{"path", options.plugin_path.string()},
{"key", plugin_key},
}},
{"environment",
{
{"python_initialized", python_initialized},
{"python_error", python_error},
{"temporary_data_dir", data_dir},
{"temporary_data_dir_removed_on_exit", true},
{"working_directory", data_dir},
{"interactive_prompts", false},
{"grants_persisted", false},
}},
{"load", {{"ok", loaded}, {"error", load_error}, {"capabilities", capability_list}}},
{"callbacks", callbacks},
{"observations", observations},
{"summary", collector.summary()},
{"coverage",
{
{"exhaustive", false},
{"sandbox", false},
{"plugin_code_executed", true},
{"notes",
{
"Only the code paths this probe exercised are represented. Audit events cover "
"particular operations, not every Python call.",
"The probe does not start the GUI. Host APIs that require a live application or "
"UI (orca.host.app/presets/ui) and plugin.storage report an explicit unsupported "
"error and are reported as failed callbacks.",
"The probe links libslic3r for core geometry/print types, but not libslic3r_gui "
"or wx.",
"The temporary data directory isolates OrcaSlicer state, not plugin-authored file, "
"network or process access. Use OS-level restrictions for untrusted packages.",
"The loader may extract adjacent .whl dependencies into the package's directory; "
"probe a copy of the package when that side effect is unwanted.",
"Import, registration and on_load share one loader phase; the production loader "
"does not expose a finer boundary without changing its behavior.",
"Callbacks run with synthetic inputs, which can change plugin control flow "
"compared with a real application session.",
"continued=true means the audit hook did not block the operation. A surrounding "
"callback or load step can still fail afterwards, as reported in callbacks/load.",
"Events raised on a plugin-spawned thread carry no phase label and are reported "
"as unattributed.",
"Plugin code can spawn threads/processes, write anywhere this process can, or "
"terminate the process. None of that is contained by the probe.",
}},
}},
};
}
void print_text_report(const nlohmann::json& report)
{
std::cout << "OrcaSlicer plugin permission probe (record-only)\n"
"WARNING: observational only; not exhaustive and not a sandbox.\n\n";
std::cout << "Plugin: " << report["plugin"]["path"].get<std::string>() << "\n";
std::cout << "Python initialized: " << (report["environment"]["python_initialized"].get<bool>() ? "yes" : "no") << "\n";
if (!report["environment"]["python_error"].get<std::string>().empty())
std::cout << "Python error: " << report["environment"]["python_error"].get<std::string>() << "\n";
std::cout << "Load: " << (report["load"]["ok"].get<bool>() ? "ok" : "failed") << "\n";
if (!report["load"]["error"].get<std::string>().empty())
std::cout << "Load error: " << report["load"]["error"].get<std::string>() << "\n";
std::cout << "\nCapabilities (" << report["load"]["capabilities"].size() << "):\n";
for (const auto& capability : report["load"]["capabilities"])
std::cout << " " << capability["name"].get<std::string>() << " [" << capability["type"].get<std::string>()
<< "] enabled=" << (capability["enabled"].get<bool>() ? "yes" : "no") << "\n";
std::cout << "\nCallbacks (" << report["callbacks"].size() << "):\n";
for (const auto& callback : report["callbacks"]) {
std::cout << " " << callback["status"].get<std::string>() << " " << callback["callback"].get<std::string>();
if (!callback["capability"].get<std::string>().empty())
std::cout << " capability=" << callback["capability"].get<std::string>();
if (!callback["detail"].get<std::string>().empty())
std::cout << " - " << callback["detail"].get<std::string>();
std::cout << "\n";
}
std::cout << "\nObserved audit events (" << report["observations"].size() << "):\n";
for (const auto& observation : report["observations"]) {
std::cout << " phase=" << observation["phase"].get<std::string>() << " event=" << observation["event"].get<std::string>()
<< " category=" << observation["category"].get<std::string>();
if (!observation["capability"].get<std::string>().empty())
std::cout << " capability=" << observation["capability"].get<std::string>();
if (!observation["targets"].empty()) {
std::cout << " targets=";
bool first = true;
for (const auto& target : observation["targets"]) {
if (!first)
std::cout << ",";
std::cout << target.get<std::string>();
first = false;
}
}
std::cout << " continued=" << (observation["continued"].get<bool>() ? "yes" : "no") << "\n";
}
std::cout << "\nObserved permission categories:\n";
for (const auto& [category, detail] : report["summary"]["categories"].items()) {
std::cout << " " << category << ":";
for (const auto& event : detail["events"])
std::cout << " " << event.get<std::string>();
if (!detail["targets"].empty()) {
std::cout << " -> ";
bool first = true;
for (const auto& target : detail["targets"]) {
if (!first)
std::cout << ", ";
std::cout << target.get<std::string>();
first = false;
}
}
std::cout << "\n";
}
if (report["summary"]["categories"].empty())
std::cout << " (none observed)\n";
std::cout << "\nCoverage limits:\n";
for (const auto& note : report["coverage"]["notes"])
std::cout << " - " << note.get<std::string>() << "\n";
}
} // namespace
int main(int argc, char** argv)
{
Options options;
std::string option_error;
if (!parse_options(argc, argv, options, option_error)) {
std::cerr << "orca-plugin-probe: " << option_error << "\n\n";
print_usage(std::cerr);
return 2;
}
boost::system::error_code ec;
const fs::path canonical_plugin = fs::weakly_canonical(options.plugin_path, ec);
if (!ec)
options.plugin_path = canonical_plugin;
ScopedProbeWorkspace workspace;
PluginProbeCollector collector;
ScopedObservationObserver observer(collector);
PluginDescriptor descriptor;
std::string descriptor_error;
const bool descriptor_ready = prepare_descriptor(options.plugin_path, descriptor, descriptor_error);
PythonInterpreter& interpreter = PythonInterpreter::instance();
bool python_initialized = false;
std::string python_error;
if (descriptor_ready) {
python_initialized = interpreter.initialize();
if (!python_initialized)
python_error = interpreter.last_error();
}
if (python_initialized) {
try {
redirect_python_stdout();
} catch (const std::exception& error) {
python_error = std::string("Could not redirect Python stdout: ") + error.what();
}
}
bool loaded = false;
std::string load_error = descriptor_error;
Plugin plugin;
std::vector<ProbeCapability> capabilities;
if (python_initialized && descriptor_ready) {
// The outer context covers import, registration and on_load in plugin_loader::load; the
// per-callback scopes below cover the follow-up calls. The production loader is unchanged.
ScopedPluginAuditContext audit_context(descriptor.plugin_key);
{
ScopedPluginAuditPhase load_phase(kLoadPhase);
loaded = plugin_loader::load(descriptor, /*skip_deps=*/true, /*capabilities_to_enable=*/{}, nullptr, plugin, load_error);
collector.record_callback({std::string{}, kLoadPhase, "plugin_loader::load", loaded ? "invoked" : "failed", load_error});
collector.record_callback({std::string{}, "callback:on_load", "on_load", loaded ? "invoked" : "skipped",
loaded ? "invoked by plugin_loader::load for each materialized capability" :
"plugin_loader::load failed before completing on_load"});
}
capabilities = describe_capabilities(plugin);
if (loaded)
exercise_callbacks(plugin, collector);
if (loaded) {
ScopedPluginAuditPhase unload_phase("callback:on_unload");
plugin_loader::unload(plugin);
collector.record_callback({std::string{}, "callback:on_unload", "on_unload", "invoked",
"invoked by plugin_loader::unload; per-capability failures are logged by the loader"});
}
}
if (interpreter.is_initialized())
interpreter.shutdown();
const nlohmann::json report = build_report(options, collector, capabilities,
descriptor.plugin_key.empty() ? make_local_plugin_key(options.plugin_path.stem().string()) :
descriptor.plugin_key,
python_initialized, python_error, loaded, load_error, workspace.root().string());
if (options.json)
std::cout << report.dump(2, ' ', false, nlohmann::json::error_handler_t::replace) << "\n";
else
print_text_report(report);
return loaded ? 0 : 1;
}
@@ -0,0 +1,97 @@
#ifndef slic3r_PluginProbeCollector_hpp_
#define slic3r_PluginProbeCollector_hpp_
// Record-only collection for the headless plugin permission probe. This is deliberately a plain
// in-memory store: it has no dialog, grant or persistence path, and it never decides whether an
// audited operation may proceed. Keeping it independent of wx/Python also makes its grouping and
// phase attribution testable without starting the interpreter.
#include "PluginAuditManager.hpp"
#include <nlohmann/json.hpp>
#include <map>
#include <mutex>
#include <set>
#include <string>
#include <utility>
#include <vector>
namespace Slic3r {
// What the probe attempted and what came back. `status` is one of "invoked", "failed" or
// "skipped"; a skipped callback is reported so the absence of observations is not mistaken for a
// clean result.
struct ProbeCallbackRecord
{
std::string capability;
std::string phase;
std::string callback;
std::string status;
std::string detail;
};
class PluginProbeCollector
{
public:
void record(const AuditObservation& observation)
{
std::lock_guard<std::mutex> lock(m_mutex);
m_observations.push_back(observation);
}
void record_callback(ProbeCallbackRecord record)
{
std::lock_guard<std::mutex> lock(m_mutex);
m_callbacks.push_back(std::move(record));
}
std::vector<AuditObservation> observations() const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_observations;
}
std::vector<ProbeCallbackRecord> callbacks() const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_callbacks;
}
// Groups the run by the permission categories the audit hook already assigned, and lists the
// distinct targets and event names seen in each. This is a view of this run only; it says
// nothing about code paths the plugin did not exercise.
nlohmann::json summary() const
{
std::lock_guard<std::mutex> lock(m_mutex);
std::map<std::string, std::set<std::string>> events;
std::map<std::string, std::set<std::string>> targets;
for (const AuditObservation& observation : m_observations) {
events[observation.category].insert(observation.event_name);
targets[observation.category].insert(observation.targets.begin(), observation.targets.end());
}
nlohmann::json categories = nlohmann::json::object();
for (const auto& [category, category_events] : events) {
categories[category] = {
{"events", category_events},
{"targets", targets[category]},
};
}
return {
{"observed_event_count", m_observations.size()},
{"categories", categories},
};
}
private:
mutable std::mutex m_mutex;
std::vector<AuditObservation> m_observations;
std::vector<ProbeCallbackRecord> m_callbacks;
};
} // namespace Slic3r
#endif // slic3r_PluginProbeCollector_hpp_
+44
View File
@@ -0,0 +1,44 @@
#pragma once
// User-facing plugin messages are translated in the GUI build. The headless probe links neither
// libslic3r_gui nor wx, so it formats the same English text without wxGetTranslation. The GUI path
// expands to exactly the calls the plugin sources used before this header existed.
#if defined(ORCA_PLUGIN_PROBE_BUILD)
#include "libslic3r/format.hpp"
#include <string>
#include <utility>
namespace Slic3r::plugin_text {
inline std::string translate_utf8(const char* text) { return text == nullptr ? std::string{} : std::string(text); }
inline std::string translate_utf8(const std::string& text) { return text; }
template<typename... TArgs>
inline std::string format(const char* fmt, TArgs&&... args)
{
return Slic3r::format(fmt, std::forward<TArgs>(args)...);
}
template<typename... TArgs>
inline std::string format(const std::string& fmt, TArgs&&... args)
{
return Slic3r::format(fmt, std::forward<TArgs>(args)...);
}
} // namespace Slic3r::plugin_text
#define PLUGIN_U8L(text) ::Slic3r::plugin_text::translate_utf8(text)
#define PLUGIN_FORMAT(...) ::Slic3r::plugin_text::format(__VA_ARGS__)
#else
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/format.hpp"
#define PLUGIN_U8L(text) _u8L(text)
#define PLUGIN_FORMAT(...) GUI::format(__VA_ARGS__)
#endif
+1 -1
View File
@@ -102,9 +102,9 @@ public:
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`. // mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
nlohmann::json get_default_config() const override nlohmann::json get_default_config() const override
{ {
pybind11::gil_scoped_acquire gil;
ORCA_PY_AUDIT_SCOPE(); ORCA_PY_AUDIT_SCOPE();
try { try {
pybind11::gil_scoped_acquire gil;
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config"); pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
if (!override) if (!override)
return Base::get_default_config(); return Base::get_default_config();
+2 -2
View File
@@ -30,7 +30,7 @@
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp" #include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include <pybind11/pytypes.h> #include <pybind11/pytypes.h>
#include <pybind11/gil.h> #include <pybind11/gil.h>
#include "slic3r/GUI/I18N.hpp" #include "PluginText.hpp"
namespace py = pybind11; namespace py = pybind11;
@@ -193,7 +193,7 @@ std::vector<CapturedCapability> PythonPluginBridge::finalize_plugin_capture(cons
granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions); granted = PluginAuditManager::instance().request_filesystem_read_permissions(plugin_key, fs_read_permissions);
} }
if (!granted) { if (!granted) {
error = _u8L("Plugin filesystem read permission request denied"); error = PLUGIN_U8L("Plugin filesystem read permission request denied");
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key; BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key(); discard_pending_for_key();
clear_active_key(); clear_active_key();
@@ -282,11 +282,11 @@ public:
int request_bind_ticket(std::string* ticket) override int request_bind_ticket(std::string* ticket) override
{ {
try { try {
ORCA_PY_AUDIT_SCOPE();
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
::Slic3r::PythonGILState gil; ::Slic3r::PythonGILState gil;
if (!gil) if (!gil)
throw std::runtime_error("Python interpreter is shutting down"); throw std::runtime_error("Python interpreter is shutting down");
ORCA_PY_AUDIT_SCOPE();
pybind11::function override = pybind11::function override =
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket"); pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
if (!override) if (!override)
@@ -0,0 +1,4 @@
// libslic3r references NanoSVG without carrying its header-only implementation. This probe links
// libslic3r without the GUI BitmapCache.cpp that supplies the implementation in the application.
#define NANOSVG_IMPLEMENTATION
#include "nanosvg/nanosvg.h"
@@ -0,0 +1,32 @@
// Headless replacement for the GUI permission front end. The probe installs an observation
// observer, so the audit hook records and returns before reaching this seam. If that observer is
// ever absent, permission decisions fail closed instead of consulting GUI or install state.
#include "slic3r/plugin/PluginAuditManagerInternal.hpp"
#include <boost/log/trivial.hpp>
#include <string>
#include <vector>
namespace Slic3r::plugin_audit {
bool request_filesystem_read_permissions(const std::string& plugin_key,
const std::vector<std::string>& paths)
{
BOOST_LOG_TRIVIAL(warning) << "[PROBE] Refusing filesystem read permission request for " << plugin_key
<< " (" << paths.size() << " path(s)): the headless probe does not evaluate grants";
return false;
}
int decide_event(PluginAuditManager& mgr,
const std::string& event_name,
AuditEventCategory category,
const std::vector<std::string>& targets)
{
(void) category;
(void) targets;
return report_denied(mgr, event_name, {false, "permission decisions are unavailable in the headless probe"});
}
} // namespace Slic3r::plugin_audit
@@ -0,0 +1,75 @@
// Probe-only `orca.host` registration. The GUI-free domains use the production bindings; the
// domains that need a live GUI application, main window or preset bundle are exposed as explicit
// unsupported submodules instead of being compiled (and linked) into the probe.
//
// A plugin that touches one of those domains fails its own callback with a clear message; the
// failure is recorded by the probe's callback report rather than becoming a link dependency.
#include "slic3r/plugin/host/PluginHost.hpp"
#include "slic3r/plugin/host/PluginHostBindings.hpp"
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <stdexcept>
#include <string>
namespace py = pybind11;
namespace Slic3r {
namespace {
void add_unavailable_domain(py::module_& host, const char* name, const char* description)
{
py::module_ domain = host.def_submodule(name, description);
const std::string prefix = std::string("orca.host.") + name + ".";
domain.attr("__getattr__") = py::cpp_function([prefix](const std::string& attribute) -> py::object {
throw std::runtime_error(prefix + attribute +
" is not available in the headless plugin permission probe because it needs the GUI application");
});
}
void add_unavailable_function(py::module_& host, const char* name)
{
// Production registers these in PluginHostApp::register_app(). Do not overwrite a slot that
// another registrar already claimed (the model domain binds the Model class through
// register_model); the probe only fills the GUI-backed accessors that are absent here.
if (py::hasattr(host, name))
return;
const std::string detail = std::string("orca.host.") + name +
" is not available in the headless plugin permission probe because it needs the GUI application";
host.def(name, [detail](py::args, py::kwargs) -> py::object { throw std::runtime_error(detail); }, detail.c_str());
}
} // namespace
void PluginHost::RegisterBindings(py::module_& module)
{
auto host = module.def_submodule("host", "Host application API");
// Value types first so the docstring signatures of later registrars resolve to the bound names.
host_bindings::register_geometry(host);
host_bindings::register_mesh(host);
host_bindings::register_model(host);
host_bindings::register_slicing(host);
// These domains are backed by the live GUI, its Plater/MainFrame and the preset bundle.
add_unavailable_domain(host, "presets", "Preset bundle access (headless probe: unavailable)");
add_unavailable_domain(host, "app", "Live application and Plater access (headless probe: unavailable)");
add_unavailable_domain(host, "ui", "Native dialogs and HTML windows (headless probe: unavailable)");
for (const char* name : {"plater", "model", "preset_bundle", "app_info", "gl_info", "selected_printer"})
add_unavailable_function(host, name);
// Storage needs the PluginManager registry, which is not part of the headless probe.
auto plugin_host = host.def_submodule("plugin", "Plugin host API (headless probe: storage unavailable)");
plugin_host.def("storage", []() -> std::string {
throw std::runtime_error("orca.host.plugin.storage() is not available in the headless plugin permission probe");
}, "Return the installed folder of the current plugin.");
plugin_host.attr("__getattr__") = py::cpp_function([](const std::string& attribute) -> py::object {
throw std::runtime_error("orca.host.plugin." + attribute +
" is not available in the headless plugin permission probe");
});
}
} // namespace Slic3r
@@ -0,0 +1,32 @@
// Minimal replacement for the config API PythonPluginBridge binds into `orca`.
//
// The production implementation (PluginConfig.cpp) resolves presets, reads the sidebar's effective
// config and writes config.json through PluginManager, all of which pull in the GUI and the
// registry. The probe has neither: a capability reports an empty config, an empty version and a
// refused save. That keeps the bridge's Python surface intact without pretending to reproduce
// application state the probe does not have.
#include "slic3r/plugin/PluginConfig.hpp"
#include "slic3r/plugin/PythonPluginInterface.hpp"
#include <nlohmann/json.hpp>
#include <string>
namespace Slic3r {
nlohmann::json capability_get_config(const PluginCapabilityInterface&)
{
return nlohmann::json::object();
}
std::string capability_get_config_version(const PluginCapabilityInterface&) { return {}; }
bool capability_save_config(const PluginCapabilityInterface&, const nlohmann::json&) { return false; }
std::string PluginConfig::capability_type_label(PluginCapabilityType type)
{
return plugin_capability_type_display_name(type);
}
} // namespace Slic3r
+121
View File
@@ -1263,6 +1263,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys)); REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
} }
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
using Catch::Matchers::WithinAbs;
SECTION("float") {
ConfigOptionFloat opt(7.5);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
}
SECTION("int") {
ConfigOptionInt opt(3);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK(opt.value == 3);
}
SECTION("percent") {
ConfigOptionPercent opt(40.);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
}
SECTION("float or percent") {
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
CHECK_FALSE(opt.percent);
}
SECTION("point") {
ConfigOptionPoint opt(Vec2d(1., 2.));
REQUIRE_FALSE(opt.deserialize("5,abc"));
CHECK(opt.value == Vec2d(1., 2.));
}
SECTION("point3") {
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
CHECK(opt.value == Vec3d(1., 2., 3.));
}
}
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
using Catch::Matchers::WithinAbs;
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE(opt.deserialize("50%"));
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
CHECK(opt.percent);
ConfigOptionPoint point;
REQUIRE(point.deserialize("3x4"));
CHECK(point.value == Vec2d(3., 4.));
}
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") { TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
Slic3r::DynamicPrintConfig config; Slic3r::DynamicPrintConfig config;
t_config_option_keys extra, keys; t_config_option_keys extra, keys;
@@ -1678,3 +1727,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
} }
CHECK(enum_lists > 0); CHECK(enum_lists > 0);
} }
TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]")
{
std::set<std::string> no_keys;
Slic3r::DynamicPrintConfig parent;
parent.set_deserialize_strict("filament_retract_before_wipe", "100%");
parent.set_deserialize_strict("filament_ironing_flow", "15%");
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
child.set_deserialize_strict("filament_ironing_flow", "nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil");
CHECK(parent.opt_serialize("filament_ironing_flow") == "nil");
}
SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]")
{
std::set<std::string> no_keys;
GIVEN("A two-variant filament parent") {
Slic3r::DynamicPrintConfig parent;
parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%");
parent.set_deserialize_strict("filament_flow_ratio", "1,1");
WHEN("the child disables the override on one variant and leaves a plain option nil on it") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%");
child.set_deserialize_strict("filament_flow_ratio", "nil,1.1");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("the override column is disabled while the plain option's nil column inherits") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
}
}
WHEN("the child lists the same variants in the other order") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard");
child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil");
child.set_deserialize_strict("filament_flow_ratio", "1.1,nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("columns are matched by variant name, the disabled one included") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%");
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
}
}
WHEN("the child was saved with a single variant") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard");
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("its one value applies to every variant of that extruder, as slicing does") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil");
}
}
}
}
TEST_CASE("Every listed filament override is nullable", "[Config]")
{
std::set<std::string> overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end());
overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" });
for (const auto &[key, def] : Slic3r::print_config_def.options) {
INFO(key);
CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1));
if (overrides.count(key) == 1)
CHECK(def.nullable);
}
}
@@ -8417,3 +8417,58 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow"); CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty()); CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
} }
TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]")
{
ScopedPresetDataDir data_dir;
PresetBundle bundle;
Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System");
parent.config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" };
parent.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values = { 100., 100. };
parent.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1., 1. };
// The child turns the override off on the first variant and changes a plain option on the second.
const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json";
Preset child(Preset::TYPE_FILAMENT, "Child PLA");
child.file = file.string();
child.version = *Semver::parse("1.0.0");
child.config = parent.config;
child.config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = parent.name;
child.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe")->set_at_to_nil(0);
child.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio")->values = { 1., 1.1 };
REQUIRE(child.save(&parent.config));
// On disk the override keeps every column, so its nil can only mean "off"; a plain
// option still stores nil for the columns that match the parent.
DynamicPrintConfig saved;
std::map<std::string, std::string> key_values;
std::string reason;
saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason);
REQUIRE(reason.empty());
CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1");
PresetsConfigSubstitutions substitutions;
bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::EnableSilent);
const Preset *loaded = bundle.filaments.find_preset("Child PLA");
REQUIRE(loaded != nullptr);
CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1");
}
TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]")
{
PresetBundle bundle;
add_inmemory_preset(bundle.filaments, "Parent PLA @System");
Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System");
// Every override is nil in both parent and child, so none is in the plain diff.
std::map<std::string, std::string> key_values;
REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0);
// Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent.
CHECK(key_values.count("filament_retract_before_wipe") == 1);
CHECK(key_values["filament_retract_before_wipe"] == "nil");
// An ironing override defaults to nil: an absent key stays nil, so it is not sent.
CHECK(key_values.count("filament_ironing_flow") == 0);
}
+872 -1
View File
@@ -1,22 +1,51 @@
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <cstddef>
#include <libslic3r/Utils.hpp> #include <libslic3r/Utils.hpp>
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h) #include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
#include <slic3r/plugin/PluginAuditManager.hpp> #include <slic3r/plugin/PluginAuditManager.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PluginProbeCollector.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME #include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "libslic3r_version.h" #include "libslic3r_version.h"
#include "plugin_test_utils.hpp" #include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <pybind11/cast.h>
#include <pybind11/embed.h>
#include <pybind11/eval.h>
#include <pybind11/gil.h>
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <pybind11/stl.h>
#include <algorithm>
#include <fstream>
#include <functional>
#include <ios>
#include <memory>
#include <string> #include <string>
#include <utility>
#include <vector>
using namespace Slic3r; using namespace Slic3r;
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
namespace py = pybind11;
namespace { namespace {
@@ -41,9 +70,29 @@ void seed_denied_keywords()
mgr.add_denied_path_keyword(keyword); mgr.add_denied_path_keyword(keyword);
} }
// Installs the record-only observer for a test and always clears it, so a failed CHECK cannot
// leave a callback pointing at a destroyed collector for the next test in this process.
struct ScopedTestObservationObserver
{
explicit ScopedTestObservationObserver(PluginProbeCollector& collector)
: m_collector(collector)
{
PluginAuditManager::instance().set_observation_observer(
[this](const AuditObservation& observation) { m_collector.record(observation); });
}
~ScopedTestObservationObserver() { PluginAuditManager::instance().clear_observation_observer(); }
ScopedTestObservationObserver(const ScopedTestObservationObserver&) = delete;
ScopedTestObservationObserver& operator=(const ScopedTestObservationObserver&) = delete;
private:
PluginProbeCollector& m_collector;
};
} // namespace } // namespace
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]") TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
{ {
seed_denied_names(); seed_denied_names();
const PluginAuditManager& mgr = PluginAuditManager::instance(); const PluginAuditManager& mgr = PluginAuditManager::instance();
@@ -55,6 +104,7 @@ TEST_CASE("Plugin audit denies app config and token 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")
@@ -110,6 +160,7 @@ 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)")
{ {
@@ -150,6 +201,17 @@ 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");
@@ -349,3 +411,812 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed); CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
} }
} }
// ---------------------------------------------------------------------------
// The audit hook, run in the embedded interpreter
// ---------------------------------------------------------------------------
//
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
// cases instead of using SECTIONs, which would restart the interpreter for every section.
namespace {
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Audit Plugin"
# description = "Plugin audit hook fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
)PY";
// A third-party library, installed outside the stdlib and outside the plugin.
const char* const LIBRARY_SOURCE = R"PY(
import socket
import urllib.request
def read_all(paths):
for path in paths:
with open(path) as f:
f.read()
def connect_from_host(port):
socket.gethostname()
socket.create_connection(("127.0.0.1", port)).close()
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
def fetch_each(urls):
for url in urls:
yield fetch(url)
class ReadingHandler(urllib.request.BaseHandler):
"""Reads a file while each response is processed."""
def __init__(self, path):
self.path = path
self.outcome = None
def http_response(self, request, response):
try:
open(self.path).close()
self.outcome = "ok"
except PermissionError:
self.outcome = "denied"
return response
)PY";
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
// HTTP server, the library, and every module the tests use imported up front, so that no import
// happens inside a plugin context. Code from exec() runs as "<string>", which is not stdlib, so
// attempt() and fetch() stand in for plugin code.
const char* const AUDIT_SETUP = R"PY(
import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading
import urllib.request, zipfile
if os.name != "nt":
import pty
APPROVED = os.path.join(OUTSIDE, "approved.txt")
PRIVATE = os.path.join(OUTSIDE, "private.txt")
MISSING = os.path.join(OUTSIDE, "missing")
TREE = os.path.join(OUTSIDE, "tree")
TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"]
for path in (APPROVED, PRIVATE):
with open(path, "w") as f:
f.write("x")
for sub in ("one", "two"):
os.makedirs(os.path.join(TREE, sub))
with open(os.path.join(TREE, sub, "f.txt"), "w") as f:
f.write("x")
LIBRARY = os.path.join(OUTSIDE, "library")
os.makedirs(LIBRARY)
with open(os.path.join(LIBRARY, "netlib.py"), "w") as f:
f.write(LIBRARY_SOURCE)
sys.path.insert(0, LIBRARY)
import netlib
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", URL_B)
body = b""
else:
self.send_response(200)
body = b"ok"
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
PORT = server.server_address[1]
BASE = "http://127.0.0.1:%d" % PORT
URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect"
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
fetch(URL_A) # urllib imports some modules on its first request
def attempt(action):
try:
action()
return "ok"
except PermissionError:
return "denied"
def spawn_checked(argv):
parent = os.getpid()
try:
return pty.spawn(argv)
finally:
if os.getpid() != parent:
os._exit(1) # a child whose exec was refused must not go on running the tests
finalizer_outcomes = []
class TrackedRequest(urllib.request.Request):
def __del__(self):
finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close()))
)PY";
struct Prompt
{
std::string plugin;
std::string event;
std::vector<std::string> targets;
};
// One line per prompt, "event: target ...", so a failed check shows every prompt.
std::string describe(const std::vector<Prompt>& prompts)
{
std::string text;
for (const Prompt& prompt : prompts) {
text += prompt.event + ":";
for (const std::string& target : prompt.targets)
text += " " + target;
text += "\n";
}
return text;
}
// The plugin system with the audit hook installed, and the permission dialog replaced by a
// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last.
struct AuditHarness
{
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
// data_dir() is a global allowed root from then on, so the test files live in `outside`.
ScopedDataDir data{"plugin-audit-hook"};
bool initialized = PluginManager::instance().initialize();
ScopedTemporaryDir outside{"orca-plugin-audit-outside"};
std::vector<Prompt> prompts;
std::function<bool(const Prompt&)> approve = [](const Prompt&) { return false; };
AuditHarness()
{
PluginAuditManager::set_permission_prompt_for_testing(
[this](const std::string& event, const std::vector<std::string>& targets) {
prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets});
return approve(prompts.back());
});
}
~AuditHarness()
{
PluginAuditManager::set_permission_prompt_for_testing(nullptr);
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
// Call before taking the GIL: discovery is not Python work.
void add_plugin(const std::string& key)
{
const fs::path root = data.plugins_dir() / key;
fs::create_directories(root);
std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE;
save_permissions(key, {});
PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true);
}
// Grants as if saved in an earlier session.
void save_permissions(const std::string& key, const PluginPermissions& permissions)
{
PluginInstallState state;
state.installed_from = "local";
state.installed_version = "1.0";
state.plugin_name = key;
state.permissions = permissions;
REQUIRE(write_install_state(data.plugins_dir() / key, state));
}
PluginPermissions saved_permissions(const std::string& key) const
{
PluginInstallState state;
read_install_state(data.plugins_dir() / key, state);
return state.permissions;
}
};
// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP
// server before the interpreter shuts down.
struct PythonSide
{
py::dict globals;
explicit PythonSide(const AuditHarness& harness)
{
globals["OUTSIDE"] = harness.outside.path().string();
globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE;
exec(AUDIT_SETUP);
}
// A Python error fails the test here, as text: an error_already_set left to propagate would
// outlive the interpreter the harness shuts down.
void exec(const char* code)
{
std::string error;
try {
py::exec(code, globals);
} catch (py::error_already_set& raised) {
error = raised.what();
}
if (!error.empty())
FAIL(error);
}
~PythonSide()
{
try {
globals["server"].attr("shutdown")();
globals["server"].attr("server_close")();
} catch (...) {
}
}
std::string str(const char* name) const { return py::str(globals[name]).cast<std::string>(); }
std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast<std::string>(); }
std::vector<std::string> strings(const char* name) const { return globals[name].cast<std::vector<std::string>>(); }
// Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an
// audit scope of its own.
void run_as(const std::string& plugin_key, const char* code)
{
ScopedPluginAuditContext audit(plugin_key);
exec(code);
}
};
PluginPermissions http_grants(std::vector<std::string> urls)
{
PluginPermissions permissions;
permissions.network_http = std::move(urls);
return permissions;
}
const char* const PLUGIN = "AuditPlugin";
} // namespace
TEST_CASE("Probe phase scope restores the previous phase", "[audit]")
{
PluginAuditManager& mgr = PluginAuditManager::instance();
mgr.set_current_audit_phase("outer");
{
ScopedPluginAuditPhase inner("inner");
CHECK(mgr.current_audit_phase() == "inner");
}
CHECK(mgr.current_audit_phase() == "outer");
mgr.clear_current_audit_phase();
CHECK(mgr.current_audit_phase().empty());
}
TEST_CASE("Record-only probe observations bypass prompts and retain phase attribution", "[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);
PluginProbeCollector collector;
ScopedTestObservationObserver observer(collector);
// A declared read permission is recorded and allowed, but must not be written to the sidecar.
{
ScopedPluginAuditContext context(PLUGIN);
CHECK(PluginAuditManager::instance().request_filesystem_read_permissions(PLUGIN, {"/tmp/probe-declared"}));
}
{
ScopedPluginAuditPhase phase("probe:callback");
python.run_as(PLUGIN, "outcome = attempt(lambda: open(PRIVATE).close())");
}
CHECK(python.str("outcome") == "ok");
CHECK(harness.prompts.empty());
CHECK(harness.saved_permissions(PLUGIN).fs_read.empty());
const std::vector<AuditObservation> observations = collector.observations();
const auto open_event = std::find_if(
observations.begin(), observations.end(), [](const AuditObservation& observation) {
return observation.event_name == "open";
});
REQUIRE(open_event != observations.end());
CHECK(open_event->plugin_key == PLUGIN);
CHECK(open_event->phase == "probe:callback");
CHECK(open_event->category == "FsRead");
CHECK(open_event->continued);
CHECK(std::find(open_event->targets.begin(), open_event->targets.end(), python.str("PRIVATE")) !=
open_event->targets.end());
const auto declared = std::find_if(observations.begin(), observations.end(), [](const AuditObservation& observation) {
return observation.event_name == "orca.request_permissions";
});
REQUIRE(declared != observations.end());
CHECK(declared->category == "FsRead");
CHECK(std::find(declared->targets.begin(), declared->targets.end(), "/tmp/probe-declared") != declared->targets.end());
const nlohmann::json summary = collector.summary();
CHECK(summary["categories"].contains("FsRead"));
CHECK(summary["observed_event_count"].get<std::size_t>() == observations.size());
}
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")}));
python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))");
CHECK(python.str("outcome") == "ok");
CHECK(describe(harness.prompts).empty());
}
TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")}));
python.run_as(PLUGIN, R"PY(
outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)]
outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)]
outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B]))))
)PY");
// The redirect target is asked for inside the approved redirecting request, and a library's
// wrapper or generator asks for the second URL as plain code does.
CHECK(python.strings("outcomes") == std::vector<std::string>{"ok", "denied", "denied", "ok", "denied", "denied"});
const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n";
CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b);
}
TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.exec(R"PY(
CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy")
handler = netlib.ReadingHandler(PRIVATE)
handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler)
walk_outcomes = []
copy_outcomes = []
def on_walk_error(error):
walk_outcomes.append(attempt(lambda: open(PRIVATE).close()))
def copy_file(source, destination):
copy_outcomes.append(attempt(lambda: open(PRIVATE).close()))
)PY");
PluginPermissions permissions = http_grants({python.str("URL_A")});
permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")};
permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")};
harness.save_permissions(PLUGIN, permissions);
// A library handler that urllib calls while the approved request runs, the plugin's own
// os.walk error callback and copytree copy function (called once per file), and a library
// loop over a saved file and an unsaved one.
python.run_as(PLUGIN, R"PY(
request_outcome = attempt(lambda: handler_opener.open(URL_A).read())
for _ in os.walk(MISSING, onerror=on_walk_error):
pass
shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file)
loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE]))
)PY");
CHECK(python.str("request_outcome") == "ok");
CHECK(python.eval("handler.outcome") == "denied");
CHECK(python.strings("walk_outcomes") == std::vector<std::string>{"denied"});
CHECK(python.strings("copy_outcomes") == std::vector<std::string>{"denied", "denied"});
CHECK(python.str("loop_outcome") == "denied");
const std::string private_file = "open: " + python.str("PRIVATE") + "\n";
CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file);
}
TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions;
permissions.fs_read = {python.str("TREE"), python.str("APPROVED")};
harness.save_permissions(PLUGIN, permissions);
// The walk lists the tree's two subdirectories on later resumes, after the plugin's own
// audited reads in between. Abandoned walks leave frames whose generator is gone, which the
// next audited event lets go.
python.run_as(PLUGIN, R"PY(
walked = []
for root, dirs, files in os.walk(TREE):
walked.append(root)
open(APPROVED).close()
globbed = list(pathlib.Path(TREE).glob("**/*"))
abandoned = os.walk(TREE)
next(abandoned)
del abandoned
open(APPROVED).close()
for _ in pathlib.Path(TREE).glob("*"):
break
open(APPROVED).close()
)PY");
CHECK(py::len(python.globals["walked"]) == 3);
CHECK(py::len(python.globals["globbed"]) == 4);
CHECK(describe(harness.prompts).empty());
}
TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
struct Case
{
const char* prompts;
const char* code;
};
// Each operation names targets no earlier one saved, so each is asked once. FsCreate and
// FsDelete grants are never saved, so those operations are asked every time. Only the
// expected prompts are approved, so a nested event left uncovered fails the operation.
std::vector<Case> cases = {
{"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"},
{"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"},
{"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"},
{"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"},
{"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"},
{"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"},
{"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"},
{"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"},
{"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"},
{"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"},
{"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"},
// send() runs after connect() has returned, so its own grant is asked once.
{"http.client.connect\nhttp.client.send",
"connection = http.client.HTTPConnection('127.0.0.1', PORT)\n"
"connection.request('GET', '/a')\n"
"connection.getresponse().read()\n"
"connection.close()"},
};
#ifndef _WIN32
cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"});
// The forked child's exec is covered by the spawn too: had it prompted, the child's own
// denial would end it with status 1.
cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"});
#endif
for (const Case& operation : cases) {
INFO(operation.code);
const std::string expected = std::string(operation.prompts) + "\n";
harness.approve = [&expected](const Prompt& prompt) {
return expected.find(prompt.event + "\n") != std::string::npos;
};
harness.prompts.clear();
python.run_as(PLUGIN, operation.code);
std::string asked;
for (const Prompt& prompt : harness.prompts)
asked += (asked.empty() ? "" : "\n") + prompt.event;
CHECK(asked == operation.prompts);
}
#ifndef _WIN32
CHECK(python.eval("spawn_status") == "0");
#endif
}
TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.run_as(PLUGIN, R"PY(
socket.socket().close()
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9)))
udp.close()
)PY");
// The socket is asked for once, without a target to show; the datagram's address is a target.
CHECK(python.str("sendto_outcome") == "denied");
CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n");
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"Socket:socket.__new__"});
CHECK(harness.saved_permissions(PLUGIN).network_socket.empty());
harness.prompts.clear();
python.run_as(PLUGIN, "socket.socket().close()");
CHECK(describe(harness.prompts).empty());
// A grant for an event with no target covers nothing else: a library's approved
// gethostname() does not carry the connection it makes next.
harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; };
python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))");
CHECK(python.str("connect_outcome") == "denied");
CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n");
harness.prompts.clear();
harness.approve = [](const Prompt&) { return true; };
#ifndef _WIN32
// sendmsg() without an address names no target either.
python.run_as(PLUGIN, R"PY(
left, right = socket.socketpair()
left.sendmsg([b"x"])
left.close()
right.close()
)PY");
CHECK(describe(harness.prompts) == "socket.sendmsg:\n");
CHECK(harness.saved_permissions(PLUGIN).events ==
std::vector<std::string>{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"});
#endif
}
TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.exec(R"PY(
READ_FD = os.open(APPROVED, os.O_RDONLY)
WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT)
)PY");
// A file descriptor has no path to show or save: a granted read of one is not a write.
python.run_as(PLUGIN, R"PY(
fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()),
attempt(lambda: open(WRITE_FD, "w", closefd=False).close())]
)PY");
CHECK(python.strings("fd_outcomes") == std::vector<std::string>{"ok", "denied"});
CHECK(describe(harness.prompts) == "open:\nopen:\n");
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"FsRead:open"});
// A saved os.fork event grant is not an os.system command named "os.fork".
PluginPermissions permissions;
permissions.events = {"FsRead:open", "ProcessCreate:os.fork"};
harness.save_permissions(PLUGIN, permissions);
harness.prompts.clear();
python.run_as(PLUGIN, R"PY(
reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close())
system_outcome = attempt(lambda: os.system("os.fork"))
os.close(READ_FD)
os.close(WRITE_FD)
)PY");
CHECK(python.str("reread_outcome") == "ok");
CHECK(python.str("system_outcome") == "denied");
REQUIRE(harness.prompts.size() == 1);
CHECK(harness.prompts.front().event == "os.system");
CHECK(harness.saved_permissions(PLUGIN).process.empty());
}
TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions = http_grants({python.str("URL_A")});
permissions.fs_read = {python.str("APPROVED")};
harness.save_permissions(PLUGIN, permissions);
PluginAuditManager& mgr = PluginAuditManager::instance();
// Once the request returns, only its held frame keeps the request object alive, and the
// request's finalizer reads a file the plugin was never granted.
python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))");
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"}); // at scope end
CHECK(describe(harness.prompts).empty());
CHECK_FALSE(mgr.audit_denial_pending());
python.exec("finalizer_outcomes.clear()");
python.run_as(PLUGIN, R"PY(
fetch(TrackedRequest(URL_A))
open(APPROVED).close() # the next event lets the returned request's frame go
outcomes_before_scope_end = list(finalizer_outcomes)
)PY");
CHECK(python.strings("outcomes_before_scope_end") == std::vector<std::string>{"denied"});
CHECK(describe(harness.prompts).empty());
// The finalizer's denial during another event's decision leaves that event's own denial intact.
python.exec("finalizer_outcomes.clear()");
python.run_as(PLUGIN, R"PY(
fetch(TrackedRequest(URL_A))
try:
os.listdir(OUTSIDE)
outer_error = ""
except PermissionError as error:
outer_error = str(error)
)PY");
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"});
CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir"));
CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n");
AuditViolation violation;
REQUIRE(mgr.last_violation(violation));
CHECK(violation.event_name == "os.listdir");
CHECK(mgr.audit_denial_pending());
}
TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin("PluginA");
harness.add_plugin("PluginB");
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions;
permissions.fs_read = {python.str("TREE")};
harness.save_permissions("PluginA", permissions);
{
// Two walks plugin A started and suspended after listing the tree itself.
ScopedPluginAuditContext plugin_a("PluginA");
python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)");
{
// Plugin B resuming one of them is asked for each subdirectory, as plugin B.
ScopedPluginAuditContext plugin_b("PluginB");
python.exec("listed_by_b = len(list(first))");
}
// Back in plugin A's scope, its walks are covered again.
python.exec("listed_by_a = len(list(second))");
}
CHECK(python.globals["listed_by_b"].cast<int>() == 0);
CHECK(python.globals["listed_by_a"].cast<int>() == 2);
REQUIRE(harness.prompts.size() == 2);
for (const Prompt& prompt : harness.prompts) {
CHECK(prompt.plugin == "PluginB");
CHECK(prompt.event == "os.scandir");
}
}
TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
python.globals["orca"] = py::module_::import("orca");
// walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker
// in its locals; the frame is let go when the capability call ends, which runs the marker's
// finalizer.
python.exec(R"PY(
class Marker:
def __init__(self, action=None):
self.action = action
def __del__(self):
finalized.append(self.action() if self.action else "ran")
finalized = []
def walk_and_drop(marker):
walker = os.walk(TREE, onerror=marker)
next(walker)
del walker
class ConfigCapability(orca.PythonPluginBase):
def get_name(self):
return "config_cap"
def get_default_config(self):
walk_and_drop(Marker())
return {"finalized_during_call": len(finalized)}
def has_config_ui(self):
walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close())))
next(walker)
attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key
del walker # the walk's frame is now held by the audit scope alone
raise RuntimeError("the capability fails after a denial")
class TicketAgent(orca.printer_agent.PrinterAgentBase):
def get_name(self):
return "ticket_agent"
def request_bind_ticket(self):
walk_and_drop(Marker())
return (0, "ticket")
)PY");
py::object config_instance = python.globals["ConfigCapability"]();
auto config_cap = config_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
config_cap->set_audit_plugin_key("GilPlugin");
config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script);
py::object agent_instance = python.globals["TicketAgent"]();
auto agent_cap = agent_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
agent_cap->set_audit_plugin_key("GilPlugin");
agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection);
auto agent = std::dynamic_pointer_cast<IPrinterAgent>(agent_cap);
REQUIRE(agent);
// A host call arrives without the GIL; both calls take it before their audit scope.
nlohmann::json config;
int ticket_result = -1;
std::string ticket;
{
py::gil_scoped_release released;
config = config_cap->get_default_config();
ticket_result = agent->request_bind_ticket(&ticket);
}
CHECK(config == nlohmann::json{{"finalized_during_call", 0}});
CHECK(ticket_result == 0);
CHECK(ticket == "ticket");
CHECK(python.strings("finalized") == std::vector<std::string>{"ran", "ran"});
// On the trampoline's denial path the frame is let go as the plugin, though the ambient key
// was cleared: the finalizer's read is audited and denied without a prompt.
python.exec("finalized.clear()");
harness.prompts.clear();
CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set);
CHECK(python.strings("finalized") == std::vector<std::string>{"denied"});
CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n");
}
TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
const fs::path link = harness.outside.path() / "linked-stdlib";
boost::system::error_code ec;
fs::create_directory_symlink(stdlib, link, ec);
if (ec)
SKIP("Cannot create a directory symlink here: " + ec.message());
CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string()));
CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string()));
CHECK(PluginAuditManager::is_stdlib_file("<frozen os>"));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
}