security: Sandbox verbirgt API-Schlüssel und Home-Verzeichnis

Erzeugter Code las über pandas.io.common.os – ohne import os, an validate_imports
vorbei – den OPENROUTER_API_KEY und das gesamte Home-Verzeichnis (nachgewiesen).
Kein Netz, aber alles Gedruckte landet in Ergebnis-JSON und Report. In gespeicherten
Ergebnissen fand sich kein Schlüssel.

- Umgebung des Programms ist eine Positivliste (solver_env), auch bei SOLVER_SANDBOX=aus
- bubblewrap verdeckt das Home per --tmpfs; nur sys.prefix/sys.base_prefix wieder lesbar

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-18 13:23:53 +02:00
commit 8dd5073cd1
3 changed files with 59 additions and 2 deletions

View file

@ -516,7 +516,7 @@ Mit dem Gemini-Gratiskontingent ist der Benchmark praktisch nicht durchführbar;
## 14. Sicherheit und Datenschutz ## 14. Sicherheit und Datenschutz
- **Der Aufgabentext und das Datenprofil werden an den LLM-Anbieter gesendet** (OpenRouter und die dahinterliegenden Modellanbieter bzw. Google). Keine vertraulichen oder personenbezogenen Daten eingeben, die diesen Weg nicht gehen dürfen. Datendateien selbst verlassen den Rechner nicht, wohl aber ihr Profil mit Beispielwerten. - **Der Aufgabentext und das Datenprofil werden an den LLM-Anbieter gesendet** (OpenRouter und die dahinterliegenden Modellanbieter bzw. Google). Keine vertraulichen oder personenbezogenen Daten eingeben, die diesen Weg nicht gehen dürfen. Datendateien selbst verlassen den Rechner nicht, wohl aber ihr Profil mit Beispielwerten.
- **Der Solver-Code stammt von einem LLM.** Er läuft in einer bubblewrap-Sandbox: kein Netzzugang, Dateisystem nur lesbar, beschreibbar nur das Temp-Verzeichnis des Laufs. Zusätzlich sind nur Mathematik- und Solver-Bibliotheken erlaubt, `open`, `eval` und `exec` verboten, und es gilt ein Zeitlimit. - **Der Solver-Code stammt von einem LLM.** Er läuft in einer bubblewrap-Sandbox: kein Netzzugang, Dateisystem nur lesbar, das Home-Verzeichnis unsichtbar, beschreibbar nur das Temp-Verzeichnis des Laufs. API-Schlüssel und andere Umgebungsvariablen bekommt das Programm nicht zu sehen. Zusätzlich sind nur Mathematik- und Solver-Bibliotheken erlaubt, `open`, `eval` und `exec` verboten, und es gilt ein Zeitlimit.
- **Ohne bubblewrap läuft der Code ungeschützt mit Ihren Benutzerrechten – und zwar ohne Warnung** (Standard `SOLVER_SANDBOX=auto`). Prüfen mit `which bwrap`. Wer sichergehen will, setzt `export SOLVER_SANDBOX=bwrap`: Dann wird ein Lauf ohne bubblewrap abgelehnt, statt ungeschützt ausgeführt. - **Ohne bubblewrap läuft der Code ungeschützt mit Ihren Benutzerrechten – und zwar ohne Warnung** (Standard `SOLVER_SANDBOX=auto`). Prüfen mit `which bwrap`. Wer sichergehen will, setzt `export SOLVER_SANDBOX=bwrap`: Dann wird ein Lauf ohne bubblewrap abgelehnt, statt ungeschützt ausgeführt.
- **Ein exportiertes Programm (`--code`) läuft außerhalb der Sandbox**, wenn Sie es selbst starten. Vorher kurz ansehen. - **Ein exportiertes Programm (`--code`) läuft außerhalb der Sandbox**, wenn Sie es selbst starten. Vorher kurz ansehen.
- **API-Schlüssel** nur als Umgebungsvariable; `.env`-Dateien sind von Git ausgeschlossen, gehören aber trotzdem nicht ins Projekt. - **API-Schlüssel** nur als Umgebungsvariable; `.env`-Dateien sind von Git ausgeschlossen, gehören aber trotzdem nicht ins Projekt.

View file

@ -482,9 +482,18 @@ def sandbox_command(script: Path, tmp: str) -> List[str]:
if SANDBOX == "bwrap": if SANDBOX == "bwrap":
raise CodeRejected("SOLVER_SANDBOX=bwrap verlangt bubblewrap, es ist aber nicht installiert.") raise CodeRejected("SOLVER_SANDBOX=bwrap verlangt bubblewrap, es ist aber nicht installiert.")
return [sys.executable, str(script)] return [sys.executable, str(script)]
# Das Home-Verzeichnis verdecken (Schlüsseldateien, .env, andere Projekte) – `validate_imports` hält den
# Zugriff nicht auf: beobachtet, dass `pandas.io.common.os` ohne `import os` das ganze Home auflistete.
# Wieder lesbar werden nur die Python-Installationen, die der Solver-Lauf braucht (.venv, Basis-Python).
home = Path.home().resolve()
python = []
for pfad in dict.fromkeys(Path(p).resolve() for p in (sys.prefix, sys.base_prefix)):
if pfad.is_relative_to(home):
python += ["--ro-bind", str(pfad), str(pfad)]
return [ return [
"bwrap", "--unshare-all", "--die-with-parent", "bwrap", "--unshare-all", "--die-with-parent",
"--ro-bind", "/", "/", # alles lesbar (Python, Solver-Bibliotheken), nichts beschreibbar "--ro-bind", "/", "/", # alles lesbar (Python, Solver-Bibliotheken), nichts beschreibbar
"--tmpfs", str(home), *python, # außer dem Home-Verzeichnis
"--bind", tmp, tmp, # einzige Ausnahme: das Arbeitsverzeichnis dieses Laufs "--bind", tmp, tmp, # einzige Ausnahme: das Arbeitsverzeichnis dieses Laufs
"--dev", "/dev", "--proc", "/proc", "--dev", "/dev", "--proc", "/proc",
"--setenv", "HOME", tmp, "--setenv", "TMPDIR", tmp, "--setenv", "HOME", tmp, "--setenv", "TMPDIR", tmp,
@ -493,6 +502,16 @@ def sandbox_command(script: Path, tmp: str) -> List[str]:
] ]
# Umgebung des Solver-Programms: nur diese Variablen, nie die ganze Umgebung – sonst liest der erzeugte Code
# OPENROUTER_API_KEY/GEMINI_API_KEY (nachgewiesen) und kann sie in Ergebnis-JSON oder Report schreiben.
SOLVER_ENV_VARS = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS")
def solver_env(tmp: str) -> dict:
env = {name: os.environ[name] for name in SOLVER_ENV_VARS if name in os.environ}
return {**env, "HOME": tmp, "TMPDIR": tmp, "PYTHONDONTWRITEBYTECODE": "1"}
def run_solver_code(code: str, timeout: int = 60, data: dict | None = None, def run_solver_code(code: str, timeout: int = 60, data: dict | None = None,
data_files=()) -> SolverRun: data_files=()) -> SolverRun:
try: try:
@ -506,7 +525,7 @@ def run_solver_code(code: str, timeout: int = 60, data: dict | None = None,
# Beigelegte Daten ins Laufverzeichnis: es ist das einzige, das die Sandbox beschreibbar bindet # Beigelegte Daten ins Laufverzeichnis: es ist das einzige, das die Sandbox beschreibbar bindet
for quelle in data_files or (): for quelle in data_files or ():
shutil.copy2(quelle, Path(tmp) / Path(quelle).name) shutil.copy2(quelle, Path(tmp) / Path(quelle).name)
env = dict(os.environ) env = solver_env(tmp)
if data is not None: # geänderte Eingabedaten ohne neuen LLM-Aufruf if data is not None: # geänderte Eingabedaten ohne neuen LLM-Aufruf
daten_datei = Path(tmp) / "daten.json" daten_datei = Path(tmp) / "daten.json"
daten_datei.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") daten_datei.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")

View file

@ -832,3 +832,41 @@ def test_unprotected_run_carries_the_warning_into_the_result(monkeypatch):
code = 'def main():\n """Test."""\n report("optimal", 1.0, {})\n\n\nif __name__ == "__main__":\n main()\n' code = 'def main():\n """Test."""\n report("optimal", 1.0, {})\n\n\nif __name__ == "__main__":\n main()\n'
run = solver_stage._check_and_run(code, 30, make_spec()) run = solver_stage._check_and_run(code, 30, make_spec())
assert any("Sandbox abgeschaltet" in w for w in run.warnings) assert any("Sandbox abgeschaltet" in w for w in run.warnings)
# Liest Umgebung und Home-Verzeichnis ohne `import os` – über ein Attribut von pandas, das
# validate_imports nicht sieht. Nachgewiesen: vorher waren API-Schlüssel und ~ vollständig lesbar.
PEEK_CODE = '''
import pandas as pd
def main():
"""Späht Umgebung und Home-Verzeichnis aus."""
_os = pd.io.common.os
try:
eintraege = len(_os.listdir({home!r}))
except OSError:
eintraege = -1
report("optimal", 0.0, {{"schluessel": len(_os.environ.get("OPENROUTER_API_KEY", "")),
"home_eintraege": eintraege}})
if __name__ == "__main__":
main()
'''
def test_program_does_not_see_api_keys(monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-geheim")
run = run_solver_code(PEEK_CODE.format(home="/"))
assert run.status == SolverStatus.OPTIMAL, run.error
assert run.solution["schluessel"] == 0
@pytest.mark.skipif(not shutil.which("bwrap"), reason="bubblewrap nicht installiert")
def test_sandbox_hides_the_home_directory():
from pathlib import Path
run = run_solver_code(PEEK_CODE.format(home=str(Path.home())))
assert run.status == SolverStatus.OPTIMAL, run.error
# Sichtbar bleiben höchstens die Einhängepunkte der Python-Installationen (.venv, Basis-Python)
assert run.solution["home_eintraege"] <= 2