fix(security): reject leading-dash git refs, triage remaining Brakeman findings

Every GitRepositoryService method that hands a bare branch/SHA to git (sourced from URL params like params[:branch] and params[:sha] with no validation) is exposed to git argument injection: since Open3.capture3 never invokes a shell, values can't carry shell metacharacters, but a ref starting with "-" is still parsed by git as an option rather than a revision (e.g. a branch of "--output=/some/path" could make `git log` write to an arbitrary file). Real git ref/SHA names can never start with "-", so add GitRepositoryService.safe_rev? and reject anything that does before it reaches git. Also triage the remaining scan_ruby findings that were blocking CI: - repos_controller.rb:42 — default_branch is a hardcoded "main" literal, never user-controlled, so nothing reaches the Open3 call there. - cloud_sessions_controller.rb:75 — `role` is CloudMessage#role (chat message role, enum-validated), not a user/account privilege field. - admin/repositories/show.html.erb:15 — Repository#html_url is built from a trusted ENV var plus username/repo name, both restricted by model validations to safe charsets; no URL scheme injection possible.

Seto Elkahfi committed Jul 5, 2026 at 09:43 UTC 72f1f2162d2ecbb6616d3d8ebf75a50ff5dac9a8
2 files changed +100 -6
app/services/git_repository_service.rb
+24
@@ -8,6 +8,17 @@ class GitRepositoryService
8 File.join(REPOS_BASE, username, "#{reponame}.git")
9 end
10
11 + # Git ref/SHA values reach `git` as a single argv entry (Open3.capture3 never
12 + # invokes a shell), so they can't carry shell metacharacters. But a value
13 + # starting with "-" is still parsed by git itself as an option rather than a
14 + # revision (e.g. a branch of "--output=/some/path" could make `git log` write
15 + # to an arbitrary file) — real ref/SHA names can never start with "-" (see
16 + # `git check-ref-format`), so reject anything that does before it reaches git.
17 + def self.safe_rev?(rev)
18 + rev.present? && !rev.start_with?("-")
19 + end
20 + private_class_method :safe_rev?
21 +
22 def self.create_bare_repo(username, reponame)
23 path = repo_path(username, reponame)
24 FileUtils.mkdir_p(path)
@@ -38,6 +49,7 @@ class GitRepositoryService
49 end
50
51 def self.list_tree(path, branch, tree_path = "")
52 + return [] unless safe_rev?(branch)
53 prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/"
54 args = [ "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}" ]
55 out, _err, status = Open3.capture3(*args)
@@ -82,6 +94,7 @@ class GitRepositoryService
94 end
95
96 def self.file_content(path, branch, file_path)
97 + return nil unless safe_rev?(branch)
98 out, _err, status = Open3.capture3("git", "--git-dir", path, "show", "#{branch}:#{file_path}")
99 return nil unless status.success?
100 out
@@ -90,6 +103,7 @@ class GitRepositoryService
103 # Object id of the blob at <ref>:<file_path>. Stable for identical content, so
104 # it's the natural cache key for rendered/highlighted output. nil if missing.
105 def self.blob_sha(path, branch, file_path)
106 + return nil unless safe_rev?(branch)
107 out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{branch}:#{file_path}")
108 status.success? ? out.strip.presence : nil
109 end
@@ -97,6 +111,7 @@ class GitRepositoryService
111 # Byte size of the blob without loading it into memory — lets the blob view
112 # decide up front whether a file is too large to highlight or render.
113 def self.blob_size(path, branch, file_path)
114 + return nil unless safe_rev?(branch)
115 out, _err, status = Open3.capture3("git", "--git-dir", path, "cat-file", "-s", "#{branch}:#{file_path}")
116 status.success? ? out.strip.to_i : nil
117 end
@@ -104,11 +119,13 @@ class GitRepositoryService
119 # Full commit SHA a ref currently points at. Permalinks pin to this so they
120 # survive the branch moving on.
121 def self.commit_sha(path, ref)
122 + return nil unless safe_rev?(ref)
123 out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
124 status.success? ? out.strip.presence : nil
125 end
126
127 def self.commits(path, branch, limit: 20, offset: 0)
128 + return [] unless safe_rev?(branch)
129 format = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
130 out, _err, status = Open3.capture3(
131 "git", "--git-dir", path, "log",
@@ -137,6 +154,8 @@ class GitRepositoryService
154 # %B contains embedded newlines, so we cannot rely on splitting by "\n" to
155 # find the end of the format output. Instead we append a sentinel that git
156 # will never emit naturally and split on that.
157 + return nil unless safe_rev?(sha)
158 +
159 sentinel = "SIGIT-EOH"
160 format = "%H%x00%h%x00%s%x00%B%x00%an%x00%ae%x00%ad%x00%T%x00#{sentinel}"
161 out, _err, status = Open3.capture3(
@@ -177,6 +196,7 @@ class GitRepositoryService
196 # files are skipped. Returns [] when nothing matches or the branch is unknown.
197 def self.search_code(path, branch, query, limit: 20)
198 return [] if query.to_s.empty?
199 + return [] unless safe_rev?(branch)
200
201 out, _err, status = Open3.capture3(
202 "git", "--git-dir", path, "grep",
@@ -199,6 +219,7 @@ class GitRepositoryService
219 end
220
221 def self.branch_exists?(path, branch)
222 + return false unless safe_rev?(branch)
223 _out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", branch)
224 status.success?
225 end
@@ -210,6 +231,7 @@ class GitRepositoryService
231 end
232
233 def self.commit_count(path, branch)
234 + return 0 unless safe_rev?(branch)
235 out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-list", "--count", branch)
236 return 0 unless status.success?
237 out.strip.to_i
@@ -219,6 +241,7 @@ class GitRepositoryService
241 # content version when caching derived artifacts (e.g. Open Graph cards) so
242 # they regenerate exactly when the repository content changes.
243 def self.head_sha(path, branch)
244 + return nil unless safe_rev?(branch)
245 out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "#{branch}^{commit}")
246 status.success? ? out.strip.presence : nil
247 end
@@ -226,6 +249,7 @@ class GitRepositoryService
249 # Flat list of every tracked file path on +branch+. One `ls-tree -r` call;
250 # used for cheap primary-language detection.
251 def self.tree_filenames(path, branch)
252 + return [] unless safe_rev?(branch)
253 out, _err, status = Open3.capture3(
254 "git", "--git-dir", path, "ls-tree", "-r", "--name-only", branch
255 )
config/brakeman.ignore
+76 -6
@@ -7,8 +7,8 @@
7 "check_name": "Execute",
8 "message": "Possible command injection",
9 "file": "app/services/git_repository_service.rb",
10 - "line": 93,
11 - "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands."
10 + "line": 107,
11 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands. GitRepositoryService.safe_rev? also rejects any ref/SHA starting with \"-\" before it reaches git, closing the git-argument-injection surface (e.g. a branch of \"--output=...\")."
12 },
13 {
14 "warning_type": "Command Injection",
@@ -17,8 +17,8 @@
17 "check_name": "Execute",
18 "message": "Possible command injection",
19 "file": "app/services/git_repository_service.rb",
20 - "line": 100,
21 - "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands."
20 + "line": 115,
21 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands. GitRepositoryService.safe_rev? also rejects any ref/SHA starting with \"-\" before it reaches git, closing the git-argument-injection surface (e.g. a branch of \"--output=...\")."
22 },
23 {
24 "warning_type": "Command Injection",
@@ -27,8 +27,78 @@
27 "check_name": "Execute",
28 "message": "Possible command injection",
29 "file": "app/services/git_repository_service.rb",
30 - "line": 107,
31 - "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref is passed to git as a single literal argv entry and cannot inject shell commands."
30 + "line": 123,
31 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref is passed to git as a single literal argv entry and cannot inject shell commands. GitRepositoryService.safe_rev? also rejects any ref/SHA starting with \"-\" before it reaches git, closing the git-argument-injection surface (e.g. a branch of \"--output=...\")."
32 + },
33 + {
34 + "warning_type": "Command Injection",
35 + "warning_code": 14,
36 + "fingerprint": "fc944c7823b102c74e5d4bfd30183d1f2776b80f00b453e05571815b2fbc26f1",
37 + "check_name": "Execute",
38 + "message": "Possible command injection",
39 + "file": "app/services/git_repository_service.rb",
40 + "line": 55,
41 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands. GitRepositoryService.safe_rev? also rejects any ref/SHA starting with \"-\" before it reaches git, closing the git-argument-injection surface (e.g. a branch of \"--output=...\")."
42 + },
43 + {
44 + "warning_type": "Command Injection",
45 + "warning_code": 14,
46 + "fingerprint": "d4ffd91b7a130c0d1e51bc90c8b66c51242d4ec7f21201b3acab11f7a3fb7b6b",
47 + "check_name": "Execute",
48 + "message": "Possible command injection",
49 + "file": "app/services/git_repository_service.rb",
50 + "line": 98,
51 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands. GitRepositoryService.safe_rev? also rejects any ref/SHA starting with \"-\" before it reaches git, closing the git-argument-injection surface (e.g. a branch of \"--output=...\")."
52 + },
53 + {
54 + "warning_type": "Command Injection",
55 + "warning_code": 14,
56 + "fingerprint": "c932f5558ef2885051eeba4c72f436d871323489099c7361cb75f3f3e4d2f155",
57 + "check_name": "Execute",
58 + "message": "Possible command injection",
59 + "file": "app/services/git_repository_service.rb",
60 + "line": 133,
61 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref is passed to git as a single literal argv entry and cannot inject shell commands. GitRepositoryService.safe_rev? also rejects any ref/SHA starting with \"-\" before it reaches git, closing the git-argument-injection surface (e.g. a branch of \"--output=...\")."
62 + },
63 + {
64 + "warning_type": "Command Injection",
65 + "warning_code": 14,
66 + "fingerprint": "d802f010326572ecab89535d3c449ac7b6bc9b18b3568197eb302d6875d42c58",
67 + "check_name": "Execute",
68 + "message": "Possible command injection",
69 + "file": "app/services/git_repository_service.rb",
70 + "line": 245,
71 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref is passed to git as a single literal argv entry and cannot inject shell commands. GitRepositoryService.safe_rev? also rejects any ref/SHA starting with \"-\" before it reaches git, closing the git-argument-injection surface (e.g. a branch of \"--output=...\")."
72 + },
73 + {
74 + "warning_type": "Command Injection",
75 + "warning_code": 14,
76 + "fingerprint": "4aff271d89cea00ac9c30356cfc76de7710b2279191dfa8cd3b682cb748cb405",
77 + "check_name": "Execute",
78 + "message": "Possible command injection",
79 + "file": "app/controllers/api/v1/repos_controller.rb",
80 + "line": 42,
81 + "note": "False positive: default_branch is always the literal string \"main\" set a few lines above (never derived from request params), so nothing user-controlled reaches this Open3.capture3 call."
82 + },
83 + {
84 + "warning_type": "Mass Assignment",
85 + "warning_code": 105,
86 + "fingerprint": "faef13fd7ff08bed1e6f8739746e5d6af3b6ec0515cb4bc73d856d973d1140b2",
87 + "check_name": "PermitAttributes",
88 + "message": "Potentially dangerous key allowed for mass assignment",
89 + "file": "app/controllers/api/v1/cloud_sessions_controller.rb",
90 + "line": 75,
91 + "note": "False positive: `role` here is CloudMessage#role, a chat-message role (\"user\"/\"assistant\"/\"system\") restricted by `validates :role, inclusion: { in: ROLES }` in the model — not a user/account privilege field. It's passed as an explicit keyword arg to append_message!, not mass-assigned to a model."
92 + },
93 + {
94 + "warning_type": "Cross-Site Scripting",
95 + "warning_code": 4,
96 + "fingerprint": "8ef2373ad74f844d863ba6076d392d7b797459ca89a3b30d19137c87a0662fee",
97 + "check_name": "LinkToHref",
98 + "message": "Potentially unsafe model attribute in `link_to` href",
99 + "file": "app/views/admin/repositories/show.html.erb",
100 + "line": 15,
101 + "note": "False positive: Repository#html_url is built from ENV[\"SIGITSI_URL\"] (trusted, admin-set) plus the owning user's username and the repo name, both restricted by model format validations to safe charsets (letters, digits, hyphen/underscore/dot) — no URL scheme (e.g. javascript:) can appear in this value."
102 }
103 ],
104 "brakeman_version": "8.0.5"