gabriel / muse public
test_security_ast_dos.py python
282 lines 10.7 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 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 pytest
26
27 from tests.cli_test_helper import CliRunner
28 from muse.core.object_store import object_path
29 from muse.core._types import fake_id
30
31 cli = None
32 runner = CliRunner()
33
34 _AST_DOS_BUDGET_S: float = 10.0 # hard wall-clock limit per test
35 _MAX_AST_BYTES: int = 2 * 1024 * 1024 # 2 MB — must match validation.MAX_AST_BYTES
36
37
38 # ---------------------------------------------------------------------------
39 # Shared repo helpers (duplicated-minimal version — no shared conftest dep)
40 # ---------------------------------------------------------------------------
41
42 def _env(root: pathlib.Path) -> Manifest:
43 return {"MUSE_REPO_ROOT": str(root)}
44
45
46 def _init_code_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
47 muse_dir = tmp_path / ".muse"
48 muse_dir.mkdir()
49 repo_id = fake_id("repo")
50 (muse_dir / "repo.json").write_text(
51 json.dumps({
52 "repo_id": repo_id,
53 "domain": "code",
54 "default_branch": "main",
55 "created_at": "2025-01-01T00:00:00+00:00",
56 }),
57 encoding="utf-8",
58 )
59 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
60 (muse_dir / "refs" / "heads").mkdir(parents=True)
61 (muse_dir / "snapshots").mkdir()
62 (muse_dir / "commits").mkdir()
63 (muse_dir / "objects").mkdir()
64 return tmp_path, repo_id
65
66
67 def _store_object(root: pathlib.Path, content: bytes) -> str:
68 from muse.core._types import blob_id
69 from muse.core.object_store import write_object
70 oid = blob_id(content)
71 write_object(root, oid, content)
72 return oid
73
74
75 def _make_commit(
76 root: pathlib.Path,
77 repo_id: str,
78 message: str = "init",
79 manifest: Manifest | None = None,
80 ) -> str:
81 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
82 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
83
84 ref_file = root / ".muse" / "refs" / "heads" / "main"
85 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
86 m: Manifest = manifest or {}
87 snap_id = compute_snapshot_id(m)
88 committed_at = datetime.datetime.now(datetime.timezone.utc)
89 commit_id = compute_commit_id(
90 repo_id=repo_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 created_on_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 cid.startswith("sha256:") and len(cid) == 71
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 cid.startswith("sha256:") and len(cid) == 71
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 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago