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 +4 -4
- data/CHANGELOG.md +5 -0
- data/lib/auto_fix.rb +1 -3
- data/lib/github_client.rb +1 -1
- data/lib/local_client.rb +1 -1
- data/lib/platforms/bitbucket.rb +1 -1
- data/lib/platforms/gitlab.rb +1 -1
- data/lib/platforms/shared_patterns.rb +2 -0
- data/lib/policy.rb +1 -1
- data/lib/rules/ai_config_injection.rb +211 -0
- data/lib/rules/concerns/guard_patterns.rb +184 -0
- data/lib/rules/github_script_injection.rb +8 -21
- data/lib/rules/hardcoded_secrets.rb +1 -1
- data/lib/rules/shell_injection_expr.rb +10 -18
- data/lib/rules/shell_injection_jq.rb +29 -0
- data/lib/rules/workflow_dispatch_injection.rb +11 -5
- data/lib/scanner.rb +1 -1
- data/lib/version.rb +1 -1
- data/lib/workflow.rb +1 -1
- data/mcp/server.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '070770478121626be95f0c860bbd83958d3c5e713574330d607e604f8e0dc374'
|
|
4
|
+
data.tar.gz: d2269120be9f63c470c8543cb1082584194318f3e5e90d8a1b26cf5c80fd55ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
data/lib/platforms/bitbucket.rb
CHANGED
|
@@ -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,
|
|
11
|
+
@data = YAML.safe_load(content, aliases: true) || {}
|
|
12
12
|
@lines = content.lines
|
|
13
13
|
rescue YAML::SyntaxError
|
|
14
14
|
@data = {}
|
data/lib/platforms/gitlab.rb
CHANGED
|
@@ -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,
|
|
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
|
@@ -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:
|
|
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:
|
|
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
data/lib/workflow.rb
CHANGED
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.
|
|
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-
|
|
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
|