Module: AutoFix

Defined in:
lib/auto_fix.rb

Constant Summary collapse

FIXABLE_RULES =
%w[
    unpinned-actions
    shell-injection-expr
    missing-persist-credentials
].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
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



35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/auto_fix.rb', line 35

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)
    else
        raw_content
    end
end

.can_fix?(finding) ⇒ Boolean

Returns:

  • (Boolean)


31
32
33
# File 'lib/auto_fix.rb', line 31

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

.fix_persist_credentials(lines, finding) ⇒ Object

— missing-persist-credentials —



155
156
157
158
159
160
161
162
163
164
165
166
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
# File 'lib/auto_fix.rb', line 155

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 —



80
81
82
83
84
85
86
87
88
89
90
91
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
# File 'lib/auto_fix.rb', line 80

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 —



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/auto_fix.rb', line 52

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