Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 181 additions & 65 deletions backend/secuscan/parser_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
other in-process state.
- Environment variables (which may contain SECUSCAN_VAULT_KEY, API keys, etc.)
are stripped from the child process.
- The child's import path is locked down: neither PYTHONPATH nor HOME is
inherited, and user-site / current-directory imports are disabled, so a
caller cannot point the "sandboxed" interpreter at an attacker-controlled
directory and have the parser import arbitrary modules.
- When the backend runs as root, the parser child drops to an unprivileged
user before it executes, so untrusted parser code never runs as root.
- Execution is bounded by a configurable timeout.
- Output size is capped so a runaway parser cannot exhaust backend memory.
- On Linux with unprivileged user namespaces available, the child runs in
Expand Down Expand Up @@ -39,6 +45,7 @@

from __future__ import annotations

import contextlib
import json
import os
import platform
Expand All @@ -47,9 +54,10 @@
import sys
import subprocess
import string
import tempfile
import logging
from pathlib import Path
from typing import Any, Dict
from typing import Any, Dict, Iterator

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -139,14 +147,107 @@ def __init__(self, plugin_id: str, reason: str, stderr: str = "") -> None:


def _sanitised_env() -> Dict[str, str]:
"""Return a minimal environment for the child process.
"""Return a minimal, hardened environment for the parser child.

Beyond stripping credentials and application secrets, this deliberately
drops the two variables that let a caller inject import search paths into
the "sandboxed" interpreter:

* ``PYTHONPATH`` — prepends arbitrary directories to ``sys.path``, so an
inherited value pointing at an attacker-controlled directory lets the
parser ``import`` anything placed there.
* ``HOME`` — controls the per-user site directory
(``~/.local/lib/pythonX.Y/site-packages``), which is another
``sys.path`` entry and therefore an equivalent injection vector.

Only variables the interpreter genuinely needs to start and to decode text
are kept (``PATH``, temp-dir hints, locale). ``PYTHONNOUSERSITE`` disables
the user-site directory even if ``HOME`` leaks in some other way, and
``PYTHONSAFEPATH`` (3.11+) stops the process working directory — added to
``sys.path`` by ``python -c`` — from being used to smuggle in modules.
Installed dependencies still resolve normally: the interpreter's own
``site-packages`` is on ``sys.path`` regardless of ``PYTHONPATH``.
"""
keep_keys = {"PATH", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL"}
env = {k: v for k, v in os.environ.items() if k in keep_keys}
env["PYTHONNOUSERSITE"] = "1"
env["PYTHONSAFEPATH"] = "1"
return env


# Fallback ids for the classic unprivileged "nobody"/"nogroup" accounts, used
# only if the names cannot be resolved on this host.
_NOBODY_UID = 65534
_NOBODY_GID = 65534


Retains PATH and PYTHONPATH (needed to locate the interpreter and any
installed packages) while stripping all credentials and application
secrets present in the parent's environment.
def _privilege_drop_kwargs() -> Dict[str, Any]:
"""subprocess kwargs that run the parser child as an unprivileged user.

Untrusted parser code must never execute with root privileges. When the
backend itself runs as root we resolve the ``nobody`` account (falling back
to the conventional 65534 ids) and hand ``user``/``group``/``extra_groups``
to :class:`subprocess.Popen`, which performs the setgid/setgroups/setuid
drop in the child between ``fork`` and ``exec``.

Returns an empty dict — i.e. no change in behaviour — when the backend is
not running as root or the platform has no concept of uids (Windows). In
that common case the process is already unprivileged and there is nothing
to drop.
"""
keep_keys = {"PATH", "PYTHONPATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL"}
return {k: v for k, v in os.environ.items() if k in keep_keys}
if os.name != "posix" or not hasattr(os, "geteuid") or os.geteuid() != 0:
return {}

uid, gid = _NOBODY_UID, _NOBODY_GID
try:
import pwd

entry = pwd.getpwnam("nobody")
uid, gid = entry.pw_uid, entry.pw_gid
except (ImportError, KeyError):
pass

return {"user": uid, "group": gid, "extra_groups": []}


@contextlib.contextmanager
def _staged_parser(
parser_path: Path, drop_kwargs: Dict[str, Any]
) -> Iterator[Path]:
"""Yield a ``parser.py`` path the (possibly privilege-dropped) child can read.

When the parser child drops to an unprivileged user (i.e. the backend runs
as root and ``drop_kwargs`` is non-empty), the original ``parser.py`` may
live under a root-owned, non-world-readable directory that the drop target
cannot traverse or read — the child would then fail to import it. Stage a
private copy owned by that account, with the staging directory and file
readable only by it (and root), and remove the copy once the child has
finished.

When no privilege drop is in effect the original path is yielded unchanged
and nothing is copied — the already-unprivileged backend can read its own
plugin files directly.
"""
if not drop_kwargs:
yield parser_path
return

uid, gid = drop_kwargs["user"], drop_kwargs["group"]
staging_dir = tempfile.mkdtemp(prefix="secuscan-parser-")
try:
staged = Path(staging_dir) / "parser.py"
shutil.copyfile(parser_path, staged)
# Give the copy to the unprivileged account read-only (0400); other
# local users cannot read it. The directory stays owned by the backend
# and only traversable by others (0711, not readable/listable), which
# lets the dropped child reach the file while keeping cleanup below
# working whether or not the backend is root.
os.chown(staged, uid, gid)
os.chmod(staged, 0o400)
os.chmod(staging_dir, 0o711)
yield staged
finally:
shutil.rmtree(staging_dir, ignore_errors=True)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -200,6 +301,10 @@ def _unshare_net_supported() -> bool:
[unshare_path, "--user", "--net", "--", "true"],
capture_output=True,
timeout=5,
# Probe under the same privilege drop the real run uses, so that a
# host where root can create user namespaces but "nobody" cannot is
# detected here rather than failing at parse time.
**_privilege_drop_kwargs(),
)
_unshare_available = probe.returncode == 0
except Exception:
Expand Down Expand Up @@ -255,12 +360,10 @@ def run_parser_in_sandbox(

max_input_bytes = max(len(parser_input.encode("utf-8")) + 128, 64 * 1024)

# Use Template.safe_substitute so that any stray $ in parser_path does not
# raise; repr() ensures the path is a valid Python string literal.
bootstrap = _BOOTSTRAP_TEMPLATE.safe_substitute(
parser_path_repr=repr(str(parser_path)),
max_input_bytes=max_input_bytes,
)
# Resolve the privilege drop once and reuse it for both staging and the
# child process, so the account the file is made readable for is exactly
# the account the child runs as.
drop_kwargs = _privilege_drop_kwargs()

envelope = json.dumps({"input": parser_input})
stdin_bytes = envelope.encode("utf-8")
Expand All @@ -274,60 +377,73 @@ def run_parser_in_sandbox(
# Stderr cap: diagnostics only — 64 KB is more than enough for any error message.
_MAX_STDERR_BYTES = 65536

proc = subprocess.Popen(
_sandbox_argv(sys.executable, bootstrap),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=_sanitised_env(),
)

def _read_stdout() -> None:
nonlocal stdout_total, overflow
assert proc.stdout is not None
while True:
chunk = proc.stdout.read(65536)
if not chunk:
break
stdout_total += len(chunk)
if stdout_total > max_output_bytes:
overflow = True
proc.kill()
break
stdout_chunks.append(chunk)

def _read_stderr() -> None:
assert proc.stderr is not None
total = 0
while True:
chunk = proc.stderr.read(4096)
if not chunk:
break
total += len(chunk)
if total <= _MAX_STDERR_BYTES:
stderr_chunks.append(chunk)
# Always drain so the child is never blocked on a full pipe.

t_out = threading.Thread(target=_read_stdout, daemon=True)
t_err = threading.Thread(target=_read_stderr, daemon=True)
t_out.start()
t_err.start()

try:
proc.stdin.write(stdin_bytes) # type: ignore[union-attr]
proc.stdin.close() # type: ignore[union-attr]
except BrokenPipeError:
pass
# Stage the parser where the (possibly privilege-dropped) child can read it,
# then run it. The staged copy must outlive the child, so the whole run
# happens inside the context; _staged_parser removes it on exit and is a
# no-op when the backend is already unprivileged.
with _staged_parser(parser_path, drop_kwargs) as effective_parser_path:
# Use Template.safe_substitute so that any stray $ in parser_path does
# not raise; repr() ensures the path is a valid Python string literal.
bootstrap = _BOOTSTRAP_TEMPLATE.safe_substitute(
parser_path_repr=repr(str(effective_parser_path)),
max_input_bytes=max_input_bytes,
)

timed_out = False
try:
proc.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
timed_out = True
proc.kill()
proc = subprocess.Popen(
_sandbox_argv(sys.executable, bootstrap),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=_sanitised_env(),
**drop_kwargs,
)

t_out.join(timeout=5)
t_err.join(timeout=5)
def _read_stdout() -> None:
nonlocal stdout_total, overflow
assert proc.stdout is not None
while True:
chunk = proc.stdout.read(65536)
if not chunk:
break
stdout_total += len(chunk)
if stdout_total > max_output_bytes:
overflow = True
proc.kill()
break
stdout_chunks.append(chunk)

def _read_stderr() -> None:
assert proc.stderr is not None
total = 0
while True:
chunk = proc.stderr.read(4096)
if not chunk:
break
total += len(chunk)
if total <= _MAX_STDERR_BYTES:
stderr_chunks.append(chunk)
# Always drain so the child is never blocked on a full pipe.

t_out = threading.Thread(target=_read_stdout, daemon=True)
t_err = threading.Thread(target=_read_stderr, daemon=True)
t_out.start()
t_err.start()

try:
proc.stdin.write(stdin_bytes) # type: ignore[union-attr]
proc.stdin.close() # type: ignore[union-attr]
except BrokenPipeError:
pass

timed_out = False
try:
proc.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
timed_out = True
proc.kill()

t_out.join(timeout=5)
t_err.join(timeout=5)

stderr_text = b"".join(stderr_chunks).decode("utf-8", errors="replace")

Expand Down
Loading
Loading