sentinel-ci 1.3.2 → 1.3.3
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 +8 -0
- data/lib/rules/build_publish_same_job.rb +92 -11
- data/lib/rules/github_script_injection.rb +58 -23
- data/lib/rules/hardcoded_secrets.rb +39 -0
- data/lib/rules/workflow_dispatch_injection.rb +62 -8
- data/lib/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff566270daf8524c526427ffe9b01af6c6af028d03fc5fb947eeb713d51cebde
|
|
4
|
+
data.tar.gz: b823edf579875824fc46d741b762f839efa5ed62df9d711f23c0fc2753731091
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15a951ba1a71be6ac1af96d2ba68b8b69c13f25e8f321007e1ac74b038cdcfb57dc63265783417e83f329ad50d76a14bfac0b9e642ac6c1990e5c45ec98d6638
|
|
7
|
+
data.tar.gz: d53ce9300d8b0404105bfb9e548d90d898231a69d114bba8605172f28070146218550e60b2c50056160fba40564cac3bfd683d76e6e1738d64fc24f136658713
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.3 (2026-05-26)
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
- build-publish-same-job: recognize `--ignore-scripts` / `--no-scripts` as a per-command mitigation (collapsing shell line continuations, stripping inline comments via a POSIX-correct helper). Reject `--ignore-scripts=false` and similar bypasses.
|
|
7
|
+
- hardcoded-secrets: allowlist `actions/setup-java` env-name slots (`server-username`, `server-password`, `gpg-passphrase`, `gpg-private-key`, `keystore-password`) when the value matches an UPPER_SNAKE_CASE env var name.
|
|
8
|
+
- github-script-injection: cover `${{ inputs.* }}` and `${{ github.event.inputs.* }}` references inside `actions/github-script` `script:` blocks. Remove 30-line outer and 15-line inner lookback caps so long script bodies / long `with:` / `env:` blocks no longer cause missed findings. Evaluate INPUT and DANGEROUS expression paths independently so the event guard is no longer bypassed by mixed-pattern lines and a workflow_dispatch-only trigger no longer short-circuits input checks.
|
|
9
|
+
- workflow-dispatch-injection: make `in_run_block?` robust to long run blocks and uncommon step properties (STEP_KEYS-anchored backward scan, no length cap). Discriminate step-level `run:` from a `with: { run: ... }` action parameter via YAML indent, eliminating false positives on composite actions that take a command as input.
|
|
10
|
+
|
|
3
11
|
## 1.3.2 (2026-05-22)
|
|
4
12
|
|
|
5
13
|
### Bug Fixes
|
|
@@ -73,6 +73,10 @@ module Rules
|
|
|
73
73
|
/\bbrew\s+bump-formula-pr\b/,
|
|
74
74
|
)
|
|
75
75
|
|
|
76
|
+
# Match --ignore-scripts or --no-scripts as standalone flags or with =true.
|
|
77
|
+
# Reject =false or other =value suffixes (which disable the mitigation).
|
|
78
|
+
IGNORE_SCRIPTS_PATTERN = /(?:^|\s)(?:--ignore-scripts|--no-scripts)(?:=true)?(?=\s|$|[;&|\\])/
|
|
79
|
+
|
|
76
80
|
PUBLISH_SECRETS = Regexp.union(
|
|
77
81
|
# JavaScript
|
|
78
82
|
/\bNPM_TOKEN\b/,
|
|
@@ -113,27 +117,104 @@ module Rules
|
|
|
113
117
|
|
|
114
118
|
workflow.jobs.each do |job_id, job|
|
|
115
119
|
steps = workflow.steps(job)
|
|
116
|
-
|
|
120
|
+
install_steps = steps.select { |s| s["run"]&.match?(INSTALL_PATTERNS) }
|
|
117
121
|
has_publish = steps.any? { |s| s["run"]&.match?(PUBLISH_PATTERNS) }
|
|
118
122
|
|
|
119
|
-
next unless
|
|
123
|
+
next unless install_steps.any? && has_publish
|
|
120
124
|
|
|
121
125
|
job_env = job["env"]&.to_s || ""
|
|
122
126
|
step_envs = steps.map { |s| (s["env"] || {}).to_s }.join(" ")
|
|
123
127
|
all_env = job_env + step_envs
|
|
124
128
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
next unless all_env.match?(PUBLISH_SECRETS) || all_env.match?(/secrets\./)
|
|
130
|
+
|
|
131
|
+
# Check if all install commands across all steps use --ignore-scripts.
|
|
132
|
+
# Each step's run block may contain multiple commands; we check per
|
|
133
|
+
# install command, not per run block.
|
|
134
|
+
all_mitigated = install_steps.all? { |s| step_installs_mitigated?(s["run"]) }
|
|
135
|
+
next if all_mitigated
|
|
136
|
+
|
|
137
|
+
line = workflow.line_of(/#{job_id}:/)
|
|
138
|
+
findings << finding(workflow,
|
|
139
|
+
line: line || 0,
|
|
140
|
+
code: "job: #{job_id}",
|
|
141
|
+
message: "Build and publish in same job — a compromised dependency could exfiltrate publish credentials",
|
|
142
|
+
fix: "Split into separate build (read-only) and publish (with secrets) jobs connected via artifacts"
|
|
143
|
+
)
|
|
134
144
|
end
|
|
135
145
|
|
|
136
146
|
findings
|
|
137
147
|
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Determine if every install command within a run block has --ignore-scripts.
|
|
152
|
+
#
|
|
153
|
+
# 1. Collapse shell line continuations (backslash-newline) into logical lines.
|
|
154
|
+
# 2. Split on newlines to get individual logical commands.
|
|
155
|
+
# 3. Strip trailing shell comments (unquoted #...) from each line before
|
|
156
|
+
# pattern matching so that `npm install # --ignore-scripts` does not
|
|
157
|
+
# falsely count as mitigated.
|
|
158
|
+
# 4. For each logical line, check if it contains an install command.
|
|
159
|
+
# 5. For each install command, verify --ignore-scripts is on that same line.
|
|
160
|
+
#
|
|
161
|
+
# Returns true only if EVERY install command in the block is mitigated.
|
|
162
|
+
def step_installs_mitigated?(run_str)
|
|
163
|
+
return true if run_str.nil?
|
|
164
|
+
|
|
165
|
+
# Collapse backslash-newline continuations into single logical lines
|
|
166
|
+
collapsed = run_str.gsub(/\\\s*\n\s*/, " ")
|
|
167
|
+
|
|
168
|
+
# Split into individual logical lines
|
|
169
|
+
lines = collapsed.split("\n")
|
|
170
|
+
|
|
171
|
+
install_lines = lines.select { |line|
|
|
172
|
+
stripped = line.strip
|
|
173
|
+
next false if stripped.start_with?("#")
|
|
174
|
+
strip_shell_comment(stripped).match?(INSTALL_PATTERNS)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# If no install lines found, the step is trivially mitigated
|
|
178
|
+
return true if install_lines.empty?
|
|
179
|
+
|
|
180
|
+
# Every install line must have --ignore-scripts on the code portion
|
|
181
|
+
install_lines.all? { |line| strip_shell_comment(line).match?(IGNORE_SCRIPTS_PATTERN) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Strip trailing shell comments from a line, respecting single and double
|
|
185
|
+
# quotes. Returns the code portion before any unquoted `#`.
|
|
186
|
+
def strip_shell_comment(line)
|
|
187
|
+
in_single = false
|
|
188
|
+
in_double = false
|
|
189
|
+
i = 0
|
|
190
|
+
|
|
191
|
+
while i < line.length
|
|
192
|
+
ch = line[i]
|
|
193
|
+
|
|
194
|
+
# Backslash escapes the next character in unquoted and double-quoted
|
|
195
|
+
# context, but NOT inside single quotes (POSIX: backslash is literal
|
|
196
|
+
# within single-quoted strings).
|
|
197
|
+
if ch == '\\' && !in_single
|
|
198
|
+
i += 2
|
|
199
|
+
next
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
case ch
|
|
203
|
+
when "'" then in_single = !in_single unless in_double
|
|
204
|
+
when '"' then in_double = !in_double unless in_single
|
|
205
|
+
when '#'
|
|
206
|
+
unless in_single || in_double
|
|
207
|
+
# Only treat as comment when at start of line or preceded by whitespace
|
|
208
|
+
if i == 0 || line[i - 1] =~ /\s/
|
|
209
|
+
return line[0...i]
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
i += 1
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
line
|
|
218
|
+
end
|
|
138
219
|
end
|
|
139
220
|
end
|
|
@@ -8,29 +8,51 @@ module Rules
|
|
|
8
8
|
def description = "Attacker-controllable ${{ }} expression in actions/github-script"
|
|
9
9
|
def severity = :critical
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
DANGEROUS_EXPR_PATTERN = /\$\{\{\s*(#{DANGEROUS_CONTEXTS.map { |c| Regexp.escape(c) }.join('|')})/
|
|
12
|
+
INPUT_EXPR_PATTERN = /\$\{\{\s*((?:inputs|github\.event\.inputs)\.[^\s}]+)/
|
|
12
13
|
|
|
13
14
|
def check(workflow)
|
|
14
15
|
findings = []
|
|
15
|
-
|
|
16
|
-
return [] if safe_trigger_only?(workflow)
|
|
16
|
+
safe_triggers = safe_trigger_only?(workflow)
|
|
17
17
|
|
|
18
18
|
workflow.raw_lines.each_with_index do |line, idx|
|
|
19
19
|
line_num = idx + 1
|
|
20
20
|
next if line.strip.start_with?('#')
|
|
21
|
-
next unless line.match?(PATTERN)
|
|
22
21
|
next unless in_github_script_block?(workflow, line_num)
|
|
23
|
-
next if guarded_by_safe_event?(workflow, line_num)
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
has_dangerous = line.match?(DANGEROUS_EXPR_PATTERN)
|
|
24
|
+
has_input = line.match?(INPUT_EXPR_PATTERN)
|
|
25
|
+
next unless has_dangerous || has_input
|
|
26
|
+
|
|
27
|
+
guarded = guarded_by_safe_event?(workflow, line_num)
|
|
28
|
+
|
|
29
|
+
# INPUT expressions (inputs.*) are user-controlled even on
|
|
30
|
+
# safe-trigger-only workflows, so they always fire.
|
|
31
|
+
if has_input
|
|
32
|
+
match = line.match(INPUT_EXPR_PATTERN)
|
|
33
|
+
if match
|
|
34
|
+
findings << finding(workflow,
|
|
35
|
+
line: line_num,
|
|
36
|
+
code: line.strip,
|
|
37
|
+
message: "Attacker-controllable expression \${{ #{match[1]} }} in actions/github-script — JavaScript injection risk",
|
|
38
|
+
fix: "Pass input via env: block and reference as process.env.VAR"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
27
42
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
# DANGEROUS expressions respect both safe_trigger_only? and
|
|
44
|
+
# event guards — they are only exploitable from unsafe triggers.
|
|
45
|
+
if has_dangerous && !safe_triggers && !guarded
|
|
46
|
+
match = line.match(DANGEROUS_EXPR_PATTERN)
|
|
47
|
+
if match
|
|
48
|
+
findings << finding(workflow,
|
|
49
|
+
line: line_num,
|
|
50
|
+
code: line.strip,
|
|
51
|
+
message: "Attacker-controllable expression \${{ #{match[1]} }} in actions/github-script — JavaScript injection risk",
|
|
52
|
+
fix: "Use context.payload instead: context.payload.pull_request.title"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
34
56
|
end
|
|
35
57
|
|
|
36
58
|
findings
|
|
@@ -38,28 +60,41 @@ module Rules
|
|
|
38
60
|
|
|
39
61
|
private
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
script_indent = nil
|
|
63
|
+
# All valid GHA step-level properties — used as scan boundaries.
|
|
64
|
+
STEP_KEYS = /(?:id|if|name|uses|run|working-directory|shell|with|env|continue-on-error|timeout-minutes|permissions|secrets)/
|
|
44
65
|
|
|
45
|
-
|
|
66
|
+
def in_github_script_block?(workflow, target_line)
|
|
67
|
+
# Scan backward with no cap — use step keys as hard boundaries.
|
|
68
|
+
(target_line - 1).downto(0) do |i|
|
|
46
69
|
content = workflow.raw_lines[i]
|
|
47
70
|
next unless content
|
|
48
71
|
|
|
49
72
|
if content.match?(/^\s+script:\s*[\|>]?\s*$/) || content.match?(/^\s+script:\s+\S/)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
73
|
+
# Found a script: key. Now scan upward from here with no cap,
|
|
74
|
+
# looking for uses: actions/github-script. Stop at any step key
|
|
75
|
+
# that is NOT with:, env:, or uses: (those can appear between
|
|
76
|
+
# uses: and script:).
|
|
77
|
+
i.downto(0) do |j|
|
|
53
78
|
step_line = workflow.raw_lines[j]
|
|
54
79
|
next unless step_line
|
|
55
80
|
return true if step_line.match?(/uses:\s*actions\/github-script/)
|
|
56
|
-
|
|
81
|
+
# Step boundary: any step key other than with:/env:/uses:
|
|
82
|
+
# on a list-item line means a different step.
|
|
83
|
+
break if step_line.match?(/^\s+-\s+#{STEP_KEYS}:\s/)
|
|
84
|
+
# Non-list step keys that are NOT with:/env:/uses: are boundaries
|
|
85
|
+
if step_line.match?(/^\s+#{STEP_KEYS}:\s/) && !step_line.match?(/^\s+(with|env|uses):/)
|
|
86
|
+
break
|
|
87
|
+
end
|
|
57
88
|
end
|
|
58
89
|
return false
|
|
59
90
|
end
|
|
60
91
|
|
|
61
|
-
|
|
62
|
-
|
|
92
|
+
# Any step-level key (other than with:/env: sub-keys) is a boundary.
|
|
93
|
+
# A list-item step key means a different step entirely.
|
|
94
|
+
break if content.match?(/^\s+-\s+#{STEP_KEYS}:\s/)
|
|
95
|
+
# A non-list step key that is NOT with:/env: is a boundary
|
|
96
|
+
if content.match?(/^\s+#{STEP_KEYS}:\s/) && !content.match?(/^\s+(with|env|script):/)
|
|
97
|
+
break
|
|
63
98
|
end
|
|
64
99
|
end
|
|
65
100
|
|
|
@@ -19,8 +19,18 @@ module Rules
|
|
|
19
19
|
SAFE_VALUE_PATTERN = /\$\{\{.*\}\}|\$[A-Z_]+|\A[A-Z][A-Z0-9_]+\z/
|
|
20
20
|
SAFE_PASSWORDS = %w[postgres password test example changeme admin root dummy placeholder true false].freeze
|
|
21
21
|
|
|
22
|
+
# Actions whose `with:` slots accept env-var *names* (not values).
|
|
23
|
+
# When the value looks like an UPPER_SNAKE_CASE identifier it is an
|
|
24
|
+
# env-var name reference, not a hardcoded credential.
|
|
25
|
+
ENV_NAME_SLOTS = {
|
|
26
|
+
/actions\/setup-java/ => %w[server-username server-password gpg-passphrase gpg-private-key keystore-password],
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
ENV_VAR_NAME_PATTERN = /\A[A-Z][A-Z0-9_]*\z/
|
|
30
|
+
|
|
22
31
|
def check(workflow)
|
|
23
32
|
findings = []
|
|
33
|
+
allowlisted_lines = build_env_name_slot_lines(workflow)
|
|
24
34
|
|
|
25
35
|
workflow.raw_lines.each_with_index do |line, idx|
|
|
26
36
|
line_num = idx + 1
|
|
@@ -46,6 +56,7 @@ module Rules
|
|
|
46
56
|
value = line[/password:\s*(.+)/i, 1]&.strip
|
|
47
57
|
if value && !value.match?(SAFE_VALUE_PATTERN) && !value.start_with?("#")
|
|
48
58
|
next if SAFE_PASSWORDS.include?(value.strip.downcase)
|
|
59
|
+
next if allowlisted_lines.include?(line_num)
|
|
49
60
|
findings << finding(workflow,
|
|
50
61
|
line: line_num,
|
|
51
62
|
code: stripped,
|
|
@@ -58,5 +69,33 @@ module Rules
|
|
|
58
69
|
|
|
59
70
|
findings
|
|
60
71
|
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Build a list of line numbers where a known action's `with:` slot
|
|
76
|
+
# contains an env-var name (UPPER_SNAKE_CASE) rather than a credential.
|
|
77
|
+
def build_env_name_slot_lines(workflow)
|
|
78
|
+
lines = []
|
|
79
|
+
workflow.jobs.each do |_job_id, job_hash|
|
|
80
|
+
workflow.steps(job_hash).each do |step|
|
|
81
|
+
next unless step["uses"]
|
|
82
|
+
ENV_NAME_SLOTS.each do |action_pattern, slot_names|
|
|
83
|
+
next unless step["uses"].match?(action_pattern)
|
|
84
|
+
with_block = step["with"] || {}
|
|
85
|
+
slot_names.each do |slot|
|
|
86
|
+
next unless with_block[slot]
|
|
87
|
+
value = with_block[slot].to_s.strip
|
|
88
|
+
next unless value.match?(ENV_VAR_NAME_PATTERN)
|
|
89
|
+
workflow.raw_lines.each_with_index do |raw_line, idx|
|
|
90
|
+
if raw_line.match?(/\b#{Regexp.escape(slot)}:\s*#{Regexp.escape(value)}\b/)
|
|
91
|
+
lines << (idx + 1)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
lines
|
|
99
|
+
end
|
|
61
100
|
end
|
|
62
101
|
end
|
|
@@ -10,6 +10,9 @@ module Rules
|
|
|
10
10
|
|
|
11
11
|
PATTERN = /\$\{\{\s*(?:inputs\.|github\.event\.inputs\.)/
|
|
12
12
|
|
|
13
|
+
# All valid GHA step-level properties (excluding run:, which is handled separately).
|
|
14
|
+
STEP_KEYS = /(?:id|if|name|uses|working-directory|shell|with|env|continue-on-error|timeout-minutes|permissions|secrets)/
|
|
15
|
+
|
|
13
16
|
# NOTE: This rule intentionally does NOT use safe_trigger_only? because
|
|
14
17
|
# dispatch inputs are user-controlled. workflow_dispatch IS in SAFE_TRIGGERS
|
|
15
18
|
# for other rules, but this rule specifically targets ${{ inputs.* }} in
|
|
@@ -38,21 +41,72 @@ module Rules
|
|
|
38
41
|
|
|
39
42
|
private
|
|
40
43
|
|
|
44
|
+
# Determines whether the line at target_line is inside a `run:` block.
|
|
45
|
+
# Scans backwards with no lookback cap to find the nearest step-level
|
|
46
|
+
# YAML property key. If that key is `run:`, the line is in a shell
|
|
47
|
+
# context. If it's any other step key (with:, uses:, env:, etc.),
|
|
48
|
+
# the line is NOT in a shell context.
|
|
49
|
+
#
|
|
50
|
+
# Uses STEP_KEYS to recognize all valid GHA step properties as
|
|
51
|
+
# boundaries, preventing false positives when a `run:` from a
|
|
52
|
+
# PREVIOUS step is encountered during backward scan.
|
|
41
53
|
def in_run_block?(workflow, target_line)
|
|
42
54
|
target_content = workflow.raw_lines[target_line - 1]
|
|
43
|
-
|
|
55
|
+
return false unless target_content
|
|
44
56
|
|
|
45
|
-
(target_line - 1).downto(
|
|
57
|
+
(target_line - 1).downto(0) do |i|
|
|
46
58
|
content = workflow.raw_lines[i]
|
|
47
59
|
next unless content
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
# Direct `run:` key (not on a list item line)
|
|
62
|
+
if content.match?(/^\s+run:\s*[\|>]?\s*$/) || content.match?(/^\s+run:\s+\S/)
|
|
63
|
+
return !nested_under_with?(workflow, i)
|
|
64
|
+
end
|
|
65
|
+
# `run:` on a list item line: `- run: |`
|
|
66
|
+
if content.match?(/^\s+-\s+run:\s*[\|>]?\s*$/) || content.match?(/^\s+-\s+run:\s+\S/)
|
|
67
|
+
return true
|
|
55
68
|
end
|
|
69
|
+
|
|
70
|
+
# Any other step-level key acts as a boundary — we're NOT in a run block.
|
|
71
|
+
# This catches with:, uses:, env:, name:, if:, id:, working-directory:,
|
|
72
|
+
# shell:, continue-on-error:, timeout-minutes:, permissions:, secrets:.
|
|
73
|
+
return false if content.match?(/^\s+#{STEP_KEYS}:\s/)
|
|
74
|
+
return false if content.match?(/^\s+-\s+#{STEP_KEYS}:\s/)
|
|
75
|
+
|
|
76
|
+
# steps: key means we've left all steps — not in a run block
|
|
77
|
+
return false if content.match?(/^\s+steps:\s*$/)
|
|
78
|
+
end
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Checks whether the `run:` key at line_index is nested inside a `with:`
|
|
83
|
+
# block (i.e. it's an action parameter named "run", not a step-level
|
|
84
|
+
# shell command). The discriminator is YAML indentation: a step-level
|
|
85
|
+
# `run:` shares indent with `with:`/`uses:`/etc., while a nested `run:`
|
|
86
|
+
# is indented deeper than its parent `with:`.
|
|
87
|
+
def nested_under_with?(workflow, line_index)
|
|
88
|
+
run_line = workflow.raw_lines[line_index]
|
|
89
|
+
run_indent = run_line[/^\s*/].length
|
|
90
|
+
|
|
91
|
+
(line_index - 1).downto(0) do |i|
|
|
92
|
+
content = workflow.raw_lines[i]
|
|
93
|
+
next unless content
|
|
94
|
+
|
|
95
|
+
line_indent = content[/^\s*/].length
|
|
96
|
+
|
|
97
|
+
# If we hit a `with:` at less indent, this run: is nested inside it.
|
|
98
|
+
return true if line_indent < run_indent && content.match?(/^\s+with:\s/)
|
|
99
|
+
|
|
100
|
+
# If we hit any step-level key at equal or less indent, this run:
|
|
101
|
+
# is at step level (not nested).
|
|
102
|
+
return false if line_indent <= run_indent && content.match?(/^\s+#{STEP_KEYS}:\s/)
|
|
103
|
+
return false if line_indent <= run_indent && content.match?(/^\s+-\s+#{STEP_KEYS}:\s/)
|
|
104
|
+
|
|
105
|
+
# Step boundary (list item start at equal or less indent) — stop.
|
|
106
|
+
return false if content.match?(/^\s+-\s/) && line_indent <= run_indent
|
|
107
|
+
|
|
108
|
+
# steps: key means we've left all steps — not nested.
|
|
109
|
+
return false if content.match?(/^\s+steps:\s*$/)
|
|
56
110
|
end
|
|
57
111
|
false
|
|
58
112
|
end
|
data/lib/version.rb
CHANGED
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.3
|
|
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-26 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
|