mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-06 10:31:12 +00:00
388 lines
17 KiB
Markdown
388 lines
17 KiB
Markdown
# Plugin Audit Hook
|
||
|
||
OrcaSlicer's plugin system runs Python, which is extremely capable — it can read and
|
||
write files, spawn processes, open sockets, and load native code. To keep plugins from
|
||
reaching outside what they legitimately need, we install a **CPython audit hook** that
|
||
inspects sensitive runtime operations performed by plugin code and blocks the ones that
|
||
fall outside an allow‑list.
|
||
|
||
> **Scope of this version.** This is intentionally a *narrow, low‑risk first version* —
|
||
> groundwork, not a complete sandbox. Today it enforces one thing: **file writes are
|
||
> restricted to an allow‑list of directories** while a plugin is executing. Reads are
|
||
> left permissive so Python can still import modules. Process/network/native‑code events
|
||
> are *not* yet enforced. See [Limitations](#limitations) before relying on it as a
|
||
> security boundary.
|
||
|
||
---
|
||
|
||
## What is a plugin audit hook?
|
||
|
||
CPython exposes an auditing API (PEP 578). Any interpreter‑wide hook registered with
|
||
`PySys_AddAuditHook` is called *before* the runtime performs a sensitive operation — for
|
||
example opening a file (`open`), spawning a subprocess (`subprocess.Popen`), or connecting
|
||
a socket (`socket.connect`). The hook receives the event name and its arguments and may
|
||
abort the operation by setting a Python exception and returning a non‑zero value.
|
||
|
||
We register exactly **one** such hook, once, from `PythonInterpreter::initialize()` via
|
||
`PluginAuditManager::instance().install_hook()`. Everything else — *which* plugin is
|
||
running, *what* mode it runs under, and *which* directories it may touch — is tracked by
|
||
`PluginAuditManager`.
|
||
|
||
The hook itself is global to the interpreter, but it only enforces anything when a plugin
|
||
**audit context** is active (see below). Non‑plugin Python code, and plugin loading before
|
||
the context is set, pass through untouched.
|
||
|
||
---
|
||
|
||
## How it works
|
||
|
||
There are three moving parts. Keep them distinct — conflating them is the usual source of
|
||
confusion.
|
||
|
||
### 1. Audit identity — *who* is running (set once, per instance)
|
||
|
||
Every plugin instance carries a C++‑only identity string, never exposed to Python:
|
||
|
||
```cpp
|
||
// PythonPluginInterface.hpp
|
||
class PluginCapabilityInterface {
|
||
public:
|
||
void set_audit_plugin_key(std::string key);
|
||
const std::string& audit_plugin_key() const;
|
||
private:
|
||
std::string m_audit_plugin_key; // == PluginDescriptor::plugin_key
|
||
};
|
||
```
|
||
|
||
This is the canonical runtime ID, `PluginDescriptor::plugin_key`. It is stamped onto the
|
||
instance by the loader **after** the plugin is captured and **before** `on_load()` runs:
|
||
|
||
- `PluginLoader::load_plugin_impl()` → `set_audit_plugin_key(descriptor.plugin_key)`
|
||
- `PluginLoader::update_loaded_plugin_key()` → re‑stamps it if a key is migrated
|
||
|
||
Stamping the identity does **not** turn on enforcement — it only labels the object so that
|
||
later calls know which plugin they belong to. This matters because printer‑agent plugins
|
||
are later invoked through `IPrinterAgent` / `NetworkAgent`, where the original `plugin_key`
|
||
is no longer available at the call site; the instance carries it instead.
|
||
|
||
### 2. Audit context — *how strict*, for the duration of one call (set per call)
|
||
|
||
The active plugin, mode, and scoped roots live in thread‑local state on
|
||
`PluginAuditManager`. They are set and restored by an RAII guard,
|
||
`ScopedPluginAuditContext`:
|
||
|
||
```cpp
|
||
// constructor: remember previous state, then apply the new plugin/mode and clear scoped roots
|
||
ScopedPluginAuditContext(const std::string& plugin_key,
|
||
AuditMode mode = AuditMode::Loading);
|
||
// destructor: restore the previous plugin/mode/scoped-roots
|
||
```
|
||
|
||
A context is constructed at the **start of every C++ → Python trampoline call** and
|
||
destroyed when that call returns or throws. So enforcement is *per call*: outside any
|
||
trampoline call the mode is just its default and `current_plugin()` is empty, so the hook
|
||
allows everything.
|
||
|
||
### 3. Audit modes — what "strict" means
|
||
|
||
```cpp
|
||
enum class AuditMode {
|
||
// Import/loading phase: allow reads anywhere, only block writes
|
||
// outside allowed roots. Python needs to read stdlib modules
|
||
// during import and those are not inside plugin directories.
|
||
Loading,
|
||
|
||
// Execution phase: block both reads and writes outside allowed
|
||
// roots, plus subprocess/socket/ctypes.
|
||
Enforcing,
|
||
};
|
||
```
|
||
|
||
The check that implements this is `PluginAuditManager::check_open(path, mode)`:
|
||
|
||
1. Empty path → allow.
|
||
2. No active plugin (`current_plugin()` empty) → allow.
|
||
3. `Loading` **and** the open is a read (`mode` has no `w`/`a`/`+`) → allow (early‑out).
|
||
4. Otherwise the path must resolve inside a **scoped allowed root** or the **global
|
||
allowed root**, else it is blocked.
|
||
|
||
So the only difference between the two modes is step 3: `Loading` lets reads through before
|
||
the allow‑list check; `Enforcing` does not, so reads are subject to the same allow‑list as
|
||
writes.
|
||
|
||
### Allowed roots
|
||
|
||
There are two tiers, checked in this order:
|
||
|
||
| Tier | Stored in | Lifetime | Set by |
|
||
|---|---|---|---|
|
||
| **Scoped** | thread‑local, cleared on every new context | one call | `add_scoped_allowed_root()` inside an `audit_setup` callback |
|
||
| **Global** | shared, mutex‑guarded | process | `add_global_allowed_root()` in `install_hook()` |
|
||
|
||
In this version the global allow‑list contains **only `data_dir()`**. The executable
|
||
directory and resources directory are deliberately *not* allowed — plugins must not write
|
||
there. G‑code plugins additionally get the temp G‑code folder as a *scoped* root for the
|
||
duration of their `execute()` call.
|
||
|
||
Path matching (`is_inside_allowed_root`) canonicalizes both paths with
|
||
`weakly_canonical` (resolving symlinks without requiring existence) and does a
|
||
component‑wise prefix match that rejects any `..` traversal.
|
||
|
||
### Putting it together — the flow of one call
|
||
|
||
```
|
||
PluginLoader (once) set_audit_plugin_key(plugin_key) // identity stamped
|
||
│
|
||
▼
|
||
C++ calls plugin->execute() ─► trampoline method
|
||
│ ├─ ScopedPluginAuditContext ctor // mode + plugin set
|
||
│ ├─ audit_setup() // e.g. add scoped roots
|
||
│ └─ PYBIND11_OVERRIDE(_PURE) ──► Python runs
|
||
│ │
|
||
│ Python does open("/x", "w") ─┤
|
||
│ ▼
|
||
│ CPython raises "open" audit event
|
||
│ │
|
||
│ PluginAuditManager::audit_hook
|
||
│ │
|
||
│ check_open("/x","w") → blocked?
|
||
│ └─ PyErr_SetString + return -1 ► PermissionError
|
||
▼
|
||
trampoline returns ─► ScopedPluginAuditContext dtor // previous state restored
|
||
```
|
||
|
||
---
|
||
|
||
## Audit hook development
|
||
|
||
The point of interest is **`PluginAuditManager.hpp` / `.cpp`** (the modes, the events, and
|
||
the policy) and the trampoline macros in **`PyPluginTrampoline.hpp`** (how each plugin
|
||
function opts into a mode).
|
||
|
||
### Handling events
|
||
|
||
Events are dispatched by name in `PluginAuditManager::audit_hook`. Return `0` to allow;
|
||
set a Python exception and return non‑zero (we use `-1`) to block:
|
||
|
||
```cpp
|
||
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
|
||
{
|
||
auto* mgr = static_cast<PluginAuditManager*>(user_data);
|
||
std::string event_name(event ? event : "");
|
||
|
||
if (event_name == "open") {
|
||
// CPython passes ("open", path, mode, flags)
|
||
const char* path = nullptr; const char* mode = nullptr; int flags = 0;
|
||
if (!PyArg_ParseTuple(args, "s|si", &path, &mode, &flags)) {
|
||
PyErr_Clear();
|
||
return 0; // couldn't parse — allow
|
||
}
|
||
if (!mgr->check_open(path ? path : "", mode ? mode : "r").allowed) {
|
||
PyErr_SetString(PyExc_PermissionError,
|
||
"Plugin attempted to access a blocked file path");
|
||
return -1; // block
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// else if (event_name == "os.rename") { ... } // see below
|
||
|
||
return 0; // unhandled event — allow
|
||
}
|
||
```
|
||
|
||
To audit a new operation, add another `else if` branch. **Each event has its own argument
|
||
tuple** — you cannot assume `(path, mode, flags)`. Look the event up in the official table
|
||
and parse accordingly:
|
||
|
||
- `os.rename` → `(src, dst, src_dir_fd, dst_dir_fd)`
|
||
- `os.remove` → `(path, dir_fd)`
|
||
- `os.mkdir` → `(path, mode, dir_fd)`
|
||
- `subprocess.Popen` → `(executable, args, cwd, env)`
|
||
|
||
The complete, version‑specific list of audit events and their arguments:
|
||
**https://docs.python.org/3/library/audit_events.html**
|
||
|
||
For filesystem mutations you'll usually want to route the extracted path(s) through
|
||
`check_open(path, "w")` (or a dedicated checker) so they share the same allow‑list logic.
|
||
|
||
### Defining the audit mode of a function
|
||
|
||
Every C++ → Python plugin call crosses a trampoline method, and those methods wrap the
|
||
pybind11 override in the `ORCA_PY_OVERRIDE_AUDITED` macro
|
||
(`PyPluginTrampoline.hpp`). The macro both (a) logs and rethrows Python exceptions at the
|
||
single boundary and (b) opens the audit context. Its signature:
|
||
|
||
```cpp
|
||
ORCA_PY_OVERRIDE_AUDITED(mode, audit_setup, override_macro, ret, base, name, /* args... */)
|
||
```
|
||
|
||
| Param | Meaning |
|
||
|---|---|
|
||
| `mode` | `AuditMode::Loading` or `AuditMode::Enforcing` for this call |
|
||
| `audit_setup` | a callable (often `[] {}`) run *after* the context is constructed — use it to register scoped roots |
|
||
| `override_macro` | `PYBIND11_OVERRIDE` or `PYBIND11_OVERRIDE_PURE` |
|
||
| `ret, base, name, …` | the usual pybind11 override arguments |
|
||
|
||
When you add a new method to any trampoline, **you must choose its mode** based on what the
|
||
function legitimately needs:
|
||
|
||
```cpp
|
||
void on_load() override
|
||
{
|
||
ORCA_PY_OVERRIDE_AUDITED(
|
||
::Slic3r::PluginAuditManager::AuditMode::Loading, // imports during load → reads allowed
|
||
[] {}, // no extra setup
|
||
PYBIND11_OVERRIDE,
|
||
void, Base, on_load);
|
||
}
|
||
```
|
||
|
||
Rule of thumb:
|
||
|
||
- Use **`Loading`** for lifecycle/setup calls that may import modules (`on_load`,
|
||
`on_unload`, `get_type`) or any call where you only care about restricting writes.
|
||
- Use **`Enforcing`** for calls that should also be prevented from *reading* outside the
|
||
allow‑list. Be aware this will block lazily‑imported stdlib/3rd‑party modules read from
|
||
disk during the call, so only use it where the plugin is not expected to import at call
|
||
time.
|
||
|
||
### Adding per‑call allowed roots (the `audit_setup` callback)
|
||
|
||
`ScopedPluginAuditContext`'s constructor **clears** the scoped roots, so any scoped root
|
||
must be added *after* construction — which is exactly what `audit_setup` is for. The G‑code
|
||
trampoline uses it to grant write access to the folder holding the current temp G‑code
|
||
file:
|
||
|
||
```cpp
|
||
ExecutionResult execute(const GCodePluginContext& ctx) override
|
||
{
|
||
ORCA_PY_OVERRIDE_AUDITED(
|
||
::Slic3r::PluginAuditManager::AuditMode::Loading,
|
||
[&] { // runs only when a context is active
|
||
if (!ctx.gcode_path.empty())
|
||
::Slic3r::PluginAuditManager::instance().add_scoped_allowed_root(
|
||
std::filesystem::path(ctx.gcode_path).parent_path());
|
||
},
|
||
PYBIND11_OVERRIDE_PURE,
|
||
ExecutionResult, GCodePlugin, execute, ctx);
|
||
}
|
||
```
|
||
|
||
The callback runs only when the instance has a non‑empty audit key (i.e. a context was
|
||
actually opened), so it's safe to assume enforcement is live inside it.
|
||
|
||
### Adding a global allowed root
|
||
|
||
If *every* plugin should be allowed a directory, add it in `install_hook()`:
|
||
|
||
```cpp
|
||
void PluginAuditManager::install_hook()
|
||
{
|
||
PySys_AddAuditHook(audit_hook, this);
|
||
add_global_allowed_root(data_dir()); // the only global root today
|
||
// add_global_allowed_root(std::filesystem::temp_directory_path()); // e.g. to allow /tmp
|
||
}
|
||
```
|
||
|
||
Prefer scoped roots over global ones — a global root widens the boundary for *all* plugins
|
||
and is process‑lifetime. Only add a global root when the access is genuinely universal.
|
||
|
||
### Identity wiring (rarely touched)
|
||
|
||
If you add a new way to load or re‑key plugin instances, make sure the new path also calls
|
||
`set_audit_plugin_key()` — otherwise the instance has an empty key and **no context is ever
|
||
opened**, so its calls run completely unaudited. The existing call sites are
|
||
`PluginLoader::load_plugin_impl()` and `PluginLoader::update_loaded_plugin_key()`.
|
||
|
||
---
|
||
|
||
## Current policy at a glance
|
||
|
||
| Plugin call | Mode | Effective access |
|
||
|---|---|---|
|
||
| `on_load` / `on_unload` / `get_type` | `Loading` | read anywhere; write only under `data_dir()` |
|
||
| G‑code `execute()` | `Loading` | + write under the current temp G‑code folder |
|
||
| Script `execute()` | `Loading` | read anywhere; write only under `data_dir()` |
|
||
| Printer‑agent methods | `Loading` | read anywhere; write only under `data_dir()` |
|
||
|
||
> Modes are chosen at each trampoline call site, so this table reflects the current source —
|
||
> always check the actual `ORCA_PY_OVERRIDE_AUDITED(...)` call when in doubt.
|
||
|
||
---
|
||
|
||
## Limitations
|
||
|
||
This version is deliberately minimal. Do **not** treat it as a hardened sandbox. Known gaps:
|
||
|
||
- **Only the `open` event is enforced.** `subprocess.Popen`, `os.system`, `socket.*`,
|
||
`ctypes.*` and friends are *not* blocked. (The `Enforcing` enum comment describes an
|
||
aspiration, not current behavior.)
|
||
- **Non‑string paths slip through the `open` check.** The audit callback parses only a
|
||
string path (`"s|si"`); any `open`‑event call whose first argument is bytes or an integer
|
||
file descriptor — including `os.open`, which additionally passes `mode = None` — fails the
|
||
parse and is allowed. Low‑level and non‑`str` opens are currently unaudited.
|
||
- **`open(path, "x")`** (exclusive create — a write) contains no `w`/`a`/`+`, so it is
|
||
classified as a read and allowed under `Loading`.
|
||
- **Non‑`open` filesystem mutations are unaudited.** `os.remove`, `os.rename`, `os.mkdir`,
|
||
`shutil.*` raise their own events, which we don't yet handle — a plugin can delete or
|
||
rename files outside `data_dir()` without tripping anything.
|
||
- Enforcement is **per process / per thread** via thread‑locals; code that hops threads
|
||
without re‑establishing a context runs unaudited.
|
||
|
||
Closing these gaps (especially the filesystem‑mutation events and `os.open` flags) is the
|
||
natural next step for anyone hardening this into a real write‑sandbox.
|
||
|
||
---
|
||
|
||
## Debugging
|
||
|
||
Enforcement only fires while a context is active, and the read/write distinction trips
|
||
people up, so when something is unexpectedly blocked (or unexpectedly allowed), get the
|
||
facts first.
|
||
|
||
**Temporary block log.** `check_open` logs each block just before returning, including the
|
||
mode that was actually live:
|
||
|
||
```
|
||
[AUDIT] block path=/tmp open_mode=w audit_mode=Loading plugin=local:.../Environment_Report_Script_
|
||
```
|
||
|
||
Read it field by field:
|
||
|
||
- `open_mode=w` → it's a **write**. Under `Loading`, writes outside the allow‑list are
|
||
*supposed* to be blocked. A blocked `open_mode=r` under `audit_mode=Loading` is
|
||
impossible from current source — if you see it, your binary is stale (see below).
|
||
- `audit_mode=` → tells you whether the live call site is `Loading` or `Enforcing`, which
|
||
is the quickest way to confirm a trampoline change actually took effect.
|
||
- `path=` → the resolved path that failed the allow‑list. Compare against `data_dir()`.
|
||
|
||
The permanent `report_violation` log (`[AUDIT BLOCKED] …`) fires on the same blocks and
|
||
includes the plugin key, event name, path, and reason.
|
||
|
||
**Common pitfalls**
|
||
|
||
- **Read vs write.** `Loading` never blocks a read. If a "read" is blocked, it's actually a
|
||
write (check `open_mode`), or the mode is `Enforcing`.
|
||
- **Stale / incremental builds.** `PyPluginTrampoline.hpp` and `PluginAuditManager.hpp` are
|
||
included by many translation units. A header‑only change (e.g. flipping a trampoline's
|
||
mode) may not propagate with an incremental build. If runtime behavior contradicts the
|
||
source, do a clean rebuild of the affected targets. `PluginAuditManager.cpp` changes are
|
||
a single‑TU recompile + relink.
|
||
- **No context = no enforcement.** If a plugin's calls are never audited, check that its
|
||
instance got `set_audit_plugin_key()` (non‑empty key) and that the method actually wraps
|
||
through `ORCA_PY_OVERRIDE_AUDITED`.
|
||
|
||
---
|
||
|
||
## Key files
|
||
|
||
| File | Responsibility |
|
||
|---|---|
|
||
| `src/slic3r/plugin/PluginAuditManager.{hpp,cpp}` | modes, allowed roots, `audit_hook`, `check_open`, `ScopedPluginAuditContext` |
|
||
| `src/slic3r/plugin/PyPluginTrampoline.hpp` | the `ORCA_PY_*` macros (logging + audit context) |
|
||
| `src/slic3r/plugin/PythonPluginInterface.hpp` | the per‑instance audit identity |
|
||
| `src/slic3r/plugin/PluginLoader.cpp` | stamps the audit key at load / key migration |
|
||
| `src/slic3r/plugin/pluginTypes/*/*Trampoline.hpp` | per‑plugin‑type methods and their chosen modes |
|
||
| `src/slic3r/plugin/PythonInterpreter.cpp` | installs the hook once at interpreter init |
|