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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +235 -0
- data/bin/gh-workflow-scanner +1 -0
- data/bin/sentinel +57 -0
- data/lib/auto_fix.rb +485 -0
- data/lib/cli/bot.rb +53 -0
- data/lib/cli/fix.rb +50 -0
- data/lib/cli/scan.rb +145 -0
- data/lib/clone_client.rb +64 -0
- data/lib/finding.rb +27 -0
- data/lib/formatter/json.rb +18 -0
- data/lib/formatter/terminal.rb +47 -0
- data/lib/github_client.rb +98 -0
- data/lib/local_client.rb +33 -0
- data/lib/rule_engine.rb +39 -0
- data/lib/rules/allow_forks_artifact.rb +22 -0
- data/lib/rules/base.rb +33 -0
- data/lib/rules/build_publish_same_job.rb +39 -0
- data/lib/rules/credential_window.rb +43 -0
- data/lib/rules/curl_pipe_shell.rb +29 -0
- data/lib/rules/dangerous_triggers.rb +43 -0
- data/lib/rules/docker_build_arg_secrets.rb +30 -0
- data/lib/rules/git_config_global.rb +25 -0
- data/lib/rules/missing_env_protection.rb +37 -0
- data/lib/rules/missing_frozen_lockfile.rb +28 -0
- data/lib/rules/missing_permissions.rb +18 -0
- data/lib/rules/missing_persist_creds.rb +51 -0
- data/lib/rules/missing_timeouts.rb +25 -0
- data/lib/rules/overly_broad_triggers.rb +31 -0
- data/lib/rules/shell_injection_expr.rb +57 -0
- data/lib/rules/shell_injection_jq.rb +59 -0
- data/lib/rules/static_aws_credentials.rb +33 -0
- data/lib/rules/unpinned_actions.rb +35 -0
- data/lib/rules/unpinned_docker_image.rb +25 -0
- data/lib/rules/unscoped_app_token.rb +31 -0
- data/lib/scanner.rb +95 -0
- data/lib/sha_resolver.rb +60 -0
- data/lib/version.rb +3 -0
- data/lib/workflow.rb +100 -0
- 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
|