Affects only main / 3.15-dev — not a PSRT case
Per python.org/dev/security/, the PSRT does not accept reports that only affect pre-release versions. The vulnerable code path was added post-3.14 branch cut and exists only on main today, so this is a regular bug report rather than a private security advisory.
| Branch | posix._clearenv present? |
Affected? |
|---|---|---|
| main (3.15-dev) | yes | yes |
| 3.14 | no | no |
| 3.13 | no | no |
| 3.12, 3.11, 3.10 | no | no |
(os._clearenv was added recently. Older branches' os.environ.clear() falls through to per-key os.unsetenv calls, which already fire the audit hook correctly.)
What's wrong
Modules/posixmodule.c::os__clearenv_impl calls libc clearenv() directly and does not fire PySys_Audit("os.unsetenv", …) for any of the variables being cleared. The single-os.unsetenv path in the same file does fire the hook (precedent for the convention) — bulk-clear is a silent gap.
static PyObject * os__clearenv_impl(PyObject *module) /*[clinic end generated code: output=2d6705d62c014b51 input=47d2fa7f323c43ca]*/ { errno = 0; int err = clearenv(); if (err) { return posix_error(); } Py_RETURN_NONE; }
This violates the documented PEP 578 contract: an operator who registers an os.unsetenv audit-hook handler to monitor environment-variable removals does not see anything when os.environ.clear() is called, even though the same operator's hook DOES fire on os.unsetenv("FOO") and os.environ.pop("FOO"). Bulk-clear is the silent path.
Reproducer
On a fresh main checkout:
import os, sys events = [] def hook(event, args): if event == "os.unsetenv": events.append(args) os.environ["VVAL_SENTINEL_A"] = "alpha" os.environ["VVAL_SENTINEL_B"] = "bravo" sys.addaudithook(hook) os.environ.clear() print("audit events fired:", len(events)) # prints: audit events fired: 0
Expected per PEP 578: one event per cleared variable. Actual: zero events.
For comparison, the per-key path (which works correctly):
import os, sys events = [] sys.addaudithook(lambda e, args: events.append(args) if e == "os.unsetenv" else None) os.environ["X"] = "y" os.environ.pop("X") # fires correctly print("events:", len(events)) # prints: events: 1
Suggested fix
Iterate extern char **environ in os__clearenv_impl and fire PySys_Audit("os.unsetenv", "s#", *e, name_len) for each variable before calling clearenv(). Pattern matches the existing os__unsetenv_impl in the same file.
diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c @@ -13663,6 +13663,18 @@ static PyObject * os__clearenv_impl(PyObject *module) /*[clinic end generated code: output=2d6705d62c014b51 input=47d2fa7f323c43ca]*/ { + /* Fire PEP 578 'os.unsetenv' audit hook for every variable being cleared, + so operators using sys.addaudithook see the bulk clear. */ + extern char **environ; + if (environ) { + for (char **e = environ; *e; e++) { + const char *eq = strchr(*e, '='); + Py_ssize_t name_len = eq ? (eq - *e) : (Py_ssize_t)strlen(*e); + if (PySys_Audit("os.unsetenv", "s#", *e, name_len) < 0) { + return NULL; + } + } + } errno = 0; int err = clearenv(); if (err) {
Empirical verification
Patched against fork SHA 836fbdaa (close to upstream main):
| Run | events captured | sentinel VVAL_SENTINEL_A seen? |
|---|---|---|
Baseline os.environ.clear() |
0 | no (no events at all) |
Patched os.environ.clear() |
23 | yes |
Negative control (os.unsetenv("VVAL_SENTINEL_A") directly) fires the hook on both baseline and patched — confirms the harness is sound.
Mutation-confirmed: removing the audit-loop from the patched code returns events: 0, demonstrating the harness asserts the right invariant.
Severity
PEP 578 contract violation (audit-hook visibility gap), not RCE. Operators using audit hooks for env-var monitoring lose visibility on the bulk-clear path. Recommend topic-security / topic-audit-hooks triage labels.
Notes for triage
- Argument Clinic boundary preserved: the patch modifies the
_implfunction body, not the generated[clinic input]/[clinic start generated code]block. No clinic regeneration required. - No public-API surface change:
os.environ.clear()signature, return type, and exception types are unchanged. - Hook-rejection path matches
os.unsetenvprecedent:if (PySys_Audit(...) < 0) return NULL;allows operators to reject bulk-clear by raising in their hook.
This issue is part of a small set of PEP 578 audit-hook bypass findings observed on main; a sibling issue covers _remote_debugging.BinaryWriter (file Modules/_remote_debugging/binary_io_writer.c).