diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 00000000..475ced9c --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-06-08 - _SAFE_GIT_CONFIG Required for git subprocesses +**Vulnerability:** Calling `git` via `subprocess` against potentially untrusted directories can lead to local code execution via malicious `.git/config` files (e.g., `core.fsmonitor` exploitation). +**Learning:** This codebase explicitly dictates that `("-c", "core.fsmonitor=false")` (typically defined as `_SAFE_GIT_CONFIG`) MUST be applied to all `git` subprocess calls to prevent this vulnerability class. Some files (e.g., `src/wardline/core/delta.py` and `src/wardline/core/legis.py`) were missed. +**Prevention:** Always verify `_SAFE_GIT_CONFIG` is prepended to `git` command arguments when using `subprocess.run` to call `git`. diff --git a/src/wardline/core/delta.py b/src/wardline/core/delta.py index c4d63f0e..0c073664 100644 --- a/src/wardline/core/delta.py +++ b/src/wardline/core/delta.py @@ -8,6 +8,8 @@ from wardline.core.errors import WardlineError +_SAFE_GIT_CONFIG = ("-c", "core.fsmonitor=false") + if TYPE_CHECKING: from wardline.scanner.index import Entity @@ -22,7 +24,7 @@ def get_changed_files_since(ref: str, root: Path) -> set[str]: # 1. Get the git toplevel directory. try: res = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "--show-toplevel"], cwd=root, capture_output=True, text=True, @@ -38,7 +40,7 @@ def get_changed_files_since(ref: str, root: Path) -> set[str]: # 2. Resolve ref to a verified object id before passing it to git diff. try: res = subprocess.run( - ["git", "rev-parse", "--verify", "--end-of-options", ref], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "--verify", "--end-of-options", ref], cwd=git_toplevel, capture_output=True, text=True, @@ -54,7 +56,7 @@ def get_changed_files_since(ref: str, root: Path) -> set[str]: # 3. Get changed files since ref (committed since ref, staged, unstaged). try: res = subprocess.run( - ["git", "diff", "--name-only", verified_ref, "--"], + ["git", *_SAFE_GIT_CONFIG, "diff", "--name-only", verified_ref, "--"], cwd=git_toplevel, capture_output=True, text=True, @@ -68,7 +70,7 @@ def get_changed_files_since(ref: str, root: Path) -> set[str]: # 4. Get untracked files. try: res = subprocess.run( - ["git", "ls-files", "--others", "--exclude-standard"], + ["git", *_SAFE_GIT_CONFIG, "ls-files", "--others", "--exclude-standard"], cwd=git_toplevel, capture_output=True, text=True, diff --git a/src/wardline/core/legis.py b/src/wardline/core/legis.py index 87144974..6ccdf6d3 100644 --- a/src/wardline/core/legis.py +++ b/src/wardline/core/legis.py @@ -57,6 +57,8 @@ SIG_PREFIX = "hmac-sha256:v2:" ARTIFACT_SIGNATURE_FIELD = "artifact_signature" +_SAFE_GIT_CONFIG = ("-c", "core.fsmonitor=false") + # Cross-member scan-artifact keys that legis reads with a DEFAULT, not a hard # requirement (``findings`` -> empty list, ``dirty`` -> false). A silent rename of one # of these routes zero defects into legis under a green ``verified`` status — the @@ -199,7 +201,7 @@ def _git_tree_sha(root: Path) -> str | None: """ try: rev = subprocess.run( - ["git", "rev-parse", "HEAD^{tree}"], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "HEAD^{tree}"], cwd=root, capture_output=True, text=True, @@ -215,7 +217,7 @@ def _git_repo_root(root: Path) -> Path | None: """The containing git repository root, or None when unavailable.""" try: rev = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], + ["git", *_SAFE_GIT_CONFIG, "rev-parse", "--show-toplevel"], cwd=root, capture_output=True, text=True, diff --git a/tests/unit/core/test_delta.py b/tests/unit/core/test_delta.py index 5b16c7af..659009ec 100644 --- a/tests/unit/core/test_delta.py +++ b/tests/unit/core/test_delta.py @@ -43,8 +43,16 @@ def run_dispatch(args, **kwargs): res = get_changed_files_since("HEAD~1", root) assert res == {"foo.py", "bar.py", "baz.py"} - assert mock_run.call_args_list[1].args[0] == ["git", "rev-parse", "--verify", "--end-of-options", "HEAD~1"] - assert mock_run.call_args_list[2].args[0] == ["git", "diff", "--name-only", "abc123", "--"] + _SAFE_GIT_CONFIG = ("-c", "core.fsmonitor=false") + assert mock_run.call_args_list[1].args[0] == [ + "git", + *_SAFE_GIT_CONFIG, + "rev-parse", + "--verify", + "--end-of-options", + "HEAD~1", + ] + assert mock_run.call_args_list[2].args[0] == ["git", *_SAFE_GIT_CONFIG, "diff", "--name-only", "abc123", "--"] @patch("subprocess.run")