Module: AutoFix

Defined in:
lib/auto_fix.rb

Constant Summary collapse

FIXABLE_RULES =
%w[
    unpinned-actions
    shell-injection-expr
    missing-persist-credentials
    workflow-dispatch-injection
    missing-permissions
    missing-timeouts
].freeze
ENV_VAR_NAMES =

Context expression -> env var name mappings

{
    "github.event.pull_request.title"      => "PR_TITLE",
    "github.event.pull_request.body"       => "PR_BODY",
    "github.event.pull_request.head.ref"   => "PR_HEAD_REF",
    "github.event.pull_request.head.label" => "PR_HEAD_LABEL",
    "github.event.issue.title"             => "ISSUE_TITLE",
    "github.event.issue.body"              => "ISSUE_BODY",
    "github.event.comment.body"            => "COMMENT_BODY",
    "github.event.review.body"             => "REVIEW_BODY",
    "github.event.discussion.title"        => "DISCUSSION_TITLE",
    "github.event.discussion.body"         => "DISCUSSION_BODY",
    "github.event.workflow_run.head_branch" => "WORKFLOW_HEAD_BRANCH",
    "github.head_ref"                      => "HEAD_REF",
    "github.actor"                         => "GH_ACTOR",
    "github.triggering_actor"              => "TRIGGERING_ACTOR",
}.freeze
DISPATCH_INPUT_PATTERN =

Workflow dispatch input expressions

/\$\{\{\s*(inputs\.[a-zA-Z0-9_.-]+|github\.event\.inputs\.[a-zA-Z0-9_.-]+)\s*\}\}/
DANGEROUS_EXPR_PATTERN =
/\$\{\{\s*(#{ENV_VAR_NAMES.keys.map { |k| Regexp.escape(k) }.join('|')})\s*\}\}/

Class Method Summary collapse

Class Method Details

.apply(finding, raw_content, sha_resolver: nil) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/auto_fix.rb', line 41

def self.apply(finding, raw_content, sha_resolver: nil)
    lines = raw_content.gsub("\r\n", "\n").lines

    case finding.rule
    when "unpinned-actions"
        fix_unpinned_action(lines, finding, sha_resolver: sha_resolver)
    when "shell-injection-expr"
        fix_shell_injection(lines, finding)
    when "missing-persist-credentials"
        fix_persist_credentials(lines, finding)
    when "workflow-dispatch-injection"
        fix_dispatch_injection(lines, finding)
    when "missing-permissions"
        fix_missing_permissions(lines, finding)
    when "missing-timeouts"
        fix_missing_timeouts(lines, finding)
    else
        raw_content
    end
end

.can_fix?(finding) ⇒ Boolean

Returns:

  • (Boolean)


37
38
39
# File 'lib/auto_fix.rb', line 37

def self.can_fix?(finding)
    FIXABLE_RULES.include?(finding.rule)
end

.fix_dispatch_injection(lines, finding) ⇒ Object

— workflow-dispatch-injection —



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/auto_fix.rb', line 249

def self.fix_dispatch_injection(lines, finding)
    target_idx = finding.line - 1
    return lines.join if target_idx < 0 || target_idx >= lines.length

    # Collect all dispatch input expressions on this line
    line = lines[target_idx]
    expressions = line.scan(DISPATCH_INPUT_PATTERN).flatten.uniq

    return lines.join if expressions.empty?

    # Find the step's run: line by walking backwards
    run_line_idx = find_run_line(lines, target_idx)
    return lines.join unless run_line_idx

    # Determine the step-level indentation (same as run:)
    run_indent = lines[run_line_idx][/^(\s*)/, 1]

    # Build env var mappings from input expressions
    env_mappings = {}
    expressions.each do |expr|
        # inputs.foo -> INPUT_FOO
        # github.event.inputs.foo -> INPUT_FOO
        var_name = expr
            .sub(/^github\.event\.inputs\./, "")
            .sub(/^inputs\./, "")
            .upcase
            .gsub(/[^A-Z0-9]/, "_")
        var_name = "INPUT_#{var_name}"
        env_mappings[var_name] = "${{ #{expr} }}"
    end

    return lines.join if env_mappings.empty?

    # Check if there's already an env: block at the step level
    existing_env_idx = find_step_env_block(lines, run_line_idx, run_indent)

    if existing_env_idx
        insert_idx = find_env_block_end(lines, existing_env_idx, run_indent)
        env_entry_indent = run_indent + "    "

        new_entries = env_mappings.map { |var, expr| "#{env_entry_indent}#{var}: #{expr}\n" }
        new_entries.reverse.each do |entry|
            lines.insert(insert_idx, entry)
        end
        if insert_idx <= run_line_idx
            run_line_idx += new_entries.length
        end
    else
        env_lines = ["#{run_indent}env:\n"]
        env_mappings.each do |var, expr|
            env_lines << "#{run_indent}    #{var}: #{expr}\n"
        end

        env_lines.reverse.each { |el| lines.insert(run_line_idx, el) }
        inserted_count = env_lines.length
        run_line_idx += inserted_count
    end

    # Replace ${{ inputs.* }} and ${{ github.event.inputs.* }} with $VAR in the run block
    run_block_range = find_run_block_range(lines, run_line_idx)

    run_block_range.each do |i|
        env_mappings.each do |var, _expr_val|
            # Find the original expression that mapped to this var
            expressions.each do |expr|
                test_name = expr
                    .sub(/^github\.event\.inputs\./, "")
                    .sub(/^inputs\./, "")
                    .upcase
                    .gsub(/[^A-Z0-9]/, "_")
                next unless "INPUT_#{test_name}" == var
                replacement = "$#{var}"
                lines[i] = lines[i].gsub(/\$\{\{\s*#{Regexp.escape(expr)}\s*\}\}/) { replacement }
            end
        end
    end

    lines.join
end

.fix_missing_permissions(lines, finding) ⇒ Object

— missing-permissions —



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# File 'lib/auto_fix.rb', line 331

def self.fix_missing_permissions(lines, finding)
    # Find where to insert permissions block.
    # Insert after the on: trigger block ends (before the next top-level key).
    on_line_idx = nil
    lines.each_with_index do |line, i|
        if line =~ /^on\s*:/ || line =~ /^'on'\s*:/ || line =~ /^"on"\s*:/
            on_line_idx = i
            break
        end
        # YAML treats bare `on` as boolean true key
        if line =~ /^true\s*:/
            on_line_idx = i
            break
        end
    end

    return lines.join unless on_line_idx

    # Walk forward from on: to find where the on: block ends.
    # The on: block ends when we hit the next top-level key (no leading whitespace).
    insert_idx = on_line_idx + 1
    while insert_idx < lines.length
        line = lines[insert_idx]
        # Skip blank lines and indented/commented lines
        if line.strip.empty? || line =~ /^\s/ || line =~ /^#/
            insert_idx += 1
            next
        end
        # We've hit a top-level key (jobs:, env:, concurrency:, etc.)
        break
    end

    # Check if permissions already exists (defensive)
    lines.each do |line|
        return lines.join if line =~ /^permissions\s*:/
    end

    # Insert permissions block
    permissions_block = "permissions:\n  contents: read\n\n"
    lines.insert(insert_idx, permissions_block)

    lines.join
end

.fix_missing_timeouts(lines, finding) ⇒ Object

— missing-timeouts —



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/auto_fix.rb', line 377

def self.fix_missing_timeouts(lines, finding)
    target_idx = finding.line - 1
    return lines.join if target_idx < 0 || target_idx >= lines.length

    # The finding line should point to the job definition or its runs-on.
    # We need to find the runs-on: line for this job.
    # If the finding line IS the runs-on line, use it directly.
    # Otherwise, search forward from the finding line for runs-on:.
    runs_on_idx = nil

    if lines[target_idx] =~ /^\s+runs-on:/
        runs_on_idx = target_idx
    else
        # Search forward from finding line for runs-on:
        search_end = [target_idx + 20, lines.length - 1].min
        (target_idx..search_end).each do |i|
            if lines[i] =~ /^\s+runs-on:/
                runs_on_idx = i
                break
            end
        end
    end

    return lines.join unless runs_on_idx

    # Get the indentation of runs-on:
    indent = lines[runs_on_idx][/^(\s*)/, 1]

    # Check if timeout-minutes already exists at this job level (defensive)
    # Walk forward from runs-on checking for timeout-minutes at same indent
    check_idx = runs_on_idx + 1
    while check_idx < lines.length
        check_line = lines[check_idx]
        check_indent = check_line[/^(\s*)/, 1] || ""
        # Stop if we leave the job block (less indentation and non-blank)
        break if check_line.strip.length > 0 && check_indent.length < indent.length
        if check_line =~ /^\s*timeout-minutes:/ && check_indent == indent
            return lines.join  # Already has timeout
        end
        check_idx += 1
    end

    # Insert timeout-minutes right after runs-on:
    timeout_line = "#{indent}timeout-minutes: 30\n"
    lines.insert(runs_on_idx + 1, timeout_line)

    lines.join
end

.fix_persist_credentials(lines, finding) ⇒ Object

— missing-persist-credentials —



167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/auto_fix.rb', line 167

def self.fix_persist_credentials(lines, finding)
    target_idx = finding.line - 1
    return lines.join if target_idx < 0 || target_idx >= lines.length

    # Verify this is a checkout uses: line
    line = lines[target_idx]
    return lines.join unless line =~ /uses:\s*actions\/checkout/

    uses_indent = line[/^(\s*)/, 1]

    # Look for an existing with: block below the uses: line
    with_idx = nil
    search_end = [target_idx + 10, lines.length - 1].min

    (target_idx + 1..search_end).each do |i|
        current = lines[i]
        current_indent = current[/^(\s*)/, 1] || ""

        # If we hit a line at the same or lesser indentation as uses: that's
        # a new step key or a new step entirely, stop looking
        if current.strip.length > 0
            if current_indent.length <= uses_indent.length
                break
            end

            if current =~ /^\s*with:\s*$/  || current =~ /^\s*with:\s+\S/
                with_idx = i
                break
            end

            # If we hit another step-level key (env:, name:, id:, if:, etc.)
            # that's at the same indent as uses:+2 spaces, stop
            if current =~ /^\s*(env|name|id|if|uses|with|continue-on-error|timeout-minutes|run|working-directory|shell):/
                break
            end
        end
    end

    if with_idx
        # with: block exists, add persist-credentials: false to it
        with_indent = lines[with_idx][/^(\s*)/, 1]

        # Detect entry indent from first existing entry under with:
        entry_indent = nil
        (with_idx + 1..[with_idx + 10, lines.length - 1].min).each do |i|
            if lines[i].strip.length > 0
                candidate_indent = lines[i][/^(\s*)/, 1] || ""
                if candidate_indent.length > with_indent.length
                    entry_indent = candidate_indent
                end
                break
            end
        end
        entry_indent ||= with_indent + "  "

        # Check if persist-credentials is already there (shouldn't be since
        # the rule flagged it, but be safe)
        has_persist = false
        (with_idx + 1..search_end).each do |i|
            break if lines[i].strip.length > 0 && (lines[i][/^(\s*)/, 1] || "").length <= with_indent.length
            has_persist = true if lines[i] =~ /persist-credentials:/
        end

        unless has_persist
            # Find the right place to insert (right after with:)
            insert_at = with_idx + 1
            lines.insert(insert_at, "#{entry_indent}persist-credentials: false\n")
        end
    else
        # No with: block — add one at same indent as uses:, entry one level deeper
        entry_indent = uses_indent + "  "

        new_block = "#{uses_indent}with:\n#{entry_indent}persist-credentials: false\n"
        lines.insert(target_idx + 1, new_block)
    end

    lines.join
end

.fix_shell_injection(lines, finding) ⇒ Object

— shell-injection-expr —



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/auto_fix.rb', line 92

def self.fix_shell_injection(lines, finding)
    target_idx = finding.line - 1
    return lines.join if target_idx < 0 || target_idx >= lines.length

    # Collect all dangerous expressions on this line
    line = lines[target_idx]
    expressions = line.scan(DANGEROUS_EXPR_PATTERN).flatten.uniq

    return lines.join if expressions.empty?

    # Find the step's run: line by walking backwards
    run_line_idx = find_run_line(lines, target_idx)
    return lines.join unless run_line_idx

    # Determine the step-level indentation (same as run:)
    run_indent = lines[run_line_idx][/^(\s*)/, 1]

    # Build env var mappings
    env_mappings = {}
    expressions.each do |expr|
        var_name = ENV_VAR_NAMES[expr]
        next unless var_name
        env_mappings[var_name] = "${{ #{expr} }}"
    end

    return lines.join if env_mappings.empty?

    # Check if there's already an env: block at the step level
    existing_env_idx = find_step_env_block(lines, run_line_idx, run_indent)

    if existing_env_idx
        # Insert new env vars into the existing env: block
        # Find the last entry in the env: block
        insert_idx = find_env_block_end(lines, existing_env_idx, run_indent)
        env_entry_indent = run_indent + "    "

        new_entries = env_mappings.map { |var, expr| "#{env_entry_indent}#{var}: #{expr}\n" }
        new_entries.reverse.each do |entry|
            lines.insert(insert_idx, entry)
        end
        # Adjust run_line_idx since entries were inserted before run:
        if insert_idx <= run_line_idx
            run_line_idx += new_entries.length
        end
    else
        # Insert env: block as individual lines before the run: line
        env_lines = ["#{run_indent}env:\n"]
        env_mappings.each do |var, expr|
            env_lines << "#{run_indent}    #{var}: #{expr}\n"
        end

        env_lines.reverse.each { |el| lines.insert(run_line_idx, el) }
        inserted_count = env_lines.length
        # Adjust run_line_idx to point to the actual run: line after insertion
        run_line_idx += inserted_count
    end

    # Replace ${{ context }} with $VAR in the run block lines
    run_block_range = find_run_block_range(lines, run_line_idx)

    run_block_range.each do |i|
        env_mappings.each do |var, _expr|
            context = ENV_VAR_NAMES.key(var)
            next unless context
            # Replace ${{ context }} with $VAR (for shell context)
            replacement = "$#{var}"
            lines[i] = lines[i].gsub(/\$\{\{\s*#{Regexp.escape(context)}\s*\}\}/) { replacement }
        end
    end

    lines.join
end

.fix_unpinned_action(lines, finding, sha_resolver: nil) ⇒ Object

— unpinned-actions —



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/auto_fix.rb', line 64

def self.fix_unpinned_action(lines, finding, sha_resolver: nil)
    sha_resolver ||= ShaResolver.new

    # Extract the uses string from the finding code
    uses_string = extract_uses_string(finding.code)
    return lines.join unless uses_string
    return lines.join unless uses_string.include?("@")

    owner_action, tag = uses_string.split("@", 2)
    return lines.join if tag.nil? || tag.empty?

    # Strip any existing inline comment from the tag
    tag = tag.split("#").first.strip

    sha = sha_resolver.resolve(owner_action, tag)
    return lines.join unless sha

    target_idx = finding.line - 1
    return lines.join if target_idx < 0 || target_idx >= lines.length

    pinned = "#{owner_action}@#{sha} # #{tag}"
    lines[target_idx] = lines[target_idx].sub(uses_string) { pinned }

    lines.join
end