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,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
@@ -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,4 @@
1
+ require "claws"
2
+ require "claws/cli/color"
3
+ require "claws/formatter/stdout"
4
+ require "claws/formatter/github"
@@ -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