sentinel-ci 1.3.0 → 1.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 23aa715c0e322cb64a295126e1c7ea6d799b5e2160b1f9afb6781c70d6d67e74
4
- data.tar.gz: 4dcef6fdb0ce75cc6df95b875c184bb2eac4664cfaa3cc08c887cba17b01544b
3
+ metadata.gz: '070770478121626be95f0c860bbd83958d3c5e713574330d607e604f8e0dc374'
4
+ data.tar.gz: d2269120be9f63c470c8543cb1082584194318f3e5e90d8a1b26cf5c80fd55ba
5
5
  SHA512:
6
- metadata.gz: a73ae7c6c62199f997171d442a4f34b5f8ba01d57606e9b4a95be3841a9b4939860dfeb4f240c6f3e1aa766d8416dc13a9b40aaeef8cd09cad96db3770caf5a2
7
- data.tar.gz: 7db22bcd42d182031dbb1d2adfa9da5e6073d9655ac58900065afd37be674bc4d477ba9ab775756c067569b0f02ec1a6d7730dfd81f0a87298d49cea796315cb
6
+ metadata.gz: 2955311417f590f00a66cf7ec9e0f8fbea4d71213f626dd1fcd854fe7242d7635ab330cd2939cecdacb089d3427d90791b4c18a40b99ade980b780f7f906a2a5
7
+ data.tar.gz: f6d57dda77bf75cc0f77d826ece255ae16efcb26c39eea5a3c2503e89c6577aadf7b666687638ab2122f2366ccbbf314a74875f16ec6a88f014685b301ec88a1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1 (2026-05-22)
4
+
5
+ ### Bug Fixes
6
+ - Fix warn-only mode: read `INPUT_FAIL-ON-FINDINGS` env var (GitHub Actions passes docker action inputs with hyphens preserved, not converted to underscores)
7
+
3
8
  ## 1.3.0 (2026-05-18)
4
9
 
5
10
  ### New Features
data/lib/auto_fix.rb CHANGED
@@ -26,8 +26,6 @@ module AutoFix
26
26
  "github.event.discussion.body" => "DISCUSSION_BODY",
27
27
  "github.event.workflow_run.head_branch" => "WORKFLOW_HEAD_BRANCH",
28
28
  "github.head_ref" => "HEAD_REF",
29
- "github.actor" => "GH_ACTOR",
30
- "github.triggering_actor" => "TRIGGERING_ACTOR",
31
29
  }.freeze
32
30
 
33
31
  # Workflow dispatch input expressions
@@ -62,7 +60,7 @@ module AutoFix
62
60
  # Validate the result is still valid YAML
63
61
  if result && result != raw_content
64
62
  begin
65
- YAML.safe_load(result)
63
+ YAML.safe_load(result, aliases: true)
66
64
  rescue YAML::SyntaxError => e
67
65
  $stderr.puts "AutoFix: generated invalid YAML for #{finding.rule} in #{finding.file}: #{e.message}"
68
66
  return raw_content # fail safe — return original
data/lib/github_client.rb CHANGED
@@ -59,7 +59,7 @@ class GitHubClient
59
59
  content ||= fetch_file_content(repo, ".github/dependabot.yaml")
60
60
  return nil unless content
61
61
  begin
62
- YAML.safe_load(content)
62
+ YAML.safe_load(content, aliases: true)
63
63
  rescue StandardError => e
64
64
  nil
65
65
  end
data/lib/local_client.rb CHANGED
@@ -25,7 +25,7 @@ class LocalClient
25
25
  path = File.join(@path, ".github", "dependabot.yaml") unless File.exist?(path)
26
26
  return nil unless File.exist?(path)
27
27
  begin
28
- YAML.safe_load(File.read(path))
28
+ YAML.safe_load(File.read(path), aliases: true)
29
29
  rescue StandardError => e
30
30
  nil
31
31
  end
@@ -8,7 +8,7 @@ module Platforms
8
8
  def initialize(content, filename: "bitbucket-pipelines.yml")
9
9
  @content = content
10
10
  @filename = filename
11
- @data = YAML.safe_load(content, permitted_classes: [Symbol]) || {}
11
+ @data = YAML.safe_load(content, aliases: true) || {}
12
12
  @lines = content.lines
13
13
  rescue YAML::SyntaxError
14
14
  @data = {}
@@ -8,7 +8,7 @@ module Platforms
8
8
  def initialize(content, filename: ".gitlab-ci.yml")
9
9
  @content = content
10
10
  @filename = filename
11
- @data = YAML.safe_load(content, permitted_classes: [Symbol]) || {}
11
+ @data = YAML.safe_load(content, aliases: true) || {}
12
12
  @lines = content.lines
13
13
  rescue YAML::SyntaxError
14
14
  @data = {}
@@ -15,6 +15,7 @@ module Platforms
15
15
 
16
16
  PASSWORD_PATTERN = /password:\s*[^\s${\#]+/i
17
17
  SAFE_VALUE_PATTERN = /\$\{\{.*\}\}|\$[A-Z_]+|\$\{[A-Z_]+\}/
18
+ SAFE_PASSWORDS = %w[postgres password test example changeme admin root dummy placeholder true false].freeze
18
19
 
19
20
  def scan_for_hardcoded_secrets(lines, filename:, platform_fix:)
20
21
  findings = []
@@ -42,6 +43,7 @@ module Platforms
42
43
  if line.match?(PASSWORD_PATTERN)
43
44
  value = line[/password:\s*(.+)/i, 1]&.strip
44
45
  if value && !value.match?(SAFE_VALUE_PATTERN) && !value.start_with?("#")
46
+ next if SAFE_PASSWORDS.include?(value.strip.downcase)
45
47
  findings << Finding.new(
46
48
  rule: "hardcoded-secrets",
47
49
  severity: :critical,
data/lib/policy.rb CHANGED
@@ -54,7 +54,7 @@ class Policy
54
54
  private
55
55
 
56
56
  def load_config
57
- raw = YAML.safe_load(File.read(@path))
57
+ raw = YAML.safe_load(File.read(@path), aliases: true)
58
58
  unless raw.is_a?(Hash)
59
59
  @errors << "#{@path}: expected a YAML mapping, got #{raw.class}"
60
60
  return
@@ -0,0 +1,211 @@
1
+ module Rules
2
+ class AiConfigInjection < Base
3
+ def name = "ai-config-injection"
4
+ def description = "AI tool runs on PR checkout code with attacker-controlled config"
5
+ def severity = :critical
6
+
7
+ PR_TRIGGERS = %w[pull_request pull_request_target].freeze
8
+
9
+ AI_TOOL_ACTION_PATTERNS = [
10
+ /\banthropics\/claude/i,
11
+ /\bgithub\/copilot/i,
12
+ /\baider[_-]ai\//i,
13
+ /\bcursor\//i,
14
+ /\bcline\//i,
15
+ /\bcontinue[_-]dev\//i,
16
+ /\bwindsurf\//i,
17
+ /\bcodex\//i,
18
+ /\bsweep[_-]ai\//i,
19
+ /\bdevin\//i,
20
+ ].freeze
21
+
22
+ AI_TOOL_COMMANDS = [
23
+ /\bclaude\b/,
24
+ /\baider\b/,
25
+ /\bcursor\s+(review|fix|chat|ask|compose|run)\b/,
26
+ /\bcopilot\b/,
27
+ /\bsgpt\b/,
28
+ /\bcline\b/,
29
+ /\bcontinue\s+(chat|review|fix|ask|suggest|generate|dev)\b/,
30
+ /\bwindsurf\b/,
31
+ /\bcodex\b/,
32
+ /\bdevin\b/,
33
+ ].freeze
34
+
35
+ SANITIZATION_DIRS = %w[
36
+ .claude/
37
+ .cursor/
38
+ .continue/
39
+ .github/copilot/
40
+ ].freeze
41
+
42
+ SANITIZATION_FILES = %w[
43
+ .mcp.json
44
+ CLAUDE.md
45
+ .cursorrules
46
+ .aider.conf.yml
47
+ .aiderignore
48
+ .copilot-instructions.md
49
+ .clinerules
50
+ .windsurfrules
51
+ .continue/config.json
52
+ ].freeze
53
+
54
+ SANITIZATION_PATHS = (SANITIZATION_DIRS + SANITIZATION_FILES).freeze
55
+
56
+ SANITIZATION_FIX = "Add a sanitization step after checkout: " \
57
+ "rm -rf .claude/ .cursor/ .continue/ .github/copilot/ && " \
58
+ "rm -f .mcp.json .cursorrules .aider.conf.yml .aiderignore " \
59
+ ".copilot-instructions.md CLAUDE.md .clinerules .windsurfrules " \
60
+ ".continue/config.json"
61
+
62
+ def check(workflow)
63
+ findings = []
64
+ triggers = workflow.triggers
65
+
66
+ pr_triggers = detect_pr_triggers(triggers)
67
+ return findings if pr_triggers.empty?
68
+
69
+ workflow.jobs.each do |_job_id, job|
70
+ pr_triggers.each do |pr_trigger|
71
+ is_prt = (pr_trigger == "pull_request_target")
72
+ pr_checkout_found = false
73
+ sanitized = false
74
+
75
+ workflow.steps(job).each do |step|
76
+ if !pr_checkout_found && pr_code_checkout?(step, is_prt)
77
+ pr_checkout_found = true
78
+ sanitized = false
79
+ next
80
+ end
81
+
82
+ next unless pr_checkout_found
83
+
84
+ if sanitization_step?(step)
85
+ sanitized = true
86
+ next
87
+ end
88
+
89
+ if ai_tool_step?(step) && !sanitized && !isolated_working_dir?(step)
90
+ tool_name = identify_ai_tool(step)
91
+ sev = is_prt ? :critical : :high
92
+
93
+ code = step["uses"] ? "uses: #{step["uses"]}" : step["run"]&.lines&.first&.strip
94
+ line = if step["uses"]
95
+ workflow.line_of(/uses:\s*#{Regexp.escape(step["uses"])}/) || 0
96
+ elsif step["run"]
97
+ first_line = step["run"].lines.first&.strip
98
+ first_line ? (workflow.line_of(/#{Regexp.escape(first_line[0..40])}/) || 0) : 0
99
+ else
100
+ 0
101
+ end
102
+
103
+ findings << Finding.new(
104
+ rule: name,
105
+ severity: sev,
106
+ file: workflow.filename,
107
+ line: line,
108
+ code: code,
109
+ message: "#{tool_name} runs on PR checkout code (#{pr_trigger} trigger) " \
110
+ "— attacker-controlled AI config files execute arbitrary code",
111
+ fix: SANITIZATION_FIX
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ findings
119
+ end
120
+
121
+ private
122
+
123
+ def detect_pr_triggers(triggers)
124
+ trigger_list = case triggers
125
+ when Hash then triggers.keys.map(&:to_s)
126
+ when Array then triggers.map(&:to_s)
127
+ when String then [triggers]
128
+ else []
129
+ end
130
+
131
+ PR_TRIGGERS.select { |t| trigger_list.include?(t) }
132
+ end
133
+
134
+ def pr_code_checkout?(step, is_prt)
135
+ return false unless step["uses"]&.include?("checkout")
136
+
137
+ with = step["with"] || {}
138
+ ref = with["ref"]&.to_s || ""
139
+
140
+ if is_prt
141
+ ref.match?(/\bgithub\.event\.pull_request\.head\b/) ||
142
+ ref.match?(/\bgithub\.head_ref\b/)
143
+ else
144
+ ref.empty? ||
145
+ ref.match?(/\bgithub\.event\.pull_request\.head\b/) ||
146
+ ref.match?(/\bgithub\.head_ref\b/) ||
147
+ ref.match?(/\bgithub\.ref\b/)
148
+ end
149
+ end
150
+
151
+ def ai_tool_step?(step)
152
+ ai_tool_action?(step["uses"]) || ai_tool_command?(step["run"])
153
+ end
154
+
155
+ def ai_tool_action?(uses)
156
+ return false unless uses
157
+ AI_TOOL_ACTION_PATTERNS.any? { |p| uses.match?(p) }
158
+ end
159
+
160
+ def ai_tool_command?(run)
161
+ return false unless run
162
+ AI_TOOL_COMMANDS.any? { |p| run.match?(p) }
163
+ end
164
+
165
+ def sanitization_step?(step)
166
+ run = step["run"]
167
+ return false unless run
168
+ return false unless run.match?(/\brm\b/)
169
+ SANITIZATION_PATHS.any? { |path| run.include?(path) }
170
+ end
171
+
172
+ def isolated_working_dir?(step)
173
+ wd = step["working-directory"] || step.dig("with", "working-directory")
174
+ return false unless wd
175
+ !wd.strip.empty? && wd.strip != "."
176
+ end
177
+
178
+ def identify_ai_tool(step)
179
+ if step["uses"]
180
+ case step["uses"]
181
+ when /claude/i then "Claude Code"
182
+ when /copilot/i then "GitHub Copilot"
183
+ when /aider/i then "Aider"
184
+ when /cursor/i then "Cursor"
185
+ when /cline/i then "Cline"
186
+ when /continue[_-]dev/i then "Continue"
187
+ when /windsurf/i then "Windsurf"
188
+ when /codex/i then "Codex"
189
+ when /devin/i then "Devin"
190
+ else "AI tool (#{step["uses"]})"
191
+ end
192
+ elsif step["run"]
193
+ case step["run"]
194
+ when /\bclaude\b/ then "Claude Code"
195
+ when /\bcopilot\b/ then "GitHub Copilot"
196
+ when /\baider\b/ then "Aider"
197
+ when /\bcursor\s+(review|fix|chat|ask|compose|run)\b/ then "Cursor"
198
+ when /\bsgpt\b/ then "Shell GPT"
199
+ when /\bcline\b/ then "Cline"
200
+ when /\bcontinue\s+(chat|review|fix|ask|suggest|generate|dev)\b/ then "Continue"
201
+ when /\bwindsurf\b/ then "Windsurf"
202
+ when /\bcodex\b/ then "Codex"
203
+ when /\bdevin\b/ then "Devin"
204
+ else "AI tool"
205
+ end
206
+ else
207
+ "AI tool"
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,184 @@
1
+ module Rules
2
+ module GuardPatterns
3
+ SAFE_TRIGGERS = %w[
4
+ workflow_dispatch schedule push workflow_call release
5
+ deployment deployment_status create delete
6
+ page_build watch fork star gollum
7
+ ].freeze
8
+
9
+ JOB_PROPERTIES = %w[
10
+ steps runs-on env strategy permissions outputs concurrency
11
+ services needs container timeout-minutes if name defaults
12
+ ].freeze
13
+
14
+ DANGEROUS_CONTEXTS = %w[
15
+ github.event.pull_request.title
16
+ github.event.pull_request.body
17
+ github.event.pull_request.head.ref
18
+ github.event.pull_request.head.label
19
+ github.event.issue.title
20
+ github.event.issue.body
21
+ github.event.comment.body
22
+ github.event.review.body
23
+ github.event.discussion.title
24
+ github.event.discussion.body
25
+ github.event.workflow_run.head_branch
26
+ github.head_ref
27
+ ].freeze
28
+
29
+ def safe_trigger_only?(workflow)
30
+ trigger_names = case workflow.triggers
31
+ when Hash then workflow.triggers.keys.map(&:to_s)
32
+ when Array then workflow.triggers.map(&:to_s)
33
+ when String then [workflow.triggers]
34
+ else []
35
+ end
36
+
37
+ trigger_names.any? && trigger_names.all? { |t| SAFE_TRIGGERS.include?(t) }
38
+ end
39
+
40
+ def guarded_by_safe_event?(workflow, line_num)
41
+ guarded_by_step_if?(workflow, line_num) || guarded_by_job_if?(workflow, line_num)
42
+ end
43
+
44
+ def strip_inline_comment(line)
45
+ in_single_quote = false
46
+ in_double_quote = false
47
+
48
+ i = 0
49
+ while i < line.length
50
+ char = line[i]
51
+
52
+ if char == "'" && !in_double_quote
53
+ in_single_quote = !in_single_quote
54
+ elsif char == '"' && !in_single_quote
55
+ in_double_quote = !in_double_quote
56
+ elsif char == '#' && !in_single_quote && !in_double_quote
57
+ # Only strip if preceded by whitespace (or at start of line content)
58
+ if i == 0 || line[i - 1] =~ /\s/
59
+ return line[0...i].rstrip
60
+ end
61
+ end
62
+
63
+ i += 1
64
+ end
65
+
66
+ line
67
+ end
68
+
69
+ private
70
+
71
+ # Walk upward from line_num looking for a step-level `if:` guard.
72
+ # Stop at step boundaries: a line matching `^\s*-\s+` at the same or
73
+ # lower indent as the step's dash signals a different step.
74
+ def guarded_by_step_if?(workflow, line_num)
75
+ (line_num - 2).downto([line_num - 30, 0].max) do |i|
76
+ content = workflow.raw_lines[i]
77
+ next unless content
78
+
79
+ # Found step-level `if:` before hitting a boundary
80
+ if content.match?(/^\s+if:\s*/)
81
+ condition = content[/if:\s*(.+)/, 1]&.strip
82
+ return safe_guard_condition?(condition) if condition
83
+ end
84
+
85
+ # Step boundary: a line starting with `- ` at step indent
86
+ if content.match?(/^\s+-\s+\S/)
87
+ # Check if the step boundary itself is `- if:` (guard on dash line)
88
+ if content.match?(/^\s+-\s+if:\s*/)
89
+ condition = content[/if:\s*(.+)/, 1]&.strip
90
+ return safe_guard_condition?(condition) if condition
91
+ end
92
+ break
93
+ end
94
+
95
+ # Job-level key (no dash prefix, at job indent) means we've left the step
96
+ if content.match?(/^\s+\w[\w-]*:/) && !content.match?(/^\s+-/)
97
+ indent = content[/^\s*/].length
98
+ # If this is shallow (job key level), stop
99
+ break if indent <= 6
100
+ end
101
+ end
102
+
103
+ false
104
+ end
105
+
106
+ # Walk upward from line_num looking for a job-level `if:` guard.
107
+ # Stop at `jobs:` key or when crossing into a different job.
108
+ def guarded_by_job_if?(workflow, line_num)
109
+ # Track job key boundaries: the first job key we encounter going
110
+ # upward is the enclosing job; the second means we've left it.
111
+ job_keys_seen = 0
112
+ enclosing_job_line = nil
113
+
114
+ (line_num - 2).downto(0) do |i|
115
+ content = workflow.raw_lines[i]
116
+ next unless content
117
+
118
+ # `jobs:` means we've gone too far without finding a job-level if:
119
+ return false if content.match?(/^jobs:\s*$/)
120
+
121
+ # Detect job key lines (e.g. " build:" at job-key indent)
122
+ if content.match?(/^\s+(\w[\w-]*):\s*$/)
123
+ key_name = content[/^\s+(\w[\w-]*):\s*$/, 1]
124
+ key_indent = content[/^\s*/].length
125
+ # Job keys are typically at indent 2 (under `jobs:`);
126
+ # skip known job properties (steps:, permissions:, etc.)
127
+ if key_indent <= 4 && !JOB_PROPERTIES.include?(key_name)
128
+ job_keys_seen += 1
129
+ enclosing_job_line = i if job_keys_seen == 1
130
+ # Second job key means we've crossed into a different job
131
+ return false if job_keys_seen > 1
132
+ end
133
+ end
134
+
135
+ # Job-level `if:` — directly under a job key, typically at indent 4 or 6
136
+ if content.match?(/^\s+if:\s*/)
137
+ # Check if this is job-level (not step-level) by verifying indent
138
+ if_indent = content[/^\s*/].length
139
+
140
+ # Look further up to see if there's a job key at indent - 2
141
+ (i - 1).downto([i - 15, 0].max) do |j|
142
+ above = workflow.raw_lines[j]
143
+ next unless above
144
+
145
+ if above.match?(/^\s+\w[\w-]*:\s*$/)
146
+ above_indent = above[/^\s*/].length
147
+ # The job key above must be our enclosing job, not a
148
+ # different one. If we already found the enclosing job
149
+ # key, verify this `if:` belongs to it.
150
+ if if_indent == above_indent + 2 &&
151
+ (enclosing_job_line.nil? || j == enclosing_job_line)
152
+ condition = content[/if:\s*(.+)/, 1]&.strip
153
+ return safe_guard_condition?(condition) if condition
154
+ end
155
+ break
156
+ end
157
+ end
158
+ end
159
+
160
+ # `steps:` key means we've passed from steps into job-level territory
161
+ next if content.match?(/^\s+steps:\s*$/)
162
+ end
163
+
164
+ false
165
+ end
166
+
167
+ # Check if a simple `if:` condition clearly excludes attacker-controlled triggers.
168
+ # Only matches simple single-clause guards, not complex boolean expressions.
169
+ def safe_guard_condition?(condition)
170
+ # Strip ${{ }} wrapper if present
171
+ condition = condition.gsub(/\$\{\{\s*/, '').gsub(/\s*\}\}/, '').strip
172
+
173
+ # Reject complex expressions
174
+ return false if condition.match?(/(\|\||&&|always\s*\(|failure\s*\(|cancelled\s*\()/)
175
+
176
+ # Pattern: github.event_name == 'push' (or any SAFE_TRIGGER)
177
+ if (m = condition.match(/\Agithub\.event_name\s*==\s*['"](\w+)['"]\z/))
178
+ return SAFE_TRIGGERS.include?(m[1])
179
+ end
180
+
181
+ false
182
+ end
183
+ end
184
+ end
@@ -1,35 +1,26 @@
1
+ require_relative "concerns/guard_patterns"
2
+
1
3
  module Rules
2
4
  class GithubScriptInjection < Base
5
+ include GuardPatterns
6
+
3
7
  def name = "github-script-injection"
4
8
  def description = "Attacker-controllable ${{ }} expression in actions/github-script"
5
9
  def severity = :critical
6
10
 
7
- DANGEROUS_CONTEXTS = %w[
8
- github.event.pull_request.title
9
- github.event.pull_request.body
10
- github.event.pull_request.head.ref
11
- github.event.pull_request.head.label
12
- github.event.issue.title
13
- github.event.issue.body
14
- github.event.comment.body
15
- github.event.review.body
16
- github.event.discussion.title
17
- github.event.discussion.body
18
- github.event.workflow_run.head_branch
19
- github.head_ref
20
- github.actor
21
- github.triggering_actor
22
- ].freeze
23
-
24
11
  PATTERN = /\$\{\{\s*(#{DANGEROUS_CONTEXTS.map { |c| Regexp.escape(c) }.join('|')})/
25
12
 
26
13
  def check(workflow)
27
14
  findings = []
28
15
 
16
+ return [] if safe_trigger_only?(workflow)
17
+
29
18
  workflow.raw_lines.each_with_index do |line, idx|
30
19
  line_num = idx + 1
20
+ next if line.strip.start_with?('#')
31
21
  next unless line.match?(PATTERN)
32
22
  next unless in_github_script_block?(workflow, line_num)
23
+ next if guarded_by_safe_event?(workflow, line_num)
33
24
 
34
25
  match = line.match(PATTERN)
35
26
  next unless match
@@ -55,22 +46,18 @@ module Rules
55
46
  content = workflow.raw_lines[i]
56
47
  next unless content
57
48
 
58
- # Found script: key — check if we're within a github-script step
59
49
  if content.match?(/^\s+script:\s*[\|>]?\s*$/) || content.match?(/^\s+script:\s+\S/)
60
50
  in_script = true
61
51
  script_indent = content[/^\s*/].length
62
- # Now look further up for the uses: actions/github-script line
63
52
  i.downto([i - 15, 0].max) do |j|
64
53
  step_line = workflow.raw_lines[j]
65
54
  next unless step_line
66
55
  return true if step_line.match?(/uses:\s*actions\/github-script/)
67
- # Stop if we hit another step boundary
68
56
  break if step_line.match?(/^\s+-\s+(name|uses|run|if|id):/)
69
57
  end
70
58
  return false
71
59
  end
72
60
 
73
- # If we hit a different key at the same or lower indent, we're outside the block
74
61
  if content.match?(/^\s+(uses|run|if|id|name|env|with):/) || content.match?(/^\s+-\s+(name|uses|run):/)
75
62
  return false
76
63
  end
@@ -17,7 +17,7 @@ module Rules
17
17
 
18
18
  PASSWORD_PATTERN = /password:\s*[^\s${\#]+/i
19
19
  SAFE_VALUE_PATTERN = /\$\{\{.*\}\}|\$[A-Z_]+/
20
- SAFE_PASSWORDS = %w[postgres password test example changeme admin root dummy placeholder].freeze
20
+ SAFE_PASSWORDS = %w[postgres password test example changeme admin root dummy placeholder true false].freeze
21
21
 
22
22
  def check(workflow)
23
23
  findings = []
@@ -1,40 +1,32 @@
1
+ require_relative "concerns/guard_patterns"
2
+
1
3
  module Rules
2
4
  class ShellInjectionExpr < Base
5
+ include GuardPatterns
6
+
3
7
  def name = "shell-injection-expr"
4
8
  def description = "Attacker-controllable ${{ }} expression in run: block"
5
9
  def severity = :critical
6
10
 
7
- DANGEROUS_CONTEXTS = %w[
8
- github.event.pull_request.title
9
- github.event.pull_request.body
10
- github.event.pull_request.head.ref
11
- github.event.pull_request.head.label
12
- github.event.issue.title
13
- github.event.issue.body
14
- github.event.comment.body
15
- github.event.review.body
16
- github.event.discussion.title
17
- github.event.discussion.body
18
- github.event.workflow_run.head_branch
19
- github.head_ref
20
- github.actor
21
- github.triggering_actor
22
- ].freeze
23
-
24
11
  PATTERN = /\$\{\{\s*(#{DANGEROUS_CONTEXTS.map { |c| Regexp.escape(c) }.join('|')})/
25
12
 
26
13
  def check(workflow)
27
14
  findings = []
15
+
16
+ return [] if safe_trigger_only?(workflow)
17
+
28
18
  workflow.lines_of(PATTERN).each do |line_num|
29
19
  line = workflow.line_content(line_num)
20
+ next if line.strip.start_with?('#')
30
21
  next unless in_run_block?(workflow, line_num)
22
+ next if guarded_by_safe_event?(workflow, line_num)
31
23
 
32
24
  match = line.match(PATTERN)
33
25
  next unless match
34
26
 
35
27
  findings << finding(workflow,
36
28
  line: line_num,
37
- code: line.strip,
29
+ code: workflow.line_content(line_num).strip,
38
30
  message: "Attacker-controllable expression ${{ #{match[1]} }} in run: block — shell injection risk",
39
31
  fix: "Move to env: block and reference as $ENV_VAR in the shell"
40
32
  )
@@ -1,5 +1,9 @@
1
+ require_relative "concerns/guard_patterns"
2
+
1
3
  module Rules
2
4
  class ShellInjectionJq < Base
5
+ include GuardPatterns
6
+
3
7
  def name = "shell-injection-jq"
4
8
  def description = "Shell variable interpolated in double-quoted jq/curl JSON argument"
5
9
  def severity = :critical
@@ -11,11 +15,17 @@ module Rules
11
15
 
12
16
  JQ_PATTERN = /jq\s+([a-zA-Z-]+\s+)*--arg\s+\w+\s+"[^"]*\$\{/
13
17
  CURL_JSON_PATTERN = /curl\s.*-d\s+"[^"]*\$\{/
18
+
14
19
  def check(workflow)
15
20
  findings = []
16
21
 
22
+ return [] if safe_trigger_only?(workflow)
23
+
17
24
  workflow.raw_lines.each_with_index do |line, i|
18
25
  line_num = i + 1
26
+ next if line.strip.start_with?('#')
27
+ next unless in_run_block?(workflow, line_num)
28
+ next if guarded_by_safe_event?(workflow, line_num)
19
29
 
20
30
  if line.match?(JQ_PATTERN)
21
31
  var_match = line.match(/\$\{(\w+)\}/)
@@ -51,6 +61,25 @@ module Rules
51
61
 
52
62
  private
53
63
 
64
+ def in_run_block?(workflow, target_line)
65
+ target_content = workflow.raw_lines[target_line - 1]
66
+ target_indent = target_content ? target_content[/^\s*/].length : 0
67
+
68
+ (target_line - 1).downto([target_line - 20, 0].max) do |i|
69
+ content = workflow.raw_lines[i]
70
+ next unless content
71
+
72
+ return true if content.match?(/^\s+run:\s*[\|>]?\s*$/) || content.match?(/^\s+run:\s+\S/)
73
+ return true if content.match?(/^\s+-\s+run:\s*[\|>]?\s*$/) || content.match?(/^\s+-\s+run:\s+\S/)
74
+
75
+ if content.match?(/^\s+(uses|with|if|id|name|env):/) || content.match?(/^\s+-\s+name:/)
76
+ line_indent = content[/^\s*/].length
77
+ return false if target_indent <= line_indent + 2
78
+ end
79
+ end
80
+ false
81
+ end
82
+
54
83
  def potentially_attacker_controlled?(var_name)
55
84
  ATTACKER_ENV_VARS.any? { |v| var_name.upcase == v } ||
56
85
  var_name.match?(/^(PR_|ISSUE_|COMMENT_)?(TITLE|BODY|HEAD_REF|BRANCH_NAME|COMMENT_BODY|AUTHOR)$/i)
@@ -1,24 +1,33 @@
1
+ require_relative "concerns/guard_patterns"
2
+
1
3
  module Rules
2
4
  class WorkflowDispatchInjection < Base
5
+ include GuardPatterns
6
+
3
7
  def name = "workflow-dispatch-injection"
4
8
  def description = "User-controlled workflow_dispatch input in run: block"
5
9
  def severity = :high
6
10
 
7
11
  PATTERN = /\$\{\{\s*(?:inputs\.|github\.event\.inputs\.)/
8
12
 
13
+ # NOTE: This rule intentionally does NOT use safe_trigger_only? because
14
+ # dispatch inputs are user-controlled. workflow_dispatch IS in SAFE_TRIGGERS
15
+ # for other rules, but this rule specifically targets ${{ inputs.* }} in
16
+ # run blocks — those inputs are always attacker-controlled.
17
+
9
18
  def check(workflow)
10
19
  findings = []
11
20
 
12
21
  workflow.lines_of(PATTERN).each do |line_num|
13
22
  line = workflow.line_content(line_num)
23
+ next if line.strip.start_with?('#')
14
24
  next unless in_run_block?(workflow, line_num)
15
-
16
25
  match = line.match(/\$\{\{\s*((?:inputs|github\.event\.inputs)\.[^\s}]+)/)
17
26
  next unless match
18
27
 
19
28
  findings << finding(workflow,
20
29
  line: line_num,
21
- code: line.strip,
30
+ code: workflow.line_content(line_num).strip,
22
31
  message: "User-controlled input ${{ #{match[1]} }} in run: block — shell injection risk",
23
32
  fix: "Move to env: block and reference as $ENV_VAR"
24
33
  )
@@ -40,9 +49,6 @@ module Rules
40
49
  return true if content.match?(/^\s+run:\s*[\|>]?\s*$/) || content.match?(/^\s+run:\s+\S/)
41
50
  return true if content.match?(/^\s+-\s+run:\s*[\|>]?\s*$/) || content.match?(/^\s+-\s+run:\s+\S/)
42
51
 
43
- # Stop at step-level keys, but only if the target line is at or
44
- # shallower than this key's indent (meaning the target is a sibling
45
- # or child of this key, not content of a deeper run: block).
46
52
  if content.match?(/^\s+(uses|with|if|id|name|env):/) || content.match?(/^\s+-\s+name:/)
47
53
  line_indent = content[/^\s*/].length
48
54
  return false if target_indent <= line_indent + 2
data/lib/scanner.rb CHANGED
@@ -124,7 +124,7 @@ class Scanner
124
124
  findings: findings
125
125
  )
126
126
 
127
- { output: output, findings: findings, workflow_count: workflow_count }
127
+ { output: output, findings: findings, workflow_count: workflow_count, workflows: raw_workflows || [] }
128
128
  end
129
129
 
130
130
  def scan_org(org)
data/lib/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Sentinel
2
- VERSION = "1.3.0"
2
+ VERSION = "1.3.1"
3
3
  end
data/lib/workflow.rb CHANGED
@@ -7,7 +7,7 @@ class Workflow
7
7
  @filename = filename
8
8
  @raw = content
9
9
  @raw_lines = content.lines
10
- @data = YAML.safe_load(content) || {}
10
+ @data = YAML.safe_load(content, aliases: true) || {}
11
11
  rescue YAML::SyntaxError => e
12
12
  @data = {}
13
13
  @parse_error = e.message
data/mcp/server.rb CHANGED
@@ -214,7 +214,7 @@ class McpServer
214
214
  patched = AutoFix.apply(finding, content, sha_resolver: sha_resolver)
215
215
  if patched && patched != content
216
216
  begin
217
- YAML.safe_load(patched)
217
+ YAML.safe_load(patched, aliases: true)
218
218
  content = patched
219
219
  rescue YAML::SyntaxError => e
220
220
  $stderr.puts "MCP fix: invalid YAML for #{finding.rule} in #{file}, skipping: #{e.message}"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sentinel-ci
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Ritter
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-19 00:00:00.000000000 Z
11
+ date: 2026-05-22 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Scan GitHub Actions workflows for 32 security vulnerabilities. SHA pinning,
14
14
  shell injection, credential exposure, dangerous triggers. Optional AI-powered remediation
@@ -44,10 +44,12 @@ files:
44
44
  - lib/platforms/shared_patterns.rb
45
45
  - lib/policy.rb
46
46
  - lib/rule_engine.rb
47
+ - lib/rules/ai_config_injection.rb
47
48
  - lib/rules/allow_forks_artifact.rb
48
49
  - lib/rules/base.rb
49
50
  - lib/rules/build_publish_same_job.rb
50
51
  - lib/rules/cache_poisoning.rb
52
+ - lib/rules/concerns/guard_patterns.rb
51
53
  - lib/rules/credential_window.rb
52
54
  - lib/rules/curl_pipe_shell.rb
53
55
  - lib/rules/dangerous_lifecycle_scripts.rb