mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
460 lines
18 KiB
Python
460 lines
18 KiB
Python
"""Pure probe tests; no backend imports, service, image, or kernel qualification."""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import runpy
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from importlib.machinery import ModuleSpec
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from unittest.mock import patch
|
|
|
|
PROBE = Path(__file__).resolve().parents[1] / "docker/native-code-environment.py"
|
|
|
|
|
|
class EnvironmentProbeTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.assertTrue(PROBE.is_file(), "bounded runtime environment probe is missing")
|
|
self.probe = runpy.run_path(str(PROBE))
|
|
# The pinned fixture is unprivileged. File tests use real bytes/FDs;
|
|
# translate only their synthetic ownership to root, preserving mode checks.
|
|
self.trusted = self.probe["_trusted"]
|
|
trusted = self.trusted
|
|
ownership = patch.dict(
|
|
trusted.__globals__,
|
|
_trusted=lambda info, directory=False: trusted(
|
|
os.stat_result(
|
|
(
|
|
info.st_mode,
|
|
info.st_ino,
|
|
info.st_dev,
|
|
info.st_nlink,
|
|
0,
|
|
info.st_gid,
|
|
info.st_size,
|
|
info.st_atime,
|
|
info.st_mtime,
|
|
info.st_ctime,
|
|
)
|
|
),
|
|
directory,
|
|
),
|
|
)
|
|
ownership.start()
|
|
self.addCleanup(ownership.stop)
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name).resolve()
|
|
self.fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
|
|
self.addCleanup(os.close, self.fd)
|
|
|
|
def manifest(self):
|
|
return self.probe["ContentManifest"](self.fd)
|
|
|
|
def profile(self, **changes):
|
|
profile = {
|
|
"python": "3.13.7",
|
|
"implementation": "CPython",
|
|
"architecture": "x86_64",
|
|
"platform": "linux",
|
|
"gil_disabled": False,
|
|
"node": "v24.22.1",
|
|
"packages": {"Jinja2": "3.1.6", "MarkupSafe": "3.0.3"},
|
|
}
|
|
profile.update(changes)
|
|
return profile
|
|
|
|
def test_default_docker_node_version_is_supported(self):
|
|
dockerfile = PROBE.with_name("Dockerfile").read_text()
|
|
node_version = next(
|
|
line.removeprefix("ARG NODE_VERSION=")
|
|
for line in dockerfile.splitlines()
|
|
if line.startswith("ARG NODE_VERSION=")
|
|
)
|
|
self.probe["validate_profile"](self.profile(node=f"v{node_version}"))
|
|
|
|
def test_concrete_manifest_canonical_digest_and_content_mutation(self):
|
|
(self.root / "pkg").mkdir()
|
|
(self.root / "pkg/z.py").write_bytes(b"z = 2\n")
|
|
(self.root / "pkg/a.py").write_bytes(b"a = 1\n")
|
|
manifest = self.manifest()
|
|
manifest.add_tree("pkg")
|
|
files = manifest.files()
|
|
self.assertEqual([f["path"] for f in files], ["pkg/a.py", "pkg/z.py"])
|
|
self.assertEqual(files[0]["sha256"], hashlib.sha256(b"a = 1\n").hexdigest())
|
|
receipt = self.probe["make_receipt"](self.profile(), files)
|
|
digest = receipt.pop("environment_content_sha256")
|
|
expected = json.dumps(receipt, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
|
self.assertEqual(digest, hashlib.sha256(expected).hexdigest())
|
|
receipt2 = self.probe["make_receipt"](self.profile(), list(reversed(files)))
|
|
self.assertEqual(digest, receipt2["environment_content_sha256"])
|
|
(self.root / "pkg/a.py").write_bytes(b"a = 7\n")
|
|
changed = self.manifest()
|
|
changed.add_tree("pkg")
|
|
self.assertNotEqual(
|
|
digest,
|
|
self.probe["make_receipt"](self.profile(), changed.files())["environment_content_sha256"],
|
|
)
|
|
|
|
def test_profile_refuses_missing_mismatched_runtime_and_packages(self):
|
|
validate = self.probe["validate_profile"]
|
|
validate(self.profile())
|
|
validate(self.profile(architecture="aarch64"))
|
|
for bad in [
|
|
self.profile(python="3.12.9"),
|
|
self.profile(implementation="PyPy"),
|
|
self.profile(architecture="riscv64"),
|
|
self.profile(platform="darwin"),
|
|
self.profile(gil_disabled=True),
|
|
self.profile(node="v24.19.0"),
|
|
self.profile(node="v24.20.0"),
|
|
self.profile(packages={"Jinja2": "3.1.5", "MarkupSafe": "3.0.3"}),
|
|
self.profile(packages={"Jinja2": "3.1.6"}),
|
|
self.profile(node=None),
|
|
]:
|
|
with self.subTest(bad=bad), self.assertRaises(self.probe["Refusal"]):
|
|
validate(bad)
|
|
|
|
def test_path_escape_symlink_and_mutable_file_refused(self):
|
|
(self.root / "pkg").mkdir()
|
|
(self.root / "pkg/a.py").write_bytes(b"a")
|
|
(self.root / "pkg/link").symlink_to("/etc/passwd")
|
|
for path in ["../etc/passwd", "/etc/passwd", "pkg/../pkg/a.py", "pkg/link"]:
|
|
with (
|
|
self.subTest(path=path),
|
|
self.assertRaises((self.probe["Refusal"], OSError)),
|
|
):
|
|
self.manifest().add_file(path)
|
|
with self.assertRaises((self.probe["Refusal"], OSError)):
|
|
self.manifest().add_tree("pkg")
|
|
(self.root / "pkg/link").unlink()
|
|
(self.root / "pkg/a.py").chmod(0o666)
|
|
with self.assertRaises((self.probe["Refusal"], OSError)):
|
|
self.manifest().add_tree("pkg")
|
|
|
|
def test_file_count_and_byte_limits_refuse(self):
|
|
(self.root / "pkg").mkdir()
|
|
(self.root / "pkg/a").write_bytes(b"abc")
|
|
with (
|
|
patch.dict(self.probe["ContentManifest"].add_tree.__globals__, MAX_FILE_BYTES=2),
|
|
self.assertRaises(self.probe["Refusal"]),
|
|
):
|
|
self.manifest().add_tree("pkg")
|
|
with (
|
|
patch.dict(self.probe["ContentManifest"].add_tree.__globals__, MAX_FILES=0),
|
|
self.assertRaises(self.probe["Refusal"]),
|
|
):
|
|
self.manifest().add_tree("pkg")
|
|
|
|
def test_untrusted_ownership_refused(self):
|
|
with self.assertRaises(self.probe["Refusal"]):
|
|
self.trusted(os.stat_result((0o100644, 0, 0, 1, 1000, 1000, 0, 0, 0, 0)))
|
|
self.trusted(os.stat_result((0o100644, 0, 0, 1, 0, 0, 0, 0, 0, 0)))
|
|
|
|
def test_empty_directory_traversal_is_globally_bounded(self):
|
|
(self.root / "pkg/one/two").mkdir(parents=True)
|
|
with (
|
|
patch.dict(self.probe["ContentManifest"].add_tree.__globals__, MAX_ENTRIES=2),
|
|
self.assertRaises(self.probe["Refusal"]),
|
|
):
|
|
self.manifest().add_tree("pkg")
|
|
|
|
def test_file_mutation_during_hash_is_refused(self):
|
|
target = self.root / "mutable"
|
|
target.write_bytes(b"first")
|
|
original_read = os.read
|
|
|
|
def read_and_mutate(fd, size):
|
|
content = original_read(fd, size)
|
|
target.write_bytes(b"second")
|
|
return content
|
|
|
|
with (
|
|
patch("os.read", side_effect=read_and_mutate),
|
|
self.assertRaises(self.probe["Refusal"]),
|
|
):
|
|
self.manifest().add_file("mutable")
|
|
|
|
def test_wrong_package_origin_refused(self):
|
|
self.probe["require_origin"](
|
|
"/usr/local/lib/python3.13/site-packages/jinja2/__init__.py",
|
|
"/usr/local/lib/python3.13/site-packages/jinja2/__init__.py",
|
|
)
|
|
for path in [
|
|
"/tmp/jinja2/__init__.py",
|
|
"/usr/local/lib/python3.13/site-packages/jinja2/../evil.py",
|
|
None,
|
|
]:
|
|
with self.subTest(path=path), self.assertRaises(self.probe["Refusal"]):
|
|
self.probe["require_origin"](path, "/usr/local/lib/python3.13/site-packages/jinja2/__init__.py")
|
|
|
|
def test_root_fd_captures_actual_absolute_path_chain(self):
|
|
stamps = {}
|
|
fd = self.probe["_root_fd"](stamps)
|
|
try:
|
|
self.assertEqual(set(stamps), {"/", "/usr", "/usr/local"})
|
|
self.assertEqual(self.probe["_stamp"](os.fstat(fd)), stamps["/usr/local"])
|
|
for path, expected in stamps.items():
|
|
self.assertEqual(self.probe["_stamp"](os.stat(path, follow_symlinks=False)), expected)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
def test_revalidation_preserves_existing_maximum_directory_depth(self):
|
|
(self.root / "a/b/c/d/e/f/g/h").mkdir(parents=True)
|
|
manifest = self.manifest()
|
|
manifest.add_tree("a")
|
|
manifest.revalidate()
|
|
|
|
def synthetic_collection(self, import_hook=None, node_hook=None):
|
|
"""Real selected files, synthetic runtime/dependency API boundaries only."""
|
|
site = self.root / "lib/python3.13/site-packages"
|
|
(self.root / "bin").mkdir(exist_ok=True)
|
|
for relative in ("bin/python3.13", "bin/node", "lib/libpython3.13.so.1.0"):
|
|
path = self.root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"synthetic runtime fixture")
|
|
for name in (
|
|
"jinja2",
|
|
"markupsafe",
|
|
"jinja2-3.1.6.dist-info",
|
|
"markupsafe-3.0.3.dist-info",
|
|
):
|
|
path = site / name
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
(path / "__init__.py").write_bytes(b"original measured package")
|
|
for name, version in self.profile()["packages"].items():
|
|
(site / f"{name.lower()}-{version}.dist-info/METADATA").write_text(
|
|
f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n"
|
|
)
|
|
suffix = ".cpython-313-x86_64-linux-gnu.so"
|
|
(site / ("markupsafe/_speedups" + suffix)).write_bytes(b"synthetic extension")
|
|
# Prepare replacement trees before the first snapshot so the hook only
|
|
# changes the ancestor under test, not a higher unrelated directory.
|
|
shutil.copytree(site, site.with_name("replacement-site"))
|
|
(site.with_name("replacement-site") / "jinja2/__init__.py").write_bytes(b"replacement package")
|
|
shutil.copytree(self.root / "bin", self.root / "replacement-bin")
|
|
|
|
collect = self.probe["collect_environment"]
|
|
|
|
def root_fd(stamps=None):
|
|
# Synthetic fixture's real ancestor metadata, without pretending its
|
|
# /tmp ownership/mode is the production /usr/local trust policy.
|
|
if stamps is not None:
|
|
for path in [*reversed(self.root.parents), self.root]:
|
|
stamps[str(path)] = self.probe["_stamp"](path.stat())
|
|
return os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
|
|
def origin(name):
|
|
return str(
|
|
site / (name.replace(".", "/") + suffix if name.endswith("._speedups") else name + "/__init__.py")
|
|
)
|
|
|
|
def find_spec(name):
|
|
loader = self.probe["importlib"].machinery.ExtensionFileLoader(name, origin(name))
|
|
return ModuleSpec(name=name, loader=loader, origin=origin(name))
|
|
|
|
def import_module(name):
|
|
if import_hook is not None and name == "jinja2":
|
|
import_hook(site)
|
|
module = ModuleType(name)
|
|
module.__file__ = origin(name)
|
|
return module
|
|
|
|
def node_version(_binary):
|
|
if node_hook is not None:
|
|
node_hook(self.root / "bin")
|
|
return "v24.22.1"
|
|
|
|
with (
|
|
patch.dict(
|
|
collect.__globals__,
|
|
PREFIX=str(self.root),
|
|
_root_fd=root_fd,
|
|
node_version=node_version,
|
|
),
|
|
patch.object(sys, "platform", "linux"),
|
|
patch.object(sys, "prefix", str(self.root)),
|
|
patch.object(sys, "base_prefix", str(self.root)),
|
|
patch.object(sys, "executable", str(self.root / "bin/python3.13")),
|
|
patch("platform.python_version", return_value="3.13.7"),
|
|
patch("platform.python_implementation", return_value="CPython"),
|
|
patch("platform.machine", return_value="x86_64"),
|
|
patch(
|
|
"sysconfig.get_config_var",
|
|
side_effect=lambda key: suffix if key == "EXT_SUFFIX" else False,
|
|
),
|
|
patch("importlib.util.find_spec", side_effect=find_spec),
|
|
patch("importlib.import_module", side_effect=import_module),
|
|
):
|
|
return collect()
|
|
|
|
def test_nonisolated_interpreter_refuses_before_dependency_discovery(self):
|
|
if sys.flags.isolated:
|
|
completed = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
str(Path(__file__).resolve()),
|
|
f"{type(self).__name__}.{self._testMethodName}",
|
|
"-v",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
|
self.assertIn("\nOK\n", completed.stderr)
|
|
return
|
|
with self.assertRaisesRegex(self.probe["Refusal"], "unexpected_python_origin"):
|
|
self.synthetic_collection()
|
|
|
|
def execute_with_isolated_interpreter(self):
|
|
"""Run the same unittest method with actual CPython isolated flags."""
|
|
if sys.flags.isolated:
|
|
return False
|
|
selected = f"{type(self).__name__}.{self._testMethodName}"
|
|
completed = subprocess.run(
|
|
[sys.executable, "-I", str(Path(__file__).resolve()), selected, "-v"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
|
self.assertIn("Ran 1 test", completed.stderr)
|
|
self.assertIn("\nOK\n", completed.stderr)
|
|
return True
|
|
|
|
def test_synthetic_collection_with_stable_ancestors(self):
|
|
if self.execute_with_isolated_interpreter():
|
|
return
|
|
result = self.synthetic_collection()
|
|
self.assertEqual(result["status"], "observed")
|
|
|
|
def test_import_ancestor_substitution_and_restoration_refuses(self):
|
|
if self.execute_with_isolated_interpreter():
|
|
return
|
|
seen = []
|
|
|
|
def import_boundary(site):
|
|
original = site.with_name("original-site")
|
|
replacement = site.with_name("replacement-site")
|
|
site.rename(original)
|
|
replacement.rename(site)
|
|
try:
|
|
seen.append((site / "jinja2/__init__.py").read_bytes())
|
|
finally:
|
|
site.rename(replacement)
|
|
original.rename(site)
|
|
|
|
with self.assertRaisesRegex(self.probe["Refusal"], "path_chain_changed"):
|
|
self.synthetic_collection(import_hook=import_boundary)
|
|
self.assertEqual(seen, [b"replacement package"])
|
|
self.assertEqual(
|
|
(self.root / "lib/python3.13/site-packages/jinja2/__init__.py").read_bytes(),
|
|
b"original measured package",
|
|
)
|
|
|
|
def test_import_ancestor_symlink_substitution_and_restoration_refuses(self):
|
|
if self.execute_with_isolated_interpreter():
|
|
return
|
|
seen = []
|
|
|
|
def import_boundary(site):
|
|
original = site.with_name("original-site")
|
|
site.rename(original)
|
|
site.symlink_to(site.with_name("replacement-site"), target_is_directory=True)
|
|
try:
|
|
seen.append((site / "jinja2/__init__.py").read_bytes())
|
|
finally:
|
|
site.unlink()
|
|
original.rename(site)
|
|
|
|
with self.assertRaisesRegex(self.probe["Refusal"], "path_chain_changed"):
|
|
self.synthetic_collection(import_hook=import_boundary)
|
|
self.assertEqual(seen, [b"replacement package"])
|
|
|
|
def test_node_ancestor_substitution_and_restoration_refuses(self):
|
|
if self.execute_with_isolated_interpreter():
|
|
return
|
|
events = []
|
|
|
|
def node_boundary(directory):
|
|
original = directory.with_name("original-bin")
|
|
replacement = directory.with_name("replacement-bin")
|
|
directory.rename(original)
|
|
replacement.rename(directory)
|
|
try:
|
|
events.append((directory / "node").read_bytes())
|
|
finally:
|
|
directory.rename(replacement)
|
|
original.rename(directory)
|
|
|
|
with self.assertRaisesRegex(self.probe["Refusal"], "path_chain_changed"):
|
|
self.synthetic_collection(node_hook=node_boundary)
|
|
self.assertEqual(events, [b"synthetic runtime fixture"])
|
|
|
|
def fake_node(self, body):
|
|
path = self.root / "node"
|
|
path.write_text("#!" + sys.executable + "\n" + body)
|
|
path.chmod(0o755)
|
|
return str(path)
|
|
|
|
def test_node_bounded_timeout_output_and_environment(self):
|
|
node_version = self.probe["node_version"]
|
|
path = self.fake_node(
|
|
"import os; print('v24.22.1' if 'SECRET_SENTINEL' not in os.environ and 'NODE_OPTIONS' not in os.environ else 'leak')\n"
|
|
)
|
|
with patch.dict(
|
|
os.environ,
|
|
SECRET_SENTINEL="never-output-this-value",
|
|
NODE_OPTIONS="--bad-option",
|
|
):
|
|
self.assertEqual(node_version(path), "v24.22.1")
|
|
with (
|
|
patch.dict(node_version.__globals__, NODE_TIMEOUT_SECONDS=0.1),
|
|
self.assertRaises(self.probe["Refusal"]),
|
|
):
|
|
node_version(self.fake_node("import time; time.sleep(5)\n"))
|
|
with self.assertRaises(self.probe["Refusal"]):
|
|
node_version(self.fake_node("import os; os.write(1, b'x' * 100000)\n"))
|
|
with self.assertRaises(self.probe["Refusal"]):
|
|
node_version(self.fake_node("import sys; sys.exit(2)\n"))
|
|
|
|
def test_refusal_output_contains_no_secrets_or_capability_claims(self):
|
|
observe = self.probe["observe"]
|
|
with (
|
|
patch.dict(os.environ, SECRET_SENTINEL="never-output-this-value"),
|
|
patch.dict(
|
|
observe.__globals__,
|
|
collect_environment=lambda: (_ for _ in ()).throw(self.probe["Refusal"]("unsupported_python")),
|
|
),
|
|
):
|
|
result = observe()
|
|
self.assertEqual(
|
|
result,
|
|
{
|
|
"schema": "dify.native-code-environment/v1",
|
|
"status": "unsupported",
|
|
"reason": "unsupported_python",
|
|
},
|
|
)
|
|
output = json.dumps(result)
|
|
for forbidden in [
|
|
"never-output-this-value",
|
|
"SECRET_SENTINEL",
|
|
"ready",
|
|
"qualified",
|
|
"HOME",
|
|
]:
|
|
self.assertNotIn(forbidden, output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|