Compare commits

..
33 changed files with 575 additions and 2953 deletions

No files matched your search

-1
View File
@@ -123,7 +123,6 @@ option(SLIC3R_ASAN "Enable ASan on Clang and GCC" 0)
# Python stubgen module
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.")
# Auto-download uv if not provided by the user.
+3 -25
View File
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
u )
export BUILD_TARGET="universal"
;;
h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]"
h ) echo "Usage: ./build_release_macos.sh [-d]"
echo " -d: Build deps only"
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
echo " -s: Build slicer only"
@@ -60,33 +60,13 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
echo " -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 " -- 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
;;
: )
echo "Option -${OPTARG} requires an argument, aborting."
exit 1
;;
* )
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
exit 1
;;
esac
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
if [ -z "$ARCH" ]; then
@@ -231,8 +211,7 @@ function build_deps() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
${CMAKE_POLICY_COMPAT}
fi
cmake --build . --config "$BUILD_CONFIG" --target deps
)
@@ -335,8 +314,7 @@ function build_slicer() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
${CMAKE_POLICY_COMPAT}
fi
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
)
-1
View File
@@ -366,7 +366,6 @@ src/slic3r/Utils/MoonrakerPrinterAgent.cpp
src/slic3r/Utils/BBLNetworkPlugin.cpp
src/slic3r/Utils/OrcaCloudServiceAgent.cpp
src/slic3r/plugin/PluginAuditManager.cpp
src/slic3r/plugin/PluginAuditManagerGui.cpp
src/slic3r/plugin/PluginConfig.cpp
src/slic3r/plugin/PluginResolver.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%"
if "!has_sources!"=="1" (
if not exist "%generated_i18n%" mkdir "%generated_i18n%"
.\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%"
.\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%"
if errorlevel 1 (
set "script_exit_code=1"
) else (
+1 -1
View File
@@ -85,7 +85,7 @@ if $FULL_MODE; then
generated_pot_file="${generated_i18n_dir}/OrcaSlicer.pot"
mkdir -p "$generated_i18n_dir"
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"
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"
python3 scripts/HintsToPot.py ./resources "$generated_i18n_dir"
if [ -f "$pot_file" ] && files_equal_ignoring_pot_date "$pot_file" "$generated_pot_file"; then
+16 -41
View File
@@ -377,8 +377,7 @@ public:
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_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 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_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_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;
// Resize the vector of values, copy the newly added values from opt_default if provided.
@@ -581,7 +580,7 @@ public:
//set a item related with extruder variants when loading user config, only set the different value of some extruder
//rhs: item from user config
//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, bool keep_nil) override
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) override
{
if (rhs->type() == this->type()) {
// Assign the first value of the rhs vector.
@@ -593,7 +592,7 @@ public:
for (size_t i = 0; i < diff_index.size(); i++) {
if (diff_index[i] != -1) {
for (size_t j = 0; j < stride; j++) {
if (keep_nil || !other->is_nil(diff_index[i] * stride))
if (!other->is_nil(diff_index[i] * stride))
this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
}
}
@@ -842,12 +841,8 @@ public:
{
UNUSED(append);
std::istringstream iss(str);
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
iss >> this->value;
return !iss.fail();
}
bool operator==(const ConfigOption &rhs) const override
@@ -1038,12 +1033,8 @@ public:
{
UNUSED(append);
std::istringstream iss(str);
int value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
iss >> this->value;
return !iss.fail();
}
ConfigOptionInt& operator=(const ConfigOption *opt)
@@ -1261,12 +1252,8 @@ public:
UNUSED(append);
// don't try to parse the trailing % since it's optional
std::istringstream iss(str);
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
iss >> this->value;
return !iss.fail();
}
private:
@@ -1378,14 +1365,10 @@ public:
bool deserialize(const std::string &str, bool append = false) override
{
UNUSED(append);
std::istringstream iss(str);
double value;
iss >> value;
if (iss.fail())
return false;
this->value = value;
this->percent = str.find_first_of("%") != std::string::npos;
return true;
std::istringstream iss(str);
iss >> this->value;
return !iss.fail();
}
private:
@@ -1555,12 +1538,8 @@ public:
{
UNUSED(append);
char dummy;
Vec2d value;
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;
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 ||
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2;
}
private:
@@ -1679,12 +1658,8 @@ public:
{
UNUSED(append);
char dummy;
Vec3d value;
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;
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 ||
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3;
}
private:
+5 -8
View File
@@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config)
{
ConfigOption *opt_src = config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
opt_dst->set(opt_src);
else {
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_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
opt_dst->set(opt_src);
else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
@@ -2199,12 +2199,9 @@ 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
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
if (m_type == Preset::TYPE_FILAMENT) {
for (const std::string& opt_key : preset.config.keys()) {
if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
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
for (const std::string& opt_key : filament_extruder_override_keys) {
if (key_values.count(opt_key))
continue; // already carried by the diff
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
if (opt_vec == nullptr)
continue;
+4 -11
View File
@@ -119,18 +119,12 @@ const std::vector<std::string> filament_extruder_override_keys = {
};
// Some filament override parameters are generated from filament_extruder_override_keys,
// while filament_retract_length_nc and the ironing overrides are defined separately.
// 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".
// while filament_retract_length_nc is defined separately. Keep the generator list
// unchanged and use this helper for behavior checks that need the full override set.
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() ||
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";
opt_key == "filament_retract_length_nc";
}
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
@@ -12278,8 +12272,7 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
opt_src->set(opt_target);
}
else
// 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));
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride);
}
}
}
-62
View File
@@ -707,10 +707,6 @@ set(SLIC3R_GUI_SOURCES
plugin/PluginManager.hpp
plugin/PluginAuditManager.cpp
plugin/PluginAuditManager.hpp
plugin/PluginAuditManagerGui.cpp
plugin/PluginAuditManagerInternal.hpp
plugin/PluginModule.cpp
plugin/PluginText.hpp
plugin/PluginResolver.cpp
plugin/PluginResolver.hpp
plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp
@@ -1096,61 +1092,3 @@ if (ORCA_BUILD_PYTHON_STUBGEN_MODULE)
PREFIX ""
)
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()
+371 -372
View File
@@ -1,5 +1,4 @@
#include "PluginAuditManager.hpp"
#include "PluginAuditManagerInternal.hpp"
#include "../Utils/OrcaCloudServiceAgent.hpp"
#include "libslic3r/Utils.hpp"
@@ -12,16 +11,28 @@
#include <boost/log/trivial.hpp>
#include <algorithm>
#include <cassert>
#include <cctype>
#include <cstddef>
#include <cstdlib>
#include <future>
#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 <unordered_map>
#include <unordered_set>
#include <utility>
#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>
namespace Slic3r {
@@ -119,20 +130,6 @@ static AuditEventCategory event_category(const std::string& event_name)
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
}
// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole
// operation, so the raising frame bounds everything the operation does. Every other mapped event
// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly
// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2
// are raised in iglob(), which returns its lazy generator before any directory is listed.
static const std::unordered_set<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
// address or a process command line -- the deny-path and allowed-root checks only make sense
// against a path.
@@ -149,49 +146,6 @@ 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
// ---------------------------------------------------------------------------
@@ -260,52 +214,27 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
thread_local std::string PluginAuditManager::m_current_plugin_key = "";
thread_local std::string PluginAuditManager::m_current_capability_name = "";
thread_local std::string PluginAuditManager::m_current_audit_phase = "";
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_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name)
: m_plugin_key(plugin_key)
, m_capability(capability_name)
, m_previous_id(PluginAuditManager::instance().current_plugin())
: m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
, m_previous_root_frames(std::move(PluginAuditManager::m_root_frames))
{
PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::m_scoped_allowed_roots.clear();
PluginAuditManager::m_root_frames.clear();
}
ScopedPluginAuditContext::~ScopedPluginAuditContext()
{
PluginAuditManager& mgr = PluginAuditManager::instance();
// Let this scope's root frames go while it is still the current context, and as its own
// plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer
// must never be judged as the outer plugin, or bypass the audit when there is none. A
// finalizer may record a new root frame, so repeat until none is held.
if (!PluginAuditManager::m_root_frames.empty()) {
assert(PyGILState_Check());
mgr.set_current_plugin(m_plugin_key);
mgr.set_current_capability(m_capability);
while (!PluginAuditManager::m_root_frames.empty()) {
std::vector<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::instance().set_current_plugin(m_previous_id);
PluginAuditManager::instance().set_current_capability(m_previous_capability);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
PluginAuditManager::m_root_frames = std::move(m_previous_root_frames);
}
// ---------------------------------------------------------------------------
@@ -330,76 +259,6 @@ std::string PluginAuditManager::current_capability() const { return m_current_ca
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)
{
if (root.empty())
@@ -437,18 +296,16 @@ void PluginAuditManager::add_denied_filename(const std::string& filename)
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
// 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
// string comparison each. Keep the plugin install-state sidecar here too: it controls which
// 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.
// string comparison each. Single-sourced here so install_hook() and the tests seed from the
// same list and cannot drift apart.
return {
SLIC3R_APP_KEY ".conf",
GCODEVIEWER_APP_KEY ".conf",
SLIC3R_APP_KEY ".ini",
GCODEVIEWER_APP_KEY ".ini",
secret_constants::USER_SECRET_FILENAME,
".install_state.json",
};
}
@@ -620,14 +477,73 @@ bool PluginAuditManager::request_filesystem_read_permissions(const std::string&
if (plugin_key.empty() || paths.empty())
return true;
// Probe mode records the declaration and proceeds. It must not consult the install state,
// open the permission dialog, or persist a grant.
if (observation_mode()) {
record_observation("orca.request_permissions", "FsRead", 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();
}
return plugin_audit::request_filesystem_read_permissions(plugin_key, paths);
if (!granted)
return false;
if (state.plugin_name.empty()) {
state.installed_from = descriptor.is_cloud_plugin() ? "cloud" : "local";
state.installed_version = !descriptor.installed_version.empty() ? descriptor.installed_version : descriptor.version;
state.plugin_name = descriptor.name;
state.cloud_uuid = descriptor.cloud_uuid();
state.enabled = true;
}
state.permissions.fs_read.insert(state.permissions.fs_read.end(), missing.begin(), missing.end());
return write_install_state(boost::filesystem::path(descriptor.plugin_root), state);
}
void PluginAuditManager::report_violation(const AuditViolation& violation)
@@ -659,6 +575,34 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
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
// ---------------------------------------------------------------------------
@@ -719,88 +663,40 @@ std::string python_str(PyObject* object)
return result;
}
// The interpreter's stdlib folders, and the site-packages folders inside them that hold
// third-party code, both resolved. Set by install_hook(); like the verdict cache below, only
// touched while the GIL is held.
static std::vector<boost::filesystem::path> s_stdlib_dirs;
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()
std::vector<std::string> call_site_identities(const std::string& plugin_root)
{
s_stdlib_dirs.clear();
s_site_package_dirs.clear();
s_stdlib_file_verdicts.clear();
std::vector<std::string> ids;
if (plugin_root.empty())
return ids;
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
Py_XINCREF(frame); // normalize to an owned reference for the loop below
while (frame) {
frames.push_back(reinterpret_cast<PyObject*>(frame));
PyCodeObject* code = PyFrame_GetCode(frame); // new reference
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
Py_DECREF(frame);
frame = back;
if (is_plugin_frame)
break;
if (!id.empty())
ids.push_back(std::move(id));
}
return frames;
}
// 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;
Py_XDECREF(frame); // only holds a reference here if the loop exited via break
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());
return ids;
}
static const std::unordered_set<std::string> two_path_fs_events{
@@ -821,15 +717,12 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
{"socket.getnameinfo", {0}},
{"socket.getservbyname", {0}},
{"socket.getservbyport", {0}},
{"socket.sendto", {1}},
{"socket.sendmsg", {1}},
{"os.system", {0}},
{"subprocess.Popen", {1, 0}},
{"os.posix_spawn", {1, 0}},
{"os.spawn", {2, 1}},
{"os.startfile", {0}},
{"os.startfile/2", {0}},
{"pty.spawn", {0}},
{"_winapi.CreateProcess", {1, 0}},
{"_posixsubprocess.fork_exec", {0}},
@@ -874,11 +767,7 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) {
for (const Py_ssize_t index : it->second) {
// 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);
std::string value = python_str(tuple_item(args, index));
if (!value.empty()) {
targets.push_back(std::move(value));
break;
@@ -889,137 +778,206 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
return targets;
}
// 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)
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions)
{
const bool fs_category = is_fs_category(event_type);
const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
// Probe mode: report the classified event and let it through. Deliberately before the deny
// registry and allowed-root checks, so the report covers operations that production would
// block as well as those it would allow. No grant is read or written.
if (mgr.record_observation(event_name, plugin_audit::category_name(event_type), targets))
return 0;
// 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);
}
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;
}
// A nested event of an operation already granted, e.g. socket.connect inside an approved
// urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a
// redirect or a nested urlopen() inside a running request is not covered by it.
if (event_name != "urllib.Request" && mgr.covered_by_root_frame())
return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
}
const int result = plugin_audit::decide_event(mgr, event_name, event_type, targets);
// A granted operation covers its nested events while it runs. A targetless grant names no
// operation, so it covers nothing else.
if (result == 0 && !targets.empty() && operation_root_events.count(event_name))
mgr.record_root_frame();
return result;
}
} // namespace PluginAuditDetail
void PluginAuditManager::record_root_frame()
bool has_permission(const std::vector<std::string>& granted, const std::string& target)
{
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);
return std::find(granted.begin(), granted.end(), target) != granted.end();
}
bool PluginAuditManager::covered_by_root_frame() const
bool persist_permission(const std::string& plugin_key,
PluginInstallState& state,
std::vector<std::string>& permission_list,
const std::string& target)
{
if (m_root_frames.empty())
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor(plugin_key, descriptor) || descriptor.plugin_root.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;
// 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);
}
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;
}
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;
}
bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; }
bool PluginAuditManager::is_stdlib_file(const std::string& filename)
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)
{
// 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);
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 it->second;
if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved))
return report_denied(mgr, event_name, {false, "audit permission required"});
if (permission_list)
for (const auto& target : unresolved)
persist_permission(plugin_key, state, *permission_list, target);
mgr.record_approved_call_sites(plugin_key, call_site_ids);
return 0;
}
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);
}
} // namespace PluginAuditDetail
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
{
@@ -1048,20 +1006,62 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
if (event_type == AuditEventCategory::None)
return 0;
// Root frames whose operation has returned are let go once this event is decided, so that a
// finalizer they run re-enters a hook whose held frames are already consistent.
std::vector<PyObject*> returned_frames;
PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
mgr->release_frames(returned_frames);
return result;
const bool fs_category = is_fs_category(event_type);
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
// unconditional block: checked before the ancestor-cascade check below, so a cascade
// approval recorded for an unrelated action can never launder access to one, and before
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources
// folder) cannot make a denied path underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr->is_denied_path(boost::filesystem::path(target)))
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target);
}
}
PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
// was already approved, socket.connect won't trigger another permission dialog.
const std::vector<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()
{
// Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame).
PluginAuditDetail::collect_stdlib_dirs();
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It
// covers the plugin system's own workflow needs -- each plugin's storage folder
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
@@ -1076,9 +1076,8 @@ void PluginAuditManager::install_hook()
// "cert" denied-path keyword seeded below.
add_global_allowed_root(resources_dir(), /*allow_write=*/false);
// The user's app config and cloud credentials live directly inside data_dir(), and plugin
// install state lives beneath it, so the root just granted would otherwise expose them to
// plugins. Deny them by name.
// The user's app config and cloud credentials live directly inside data_dir(), so the
// root just granted would otherwise expose them to any plugin. Deny them by name.
//
// 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
+13 -93
View File
@@ -4,9 +4,10 @@
// Via pybind11 so this file requests the same python3xx.lib as everything else.
#include <boost/filesystem/path.hpp>
#include <pybind11/conduit/wrap_include_python_h.h>
#include <functional>
#include <mutex>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <boost/filesystem.hpp>
@@ -24,19 +25,6 @@ struct AuditViolation {
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
// 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.
@@ -150,56 +138,21 @@ public:
bool request_filesystem_read_permissions(const std::string& plugin_key,
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);
bool audit_denial_pending() const;
void clear_audit_denial();
void clear_last_violation();
bool last_violation(AuditViolation& violation) const;
// --- operation-root frames ---
// One stdlib operation fires several nested audit events as it passes through lower layers:
// urlopen() raises urllib.Request, then http.client and socket events. When an operation's
// own root event is granted (by a saved grant or the user), the stdlib frame that raised it
// is held, and a later event whose stack reaches that frame through stdlib frames only is
// part of the same operation, so it is allowed without a prompt of its own. Plugin or
// library code the operation calls back into is judged on its own. A frame is let go once it
// returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
// run finalizers of objects its locals held; they are judged as the plugin and never prompt.
void record_root_frame();
bool covered_by_root_frame() const;
bool releasing_frames() const;
// True when code from `filename` (a code object's co_filename) is the interpreter's own
// stdlib: the only code a root frame's coverage passes through. Valid after install_hook().
static bool is_stdlib_file(const std::string& filename);
// Answers permission prompts in place of the dialog, for tests. Production never sets it.
using PermissionPrompt = std::function<bool(const std::string& event_name, const std::vector<std::string>& targets)>;
static void set_permission_prompt_for_testing(PermissionPrompt prompt);
// --- call-site cascade cache ---
// A single plugin action often fires several nested CPython audit events as it passes
// through stdlib layers (urllib.request calling http.client calling socket, for example).
// Once the user approves one event, every stdlib frame still on the stack for that call
// is recorded here by (filename, function, first line) identity. A later event whose own
// ancestor chain still contains one of those frames is the same logical action seen from
// a deeper layer, so it is auto-approved instead of prompting again.
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
bool verbose_events = true;
@@ -210,38 +163,24 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data);
// Drops the references in `frames`, keeping the caller's error and denial state intact.
void release_frames(std::vector<PyObject*>& frames);
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_audit_phase;
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_has_last_violation;
static thread_local AuditViolation m_last_violation;
// mutable: is_denied_filename() is a const query that must lock.
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock.
mutable std::mutex m_mutex;
std::vector<AllowedRoot> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords;
// 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;
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
};
// RAII guard that sets the current plugin key and capability name, restoring the previous
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
// single capability.
//
// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before
// restoring the outer context. That needs the GIL whenever a frame is still held, so construct
// the scope after the GIL guard, to be destroyed before it.
class ScopedPluginAuditContext
{
public:
@@ -255,28 +194,9 @@ public:
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
private:
std::string m_plugin_key;
std::string m_capability;
std::string m_previous_id;
std::string m_previous_capability;
std::vector<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
-332
View File
@@ -1,332 +0,0 @@
#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
@@ -1,38 +0,0 @@
#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_
+17 -18
View File
@@ -14,7 +14,8 @@
#include "PluginAuditManager.hpp"
#include "PythonInterpreter.hpp"
#include "PluginText.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/format.hpp"
#include <exception>
#include <miniz.h>
@@ -91,7 +92,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
const fs::path plugin_root = resolve_plugin_root_from_descriptor(descriptor);
if (plugin_root.empty()) {
error = PLUGIN_U8L("Plugin folder could not be determined.");
error = _u8L("Plugin folder could not be determined.");
return false;
}
@@ -102,7 +103,7 @@ bool resolve_allowed_plugin_root(const PluginDescriptor& descriptor,
resolved_root = fs::absolute(plugin_root, ec);
}
if (ec || resolved_root.empty()) {
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
error = GUI::format(_u8L("Failed to resolve plugin folder: %1%"), plugin_root.string());
return false;
}
@@ -122,7 +123,7 @@ bool delete_plugin_root(const boost::filesystem::path& resolved_root, const std:
const auto removed_count = fs::remove_all(resolved_root, ec);
if (ec) {
// TRN %1% is a folder path, %2% the system error message.
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
error = GUI::format(_u8L("Failed to delete plugin folder %1%: %2%"), resolved_root.string(), ec.message());
return false;
}
@@ -807,7 +808,6 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
read_string_list("network_socket", parsed.permissions.network_socket);
read_string_list("process", parsed.permissions.process);
read_string_list("threading", parsed.permissions.threading);
read_string_list("events", parsed.permissions.events);
}
if (state.contains("enabled") && state["enabled"].is_boolean())
@@ -852,7 +852,6 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
{"network_socket", state.permissions.network_socket},
{"process", state.permissions.process},
{"threading", state.permissions.threading},
{"events", state.permissions.events},
};
nlohmann::json capabilities = nlohmann::json::array();
@@ -1198,7 +1197,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
namespace fs = boost::filesystem;
if (!fs::exists(plugin_dir) || !fs::is_directory(plugin_dir)) {
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory does not exist: %1%"), plugin_dir.string());
error = GUI::format(_u8L("Plugin directory does not exist: %1%"), plugin_dir.string());
return {};
}
@@ -1215,7 +1214,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
if (ext == ".py") {
if (!py_entry.empty()) {
// TRN %1% and %2% are file names.
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .py files: %1% and %2%"),
error = GUI::format(_u8L("Plugin directory contains multiple .py files: %1% and %2%"),
py_entry.filename().string(), it->path().filename().string());
return {};
}
@@ -1223,7 +1222,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
} else if (ext == ".whl") {
if (!whl_entry.empty()) {
// TRN %1% and %2% are file names.
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin directory contains multiple .whl files: %1% and %2%"),
error = GUI::format(_u8L("Plugin directory contains multiple .whl files: %1% and %2%"),
whl_entry.filename().string(), it->path().filename().string());
return {};
}
@@ -1232,7 +1231,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
}
if (!py_entry.empty() && !whl_entry.empty()) {
error = PLUGIN_U8L("Plugin directory contains both .py and .whl entry files");
error = _u8L("Plugin directory contains both .py and .whl entry files");
return {};
}
@@ -1242,7 +1241,7 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
if (!whl_entry.empty())
return whl_entry;
error = PLUGIN_U8L("Plugin directory does not contain a .py or .whl entry file");
error = _u8L("Plugin directory does not contain a .py or .whl entry file");
return {};
}
@@ -1259,34 +1258,34 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
error.clear();
if (dir.empty()) {
error = PLUGIN_U8L("Resource texture directory is not set");
error = _u8L("Resource texture directory is not set");
return files;
}
if (plugin_root.empty()) {
error = PLUGIN_U8L("Plugin folder could not be determined.");
error = _u8L("Plugin folder could not be determined.");
return files;
}
boost::system::error_code ec;
const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec);
if (ec) {
error = PLUGIN_U8L("Resource texture directory could not be resolved");
error = _u8L("Resource texture directory could not be resolved");
return files;
}
if (!fs::is_directory(resolved_dir, ec) || ec) {
error = PLUGIN_U8L("Resource texture path is not a directory");
error = _u8L("Resource texture path is not a directory");
return files;
}
const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec);
if (ec) {
error = PLUGIN_U8L("Plugin folder could not be resolved");
error = _u8L("Plugin folder could not be resolved");
return files;
}
if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
error = PLUGIN_U8L("Resource texture directory is outside the plugin folder");
error = _u8L("Resource texture directory is outside the plugin folder");
return files;
}
@@ -1311,7 +1310,7 @@ std::vector<std::string> collect_resource_texture_files(const std::string& dir,
}
if (ec) {
error = PLUGIN_U8L("Resource texture directory could not be read");
error = _u8L("Resource texture directory could not be read");
return {};
}
-2
View File
@@ -104,8 +104,6 @@ struct PluginPermissions
std::vector<std::string> network_socket;
std::vector<std::string> process;
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 {
+25 -24
View File
@@ -11,7 +11,8 @@
#include "PythonPluginBridge.hpp"
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include "libslic3r/Utils.hpp"
#include "PluginText.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/format.hpp"
#include <boost/algorithm/string.hpp>
#include <boost/algorithm/string/case_conv.hpp>
@@ -101,7 +102,7 @@ bool read_local_plugin_package_metadata(const boost::filesystem::path& source_pa
const std::string ext = plugin_package_extension(source_path);
if (ext != ".py" && ext != ".whl") {
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
error = GUI::format(_u8L("Plugin package must be a .py or .whl file, got: %1%"), ext);
return false;
}
@@ -178,14 +179,14 @@ bool add_wheel_dependencies_to_sys_path(const PluginDescriptor& descriptor,
std::string extract_error;
if (!extract_zip_to_directory(it->path(), dep_dir, extract_error)) {
// TRN %1% is the dependency file, %2% the error detail.
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
error = GUI::format(_u8L("Failed to extract plugin .whl dependency %1%: %2%"), it->path().string(), extract_error);
return false;
}
}
std::string syspath_error;
if (!interpreter.add_plugin_sys_path(dep_dir.string(), syspath_error)) {
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
error = GUI::format(_u8L("Failed to add .whl dependency to sys.path: %1%"), syspath_error);
return false;
}
plugin_paths.push_back(dep_dir.string());
@@ -247,12 +248,12 @@ bool load(const PluginDescriptor& descriptor,
PythonInterpreter& interpreter = PythonInterpreter::instance();
if (!interpreter.is_initialized()) {
error = PLUGIN_FORMAT(PLUGIN_U8L("Python interpreter not initialized: %1%"), interpreter.last_error());
error = GUI::format(_u8L("Python interpreter not initialized: %1%"), interpreter.last_error());
return false;
}
if (!descriptor.is_metadata_valid()) {
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
error = GUI::format(_u8L("Plugin manifest is invalid: %1%"), descriptor.plugin_key);
if (descriptor.has_error())
error += " - " + descriptor.normalized_error();
return false;
@@ -273,7 +274,7 @@ bool load(const PluginDescriptor& descriptor,
if (!skip_deps) {
std::string pkg_install_error;
if (!install_packages(descriptor.dependencies, pkg_install_error)) {
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
error = GUI::format(_u8L("Failed to install plugin dependencies: %1%"), pkg_install_error);
return false;
}
}
@@ -292,7 +293,7 @@ bool load(const PluginDescriptor& descriptor,
PyObject* module = import_plugin_module(descriptor, plugin.plugin_sys_paths, plugin.plugin_modules, load_error);
if (module == nullptr) {
bridge.cancel_plugin_capture(descriptor.entry_path);
error = PLUGIN_FORMAT(PLUGIN_U8L("Failed to load plugin module: %1%"), load_error);
error = GUI::format(_u8L("Failed to load plugin module: %1%"), load_error);
return false;
}
@@ -307,13 +308,13 @@ bool load(const PluginDescriptor& descriptor,
auto capabilities_found = bridge.finalize_plugin_capture(descriptor.entry_path, descriptor.plugin_key, bridge_error);
if (!bridge_error.empty()) {
capabilities_found.clear();
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin registration failed: %1%"), bridge_error);
error = GUI::format(_u8L("Plugin registration failed: %1%"), bridge_error);
if (registration_failed != nullptr)
*registration_failed = true;
return false;
}
if (capabilities_found.empty()) {
error = PLUGIN_U8L("Plugin module did not register any capabilities");
error = _u8L("Plugin module did not register any capabilities");
return false;
}
@@ -365,7 +366,7 @@ bool load(const PluginDescriptor& descriptor,
if (!seen_capabilities[type].insert(found.name).second) {
// TRN %1% is the capability name, %2% the capability type (e.g. Script).
materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin declares duplicate capability '%1%' for type %2%"),
materialization_error = GUI::format(_u8L("Plugin declares duplicate capability '%1%' for type %2%"),
found.name, PluginConfig::capability_type_label(type));
break;
}
@@ -421,9 +422,9 @@ bool load(const PluginDescriptor& descriptor,
capabilities.push_back(std::move(instance));
}
} catch (const std::exception& ex) {
materialization_error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin capability materialization failed: %1%"), ex.what());
materialization_error = GUI::format(_u8L("Plugin capability materialization failed: %1%"), ex.what());
} catch (...) {
materialization_error = PLUGIN_U8L("Plugin capability materialization failed");
materialization_error = _u8L("Plugin capability materialization failed");
}
}
@@ -465,11 +466,11 @@ bool load(const PluginDescriptor& descriptor,
} catch (const std::exception& ex) {
teardown_capabilities(plugin.capabilities, lifecycle_count);
// TRN on_load is the name of the plugin's startup function; keep it untranslated.
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin on_load failed: %1%"), ex.what());
error = GUI::format(_u8L("Plugin on_load failed: %1%"), ex.what());
return false;
} catch (...) {
teardown_capabilities(plugin.capabilities, lifecycle_count);
error = PLUGIN_U8L("Plugin on_load failed");
error = _u8L("Plugin on_load failed");
return false;
}
@@ -650,7 +651,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
bool backup_created = false;
auto fail = [&error](const std::string& message) {
error = PLUGIN_FORMAT(PLUGIN_U8L("Plugin installation failed: %1%"), message);
error = GUI::format(_u8L("Plugin installation failed: %1%"), message);
return false;
};
@@ -658,23 +659,23 @@ bool install_plugin(const boost::filesystem::path& filepath,
const fs::path source_path = canonical_or_absolute(filepath);
if (!fs::exists(source_path, ec) || !fs::is_regular_file(source_path, ec))
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package is not a file: %1%"), filepath.string()));
return fail(GUI::format(_u8L("Plugin package is not a file: %1%"), filepath.string()));
const std::string ext = plugin_package_extension(source_path);
if (ext != ".py" && ext != ".whl")
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Plugin package must be a .py or .whl file, got: %1%"), ext));
return fail(GUI::format(_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).
// 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.
if (is_cloud_install && !is_valid_plugin_id(cloud_uuid))
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Cloud plugin UUID is not a valid identifier: %1%"), cloud_uuid));
return fail(GUI::format(_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)) :
local_plugin_root();
fs::create_directories(plugin_root, ec);
if (ec)
// TRN %1% is a folder path, %2% the system error message.
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), plugin_root.string(), ec.message()));
std::string meta_error;
if (is_cloud_install) {
@@ -717,7 +718,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
fs::rename(final_dir, backup_dir, ec);
if (ec)
// TRN %1% is a folder path, %2% the system error message.
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
return fail(GUI::format(_u8L("Failed to back up existing plugin %1%: %2%"), final_dir.string(), ec.message()));
backup_created = true;
}
@@ -728,7 +729,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
boost::system::error_code restore_ec;
fs::rename(backup_dir, final_dir, restore_ec);
}
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
return fail(GUI::format(_u8L("Failed to create plugin directory %1%: %2%"), final_dir.string(), ec.message()));
}
fs::copy_file(source_path, dest_file, ec);
@@ -739,7 +740,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
fs::rename(backup_dir, final_dir, restore_ec);
}
// TRN %1% is a file path, %2% the system error message.
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
return fail(GUI::format(_u8L("Failed to copy plugin file to %1%: %2%"), dest_file.string(), ec.message()));
}
// Update entry_path to the installed location.
@@ -759,7 +760,7 @@ bool install_plugin(const boost::filesystem::path& filepath,
fs::remove_all(final_dir, rollback_ec);
if (backup_created)
fs::rename(backup_dir, final_dir, rollback_ec);
return fail(PLUGIN_FORMAT(PLUGIN_U8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
return fail(GUI::format(_u8L("Failed to write plugin install state: %1%"), (final_dir / INSTALL_STATE_FILE).string()));
}
}
+12
View File
@@ -53,6 +53,18 @@ const char* const LOAD_CANCELLED = "__plugin_load_cancelled__";
} // 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()
{
// Touch the interpreter first so its static outlives this one: ~Plugin needs it alive to
-22
View File
@@ -1,22 +0,0 @@
// 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
@@ -1,565 +0,0 @@
// 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;
}
@@ -1,97 +0,0 @@
#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
@@ -1,44 +0,0 @@
#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`.
nlohmann::json get_default_config() const override
{
pybind11::gil_scoped_acquire gil;
ORCA_PY_AUDIT_SCOPE();
try {
pybind11::gil_scoped_acquire gil;
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
if (!override)
return Base::get_default_config();
+6
View File
@@ -2,6 +2,7 @@
#include "GeneratedConfig.hpp"
#include "libslic3r/Utils.hpp"
#include "PluginAuditManager.hpp"
#include "PythonPluginBridge.hpp"
#include <atomic>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
@@ -664,6 +665,11 @@ bool PythonInterpreter::initialize()
// background-thread exceptions) to <data_dir>/log/python_*.log.
install_python_stderr_redirect();
// Attribute plugin-spawned Python threads to the plugin/capability that created them.
// Must run while the GIL is still held here, after the audit hook is in place and
// before the GIL is released below.
install_python_thread_audit_shim();
// Release the GIL so other threads can acquire it via PyGILState_Ensure.
// Without this, calls from background threads will block trying to acquire the GIL.
m_main_thread_state = PyEval_SaveThread();
+89 -2
View File
@@ -30,7 +30,7 @@
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include <pybind11/pytypes.h>
#include <pybind11/gil.h>
#include "PluginText.hpp"
#include "slic3r/GUI/I18N.hpp"
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);
}
if (!granted) {
error = PLUGIN_U8L("Plugin filesystem read permission request denied");
error = _u8L("Plugin filesystem read permission request denied");
BOOST_LOG_TRIVIAL(warning) << error << " for key " << plugin_key;
discard_pending_for_key();
clear_active_key();
@@ -603,6 +603,93 @@ request aborts the plugin load.)pbdoc");
}
void install_python_thread_audit_shim()
{
// Runs with the GIL held from PythonInterpreter::initialize(). CPython's own thread bootstrap
// calls the target with no knowledge of the audit context that created it: the calling
// thread is the only place the plugin key and capability are still available. Capture them
// here, then re-establish the same ScopedPluginAuditContext inside the spawned thread before
// the plugin body runs. The identity lives only in this C++ closure -- no Python-visible
// setter is added.
try {
py::object thread_module = py::module_::import("_thread");
const int installed = PyObject_HasAttrString(thread_module.ptr(), "_orca_thread_audit_shim_installed");
if (installed == 1) {
BOOST_LOG_TRIVIAL(debug) << "[AUDIT] Python thread attribution shim already installed";
return;
}
if (installed < 0)
PyErr_Clear();
py::object original = py::reinterpret_steal<py::object>(
PyObject_GetAttrString(thread_module.ptr(), "start_new_thread"));
if (!original || !PyCallable_Check(original.ptr())) {
PyErr_Clear();
BOOST_LOG_TRIVIAL(error) << "[AUDIT] _thread.start_new_thread is missing or not callable; "
"plugin threads will run without audit attribution";
return;
}
py::object shim = py::cpp_function(
[original](py::object function, py::object args, py::object kwargs) -> py::object {
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
if (plugin_key.empty()) {
// Not called from a plugin: preserve the stock implementation untouched.
return kwargs.is_none() ? original(function, args) : original(function, args, kwargs);
}
const std::string capability = PluginAuditManager::instance().current_capability();
py::object guarded = py::cpp_function(
[function, plugin_key, capability](py::args call_args, py::kwargs call_kwargs) -> py::object {
ScopedPluginAuditContext audit_scope(plugin_key, capability);
PyObject* result = PyObject_Call(function.ptr(), call_args.ptr(), call_kwargs.ptr());
if (result == nullptr)
throw py::error_already_set();
return py::reinterpret_steal<py::object>(result);
});
return kwargs.is_none() ? original(guarded, args) : original(guarded, args, kwargs);
},
py::arg("function"), py::arg("args"), py::arg("kwargs") = py::none());
if (PyObject_SetAttrString(thread_module.ptr(), "start_new_thread", shim.ptr()) < 0) {
PyErr_Clear();
BOOST_LOG_TRIVIAL(error)
<< "[AUDIT] Failed to install Python thread attribution shim on _thread.start_new_thread";
return;
}
if (PyObject_SetAttrString(thread_module.ptr(), "_orca_thread_audit_shim_installed", Py_True) < 0)
PyErr_Clear();
// threading.py caches the function at import time (as `_start_new_thread` in 3.12, and as
// `start_new_thread` in older versions). Patch the cached reference too when the module is
// already loaded; otherwise its Thread.start() would keep calling the unwrapped original.
py::object sys_modules = py::module_::import("sys").attr("modules");
PyObject* threading_module = PyDict_GetItemString(sys_modules.ptr(), "threading");
if (threading_module && threading_module != Py_None) {
const char* cached_names[] = {"_start_new_thread", "start_new_thread"};
for (const char* cached_name : cached_names) {
const int has_cached_name = PyObject_HasAttrString(threading_module, cached_name);
if (has_cached_name < 0) {
PyErr_Clear();
continue;
}
if (has_cached_name != 1)
continue;
if (PyObject_SetAttrString(threading_module, cached_name, shim.ptr()) < 0) {
PyErr_Clear();
BOOST_LOG_TRIVIAL(warning) << "[AUDIT] Failed to update threading." << cached_name
<< " with the Python thread attribution shim";
}
}
}
BOOST_LOG_TRIVIAL(info) << "[AUDIT] Python thread attribution shim installed";
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to install Python thread attribution shim: " << ex.what();
}
}
} // namespace Slic3r
#ifdef ORCA_PYTHON_STUBGEN_MODULE
+9
View File
@@ -13,6 +13,15 @@ namespace Slic3r { class PluginCapabilityInterface; }
namespace Slic3r {
// Install the thread-attribution shim: wraps `_thread.start_new_thread` (and the reference
// cached by an already-imported `threading` module) so a thread spawned from inside a plugin
// call runs with that call's plugin key/capability audit scope. The captured identity is held
// in a private C++ wrapper; nothing is exposed to Python that could set it.
//
// Call with the GIL held, after PluginAuditManager::install_hook() and before the interpreter
// releases the GIL. Safe to call more than once per interpreter.
void install_python_thread_audit_shim();
// One materialized capability returned from finalize_plugin_capture: the C++ instance
// and its resolved get_name() (cached while the GIL was held).
struct CapturedCapability
@@ -282,11 +282,11 @@ public:
int request_bind_ticket(std::string* ticket) override
{
try {
ORCA_PY_AUDIT_SCOPE();
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
::Slic3r::PythonGILState gil;
if (!gil)
throw std::runtime_error("Python interpreter is shutting down");
ORCA_PY_AUDIT_SCOPE();
pybind11::function override =
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
if (!override)
@@ -1,4 +0,0 @@
// 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"
@@ -1,32 +0,0 @@
// 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
@@ -1,75 +0,0 @@
// 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
@@ -1,32 +0,0 @@
// 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,55 +1263,6 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
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]") {
Slic3r::DynamicPrintConfig config;
t_config_option_keys extra, keys;
@@ -1727,75 +1678,3 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
}
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,58 +8417,3 @@ 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.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);
}
+1 -872
View File
@@ -1,51 +1,22 @@
#include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_all.hpp>
#include <cstddef>
#include <libslic3r/Utils.hpp>
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
#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 <catch2/catch_message.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 "plugin_test_utils.hpp"
#include "test_utils.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 <utility>
#include <vector>
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace py = pybind11;
namespace {
@@ -70,29 +41,9 @@ void seed_denied_keywords()
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
TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]")
{
seed_denied_names();
const PluginAuditManager& mgr = PluginAuditManager::instance();
@@ -104,7 +55,6 @@ TEST_CASE("Plugin audit denies host-managed filenames anywhere", "[audit]")
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini")));
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini")));
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")
@@ -160,7 +110,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
const fs::path 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)")
{
@@ -201,17 +150,6 @@ TEST_CASE("Plugin audit deny beats allowed roots", "[audit]")
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")
{
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
@@ -411,812 +349,3 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
}
}
// ---------------------------------------------------------------------------
// The audit hook, run in the embedded interpreter
// ---------------------------------------------------------------------------
//
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
// cases instead of using SECTIONs, which would restart the interpreter for every section.
namespace {
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Audit Plugin"
# description = "Plugin audit hook fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
)PY";
// A third-party library, installed outside the stdlib and outside the plugin.
const char* const LIBRARY_SOURCE = R"PY(
import socket
import urllib.request
def read_all(paths):
for path in paths:
with open(path) as f:
f.read()
def connect_from_host(port):
socket.gethostname()
socket.create_connection(("127.0.0.1", port)).close()
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
def fetch_each(urls):
for url in urls:
yield fetch(url)
class ReadingHandler(urllib.request.BaseHandler):
"""Reads a file while each response is processed."""
def __init__(self, path):
self.path = path
self.outcome = None
def http_response(self, request, response):
try:
open(self.path).close()
self.outcome = "ok"
except PermissionError:
self.outcome = "denied"
return response
)PY";
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
// HTTP server, the library, and every module the tests use imported up front, so that no import
// happens inside a plugin context. Code from exec() runs as "<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>"));
}