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,94 @@
|
|
1
|
+
class BaseRule
|
2
|
+
attr_accessor :on_workflow, :on_job, :on_step, :configuration
|
3
|
+
|
4
|
+
def self.parse_rule(rule) # rubocop:disable Metrics/AbcSize
|
5
|
+
ExpressionParser.parse_expression(rule).tap do |expression|
|
6
|
+
expression.instance_eval do
|
7
|
+
def ctx # rubocop:disable Metrics/AbcSize
|
8
|
+
@ctx ||= Context.new(
|
9
|
+
default: {},
|
10
|
+
methods: {
|
11
|
+
contains: ->(haystack, needle) { !haystack.nil? and haystack.include? needle },
|
12
|
+
contains_any: ->(haystack, needles) { !haystack.nil? and needles.any? { |n| haystack.include? n } },
|
13
|
+
startswith: ->(string, needle) { string.to_s.start_with? needle },
|
14
|
+
endswith: ->(string, needle) { string.to_s.end_with? needle },
|
15
|
+
difference: ->(arr1, arr2) { arr1.difference arr2 },
|
16
|
+
intersection: ->(arr1, arr2) { arr1.intersection arr2 },
|
17
|
+
count: ->(n) { n.length }
|
18
|
+
}
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def eval_with(values: {})
|
23
|
+
value(
|
24
|
+
ctx: ctx.tap { |c| c.transient_symbols = values }
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
def inspect
|
29
|
+
to_s
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_s
|
33
|
+
"<Expression '#{input}'>"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.name(value)
|
40
|
+
define_method(:name) { value }
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.description(value)
|
44
|
+
define_method(:description) { value }
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.on_workflow(value, highlight: nil, debug: false)
|
48
|
+
(@on_workflow ||= []) << extract_value(value, highlight: highlight, debug: debug)
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.on_job(value, highlight: nil, debug: false)
|
52
|
+
highlight = highlight.to_s unless highlight.nil?
|
53
|
+
(@on_job ||= []) << extract_value(value, highlight: highlight, debug: debug)
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.on_step(value, highlight: nil, debug: false)
|
57
|
+
highlight = highlight.to_s unless highlight.nil?
|
58
|
+
(@on_step ||= []) << extract_value(value, highlight: highlight, debug: debug)
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.extract_value(value, highlight: nil, debug: false)
|
62
|
+
case value
|
63
|
+
when String
|
64
|
+
{ expression: parse_rule(value), highlight: highlight, debug: debug }
|
65
|
+
when Symbol
|
66
|
+
value
|
67
|
+
else
|
68
|
+
raise "Hook must receive either a String (rule) or Symbol (method name), not: #{value.class}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def initialize(configuration: nil)
|
73
|
+
@on_workflow = self.class.instance_variable_get(:@on_workflow) || []
|
74
|
+
@on_job = self.class.instance_variable_get(:@on_job) || []
|
75
|
+
@on_step = self.class.instance_variable_get(:@on_step) || []
|
76
|
+
@configuration = configuration
|
77
|
+
end
|
78
|
+
|
79
|
+
def name
|
80
|
+
self.class.to_s.split("::").last
|
81
|
+
end
|
82
|
+
|
83
|
+
def inspect
|
84
|
+
to_s
|
85
|
+
end
|
86
|
+
|
87
|
+
def to_s
|
88
|
+
"<Rule #{name} (#{@on_workflow.length} Workflow Rules; #{@on_job.length} Job Rules; #{@on_step.length} Step Rules)>"
|
89
|
+
end
|
90
|
+
|
91
|
+
def data
|
92
|
+
{}
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class String
|
2
|
+
# colorization
|
3
|
+
def colorize(color_code)
|
4
|
+
"\e[#{color_code}m#{self}\e[0m"
|
5
|
+
end
|
6
|
+
|
7
|
+
def red
|
8
|
+
colorize(31)
|
9
|
+
end
|
10
|
+
|
11
|
+
def green
|
12
|
+
colorize(32)
|
13
|
+
end
|
14
|
+
|
15
|
+
def yellow
|
16
|
+
colorize(33)
|
17
|
+
end
|
18
|
+
|
19
|
+
def blue
|
20
|
+
colorize(34)
|
21
|
+
end
|
22
|
+
|
23
|
+
def pink
|
24
|
+
colorize(35)
|
25
|
+
end
|
26
|
+
|
27
|
+
def light_blue
|
28
|
+
colorize(36)
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
require "psych"
|
2
|
+
require "pry"
|
3
|
+
|
4
|
+
module Locatable
|
5
|
+
attr_accessor :line
|
6
|
+
end
|
7
|
+
|
8
|
+
module Psych
|
9
|
+
module Nodes
|
10
|
+
class Node
|
11
|
+
attr_accessor :line
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
module Psych
|
17
|
+
module Visitors
|
18
|
+
class ToRuby
|
19
|
+
def accept(target)
|
20
|
+
s = super(target)
|
21
|
+
if target.respond_to?(:line) and ![TrueClass, FalseClass, NilClass, Integer].include? s.class
|
22
|
+
s.instance_eval do
|
23
|
+
extend(Locatable)
|
24
|
+
end
|
25
|
+
|
26
|
+
s.line = target.line
|
27
|
+
end
|
28
|
+
|
29
|
+
s
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def register_empty(object)
|
35
|
+
list = register(object, [])
|
36
|
+
object.children.each do |c|
|
37
|
+
c.line = 0 if c.respond_to? :line and c.line.nil?
|
38
|
+
c.line += 1 if c.respond_to? :line
|
39
|
+
list.push accept c
|
40
|
+
end
|
41
|
+
list
|
42
|
+
end
|
43
|
+
|
44
|
+
def revive_hash(hash, o, _tagged: false) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
|
45
|
+
o.children.each_slice(2) do |k, v|
|
46
|
+
key = accept(k)
|
47
|
+
val = accept(v)
|
48
|
+
|
49
|
+
key.line = 0 if key.respond_to? :line and key.line.nil?
|
50
|
+
key.line += 1 if key.respond_to? :line
|
51
|
+
key.freeze
|
52
|
+
if [TrueClass, FalseClass, NilClass, Integer].include? key.class
|
53
|
+
val.line = 0 if val.respond_to? :line and val.line.nil?
|
54
|
+
val.line += 1 if val.respond_to? :line
|
55
|
+
end
|
56
|
+
|
57
|
+
hash[key] = val
|
58
|
+
end
|
59
|
+
|
60
|
+
hash
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
class TreeBuilderWithLines < Psych::TreeBuilder
|
67
|
+
attr_accessor :parser
|
68
|
+
|
69
|
+
def scalar(value, anchor, tag, plain, quoted, style) # rubocop:disable Metrics/ParameterLists
|
70
|
+
# github uses "on" in its schema for workflows, which
|
71
|
+
# YAML 1.1 turns into a boolean. YAML 1.2 does not, but
|
72
|
+
# Psych doesn't support that.
|
73
|
+
# https://github.com/ruby/psych/blob/56d545e278/test/psych/test_boolean.rb#L9-L13
|
74
|
+
quoted = true if value.downcase == "on"
|
75
|
+
|
76
|
+
super(value, anchor, tag, plain, quoted, style).tap do |l|
|
77
|
+
l.line = parser.mark.line
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def start_document(version, tag_directives, implicit)
|
82
|
+
super(version, tag_directives, implicit).tap do |l|
|
83
|
+
l.line = parser.mark.line
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def start_sequence(anchor, tag, implicit, style)
|
88
|
+
super(anchor, tag, implicit, style).tap do |l|
|
89
|
+
l.line = parser.mark.line
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def start_stream(encoding)
|
94
|
+
super(encoding).tap do |l|
|
95
|
+
l.line = parser.mark.line
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def start_mapping(anchor, tag, implicit, style)
|
100
|
+
super(anchor, tag, implicit, style).tap do |l|
|
101
|
+
l.line = parser.mark.line
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
class YAMLWithLines
|
107
|
+
def self.load(blob)
|
108
|
+
handler = TreeBuilderWithLines.new
|
109
|
+
parser = Psych::Parser.new(handler)
|
110
|
+
handler.parser = parser
|
111
|
+
parser.parse(blob)
|
112
|
+
parser.handler.root.to_ruby.first.tap do |c|
|
113
|
+
c.instance_eval do
|
114
|
+
@lines = blob.split("\n")
|
115
|
+
|
116
|
+
def get_line(line:)
|
117
|
+
raise "Line number must be positive and one-indexed" if line < 1
|
118
|
+
|
119
|
+
@lines[line - 1]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
data/lib/claws/engine.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
require "equation"
|
2
|
+
|
3
|
+
class ExpressionParser
|
4
|
+
def self.parse_expression(expression)
|
5
|
+
get_engine.parse(rule: expression)
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.get_engine # rubocop:disable Naming/AccessorMethodName, Metrics/AbcSize
|
9
|
+
EquationEngine.new(
|
10
|
+
default: {
|
11
|
+
# workflow: workflow,
|
12
|
+
# jobs: workflow["jobs"],
|
13
|
+
# data: rules["data"],
|
14
|
+
},
|
15
|
+
methods: {
|
16
|
+
contains: ->(haystack, needle) { !haystack.nil? and haystack.include? needle },
|
17
|
+
contains_any: ->(haystack, needles) { !haystack.nil? and needles.any? { |n| haystack.include? n } },
|
18
|
+
startswith: ->(string, needle) { string.to_s.start_with? needle },
|
19
|
+
endswith: ->(string, needle) { string.to_s.end_with? needle },
|
20
|
+
difference: ->(arr1, arr2) { arr1.difference arr2 },
|
21
|
+
count: ->(n) { n.length }
|
22
|
+
}
|
23
|
+
)
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Claws
|
2
|
+
module Formatter
|
3
|
+
class Github
|
4
|
+
def self.report_violations(violations)
|
5
|
+
violations.each do |v|
|
6
|
+
printf(
|
7
|
+
"::%<severity>s file=%<file>s,line=%<line>d::%<message>s\n",
|
8
|
+
severity: :error,
|
9
|
+
file: v.file,
|
10
|
+
line: v.line,
|
11
|
+
message: v.description.gsub("\n", "%0A")
|
12
|
+
)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module Claws
|
2
|
+
module Formatter
|
3
|
+
class Stdout
|
4
|
+
def self.report_violations(violations)
|
5
|
+
violations.each do |v|
|
6
|
+
puts "Violation: #{v.name} on #{v.file}:#{v.line}".red
|
7
|
+
puts v.description
|
8
|
+
puts v.snippet unless v.snippet.nil?
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class AutomaticMerge < BaseRule
|
4
|
+
description <<~DESC
|
5
|
+
This workflow automatically merges user-supplied pull requests.
|
6
|
+
Please review the workflow to ensure this is necessary and its logic is sound.
|
7
|
+
|
8
|
+
For more information:
|
9
|
+
https://github.com/betterment/claws/blob/main/README.md#automaticmerge
|
10
|
+
DESC
|
11
|
+
|
12
|
+
on_step %(
|
13
|
+
contains_any($workflow.meta.triggers, $data.pr_events) && (
|
14
|
+
$step.run =~ "gh\s*pr\s*merge"
|
15
|
+
)
|
16
|
+
), highlight: "run"
|
17
|
+
|
18
|
+
on_step %(
|
19
|
+
contains_any($workflow.meta.triggers, $data.pr_events) && (
|
20
|
+
$step.meta.action.name in $data.automerge_actions
|
21
|
+
)
|
22
|
+
), highlight: "uses"
|
23
|
+
|
24
|
+
def data
|
25
|
+
{
|
26
|
+
"automerge_actions":
|
27
|
+
configuration.fetch("automerge_actions", default_automerge_actions),
|
28
|
+
"pr_events":
|
29
|
+
configuration.fetch("pr_events", default_pr_events)
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def default_pr_events
|
36
|
+
%w[
|
37
|
+
push pull_request_target pull_request
|
38
|
+
pull_request_comment pull_request_review
|
39
|
+
pull_request_review_comment workflow_dispatch
|
40
|
+
workflow_call
|
41
|
+
]
|
42
|
+
end
|
43
|
+
|
44
|
+
def default_automerge_actions
|
45
|
+
["reitermarkus/automerge", "pascalgn/automerge-action"]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class BulkPermissions < BaseRule
|
4
|
+
description <<~DESC
|
5
|
+
Permissions should be requested based on access required for a job to complete instead of in bulk.
|
6
|
+
|
7
|
+
For more information:
|
8
|
+
https://github.com/betterment/claws/blob/main/README.md#bulkpermissions
|
9
|
+
DESC
|
10
|
+
|
11
|
+
on_workflow %(
|
12
|
+
$workflow.permissions in ["write-all", "read-all"]
|
13
|
+
), highlight: "permissions"
|
14
|
+
|
15
|
+
on_job %(
|
16
|
+
$job.permissions in ["write-all", "read-all"]
|
17
|
+
), highlight: "permissions"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class CommandInjection < BaseRule
|
4
|
+
description <<~DESC
|
5
|
+
This step executes commands with user input which may allow an attacker to execute code in the context of this step, exposing source code or credentials. Consider moving user input into an environment variable instead of directly placing it into the shell command.
|
6
|
+
|
7
|
+
For more information:
|
8
|
+
https://github.com/betterment/claws/blob/main/README.md#commandinjection
|
9
|
+
DESC
|
10
|
+
|
11
|
+
on_step '$step.run =~ ".*{{[ ]+.*(github.event|inputs).*}}.*"', highlight: "run"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class EmptyName < BaseRule
|
4
|
+
description <<~DESC
|
5
|
+
All workflows must have an easily identifiable name.
|
6
|
+
|
7
|
+
For more information:
|
8
|
+
https://github.com/betterment/claws/blob/main/README.md#emptyname
|
9
|
+
DESC
|
10
|
+
|
11
|
+
on_workflow "$workflow.name == null"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class InheritedSecrets < BaseRule
|
4
|
+
description <<~DESC
|
5
|
+
All workflows must explicitly state the secrets necessary for them to function properly.
|
6
|
+
|
7
|
+
For more information:
|
8
|
+
https://github.com/betterment/claws/blob/main/README.md#inheritedsecrets
|
9
|
+
DESC
|
10
|
+
|
11
|
+
on_job %(
|
12
|
+
contains($workflow.meta.triggers, "workflow_call") &&
|
13
|
+
$job.secrets == "inherit"
|
14
|
+
), highlight: "secrets"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class NoContainers < BaseRule
|
4
|
+
description <<~DESC
|
5
|
+
This job uses non-standard container images.
|
6
|
+
|
7
|
+
For more information:
|
8
|
+
https://github.com/betterment/claws/blob/main/README.md#nocontainers
|
9
|
+
DESC
|
10
|
+
|
11
|
+
on_job %(
|
12
|
+
$job.meta.container != null &&
|
13
|
+
!contains($data.approved_images, $job.meta.container.full)
|
14
|
+
), highlight: "container.image"
|
15
|
+
|
16
|
+
on_step %(
|
17
|
+
$step.uses =~ "^docker://" &&
|
18
|
+
!contains($data.approved_images, $step.uses)
|
19
|
+
), highlight: :uses
|
20
|
+
|
21
|
+
def data
|
22
|
+
{
|
23
|
+
'approved_images': configuration.fetch("approved_images", [])
|
24
|
+
}
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class RiskyTriggers < BaseRule
|
4
|
+
description <<~DESC
|
5
|
+
This flags workflows that may be using risky triggers to execute.
|
6
|
+
|
7
|
+
For more information:
|
8
|
+
https://github.com/betterment/claws/blob/main/README.md#riskytriggers
|
9
|
+
DESC
|
10
|
+
|
11
|
+
on_workflow %(
|
12
|
+
contains($data.triggers, $workflow.meta.triggers) ||
|
13
|
+
contains_any($workflow.meta.triggers, $data.triggers)
|
14
|
+
), highlight: "on"
|
15
|
+
|
16
|
+
def data
|
17
|
+
{
|
18
|
+
'triggers': risky_triggers
|
19
|
+
}
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def risky_triggers
|
25
|
+
configuration.fetch(
|
26
|
+
"risky_triggers",
|
27
|
+
%w[pull_request_target workflow_dispatch]
|
28
|
+
)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,109 @@
|
|
1
|
+
require "open3"
|
2
|
+
|
3
|
+
module Claws
|
4
|
+
module Rule
|
5
|
+
class Shellcheck < BaseRule
|
6
|
+
description <<~DESC
|
7
|
+
This shell script did not pass Shellcheck.
|
8
|
+
|
9
|
+
For more information:
|
10
|
+
https://github.com/betterment/claws/blob/main/README.md#shellcheck
|
11
|
+
DESC
|
12
|
+
|
13
|
+
on_step :shellcheck
|
14
|
+
|
15
|
+
def shellcheck(workflow:, job:, step:) # rubocop:disable Lint/UnusedMethodArgument, Metrics/AbcSize
|
16
|
+
unless File.exist? shellcheck_bin
|
17
|
+
warn "Couldn't find shellcheck binary (#{shellcheck_bin}).\n"
|
18
|
+
warn "Make sure it's installed and configure `shellcheck_bin` appropriately."
|
19
|
+
exit 1
|
20
|
+
end
|
21
|
+
|
22
|
+
return if step["run"].nil?
|
23
|
+
|
24
|
+
shell = if step["shell"].nil?
|
25
|
+
identify_shell(step["run"])
|
26
|
+
else
|
27
|
+
step["shell"]
|
28
|
+
end
|
29
|
+
|
30
|
+
return if shell.nil?
|
31
|
+
|
32
|
+
exit_status, stdout, = analyze_script(step["run"], shell)
|
33
|
+
|
34
|
+
return unless exit_status == 1
|
35
|
+
|
36
|
+
Violation.new(
|
37
|
+
line: step.keys.filter { |x| x == "run" }.first.line,
|
38
|
+
description: "Shellcheck found some issues with this shell script:\n#{stdout}"
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def sanitize_script(script)
|
45
|
+
mapping = {}
|
46
|
+
|
47
|
+
new_script = script.gsub(/\$\{\{\s*(.*?)\s*\}\}/) do
|
48
|
+
inner_content = ::Regexp.last_match(1).strip
|
49
|
+
placeholder_name = "GITHUB_ACTION_PLACEHOLDER_#{inner_content.gsub(/[^a-zA-Z0-9]/, "_").upcase}"
|
50
|
+
|
51
|
+
mapping[placeholder_name] = "${{ #{inner_content} }}"
|
52
|
+
"$#{placeholder_name}"
|
53
|
+
end
|
54
|
+
|
55
|
+
[new_script.to_s, mapping]
|
56
|
+
end
|
57
|
+
|
58
|
+
def unsanitize_script(script, mapping)
|
59
|
+
mapping.each do |k, v|
|
60
|
+
script = script.gsub(k, v)
|
61
|
+
end
|
62
|
+
|
63
|
+
script
|
64
|
+
end
|
65
|
+
|
66
|
+
def analyze_script(script, shell)
|
67
|
+
sanitized_script, variables = *sanitize_script(script)
|
68
|
+
|
69
|
+
Open3.popen3(
|
70
|
+
shellcheck_bin, "-", "-s", shell
|
71
|
+
) do |stdin, stdout, stderr, wait_thr|
|
72
|
+
stdin.write(sanitized_script)
|
73
|
+
stdin.close
|
74
|
+
|
75
|
+
stdout_buffer = stdout.read
|
76
|
+
stderr_buffer = stderr.read
|
77
|
+
|
78
|
+
stderr.close
|
79
|
+
stdout.close
|
80
|
+
|
81
|
+
return [
|
82
|
+
wait_thr.value.exitstatus,
|
83
|
+
unsanitize_script(stdout_buffer, variables),
|
84
|
+
unsanitize_script(stderr_buffer, variables)
|
85
|
+
]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def identify_shell(command)
|
90
|
+
return "bash" unless command.lines.first.start_with? "#!"
|
91
|
+
|
92
|
+
supported_shells.select do |shell|
|
93
|
+
command.lines.first.start_with? "#!/bin/#{shell}"
|
94
|
+
end.first
|
95
|
+
end
|
96
|
+
|
97
|
+
def supported_shells
|
98
|
+
%w[bash sh dash ksh]
|
99
|
+
end
|
100
|
+
|
101
|
+
def shellcheck_bin
|
102
|
+
configuration.fetch(
|
103
|
+
"shellcheck_bin",
|
104
|
+
"/opt/homebrew/bin/shellcheck"
|
105
|
+
)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class SpecialPermissions < BaseRule
|
4
|
+
# Unfortunately because `highlight` is a static key, we can't
|
5
|
+
# dynamically highlight the specific, problematic permission.
|
6
|
+
#
|
7
|
+
# This means ignoring SpecialPermissions will ignore any new
|
8
|
+
# special permissions that might be added at a later date.
|
9
|
+
description <<~DESC
|
10
|
+
Confirm whether this job needs these write permissions.
|
11
|
+
|
12
|
+
For more information:
|
13
|
+
https://github.com/betterment/claws/blob/main/README.md#specialpermissions
|
14
|
+
DESC
|
15
|
+
|
16
|
+
on_workflow %(
|
17
|
+
count(intersection($workflow.meta.permissions.write, $data.sensitive_writes)) > 0
|
18
|
+
), highlight: "permissions"
|
19
|
+
|
20
|
+
on_job %(
|
21
|
+
count(intersection($job.meta.permissions.write, $data.sensitive_writes)) > 0
|
22
|
+
), highlight: "permissions"
|
23
|
+
|
24
|
+
def data
|
25
|
+
{
|
26
|
+
sensitive_writes: %w[
|
27
|
+
checks
|
28
|
+
id-token
|
29
|
+
packages
|
30
|
+
security-events
|
31
|
+
statuses
|
32
|
+
]
|
33
|
+
}
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Claws
|
2
|
+
module Rule
|
3
|
+
class UnapprovedRunners < BaseRule
|
4
|
+
description <<~DESC
|
5
|
+
This workflow is using an unapproved runner.
|
6
|
+
|
7
|
+
For more information:
|
8
|
+
https://github.com/betterment/claws/blob/main/README.md#unapprovedrunners
|
9
|
+
DESC
|
10
|
+
|
11
|
+
on_job %(
|
12
|
+
$job.runs_on != null && !contains($data.allowed_runners, $job.runs_on)
|
13
|
+
), highlight: "runs_on"
|
14
|
+
|
15
|
+
def data
|
16
|
+
{
|
17
|
+
'allowed_runners': allowed_runners
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def allowed_runners
|
24
|
+
configuration.fetch(
|
25
|
+
"allowed_runners",
|
26
|
+
%w[ubuntu-latest]
|
27
|
+
)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|