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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +31 -0
- data/.ruby-version +1 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +99 -0
- data/README.md +557 -0
- data/Rakefile +12 -0
- data/bin/analyze +62 -0
- data/config.yml +16 -0
- data/corpus/automerge_via_action.yml +28 -0
- data/corpus/automerge_via_cli.yml +14 -0
- data/corpus/build-docker-image-run-drc-for-cell-gds-using-magic.yml +170 -0
- data/corpus/cmd.yml +14 -0
- data/corpus/container.yml +19 -0
- data/corpus/container_docker.yml +9 -0
- data/corpus/dispatch_command_injection.yml +17 -0
- data/corpus/inherit_secrets.yml +20 -0
- data/corpus/nameless.yml +11 -0
- data/corpus/permissions.yml +19 -0
- data/corpus/ruby.yml +12 -0
- data/corpus/shellcheck.yml +12 -0
- data/corpus/unsafe_checkout_code_execution.yml +21 -0
- data/corpus/unsafe_checkout_token_leak.yml +33 -0
- data/corpus/unscoped_secrets.yml +16 -0
- data/github_action.yml +36 -0
- data/lib/claws/application.rb +237 -0
- data/lib/claws/base_rule.rb +94 -0
- data/lib/claws/cli/color.rb +30 -0
- data/lib/claws/cli/yaml_with_lines.rb +124 -0
- data/lib/claws/engine.rb +25 -0
- data/lib/claws/formatter/github.rb +17 -0
- data/lib/claws/formatter/stdout.rb +13 -0
- data/lib/claws/formatters.rb +4 -0
- data/lib/claws/rule/automatic_merge.rb +49 -0
- data/lib/claws/rule/bulk_permissions.rb +20 -0
- data/lib/claws/rule/command_injection.rb +14 -0
- data/lib/claws/rule/empty_name.rb +14 -0
- data/lib/claws/rule/inherited_secrets.rb +17 -0
- data/lib/claws/rule/no_containers.rb +28 -0
- data/lib/claws/rule/risky_triggers.rb +32 -0
- data/lib/claws/rule/shellcheck.rb +109 -0
- data/lib/claws/rule/special_permissions.rb +37 -0
- data/lib/claws/rule/unapproved_runners.rb +31 -0
- data/lib/claws/rule/unpinned_action.rb +30 -0
- data/lib/claws/rule/unsafe_checkout.rb +36 -0
- data/lib/claws/rule.rb +13 -0
- data/lib/claws/version.rb +5 -0
- data/lib/claws/violation.rb +11 -0
- data/lib/claws/workflow.rb +221 -0
- data/lib/claws.rb +6 -0
- 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,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
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: []
|