gabriel / muse public
test_security_ast_dos.py python
282 lines 10.7 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Security tests: unbounded ast.parse — CPU/memory denial of service.
2
3 Python's ast.parse exhibits super-linear behaviour on certain constructs:
4 deeply nested list/dict literals, long chains of binary operators, and
5 multi-megabyte source files all cause parsing time to spike non-linearly.
6
7 A malicious agent can commit a crafted Python file that causes any command
8 which calls ast.parse on workspace files (blast-risk, entangle,
9 semantic-test-coverage, narrative, gravity, contract, rename, dead) to peg
10 a CPU core indefinitely.
11
12 The fix: check len(source_bytes) > MAX_AST_BYTES (2 MB) before calling
13 ast.parse. Commands must gracefully skip or report an error rather than
14 blocking the event loop.
15 """
16
17 from __future__ import annotations
18
19 import ast
20 import datetime
21 import hashlib
22 import json
23 import pathlib
24 import time
25 import uuid
26
27 import pytest
28
29 from tests.cli_test_helper import CliRunner
30 from muse.core.object_store import object_path
31
32 cli = None
33 runner = CliRunner()
34
35 _AST_DOS_BUDGET_S: float = 10.0 # hard wall-clock limit per test
36 _MAX_AST_BYTES: int = 2 * 1024 * 1024 # 2 MB — must match validation.MAX_AST_BYTES
37
38
39 # ---------------------------------------------------------------------------
40 # Shared repo helpers (duplicated-minimal version — no shared conftest dep)
41 # ---------------------------------------------------------------------------
42
43 def _env(root: pathlib.Path) -> Manifest:
44 return {"MUSE_REPO_ROOT": str(root)}
45
46
47 def _init_code_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
48 muse_dir = tmp_path / ".muse"
49 muse_dir.mkdir()
50 repo_id = str(uuid.uuid4())
51 (muse_dir / "repo.json").write_text(
52 json.dumps({
53 "repo_id": repo_id,
54 "domain": "code",
55 "default_branch": "main",
56 "created_at": "2025-01-01T00:00:00+00:00",
57 }),
58 encoding="utf-8",
59 )
60 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
61 (muse_dir / "refs" / "heads").mkdir(parents=True)
62 (muse_dir / "snapshots").mkdir()
63 (muse_dir / "commits").mkdir()
64 (muse_dir / "objects").mkdir()
65 return tmp_path, repo_id
66
67
68 def _store_object(root: pathlib.Path, content: bytes) -> str:
69 from muse.core._types import blob_id
70 from muse.core.object_store import write_object
71 oid = blob_id(content)
72 write_object(root, oid, content)
73 return oid
74
75
76 def _make_commit(
77 root: pathlib.Path,
78 repo_id: str,
79 message: str = "init",
80 manifest: Manifest | None = None,
81 ) -> str:
82 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
83 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
84
85 ref_file = root / ".muse" / "refs" / "heads" / "main"
86 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
87 m: Manifest = manifest or {}
88 snap_id = compute_snapshot_id(m)
89 committed_at = datetime.datetime.now(datetime.timezone.utc)
90 commit_id = compute_commit_id(
91 parent_ids=[parent_id] if parent_id else [],
92 snapshot_id=snap_id,
93 message=message,
94 committed_at_iso=committed_at.isoformat(),
95 )
96 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
97 write_commit(root, CommitRecord(
98 commit_id=commit_id,
99 repo_id=repo_id,
100 branch="main",
101 snapshot_id=snap_id,
102 message=message,
103 committed_at=committed_at,
104 parent_commit_id=parent_id,
105 ))
106 ref_file.parent.mkdir(parents=True, exist_ok=True)
107 ref_file.write_text(commit_id, encoding="utf-8")
108 return commit_id
109
110
111 # ---------------------------------------------------------------------------
112 # Payload generators
113 # ---------------------------------------------------------------------------
114
115 def _oversized_py_source() -> bytes:
116 """Produce a valid Python source file just over MAX_AST_BYTES (2 MB + 1)."""
117 # Simple repeated variable assignments — valid Python, linear AST.
118 header = "# generated oversized file\n"
119 line = "x = 1\n"
120 target = _MAX_AST_BYTES + 1
121 lines_needed = (target - len(header.encode())) // len(line.encode())
122 return (header + line * lines_needed).encode()
123
124
125 def _deep_nesting_bomb(depth: int = 2_000) -> bytes:
126 """Produce a Python source with *depth*-level nested list literals.
127
128 CPython's compile stage (inside ast.parse) shows super-linear behaviour
129 on this input; at depth 10_000 it can take minutes. We use a moderate
130 depth here to keep the test fast on CI while still showing the pattern.
131 """
132 inner = "0"
133 for _ in range(depth):
134 inner = f"[{inner}]"
135 return f"x = {inner}\n".encode()
136
137
138 # ---------------------------------------------------------------------------
139 # § 1 — MAX_AST_BYTES constant is exported
140 # ---------------------------------------------------------------------------
141
142 class TestMaxAstBytesConstant:
143 def test_constant_exported_from_validation(self) -> None:
144 from muse.core.validation import MAX_AST_BYTES
145 assert isinstance(MAX_AST_BYTES, int)
146 assert MAX_AST_BYTES == 2 * 1024 * 1024
147
148 def test_python_adapter_respects_limit(self) -> None:
149 """PythonAdapter.parse_symbols must reject oversized files gracefully."""
150 from muse.plugins.code.ast_parser import PythonAdapter
151 adapter = PythonAdapter()
152 oversized = _oversized_py_source()
153 assert len(oversized) > _MAX_AST_BYTES
154 # Should return empty SymbolTree, not raise or hang.
155 t0 = time.monotonic()
156 result = adapter.parse_symbols(oversized, "big.py")
157 elapsed = time.monotonic() - t0
158 assert isinstance(result, dict)
159 # Grace: either rejected (empty) or parsed quickly (< 5s).
160 assert len(result) == 0 or elapsed < 5.0, (
161 f"PythonAdapter spent {elapsed:.1f}s on a {len(oversized)}-byte file; "
162 "MAX_AST_BYTES guard is missing"
163 )
164
165 def test_python_adapter_file_content_id_respects_limit(self) -> None:
166 """file_content_id must also apply the size limit."""
167 from muse.plugins.code.ast_parser import PythonAdapter
168 adapter = PythonAdapter()
169 oversized = _oversized_py_source()
170 t0 = time.monotonic()
171 cid = adapter.file_content_id(oversized)
172 elapsed = time.monotonic() - t0
173 assert len(cid) == 64 # still returns a valid hex sha-256
174 assert elapsed < 5.0, (
175 f"file_content_id spent {elapsed:.1f}s on oversized file; "
176 "MAX_AST_BYTES guard is missing from file_content_id path"
177 )
178
179
180 # ---------------------------------------------------------------------------
181 # § 2 — Deep-nesting AST bomb
182 # ---------------------------------------------------------------------------
183
184 class TestDeepNestingBomb:
185 def test_deep_nesting_parse_symbols_bounded(self) -> None:
186 """A 2000-deep nested list must not block parse_symbols for > 10s."""
187 from muse.plugins.code.ast_parser import PythonAdapter
188 adapter = PythonAdapter()
189 bomb = _deep_nesting_bomb(depth=2_000)
190 assert len(bomb) < _MAX_AST_BYTES # still under the size limit
191
192 t0 = time.monotonic()
193 result = adapter.parse_symbols(bomb, "bomb.py")
194 elapsed = time.monotonic() - t0
195 assert elapsed < _AST_DOS_BUDGET_S, (
196 f"parse_symbols spent {elapsed:.1f}s on a depth-2000 nesting bomb "
197 f"(budget {_AST_DOS_BUDGET_S}s)"
198 )
199 assert isinstance(result, dict)
200
201 def test_deep_nesting_file_content_id_bounded(self) -> None:
202 """file_content_id must also be bounded on deeply nested structures."""
203 from muse.plugins.code.ast_parser import PythonAdapter
204 adapter = PythonAdapter()
205 bomb = _deep_nesting_bomb(depth=2_000)
206 t0 = time.monotonic()
207 cid = adapter.file_content_id(bomb)
208 elapsed = time.monotonic() - t0
209 assert len(cid) == 64
210 assert elapsed < _AST_DOS_BUDGET_S, (
211 f"file_content_id spent {elapsed:.1f}s on depth-2000 bomb"
212 )
213
214
215 # ---------------------------------------------------------------------------
216 # § 3 — CLI commands reject oversized Python files gracefully
217 # ---------------------------------------------------------------------------
218
219 def _oversized_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
220 """Create a repo containing one oversized Python file (> MAX_AST_BYTES)."""
221 root, repo_id = _init_code_repo(tmp_path)
222 src = _oversized_py_source()
223 oid = _store_object(root, src)
224 src_dir = root / "src"
225 src_dir.mkdir()
226 (src_dir / "huge.py").write_bytes(src)
227 _make_commit(root, repo_id, "add oversized file", {"src/huge.py": oid})
228 return root, repo_id
229
230
231 class TestOversizedFileCli:
232 """Commands that parse Python AST must handle oversized files without hanging."""
233
234 def _run_bounded(
235 self,
236 root: pathlib.Path,
237 args: list[str],
238 budget_s: float = _AST_DOS_BUDGET_S,
239 ) -> None:
240 t0 = time.monotonic()
241 r = runner.invoke(cli, args, env=_env(root))
242 elapsed = time.monotonic() - t0
243 assert elapsed < budget_s, (
244 f"Command {args} took {elapsed:.1f}s > budget {budget_s}s on "
245 "oversized Python file — MAX_AST_BYTES guard is missing"
246 )
247 # exit_code may be non-zero (file skipped / error reported) — that's fine.
248 assert r.exception is None, f"Command raised unexpectedly: {r.exception}"
249
250 def test_symbols_bounded(self, tmp_path: pathlib.Path) -> None:
251 root, _ = _oversized_repo(tmp_path)
252 self._run_bounded(root, ["code", "symbols"])
253
254 def test_dead_bounded(self, tmp_path: pathlib.Path) -> None:
255 root, _ = _oversized_repo(tmp_path)
256 self._run_bounded(root, ["code", "dead"])
257
258 def test_blast_risk_bounded(self, tmp_path: pathlib.Path) -> None:
259 root, _ = _oversized_repo(tmp_path)
260 self._run_bounded(root, ["code", "blast-risk", "--max-commits", "5"])
261
262 def test_semantic_test_coverage_bounded(self, tmp_path: pathlib.Path) -> None:
263 root, _ = _oversized_repo(tmp_path)
264 self._run_bounded(root, ["code", "semantic-test-coverage", "--max-commits", "5"])
265
266 def test_narrative_bounded(self, tmp_path: pathlib.Path) -> None:
267 root, _ = _oversized_repo(tmp_path)
268 self._run_bounded(
269 root, ["code", "narrative", "src/huge.py::x", "--max-commits", "5"]
270 )
271
272 def test_gravity_bounded(self, tmp_path: pathlib.Path) -> None:
273 root, _ = _oversized_repo(tmp_path)
274 self._run_bounded(
275 root, ["code", "gravity", "src/huge.py::x", "--max-commits", "5"]
276 )
277
278 def test_contract_bounded(self, tmp_path: pathlib.Path) -> None:
279 root, _ = _oversized_repo(tmp_path)
280 self._run_bounded(
281 root, ["code", "contract", "src/huge.py::x", "--max-commits", "5"]
282 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago