sentinel-ci 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +235 -0
  4. data/bin/gh-workflow-scanner +1 -0
  5. data/bin/sentinel +57 -0
  6. data/lib/auto_fix.rb +485 -0
  7. data/lib/cli/bot.rb +53 -0
  8. data/lib/cli/fix.rb +50 -0
  9. data/lib/cli/scan.rb +145 -0
  10. data/lib/clone_client.rb +64 -0
  11. data/lib/finding.rb +27 -0
  12. data/lib/formatter/json.rb +18 -0
  13. data/lib/formatter/terminal.rb +47 -0
  14. data/lib/github_client.rb +98 -0
  15. data/lib/local_client.rb +33 -0
  16. data/lib/rule_engine.rb +39 -0
  17. data/lib/rules/allow_forks_artifact.rb +22 -0
  18. data/lib/rules/base.rb +33 -0
  19. data/lib/rules/build_publish_same_job.rb +39 -0
  20. data/lib/rules/credential_window.rb +43 -0
  21. data/lib/rules/curl_pipe_shell.rb +29 -0
  22. data/lib/rules/dangerous_triggers.rb +43 -0
  23. data/lib/rules/docker_build_arg_secrets.rb +30 -0
  24. data/lib/rules/git_config_global.rb +25 -0
  25. data/lib/rules/missing_env_protection.rb +37 -0
  26. data/lib/rules/missing_frozen_lockfile.rb +28 -0
  27. data/lib/rules/missing_permissions.rb +18 -0
  28. data/lib/rules/missing_persist_creds.rb +51 -0
  29. data/lib/rules/missing_timeouts.rb +25 -0
  30. data/lib/rules/overly_broad_triggers.rb +31 -0
  31. data/lib/rules/shell_injection_expr.rb +57 -0
  32. data/lib/rules/shell_injection_jq.rb +59 -0
  33. data/lib/rules/static_aws_credentials.rb +33 -0
  34. data/lib/rules/unpinned_actions.rb +35 -0
  35. data/lib/rules/unpinned_docker_image.rb +25 -0
  36. data/lib/rules/unscoped_app_token.rb +31 -0
  37. data/lib/scanner.rb +95 -0
  38. data/lib/sha_resolver.rb +60 -0
  39. data/lib/version.rb +3 -0
  40. data/lib/workflow.rb +100 -0
  41. metadata +84 -0
data/lib/auto_fix.rb ADDED
@@ -0,0 +1,485 @@
1
+ require_relative "sha_resolver"
2
+ require_relative "finding"
3
+
4
+ module AutoFix
5
+ FIXABLE_RULES = %w[
6
+ unpinned-actions
7
+ shell-injection-expr
8
+ missing-persist-credentials
9
+ ].freeze
10
+
11
+ # Context expression -> env var name mappings
12
+ ENV_VAR_NAMES = {
13
+ "github.event.pull_request.title" => "PR_TITLE",
14
+ "github.event.pull_request.body" => "PR_BODY",
15
+ "github.event.pull_request.head.ref" => "PR_HEAD_REF",
16
+ "github.event.pull_request.head.label" => "PR_HEAD_LABEL",
17
+ "github.event.issue.title" => "ISSUE_TITLE",
18
+ "github.event.issue.body" => "ISSUE_BODY",
19
+ "github.event.comment.body" => "COMMENT_BODY",
20
+ "github.event.review.body" => "REVIEW_BODY",
21
+ "github.event.discussion.title" => "DISCUSSION_TITLE",
22
+ "github.event.discussion.body" => "DISCUSSION_BODY",
23
+ "github.event.workflow_run.head_branch" => "WORKFLOW_HEAD_BRANCH",
24
+ "github.head_ref" => "HEAD_REF",
25
+ "github.actor" => "GH_ACTOR",
26
+ "github.triggering_actor" => "TRIGGERING_ACTOR",
27
+ }.freeze
28
+
29
+ DANGEROUS_EXPR_PATTERN = /\$\{\{\s*(#{ENV_VAR_NAMES.keys.map { |k| Regexp.escape(k) }.join('|')})\s*\}\}/
30
+
31
+ def self.can_fix?(finding)
32
+ FIXABLE_RULES.include?(finding.rule)
33
+ end
34
+
35
+ def self.apply(finding, raw_content, sha_resolver: nil)
36
+ lines = raw_content.gsub("\r\n", "\n").lines
37
+
38
+ case finding.rule
39
+ when "unpinned-actions"
40
+ fix_unpinned_action(lines, finding, sha_resolver: sha_resolver)
41
+ when "shell-injection-expr"
42
+ fix_shell_injection(lines, finding)
43
+ when "missing-persist-credentials"
44
+ fix_persist_credentials(lines, finding)
45
+ else
46
+ raw_content
47
+ end
48
+ end
49
+
50
+ # --- unpinned-actions ---
51
+
52
+ def self.fix_unpinned_action(lines, finding, sha_resolver: nil)
53
+ sha_resolver ||= ShaResolver.new
54
+
55
+ # Extract the uses string from the finding code
56
+ uses_string = extract_uses_string(finding.code)
57
+ return lines.join unless uses_string
58
+ return lines.join unless uses_string.include?("@")
59
+
60
+ owner_action, tag = uses_string.split("@", 2)
61
+ return lines.join if tag.nil? || tag.empty?
62
+
63
+ # Strip any existing inline comment from the tag
64
+ tag = tag.split("#").first.strip
65
+
66
+ sha = sha_resolver.resolve(owner_action, tag)
67
+ return lines.join unless sha
68
+
69
+ target_idx = finding.line - 1
70
+ return lines.join if target_idx < 0 || target_idx >= lines.length
71
+
72
+ pinned = "#{owner_action}@#{sha} # #{tag}"
73
+ lines[target_idx] = lines[target_idx].sub(uses_string) { pinned }
74
+
75
+ lines.join
76
+ end
77
+
78
+ # --- shell-injection-expr ---
79
+
80
+ def self.fix_shell_injection(lines, finding)
81
+ target_idx = finding.line - 1
82
+ return lines.join if target_idx < 0 || target_idx >= lines.length
83
+
84
+ # Collect all dangerous expressions on this line
85
+ line = lines[target_idx]
86
+ expressions = line.scan(DANGEROUS_EXPR_PATTERN).flatten.uniq
87
+
88
+ return lines.join if expressions.empty?
89
+
90
+ # Find the step's run: line by walking backwards
91
+ run_line_idx = find_run_line(lines, target_idx)
92
+ return lines.join unless run_line_idx
93
+
94
+ # Determine the step-level indentation (same as run:)
95
+ run_indent = lines[run_line_idx][/^(\s*)/, 1]
96
+
97
+ # Build env var mappings
98
+ env_mappings = {}
99
+ expressions.each do |expr|
100
+ var_name = ENV_VAR_NAMES[expr]
101
+ next unless var_name
102
+ env_mappings[var_name] = "${{ #{expr} }}"
103
+ end
104
+
105
+ return lines.join if env_mappings.empty?
106
+
107
+ # Check if there's already an env: block at the step level
108
+ existing_env_idx = find_step_env_block(lines, run_line_idx, run_indent)
109
+
110
+ if existing_env_idx
111
+ # Insert new env vars into the existing env: block
112
+ # Find the last entry in the env: block
113
+ insert_idx = find_env_block_end(lines, existing_env_idx, run_indent)
114
+ env_entry_indent = run_indent + " "
115
+
116
+ new_entries = env_mappings.map { |var, expr| "#{env_entry_indent}#{var}: #{expr}\n" }
117
+ new_entries.reverse.each do |entry|
118
+ lines.insert(insert_idx, entry)
119
+ end
120
+ # Adjust run_line_idx since entries were inserted before run:
121
+ if insert_idx <= run_line_idx
122
+ run_line_idx += new_entries.length
123
+ end
124
+ else
125
+ # Insert env: block as individual lines before the run: line
126
+ env_lines = ["#{run_indent}env:\n"]
127
+ env_mappings.each do |var, expr|
128
+ env_lines << "#{run_indent} #{var}: #{expr}\n"
129
+ end
130
+
131
+ env_lines.reverse.each { |el| lines.insert(run_line_idx, el) }
132
+ inserted_count = env_lines.length
133
+ # Adjust run_line_idx to point to the actual run: line after insertion
134
+ run_line_idx += inserted_count
135
+ end
136
+
137
+ # Replace ${{ context }} with $VAR in the run block lines
138
+ run_block_range = find_run_block_range(lines, run_line_idx)
139
+
140
+ run_block_range.each do |i|
141
+ env_mappings.each do |var, _expr|
142
+ context = ENV_VAR_NAMES.key(var)
143
+ next unless context
144
+ # Replace ${{ context }} with $VAR (for shell context)
145
+ replacement = "$#{var}"
146
+ lines[i] = lines[i].gsub(/\$\{\{\s*#{Regexp.escape(context)}\s*\}\}/) { replacement }
147
+ end
148
+ end
149
+
150
+ lines.join
151
+ end
152
+
153
+ # --- missing-persist-credentials ---
154
+
155
+ def self.fix_persist_credentials(lines, finding)
156
+ target_idx = finding.line - 1
157
+ return lines.join if target_idx < 0 || target_idx >= lines.length
158
+
159
+ # Verify this is a checkout uses: line
160
+ line = lines[target_idx]
161
+ return lines.join unless line =~ /uses:\s*actions\/checkout/
162
+
163
+ uses_indent = line[/^(\s*)/, 1]
164
+
165
+ # Look for an existing with: block below the uses: line
166
+ with_idx = nil
167
+ search_end = [target_idx + 10, lines.length - 1].min
168
+
169
+ (target_idx + 1..search_end).each do |i|
170
+ current = lines[i]
171
+ current_indent = current[/^(\s*)/, 1] || ""
172
+
173
+ # If we hit a line at the same or lesser indentation as uses: that's
174
+ # a new step key or a new step entirely, stop looking
175
+ if current.strip.length > 0
176
+ if current_indent.length <= uses_indent.length
177
+ break
178
+ end
179
+
180
+ if current =~ /^\s*with:\s*$/ || current =~ /^\s*with:\s+\S/
181
+ with_idx = i
182
+ break
183
+ end
184
+
185
+ # If we hit another step-level key (env:, name:, id:, if:, etc.)
186
+ # that's at the same indent as uses:+2 spaces, stop
187
+ if current =~ /^\s*(env|name|id|if|uses|with|continue-on-error|timeout-minutes|run|working-directory|shell):/
188
+ break
189
+ end
190
+ end
191
+ end
192
+
193
+ if with_idx
194
+ # with: block exists, add persist-credentials: false to it
195
+ with_indent = lines[with_idx][/^(\s*)/, 1]
196
+
197
+ # Detect entry indent from first existing entry under with:
198
+ entry_indent = nil
199
+ (with_idx + 1..[with_idx + 10, lines.length - 1].min).each do |i|
200
+ if lines[i].strip.length > 0
201
+ candidate_indent = lines[i][/^(\s*)/, 1] || ""
202
+ if candidate_indent.length > with_indent.length
203
+ entry_indent = candidate_indent
204
+ end
205
+ break
206
+ end
207
+ end
208
+ entry_indent ||= with_indent + " "
209
+
210
+ # Check if persist-credentials is already there (shouldn't be since
211
+ # the rule flagged it, but be safe)
212
+ has_persist = false
213
+ (with_idx + 1..search_end).each do |i|
214
+ break if lines[i].strip.length > 0 && (lines[i][/^(\s*)/, 1] || "").length <= with_indent.length
215
+ has_persist = true if lines[i] =~ /persist-credentials:/
216
+ end
217
+
218
+ unless has_persist
219
+ # Find the right place to insert (right after with:)
220
+ insert_at = with_idx + 1
221
+ lines.insert(insert_at, "#{entry_indent}persist-credentials: false\n")
222
+ end
223
+ else
224
+ # No with: block — add one at same indent as uses:, entry one level deeper
225
+ entry_indent = uses_indent + " "
226
+
227
+ new_block = "#{uses_indent}with:\n#{entry_indent}persist-credentials: false\n"
228
+ lines.insert(target_idx + 1, new_block)
229
+ end
230
+
231
+ lines.join
232
+ end
233
+
234
+ # --- Private helpers ---
235
+
236
+ def self.extract_uses_string(code)
237
+ return nil unless code
238
+ match = code.match(/uses:\s*(.+)/)
239
+ return nil unless match
240
+ match[1].strip
241
+ end
242
+
243
+ def self.find_run_line(lines, from_idx)
244
+ from_idx.downto([from_idx - 20, 0].max) do |i|
245
+ return i if lines[i] =~ /^\s+run:\s*[\|>]?\s*$/ || lines[i] =~ /^\s+run:\s+\S/
246
+ end
247
+ nil
248
+ end
249
+
250
+ def self.find_step_env_block(lines, run_line_idx, run_indent)
251
+ # Walk backwards from run: to find if there's an env: at the same indent
252
+ # within this step (stop at step boundary: "- name:", "- uses:", etc.)
253
+ (run_line_idx - 1).downto([run_line_idx - 15, 0].max) do |i|
254
+ line = lines[i]
255
+ line_indent = line[/^(\s*)/, 1] || ""
256
+
257
+ # Step boundary
258
+ return nil if line =~ /^\s*-\s+(name|uses|run|id|if):/
259
+ return nil if line_indent.length < run_indent.length && line.strip.length > 0
260
+
261
+ if line =~ /^#{Regexp.escape(run_indent)}env:\s*$/ || line =~ /^#{Regexp.escape(run_indent)}env:\s+\S/
262
+ return i
263
+ end
264
+ end
265
+ nil
266
+ end
267
+
268
+ def self.find_env_block_end(lines, env_idx, run_indent)
269
+ # Find the line after the last entry in the env: block
270
+ env_entry_indent = run_indent + " "
271
+ i = env_idx + 1
272
+ while i < lines.length
273
+ line = lines[i]
274
+ line_indent = line[/^(\s*)/, 1] || ""
275
+ break if line.strip.length > 0 && line_indent.length <= run_indent.length
276
+ i += 1
277
+ end
278
+ i
279
+ end
280
+
281
+ def self.find_run_block_range(lines, run_line_idx)
282
+ range = []
283
+ run_indent = lines[run_line_idx][/^(\s*)/, 1]
284
+
285
+ if lines[run_line_idx] =~ /^\s+run:\s*[|>]\s*$/
286
+ # Multi-line run block — detect actual indent from first continuation line
287
+ next_line = lines[run_line_idx + 1]
288
+ if next_line && next_line.strip.length > 0
289
+ actual_indent = next_line[/^(\s*)/, 1]
290
+ content_indent_length = actual_indent.length
291
+ else
292
+ content_indent_length = run_indent.length + 2
293
+ end
294
+ i = run_line_idx + 1
295
+ while i < lines.length
296
+ line = lines[i]
297
+ if line.strip.empty?
298
+ range << i
299
+ i += 1
300
+ next
301
+ end
302
+ line_indent = line[/^(\s*)/, 1] || ""
303
+ break if line_indent.length < content_indent_length
304
+ range << i
305
+ i += 1
306
+ end
307
+ elsif lines[run_line_idx] =~ /^\s+run:\s+\S/
308
+ # Single-line run: — only this line
309
+ range << run_line_idx
310
+ end
311
+
312
+ range
313
+ end
314
+
315
+ private_class_method :extract_uses_string, :find_run_line,
316
+ :find_step_env_block, :find_env_block_end,
317
+ :find_run_block_range
318
+ end
319
+
320
+ if __FILE__ == $0
321
+ # Simple self-test
322
+
323
+ sample_workflow = [
324
+ "name: CI\n", # 1
325
+ "on: [push]\n", # 2
326
+ "jobs:\n", # 3
327
+ " build:\n", # 4
328
+ " runs-on: ubuntu-latest\n", # 5
329
+ " steps:\n", # 6
330
+ " - uses: actions/checkout@v4\n", # 7
331
+ " - uses: actions/setup-node@v4\n", # 8
332
+ " with:\n", # 9
333
+ " node-version: 18\n", # 10
334
+ " - name: Greet\n", # 11
335
+ " run: |\n", # 12
336
+ " echo \"PR: ${{ github.event.pull_request.title }}\"\n", # 13
337
+ ].join
338
+
339
+ puts "=== Auto-Fix Self-Test ==="
340
+ puts
341
+
342
+ # Test 1: can_fix? detection
343
+ pinnable = Finding.new(
344
+ rule: "unpinned-actions",
345
+ severity: :medium,
346
+ file: "ci.yml",
347
+ line: 7,
348
+ code: "uses: actions/checkout@v4",
349
+ message: "Action not SHA-pinned",
350
+ fix: "Pin to SHA"
351
+ )
352
+
353
+ unfixable = Finding.new(
354
+ rule: "missing-permissions",
355
+ severity: :medium,
356
+ file: "ci.yml",
357
+ line: 1,
358
+ code: "on: [push]",
359
+ message: "No permissions block",
360
+ fix: "Add permissions"
361
+ )
362
+
363
+ puts "can_fix?(unpinned-actions): #{AutoFix.can_fix?(pinnable)}"
364
+ puts "can_fix?(missing-permissions): #{AutoFix.can_fix?(unfixable)}"
365
+ puts
366
+
367
+ # Test 2: SHA pinning (with a mock resolver)
368
+ class MockShaResolver
369
+ def resolve(_owner_action, _tag)
370
+ "b4ffde65f46336ab88eb53be808477a3936bae11"
371
+ end
372
+ end
373
+
374
+ result = AutoFix.apply(pinnable, sample_workflow, sha_resolver: MockShaResolver.new)
375
+ has_sha = result.include?("b4ffde65f46336ab88eb53be808477a3936bae11")
376
+ has_comment = result.include?("# v4")
377
+ puts "SHA pin applied: #{has_sha}"
378
+ puts "Tag comment preserved: #{has_comment}"
379
+ puts
380
+
381
+ # Test 3: persist-credentials fix (checkout without a with: block)
382
+ persist_finding = Finding.new(
383
+ rule: "missing-persist-credentials",
384
+ severity: :high,
385
+ file: "ci.yml",
386
+ line: 7,
387
+ code: "uses: actions/checkout@v4",
388
+ message: "Missing persist-credentials: false",
389
+ fix: "Add persist-credentials: false"
390
+ )
391
+
392
+ result2 = AutoFix.apply(persist_finding, sample_workflow)
393
+ has_persist = result2.include?("persist-credentials: false")
394
+ puts "persist-credentials added: #{has_persist}"
395
+ puts
396
+
397
+ # Test 4: shell injection fix
398
+ injection_finding = Finding.new(
399
+ rule: "shell-injection-expr",
400
+ severity: :critical,
401
+ file: "ci.yml",
402
+ line: 13,
403
+ code: 'echo "PR: ${{ github.event.pull_request.title }}"',
404
+ message: "Shell injection risk",
405
+ fix: "Move to env block"
406
+ )
407
+
408
+ result3 = AutoFix.apply(injection_finding, sample_workflow)
409
+ has_env_block = result3.include?("env:")
410
+ has_pr_title_var = result3.include?("PR_TITLE:")
411
+ has_dollar_var = result3.include?("$PR_TITLE")
412
+ # The expression should still be in the env: mapping, but NOT in the run block
413
+ run_block_clean = result3.include?('echo "PR: $PR_TITLE"')
414
+ puts "env: block added: #{has_env_block}"
415
+ puts "PR_TITLE mapping: #{has_pr_title_var}"
416
+ puts "$PR_TITLE substitution: #{has_dollar_var}"
417
+ puts "Run block uses env var: #{run_block_clean}"
418
+ puts
419
+
420
+ # Test 5: persist-credentials with existing with: block
421
+ sample_with_existing = [
422
+ "name: CI\n",
423
+ "on: [push]\n",
424
+ "jobs:\n",
425
+ " build:\n",
426
+ " runs-on: ubuntu-latest\n",
427
+ " steps:\n",
428
+ " - uses: actions/checkout@v4\n",
429
+ " with:\n",
430
+ " ref: main\n",
431
+ ].join
432
+
433
+ persist_with_existing = Finding.new(
434
+ rule: "missing-persist-credentials",
435
+ severity: :high,
436
+ file: "ci.yml",
437
+ line: 7,
438
+ code: "uses: actions/checkout@v4",
439
+ message: "Missing persist-credentials: false",
440
+ fix: "Add persist-credentials: false"
441
+ )
442
+
443
+ result4 = AutoFix.apply(persist_with_existing, sample_with_existing)
444
+ has_persist_existing = result4.include?("persist-credentials: false")
445
+ still_has_ref = result4.include?("ref: main")
446
+ puts "persist-credentials added to existing with: #{has_persist_existing}"
447
+ puts "Existing with: entries preserved: #{still_has_ref}"
448
+ puts
449
+
450
+ # Test 6: subpath action pinning (actions/cache/restore@v4)
451
+ sample_subpath = [
452
+ "name: CI\n",
453
+ "on: [push]\n",
454
+ "jobs:\n",
455
+ " build:\n",
456
+ " runs-on: ubuntu-latest\n",
457
+ " steps:\n",
458
+ " - uses: actions/cache/restore@v4\n",
459
+ ].join
460
+
461
+ subpath_finding = Finding.new(
462
+ rule: "unpinned-actions",
463
+ severity: :medium,
464
+ file: "ci.yml",
465
+ line: 7,
466
+ code: "uses: actions/cache/restore@v4",
467
+ message: "Action not SHA-pinned",
468
+ fix: "Pin to SHA"
469
+ )
470
+
471
+ result5 = AutoFix.apply(subpath_finding, sample_subpath, sha_resolver: MockShaResolver.new)
472
+ has_subpath_sha = result5.include?("actions/cache/restore@b4ffde65f46336ab88eb53be808477a3936bae11")
473
+ has_subpath_comment = result5.include?("# v4")
474
+ puts "Subpath action SHA pin: #{has_subpath_sha}"
475
+ puts "Subpath tag comment: #{has_subpath_comment}"
476
+ puts
477
+
478
+ # Summary
479
+ all_pass = has_sha && has_comment && has_persist && has_env_block &&
480
+ has_pr_title_var && has_dollar_var && run_block_clean &&
481
+ has_persist_existing && still_has_ref &&
482
+ has_subpath_sha && has_subpath_comment
483
+ puts all_pass ? "ALL TESTS PASSED" : "SOME TESTS FAILED"
484
+ exit(all_pass ? 0 : 1)
485
+ end
data/lib/cli/bot.rb ADDED
@@ -0,0 +1,53 @@
1
+ require "optparse"
2
+ require_relative "../../bot/scanner_bot"
3
+
4
+ options = {
5
+ pattern: "rotate",
6
+ dry_run: false,
7
+ }
8
+
9
+ parser = OptionParser.new do |opts|
10
+ opts.banner = "Usage: sentinel bot [options]"
11
+ opts.separator ""
12
+ opts.separator "Run the Sentinel PR bot to scan and file issues."
13
+ opts.separator ""
14
+
15
+ opts.on("--pattern PATTERN", "Search pattern (default: rotate)") do |p|
16
+ options[:pattern] = p
17
+ end
18
+
19
+ opts.on("--dry-run", "Show what would be done without making changes") do
20
+ options[:dry_run] = true
21
+ end
22
+
23
+ opts.on("--token TOKEN", "GitHub API token (default: GITHUB_TOKEN env var)") do |t|
24
+ options[:token] = t
25
+ end
26
+
27
+ opts.on("-h", "--help", "Show this help message") do
28
+ puts opts
29
+ exit 0
30
+ end
31
+ end
32
+
33
+ begin
34
+ parser.parse!
35
+ rescue OptionParser::InvalidArgument, OptionParser::InvalidOption => e
36
+ $stderr.puts e.message
37
+ $stderr.puts parser
38
+ exit 2
39
+ end
40
+
41
+ token = options[:token] || ENV["GITHUB_TOKEN"]
42
+ unless token
43
+ $stderr.puts "Error: GitHub token required. Set GITHUB_TOKEN or use --token."
44
+ exit 2
45
+ end
46
+
47
+ bot = Bot::ScannerBot.new(
48
+ token: token,
49
+ pattern: options[:pattern],
50
+ dry_run: options[:dry_run],
51
+ )
52
+
53
+ bot.run
data/lib/cli/fix.rb ADDED
@@ -0,0 +1,50 @@
1
+ require "optparse"
2
+
3
+ options = {}
4
+
5
+ parser = OptionParser.new do |opts|
6
+ opts.banner = "Usage: sentinel fix [options] [REPO]"
7
+ opts.separator ""
8
+ opts.separator "Auto-fix security findings in GitHub Actions workflows."
9
+ opts.separator ""
10
+
11
+ opts.on("--format FORMAT", %w[terminal json], "Output format: terminal (default) or json") do |f|
12
+ options[:format] = f
13
+ end
14
+
15
+ opts.on("--severity LEVEL", %i[critical high medium low],
16
+ "Minimum severity: critical, high, medium, low (default: low)") do |s|
17
+ options[:severity] = s
18
+ end
19
+
20
+ opts.on("--local PATH", "Scan a local directory instead of GitHub API") do |p|
21
+ options[:local] = p
22
+ end
23
+
24
+ opts.on("--token TOKEN", "GitHub API token (default: GITHUB_TOKEN env var)") do |t|
25
+ options[:token] = t
26
+ end
27
+
28
+ opts.on("-h", "--help", "Show this help message") do
29
+ puts opts
30
+ exit 0
31
+ end
32
+ end
33
+
34
+ begin
35
+ parser.parse!
36
+ rescue OptionParser::InvalidArgument, OptionParser::InvalidOption => e
37
+ $stderr.puts e.message
38
+ $stderr.puts parser
39
+ exit 2
40
+ end
41
+
42
+ $stderr.puts "Auto-fix coming soon."
43
+ $stderr.puts ""
44
+ $stderr.puts "For now, use the Ruby API directly:"
45
+ $stderr.puts ""
46
+ $stderr.puts " require 'sentinel-ci'"
47
+ $stderr.puts " AutoFix.apply(path: '.github/workflows')"
48
+ $stderr.puts ""
49
+
50
+ exit 0