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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05ec8a3cc855319be1e044cb67785456089df4eb8b4521b63851e632d4f6c85b
4
- data.tar.gz: bbc9aeaebde81a269a86c0fb4cf79887160276be65aa19dcc8cc41c00179e73c
3
+ metadata.gz: ff566270daf8524c526427ffe9b01af6c6af028d03fc5fb947eeb713d51cebde
4
+ data.tar.gz: b823edf579875824fc46d741b762f839efa5ed62df9d711f23c0fc2753731091
5
5
  SHA512:
6
- metadata.gz: c56ab53a07504a0354771338fb5ba1749e97208c54fba88409b7fd3b0736cab47531a355f13eea2977d40fb87001e2ed2d8254fccca4b8662217b0e7071bc29a
7
- data.tar.gz: 6345de4a020a502b11ade9aa98042a912c8445b0d32af26c1fab6e22e0374ad3f62c41f469146262f8b7f584959fa34ce54ec686376d82a6aa1ccc47bf36b378
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
- has_install = steps.any? { |s| s["run"]&.match?(INSTALL_PATTERNS) }
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 has_install && has_publish
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
- if all_env.match?(PUBLISH_SECRETS) || all_env.match?(/secrets\./)
126
- line = workflow.line_of(/#{job_id}:/)
127
- findings << finding(workflow,
128
- line: line || 0,
129
- code: "job: #{job_id}",
130
- message: "Build and publish in same job — a compromised dependency could exfiltrate publish credentials",
131
- fix: "Split into separate build (read-only) and publish (with secrets) jobs connected via artifacts"
132
- )
133
- end
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
- PATTERN = /\$\{\{\s*(#{DANGEROUS_CONTEXTS.map { |c| Regexp.escape(c) }.join('|')})/
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
- match = line.match(PATTERN)
26
- next unless match
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
- findings << finding(workflow,
29
- line: line_num,
30
- code: line.strip,
31
- message: "Attacker-controllable expression ${{ #{match[1]} }} in actions/github-script — JavaScript injection risk",
32
- fix: "Use context.payload instead: context.payload.pull_request.title"
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
- def in_github_script_block?(workflow, target_line)
42
- in_script = false
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
- (target_line - 1).downto([target_line - 30, 0].max) do |i|
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
- in_script = true
51
- script_indent = content[/^\s*/].length
52
- i.downto([i - 15, 0].max) do |j|
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
- break if step_line.match?(/^\s+-\s+(name|uses|run|if|id):/)
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
- if content.match?(/^\s+(uses|run|if|id|name|env|with):/) || content.match?(/^\s+-\s+(name|uses|run):/)
62
- return false
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
- target_indent = target_content ? target_content[/^\s*/].length : 0
55
+ return false unless target_content
44
56
 
45
- (target_line - 1).downto([target_line - 20, 0].max) do |i|
57
+ (target_line - 1).downto(0) do |i|
46
58
  content = workflow.raw_lines[i]
47
59
  next unless content
48
60
 
49
- return true if content.match?(/^\s+run:\s*[\|>]?\s*$/) || content.match?(/^\s+run:\s+\S/)
50
- return true if content.match?(/^\s+-\s+run:\s*[\|>]?\s*$/) || content.match?(/^\s+-\s+run:\s+\S/)
51
-
52
- if content.match?(/^\s+(uses|with|if|id|name|env):/) || content.match?(/^\s+-\s+name:/)
53
- line_indent = content[/^\s*/].length
54
- return false if target_indent <= line_indent + 2
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
@@ -1,3 +1,3 @@
1
1
  module Sentinel
2
- VERSION = "1.3.2"
2
+ VERSION = "1.3.3"
3
3
  end
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.2
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-24 00:00:00.000000000 Z
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