claws-scan 0.7.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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +31 -0
  4. data/.ruby-version +1 -0
  5. data/Gemfile +17 -0
  6. data/Gemfile.lock +99 -0
  7. data/README.md +557 -0
  8. data/Rakefile +12 -0
  9. data/bin/analyze +62 -0
  10. data/config.yml +16 -0
  11. data/corpus/automerge_via_action.yml +28 -0
  12. data/corpus/automerge_via_cli.yml +14 -0
  13. data/corpus/build-docker-image-run-drc-for-cell-gds-using-magic.yml +170 -0
  14. data/corpus/cmd.yml +14 -0
  15. data/corpus/container.yml +19 -0
  16. data/corpus/container_docker.yml +9 -0
  17. data/corpus/dispatch_command_injection.yml +17 -0
  18. data/corpus/inherit_secrets.yml +20 -0
  19. data/corpus/nameless.yml +11 -0
  20. data/corpus/permissions.yml +19 -0
  21. data/corpus/ruby.yml +12 -0
  22. data/corpus/shellcheck.yml +12 -0
  23. data/corpus/unsafe_checkout_code_execution.yml +21 -0
  24. data/corpus/unsafe_checkout_token_leak.yml +33 -0
  25. data/corpus/unscoped_secrets.yml +16 -0
  26. data/github_action.yml +36 -0
  27. data/lib/claws/application.rb +237 -0
  28. data/lib/claws/base_rule.rb +94 -0
  29. data/lib/claws/cli/color.rb +30 -0
  30. data/lib/claws/cli/yaml_with_lines.rb +124 -0
  31. data/lib/claws/engine.rb +25 -0
  32. data/lib/claws/formatter/github.rb +17 -0
  33. data/lib/claws/formatter/stdout.rb +13 -0
  34. data/lib/claws/formatters.rb +4 -0
  35. data/lib/claws/rule/automatic_merge.rb +49 -0
  36. data/lib/claws/rule/bulk_permissions.rb +20 -0
  37. data/lib/claws/rule/command_injection.rb +14 -0
  38. data/lib/claws/rule/empty_name.rb +14 -0
  39. data/lib/claws/rule/inherited_secrets.rb +17 -0
  40. data/lib/claws/rule/no_containers.rb +28 -0
  41. data/lib/claws/rule/risky_triggers.rb +32 -0
  42. data/lib/claws/rule/shellcheck.rb +109 -0
  43. data/lib/claws/rule/special_permissions.rb +37 -0
  44. data/lib/claws/rule/unapproved_runners.rb +31 -0
  45. data/lib/claws/rule/unpinned_action.rb +30 -0
  46. data/lib/claws/rule/unsafe_checkout.rb +36 -0
  47. data/lib/claws/rule.rb +13 -0
  48. data/lib/claws/version.rb +5 -0
  49. data/lib/claws/violation.rb +11 -0
  50. data/lib/claws/workflow.rb +221 -0
  51. data/lib/claws.rb +6 -0
  52. metadata +151 -0
@@ -0,0 +1,30 @@
1
+ module Claws
2
+ module Rule
3
+ class UnpinnedAction < BaseRule
4
+ description <<~DESC
5
+ All reusable actions must be pinned to a full sha1 commit hash.
6
+
7
+ For more information:
8
+ https://github.com/betterment/claws/blob/main/README.md#unpinnedaction
9
+ DESC
10
+
11
+ on_step %(
12
+ $step.meta.action != null &&
13
+ (
14
+ $step.meta.action.version == null ||
15
+ contains(["main", "master"], $step.meta.action.version) ||
16
+ !($step.meta.action.version =~ "^[a-fA-F0-9]{40}$")
17
+ ) &&
18
+ !contains($data.trusted_authors, $step.meta.action.author) &&
19
+ !$step.meta.action.local
20
+ ), highlight: "uses"
21
+
22
+ def data
23
+ {
24
+ "trusted_authors": configuration.fetch("trusted_authors", []),
25
+ "loose": configuration.fetch("loose_validation", false)
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ module Claws
2
+ module Rule
3
+ class UnsafeCheckout < BaseRule
4
+ description <<~DESC
5
+ This workflow checks out a user supplied branch, which could be risky if any code is executed using it.
6
+
7
+ For more information:
8
+ https://github.com/betterment/claws/blob/main/README.md#unsafecheckout
9
+ DESC
10
+
11
+ on_step %(
12
+ contains_any($workflow.meta.triggers, $data.risky_events) &&
13
+ $step.meta.action.name == "actions/checkout" &&
14
+ (
15
+ contains($step.with.ref, "github.event") ||
16
+ contains($step.with.ref, "inputs.")
17
+ )
18
+ ), highlight: "with.ref"
19
+
20
+ def data
21
+ {
22
+ "risky_events": risky_events
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def risky_events
29
+ configuration.fetch(
30
+ "risky_events",
31
+ %w[pull_request_target workflow_dispatch]
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
data/lib/claws/rule.rb ADDED
@@ -0,0 +1,13 @@
1
+ require "claws/base_rule"
2
+ require "claws/rule/no_containers"
3
+ require "claws/rule/special_permissions"
4
+ require "claws/rule/empty_name"
5
+ require "claws/rule/risky_triggers"
6
+ require "claws/rule/unapproved_runners"
7
+ require "claws/rule/automatic_merge"
8
+ require "claws/rule/unpinned_action"
9
+ require "claws/rule/unsafe_checkout"
10
+ require "claws/rule/inherited_secrets"
11
+ require "claws/rule/command_injection"
12
+ require "claws/rule/bulk_permissions"
13
+ require "claws/rule/shellcheck"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claws
4
+ VERSION = "0.7.3"
5
+ end
@@ -0,0 +1,11 @@
1
+ class Violation
2
+ attr_accessor :file, :name, :line, :snippet, :description
3
+
4
+ def initialize(line:, description:, file: nil, name: nil, snippet: nil)
5
+ @file = file
6
+ @name = name
7
+ @line = line
8
+ @snippet = snippet
9
+ @description = description
10
+ end
11
+ end
@@ -0,0 +1,221 @@
1
+ require "forwardable"
2
+ require "claws/cli/yaml_with_lines"
3
+
4
+ class Workflow
5
+ extend Forwardable
6
+
7
+ attr_accessor :data, :on, :jobs, :name, :meta, :permissions
8
+
9
+ def_delegators :@workflow, :get_line, :include?, :keys
10
+
11
+ def initialize(raw_yaml)
12
+ @workflow = YAMLWithLines.load(raw_yaml)
13
+
14
+ # enriched metadata about the workflow as a whole
15
+ @meta = {}
16
+
17
+ normalize_dashes(@workflow)
18
+ extract_normalized_on(@workflow)
19
+ extract_normalized_jobs(@workflow)
20
+ extract_normalized_name(@workflow)
21
+ extract_permissions(@workflow)
22
+
23
+ @raw_yaml = raw_yaml
24
+ end
25
+
26
+ def self.load(blob)
27
+ Workflow.new(blob)
28
+ end
29
+
30
+ def line
31
+ @workflow.line
32
+ end
33
+
34
+ def [](key)
35
+ return @on if key.to_s == "on"
36
+ return @jobs if key.to_s == "jobs"
37
+ return @name if key.to_s == "name"
38
+ end
39
+
40
+ def get_snippet(line, context: 3)
41
+ buffer = ""
42
+ (([0, line - context].max)..(line + context)).each do |i|
43
+ next if @raw_yaml.lines[i].nil?
44
+
45
+ buffer += if i + 1 == line
46
+ ">>> #{@raw_yaml.lines[i]}"
47
+ else
48
+ @raw_yaml.lines[i]
49
+ end
50
+ end
51
+
52
+ buffer
53
+ end
54
+
55
+ def ignores
56
+ ignores = {}
57
+
58
+ @raw_yaml.lines.each_with_index do |line, i|
59
+ i += 1 # line numbers are one indexed
60
+
61
+ matches = line.match(/^\s*#.*ignore: (.*)/)
62
+ next if matches.nil?
63
+
64
+ matches = matches[1].split(",").map(&:strip)
65
+ ignores[i] = matches
66
+ end
67
+
68
+ ignores
69
+ end
70
+
71
+ private
72
+
73
+ def normalize_dashes(input)
74
+ return input unless input.is_a? Hash
75
+
76
+ input.clone.each do |old_key, v|
77
+ new_key = old_key.to_s.gsub(/-/, "_")
78
+
79
+ if old_key != new_key
80
+ copy_key_with_line(input, old_key, new_key)
81
+ input.delete(old_key)
82
+ end
83
+
84
+ normalize_dashes(v)
85
+ end
86
+
87
+ input
88
+ end
89
+
90
+ def extract_permissions(input)
91
+ @permissions = input["permissions"]
92
+ @meta["permissions"] = normalize_permissions(input["permissions"])
93
+ end
94
+
95
+ def normalize_permissions(input) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
96
+ permissions = {
97
+ read: [],
98
+ write: [],
99
+ none: [],
100
+ read_all: false,
101
+ write_all: false
102
+ }
103
+
104
+ return permissions if input.nil?
105
+
106
+ if input.is_a? String
107
+ permissions[:read_all] = true if input == "read-all"
108
+ permissions[:write_all] = true if input == "write-all"
109
+
110
+ return permissions
111
+ end
112
+
113
+ return unless input.is_a? Hash
114
+
115
+ input.each do |k, v|
116
+ permissions[:read] << k if v == "read"
117
+ permissions[:write] << k if v == "write"
118
+ permissions[:none] << k if v == "none"
119
+ end
120
+
121
+ permissions
122
+ end
123
+
124
+ def extract_normalized_on(workflow)
125
+ if workflow["on"].is_a? String
126
+ line_number = workflow.keys.first { |k| k == "on" }.line
127
+ @on = workflow["on"] = [workflow["on"]]
128
+ set_attr_line_number(:@on, line_number)
129
+ else
130
+ @on = workflow["on"]
131
+ end
132
+
133
+ @meta["triggers"] = @on
134
+ @meta["triggers"] = @on.keys if @on.is_a? Hash
135
+ end
136
+
137
+ def extract_normalized_jobs(workflow)
138
+ @jobs = workflow["jobs"]
139
+ @jobs.each do |job_name, job|
140
+ @jobs[job_name] = job
141
+ job["meta"] = {
142
+ container: extract_container_info_from_job(job),
143
+ permissions: normalize_permissions(job["permissions"])
144
+ }
145
+
146
+ job.fetch("steps", []).each do |step|
147
+ step["meta"] = {
148
+ secrets: extract_used_secrets(step["env"]),
149
+ action: extract_action_data(step["uses"])
150
+ }
151
+ end
152
+ end
153
+ end
154
+
155
+ def extract_used_secrets(env)
156
+ return [] if env.nil?
157
+
158
+ secrets = []
159
+ env.each do |_k, v|
160
+ next unless v.is_a? String
161
+
162
+ secrets += v.scan(/secrets\.([a-zA-Z0-9_]+)/).flatten
163
+ end
164
+
165
+ secrets
166
+ end
167
+
168
+ def extract_action_data(action)
169
+ return nil if action.nil?
170
+
171
+ return extract_container_info_from_action(action) if action.start_with? "docker://"
172
+
173
+ name, version = action.split("@", 2)
174
+ author = name.split("/", 2)[0]
175
+ local = author == "."
176
+ { type: "action", name: name, author: author, version: version, local: local }
177
+ end
178
+
179
+ def extract_container_info_from_job(job)
180
+ return nil if job["container"].nil?
181
+
182
+ image = if job["container"].is_a? Hash
183
+ job["container"]["image"]
184
+ else
185
+ job["container"]
186
+ end
187
+
188
+ extract_container_info_from_action(image)
189
+ end
190
+
191
+ def extract_container_info_from_action(action)
192
+ return nil if action.nil?
193
+
194
+ image, version = action.split("docker://").last.split(":", 2)
195
+
196
+ {
197
+ type: "container",
198
+ image: image,
199
+ version: version,
200
+ full: "#{image}:#{version}"
201
+ }
202
+ end
203
+
204
+ def extract_normalized_name(workflow)
205
+ @name = workflow["name"]
206
+ set_attr_line_number(:@name, 0)
207
+ end
208
+
209
+ def set_attr_line_number(key, line)
210
+ instance_variable_get(key).instance_eval { |_x| define_singleton_method(:line, -> { line }) }
211
+ end
212
+
213
+ def copy_key_with_line(blob, src, dst)
214
+ line = blob.keys.first { |k| k.to_sym == src.to_sym }.line
215
+
216
+ new_key = String.new(dst).tap { |x| x.instance_eval { |_x| define_singleton_method(:line, -> { line }) } }
217
+ # freezing it keeps ruby from making a copy w/o `line`
218
+ new_key.freeze
219
+ blob[new_key] = blob[src]
220
+ end
221
+ end
data/lib/claws.rb ADDED
@@ -0,0 +1,6 @@
1
+ require "claws/application"
2
+ require "claws/engine"
3
+ require "claws/formatters"
4
+ require "claws/rule"
5
+ require "claws/violation"
6
+ require "claws/workflow"
metadata ADDED
@@ -0,0 +1,151 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: claws-scan
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.7.3
5
+ platform: ruby
6
+ authors:
7
+ - Omar
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-04-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: equation
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: slop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '4.9'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '4.9'
55
+ - !ruby/object:Gem::Dependency
56
+ name: treetop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: Analyzes your Github Actions
70
+ email:
71
+ - omar@betterment.com
72
+ executables:
73
+ - analyze
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".rspec"
78
+ - ".rubocop.yml"
79
+ - ".ruby-version"
80
+ - Gemfile
81
+ - Gemfile.lock
82
+ - README.md
83
+ - Rakefile
84
+ - bin/analyze
85
+ - config.yml
86
+ - corpus/automerge_via_action.yml
87
+ - corpus/automerge_via_cli.yml
88
+ - corpus/build-docker-image-run-drc-for-cell-gds-using-magic.yml
89
+ - corpus/cmd.yml
90
+ - corpus/container.yml
91
+ - corpus/container_docker.yml
92
+ - corpus/dispatch_command_injection.yml
93
+ - corpus/inherit_secrets.yml
94
+ - corpus/nameless.yml
95
+ - corpus/permissions.yml
96
+ - corpus/ruby.yml
97
+ - corpus/shellcheck.yml
98
+ - corpus/unsafe_checkout_code_execution.yml
99
+ - corpus/unsafe_checkout_token_leak.yml
100
+ - corpus/unscoped_secrets.yml
101
+ - github_action.yml
102
+ - lib/claws.rb
103
+ - lib/claws/application.rb
104
+ - lib/claws/base_rule.rb
105
+ - lib/claws/cli/color.rb
106
+ - lib/claws/cli/yaml_with_lines.rb
107
+ - lib/claws/engine.rb
108
+ - lib/claws/formatter/github.rb
109
+ - lib/claws/formatter/stdout.rb
110
+ - lib/claws/formatters.rb
111
+ - lib/claws/rule.rb
112
+ - lib/claws/rule/automatic_merge.rb
113
+ - lib/claws/rule/bulk_permissions.rb
114
+ - lib/claws/rule/command_injection.rb
115
+ - lib/claws/rule/empty_name.rb
116
+ - lib/claws/rule/inherited_secrets.rb
117
+ - lib/claws/rule/no_containers.rb
118
+ - lib/claws/rule/risky_triggers.rb
119
+ - lib/claws/rule/shellcheck.rb
120
+ - lib/claws/rule/special_permissions.rb
121
+ - lib/claws/rule/unapproved_runners.rb
122
+ - lib/claws/rule/unpinned_action.rb
123
+ - lib/claws/rule/unsafe_checkout.rb
124
+ - lib/claws/version.rb
125
+ - lib/claws/violation.rb
126
+ - lib/claws/workflow.rb
127
+ homepage: https://github.com/Betterment/claws
128
+ licenses: []
129
+ metadata:
130
+ homepage_uri: https://github.com/Betterment/claws
131
+ source_code_uri: https://github.com/Betterment/claws
132
+ post_install_message:
133
+ rdoc_options: []
134
+ require_paths:
135
+ - lib
136
+ required_ruby_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '3.0'
141
+ required_rubygems_version: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ requirements: []
147
+ rubygems_version: 3.4.19
148
+ signing_key:
149
+ specification_version: 4
150
+ summary: Analyzes your Github Actions
151
+ test_files: []