claws-scan 0.8.0 → 0.9.5

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: b06dc58b6a6cca01d4a26c696c0ad84ee026a850ecb154e5363ac87fe056c132
4
- data.tar.gz: '006479039bafbefb2c33c15324c645b48a8dc36a607a6ac78c1922de273621f4'
3
+ metadata.gz: 99e68a15fab38b583b1b9c34717ef25f2a1612f0da8b0bf50d5b0d4530829ce5
4
+ data.tar.gz: ca653965f7c90d93e2122d70ceb1a54e0c1a326d13d8cbbfbfaa8cadc8e01273
5
5
  SHA512:
6
- metadata.gz: 162535cf6679e3ac35a352ddf4c8389063e9196f848707cf23dab847b2d42dae26803f2918418c13a8e50432fc0a74138eb44d61535692e01c1ff92a70291e06
7
- data.tar.gz: 92d781d858a2da50fd81f4c9b1da5c2dab822e0dc9c9f4d69044e193481842b107e151d2dad8580233996e548c45b2e58fdd6bec47f4d4df56f076b35d858eed
6
+ metadata.gz: c1fbbe050665049b3a913681debe61e374d825197e3be5883e7bcbf3e0ea7818937700e576feba9fdd5fd0edb16798a9b2ce1c6a157781036324ff8b6ab4aa59
7
+ data.tar.gz: f715f4ea43b80cdc3d25a41554d30b0fcf88a3055e500f325c93f31a3d35020083cf45bcbff14cf10f9373952a8fb918da674fec2bb9aebf1b6c341a522a7eec
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 3.0
2
+ TargetRubyVersion: 3.2.3
3
3
 
4
4
  Style/StringLiterals:
5
5
  Enabled: true
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- claws-scan (0.8.0)
4
+ claws-scan (0.9.5)
5
5
  equation (~> 0.6)
6
6
  pry
7
7
  slop (~> 4.9)
@@ -10,21 +10,26 @@ PATH
10
10
  GEM
11
11
  remote: https://rubygems.org/
12
12
  specs:
13
- addressable (2.8.1)
14
- public_suffix (>= 2.0.2, < 6.0)
13
+ addressable (2.9.0)
14
+ public_suffix (>= 2.0.2, < 8.0)
15
15
  ast (2.4.2)
16
16
  coderay (1.1.3)
17
17
  diff-lcs (1.5.0)
18
18
  equation (0.6.2)
19
19
  fakefs (2.3.0)
20
- faraday (2.7.4)
21
- faraday-net_http (>= 2.0, < 3.1)
22
- ruby2_keywords (>= 0.0.4)
23
- faraday-net_http (3.0.2)
20
+ faraday (2.14.3)
21
+ faraday-net_http (>= 2.0, < 3.5)
22
+ json
23
+ logger
24
+ faraday-net_http (3.4.4)
25
+ net-http (~> 0.5)
24
26
  faraday-retry (2.0.0)
25
27
  faraday (~> 2.0)
26
- json (2.6.2)
28
+ json (2.21.2)
29
+ logger (1.7.0)
27
30
  method_source (1.0.0)
31
+ net-http (0.9.1)
32
+ uri (>= 0.11.1)
28
33
  octokit (6.0.1)
29
34
  faraday (>= 1, < 3)
30
35
  sawyer (~> 0.9)
@@ -35,11 +40,11 @@ GEM
35
40
  pry (0.14.2)
36
41
  coderay (~> 1.1)
37
42
  method_source (~> 1.0)
38
- public_suffix (5.0.1)
43
+ public_suffix (7.0.5)
39
44
  rainbow (3.1.1)
40
45
  rake (13.0.6)
41
46
  regexp_parser (2.6.0)
42
- rexml (3.3.9)
47
+ rexml (3.4.2)
43
48
  rspec (3.11.0)
44
49
  rspec-core (~> 3.11.0)
45
50
  rspec-expectations (~> 3.11.0)
@@ -66,7 +71,6 @@ GEM
66
71
  rubocop-ast (1.23.0)
67
72
  parser (>= 3.1.1.0)
68
73
  ruby-progressbar (1.11.0)
69
- ruby2_keywords (0.0.5)
70
74
  sawyer (0.9.2)
71
75
  addressable (>= 2.3.5)
72
76
  faraday (>= 0.17.3, < 3)
@@ -74,12 +78,14 @@ GEM
74
78
  treetop (1.6.12)
75
79
  polyglot (~> 0.3)
76
80
  unicode-display_width (2.3.0)
81
+ uri (1.1.1)
77
82
 
78
83
  PLATFORMS
79
84
  arm64-darwin-21
80
85
  arm64-darwin-22
81
86
  arm64-darwin-23
82
87
  arm64-darwin-24
88
+ arm64-darwin-25
83
89
  x86_64-linux
84
90
 
85
91
  DEPENDENCIES
data/README.md CHANGED
@@ -8,6 +8,82 @@ This is in contrast to common static analysis tools that achieve this by requiri
8
8
 
9
9
  While it's important to be able to easily write a Rule, it's just as important (if not more!) to write good tests for them. Like with Rubocop, Claws comes with a couple RSpec helpers that makes it easy to write test cases. Test cases are simply example Workflows that exercise a Rule's expressions, ensuring that a modification to a Rule can't accidentally affect its ability to detect known bad content.
10
10
 
11
+ ## Getting Started
12
+
13
+ Claws is written in Ruby and distributed as a Ruby Gem, so you can install it using `gem`. Just point it at the [example configuration](example-config.yml) file and you should be good to go.
14
+
15
+ For one off scans, you can just follow these commands:
16
+ ```
17
+ # Install claws
18
+ $ gem install claws-scan
19
+
20
+ # Optionally, specify a version
21
+ $ gem install claws-scan -v 0.7.5
22
+
23
+ # Scan a Github Action file
24
+ analyze -c example_config.yml -t .github/workflows/ci.yml
25
+ ```
26
+
27
+ If you'd like to integrate this into your workflow like we have, this should be enough to get you started.
28
+
29
+ ```yaml
30
+ name: Workflow Static Analyzer
31
+
32
+ on:
33
+ pull_request:
34
+ branches:
35
+ - main
36
+
37
+ jobs:
38
+ build:
39
+ name: Analyze Github Workflows
40
+ runs-on: ubuntu-latest
41
+ steps:
42
+ - name: Set Up Ruby
43
+ uses: ruby/setup-ruby@d8d83c3960843afb664e821fed6be52f37da5267 # v1.231.0
44
+ with:
45
+ ruby-version: '3.0'
46
+ # Grab your configuration file however makes sense for you
47
+ # We keep ours in a separate Github repo.
48
+ - name: Set Up Claws Config
49
+ run: |
50
+ echo ... > /tmp/claws-config.yml
51
+ # Optional, useful if you want Claws to run shellcheck for you
52
+ - name: Set Up Shellcheck
53
+ run: |
54
+ sudo apt-get update
55
+ sudo apt-get install -y shellcheck
56
+ - uses: actions/checkout@v4
57
+ with:
58
+ fetch-depth: 0
59
+ - name: Set Up Claws
60
+ run: |
61
+ gem install claws-scan -v 0.7.5
62
+ - name: Analyze Workflows
63
+ run: |
64
+ #!/bin/bash
65
+
66
+ # Collect all files in the .github/workflows directory
67
+ workflow_files=$(find .github/workflows -type f)
68
+
69
+ # Exit early if there are no workflow files
70
+ if [[ -z "$workflow_files" ]]; then
71
+ echo "No workflow files found in .github/workflows"
72
+ exit 0
73
+ fi
74
+
75
+ flags=()
76
+
77
+ # Iterate over each workflow file
78
+ while IFS= read -r file; do
79
+ echo "Processing $file"
80
+ flags+=("-t" "$file")
81
+ done <<< "$workflow_files"
82
+
83
+ # Run the analyze command with all gathered flags
84
+ analyze -f github -c /tmp/claws-config.yml "${flags[@]}"
85
+ ```
86
+
11
87
  ## Built In Rules
12
88
 
13
89
  These are all the rules that come out of the box with Claws. They can all be found in [the rules subdirectory](https://github.com/Betterment/claws/tree/main/lib/claws/rule), and some of them have configuration options.
@@ -204,6 +280,24 @@ Shellcheck is a great tool for dealing with bugs or otherwise unintended effects
204
280
 
205
281
  This rule flags workflows that request write access to specific unusual permissions. While this rule cannot flag how these permissions are exercised, it serves as a warning to code reviewers that if these permissions are requested, the way they are used should be scrutinized. A reviewer may find that a permission is left over from testing and no longer needed, or that a specific permission was never needed.
206
282
 
283
+ ### CheckoutWithStaticCredentials
284
+
285
+ This rule flags any uses of `actions/checkout` using static credentials. Using static credentials can pose a risk because these credentials are typically not auditable and can be tricky to rotate. In the event of an incident where they are leaked, incident response to determine the scope of impact may be tough.
286
+
287
+ Where possible, the default `$GITHUB_TOKEN` should be used. Its settings can be configured directly from within the workflow. Check [the official documentation](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token#modifying-the-permissions-for-the-github_token) for more information on how to do this.
288
+
289
+ If you are using a deploy key via SSH to access a package or otherwise an artifact from another repository, you can instead configure the repository to grant it access explicitly to that other repository. This will give the default `$GITHUB_TOKEN` access to that repository without needing to use a deploy key. To learn more about this, [check out the official documentation](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility). This is safer than a static deploy key because the credential is short lived and access can be audited.
290
+
291
+ If you need a Github Token to perform some authenticated action where the default `$GITHUB_TOKEN` doesn't do what you need, consider setting up a Github App and using [`actions/create-github-app-token`](https://github.com/actions/create-github-app-token). This will generate a short lived access token, and using an app creates a useful audit trail for what this access token can actually do. Then, use this token in just the build steps where it's needed.
292
+
293
+ In some cases, a static access token or deploy key may still be necessary, especially for APIs that are not yet supported by Github App Tokens. In these cases, make sure to limit the scope of the access token to the bare minimum necessary to function.
294
+
295
+ ### StaticAwsCredentials
296
+
297
+ This rule flags long-lived AWS credentials in Github workflows — not just `aws-actions/configure-aws-credentials`, but also `env:` blocks and common shell patterns (`export`, `aws configure set`). Static credentials stored in GitHub secrets, variables, or environment variables can be tricky to audit and rotate. In the event of an incident where they are leaked, incident response may be tough.
298
+
299
+ Where possible, configure AWS to trust GitHub's OIDC provider and use `role-to-assume` to get short-lived credentials for each workflow run. Check [GitHub's documentation on configuring OIDC in AWS](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-for-github-actions/configuring-openid-connect-in-amazon-web-services) for setup instructions.
300
+
207
301
  ### UnapprovedRunners
208
302
 
209
303
  This rule flags workflows that use runners that they might not need or should not use. This can come in handy when an organization has available self hosted or otherwise expensive runners but wants to be particular about when they're used.
@@ -307,6 +401,121 @@ This rule only looks for user supplied branches being checked out for `pull_requ
307
401
  |-------------|------------------------|---------------------------------------------------------------|
308
402
  | `risky_events` | ["pull_request_target", "workflow_dispatch"] | An array of Github events you consider risky. |
309
403
 
404
+ ### ImplicitPersistCredentials
405
+
406
+ By default, all versions of the official [`actions/checkout`](https://github.com/actions/checkout) have a default value of `true` for the `persist-credentials` setting. This means the temporary credentials generated to clone a repository's source code will be written to disk. This is necessary if you intend on using the `git` command line tool to further interact with this repository (e.g. check out a specific branch or push new commits to it). However, if you don't intend on doing this, this opens up an unnecessary risk. Untrusted or otherwise malicious code can find these temporary credentials and use them to access source code and other resources that would otherwise not have been exposed by your workflow. For example, if during a supply chain attack someone steals your job's environment variables, they may be able to use these credentials from their own system to access private repositories.
407
+
408
+ For example, take the following workflow:
409
+
410
+ ```yaml
411
+ name: Ruby CI (PR)
412
+
413
+ on: [pull_request]
414
+
415
+ jobs:
416
+ bundle-install:
417
+ name: Bundle install
418
+ runs-on: ubuntu-latest
419
+
420
+ steps:
421
+ - name: Checkout repository
422
+ uses: actions/checkout@v6
423
+
424
+ - name: Set up Ruby
425
+ uses: ruby/setup-ruby@v1
426
+
427
+ - name: Install dependencies
428
+ run: bundle install
429
+ ```
430
+
431
+ Because `persist-credentials` is not specified, its value is `true` by default. That means the temporary Github credentials used to clone the repository will be either written to `.git/config` or to a temporary directory. Both of these locations will be accessible to subsequent steps, so if for example we were installing a malicious dependency with the `bundle install` command, it could find those credentials on disk and send them to someone via the network, letting them clone this repository and potentially access even other repositories. In this workflow, we aren't interacting with git beyond that checkout, so we don't need to persist the credential. We can fix this by setting `persist-credentials` to `false`:
432
+
433
+ ```yaml
434
+ # ...
435
+ steps:
436
+ - name: Checkout repository
437
+ uses: actions/checkout@v6
438
+ with:
439
+ persist-credentials: false
440
+ ```
441
+
442
+ However, if you do need to interact with the repository via git, you should explicitly set `persist-credentials` to `true`. This way, you signal to your reviewers that your workflow interacts with the local checkout in a way that needs this credential.
443
+
444
+ Note, there is [a Github Issue tracking this](https://github.com/actions/checkout/issues/485) that has been open for a few years by now.
445
+
446
+ ### GlobalPermissionsBlock
447
+
448
+ [The permissions block](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#permissions) dictates which permissions a workflow or a job have or don't have. When permissions are defined at the workflow level, each job gets the same set of permissions, potentially giving more access than a job or a step truly needs. Instead, permissions should be defined at the job level, making sure each job and step has access only to the permissions it needs. This can minimize the impact of untrusted code using an overly permissive `$GITHUB_TOKEN`, such as during a supply chain attack.
449
+
450
+ For example, take the following workflow:
451
+
452
+ ```yaml
453
+ name: Build and Deploy
454
+
455
+ on: [push]
456
+
457
+ permissions:
458
+ id-token: write
459
+
460
+ jobs:
461
+ build:
462
+ name: Build
463
+
464
+ steps:
465
+ - name: Checkout
466
+ uses: actions/checkout@v6
467
+
468
+ - name: Install dependencies
469
+ run: bundle install
470
+
471
+ - name: Upload build artifact to GitHub
472
+ uses: actions/upload-artifact@v4
473
+ with:
474
+ name: ${{ env.ARTIFACT_NAME }}-${{ steps.meta.outputs.version }}
475
+ path: ${{ env.ARTIFACT_NAME }}.tgz
476
+
477
+ deploy:
478
+ name: Deploy
479
+ needs: build
480
+
481
+ steps:
482
+ - name: Configure AWS credentials (OIDC)
483
+ uses: aws-actions/configure-aws-credentials@v4
484
+ with:
485
+ role-to-assume: ${{ env.ROLE_TO_ASSUME }}
486
+ aws-region: ${{ env.AWS_REGION }}
487
+
488
+ - name: Upload release artifact to S3
489
+ run: |
490
+ aws s3 cp "${ARTIFACT_NAME}.tgz" "s3://${S3_BUCKET}/${{ needs.build.outputs.s3_key }}"
491
+ ```
492
+
493
+ This sample workflow deploys to AWS using OIDC, so it needs the `id-token: write` permission to generate temporary AWS credentials. However, because this permission is defined at the workflow level, the `build` job can also use OIDC to grab temporary AWS credentials. This means if this job has some kind of vulnerability, an attacker could use it to gain access to an AWS environment that would otherwise have been entirely inaccessible.
494
+
495
+ To remedy this, we should move the `permissions:` block into the specific job that actually needs this permission:
496
+
497
+ ```yaml
498
+ # ...
499
+ deploy:
500
+ name: Deploy
501
+ needs: build
502
+ permissions:
503
+ id-token: write
504
+
505
+ steps:
506
+ - name: Configure AWS credentials (OIDC)
507
+ uses: aws-actions/configure-aws-credentials@v4
508
+ with:
509
+ role-to-assume: ${{ env.ROLE_TO_ASSUME }}
510
+ aws-region: ${{ env.AWS_REGION }}
511
+
512
+ - name: Upload release artifact to S3
513
+ run: |
514
+ aws s3 cp "${ARTIFACT_NAME}.tgz" "s3://${S3_BUCKET}/${{ needs.build.outputs.s3_key }}"
515
+ ```
516
+
517
+ This limits AWS OIDC access to just `deploy`.
518
+
310
519
  ## Walkthrough
311
520
 
312
521
  Let's start with a minimal configuration file that enables some basic Rules.
data/bin/analyze CHANGED
@@ -5,7 +5,7 @@ require "claws"
5
5
  require "slop"
6
6
 
7
7
  flags = Slop::Options.new
8
- flags.banner = "usage: process [options] ..."
8
+ flags.banner = "usage: analyze [options] ..."
9
9
  flags.separator ""
10
10
  flags.separator "Options:"
11
11
  flags.string "-c", "--config", required: true
@@ -0,0 +1,18 @@
1
+ Enabled:
2
+ NoContainers:
3
+ approved_images: ["ubuntu-latest"]
4
+ SpecialPermissions:
5
+ EmptyName:
6
+ RiskyTriggers:
7
+ UnapprovedRunners:
8
+ allowed_runners: ["ubuntu-latest", "macos-latest"]
9
+ CommandInjection:
10
+ StaticAwsCredentials:
11
+ AutomaticMerge:
12
+ UnpinnedAction:
13
+ trusted_authors: ["actions"]
14
+ UnsafeCheckout:
15
+ InheritedSecrets:
16
+ BulkPermissions:
17
+ Shellcheck:
18
+ shellcheck_bin: "/usr/bin/shellcheck"
@@ -43,10 +43,10 @@ module Claws
43
43
  @detections.each do |detection|
44
44
  detection.on_workflow.each do |rule|
45
45
  violation = run_detection(
46
- filename: filename,
47
- detection: detection,
48
- rule: rule,
49
- workflow: workflow
46
+ filename:,
47
+ detection:,
48
+ rule:,
49
+ workflow:
50
50
  )
51
51
 
52
52
  violations << violation if violation
@@ -58,7 +58,7 @@ module Claws
58
58
  expression: rule[:expression],
59
59
  values: {
60
60
  data: detection.data,
61
- workflow: workflow
61
+ workflow:
62
62
  }
63
63
  )
64
64
  end
@@ -72,11 +72,11 @@ module Claws
72
72
  @detections.each do |detection|
73
73
  detection.on_job.each do |rule|
74
74
  violation = run_detection(
75
- filename: filename,
76
- detection: detection,
77
- rule: rule,
78
- workflow: workflow,
79
- job: job
75
+ filename:,
76
+ detection:,
77
+ rule:,
78
+ workflow:,
79
+ job:
80
80
  )
81
81
 
82
82
  violations << violation if violation
@@ -88,8 +88,8 @@ module Claws
88
88
  expression: rule[:expression],
89
89
  values: {
90
90
  data: detection.data,
91
- workflow: workflow,
92
- job: job
91
+ workflow:,
92
+ job:
93
93
  }
94
94
  )
95
95
  end
@@ -104,12 +104,12 @@ module Claws
104
104
  @detections.each do |detection|
105
105
  detection.on_step.each do |rule|
106
106
  violation = run_detection(
107
- filename: filename,
108
- detection: detection,
109
- rule: rule,
110
- workflow: workflow,
111
- job: job,
112
- step: step
107
+ filename:,
108
+ detection:,
109
+ rule:,
110
+ workflow:,
111
+ job:,
112
+ step:
113
113
  )
114
114
 
115
115
  violations << violation if violation
@@ -121,9 +121,9 @@ module Claws
121
121
  expression: rule[:expression],
122
122
  values: {
123
123
  data: detection.data,
124
- workflow: workflow,
125
- job: job,
126
- step: step
124
+ workflow:,
125
+ job:,
126
+ step:
127
127
  }
128
128
  )
129
129
  end
@@ -137,19 +137,19 @@ module Claws
137
137
  def run_detection(filename:, detection:, rule:, workflow:, job: nil, step: nil) # rubocop:disable Metrics/ParameterLists
138
138
  violation = if rule.is_a? Symbol
139
139
  get_dynamic_violation(
140
- detection: detection,
140
+ detection:,
141
141
  method: rule,
142
- workflow: workflow,
143
- job: job,
144
- step: step
142
+ workflow:,
143
+ job:,
144
+ step:
145
145
  )
146
146
  else
147
147
  get_static_violations(
148
- detection: detection,
149
- rule: rule,
150
- workflow: workflow,
151
- job: job,
152
- step: step
148
+ detection:,
149
+ rule:,
150
+ workflow:,
151
+ job:,
152
+ step:
153
153
  )
154
154
  end
155
155
 
@@ -164,18 +164,18 @@ module Claws
164
164
  def get_dynamic_violation(detection:, method:, workflow:, job:, step:)
165
165
  detection.send(
166
166
  method,
167
- workflow: workflow,
168
- job: job,
169
- step: step
167
+ workflow:,
168
+ job:,
169
+ step:
170
170
  )
171
171
  end
172
172
 
173
173
  def get_static_violations(rule:, detection:, workflow:, job:, step:)
174
174
  result = rule[:expression].eval_with(values: {
175
175
  data: detection.data,
176
- workflow: workflow,
177
- job: job,
178
- step: step
176
+ workflow:,
177
+ job:,
178
+ step:
179
179
  })
180
180
 
181
181
  return unless result
@@ -1,10 +1,10 @@
1
1
  class BaseRule
2
2
  attr_accessor :on_workflow, :on_job, :on_step, :configuration
3
3
 
4
- def self.parse_rule(rule) # rubocop:disable Metrics/AbcSize
4
+ def self.parse_rule(rule) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
5
5
  ExpressionParser.parse_expression(rule).tap do |expression|
6
6
  expression.instance_eval do
7
- def ctx # rubocop:disable Metrics/AbcSize
7
+ def ctx # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
8
8
  @ctx ||= Context.new(
9
9
  default: {},
10
10
  methods: {
@@ -14,7 +14,29 @@ class BaseRule
14
14
  endswith: ->(string, needle) { string.to_s.end_with? needle },
15
15
  difference: ->(arr1, arr2) { arr1.difference arr2 },
16
16
  intersection: ->(arr1, arr2) { arr1.intersection arr2 },
17
- count: ->(n) { n.length }
17
+ get_key: ->(arr, key) { (arr || {}).fetch(key, nil) },
18
+ count: ->(n) { n.length },
19
+ dig: lambda { |object, path, default = nil|
20
+ # sometimes we might want to traverse the object as if it were a hash
21
+ # sometimes we might want to traverse it as a Ruby object
22
+ # annoying up front, but the edge cases are few and keeps expressions simple
23
+ path.to_s.split(".").reduce(object) do |current, part|
24
+ return default if current.nil?
25
+
26
+ if current.is_a?(Hash)
27
+ # Prefer exact string key, then symbol key
28
+ if current.key?(part)
29
+ current[part]
30
+ elsif current.key?(part.to_sym)
31
+ current[part.to_sym]
32
+ else
33
+ default
34
+ end
35
+ else
36
+ current.respond_to?(part) ? current.public_send(part) : default
37
+ end
38
+ end
39
+ }
18
40
  }
19
41
  )
20
42
  end
@@ -45,23 +67,23 @@ class BaseRule
45
67
  end
46
68
 
47
69
  def self.on_workflow(value, highlight: nil, debug: false)
48
- (@on_workflow ||= []) << extract_value(value, highlight: highlight, debug: debug)
70
+ (@on_workflow ||= []) << extract_value(value, highlight:, debug:)
49
71
  end
50
72
 
51
73
  def self.on_job(value, highlight: nil, debug: false)
52
74
  highlight = highlight.to_s unless highlight.nil?
53
- (@on_job ||= []) << extract_value(value, highlight: highlight, debug: debug)
75
+ (@on_job ||= []) << extract_value(value, highlight:, debug:)
54
76
  end
55
77
 
56
78
  def self.on_step(value, highlight: nil, debug: false)
57
79
  highlight = highlight.to_s unless highlight.nil?
58
- (@on_step ||= []) << extract_value(value, highlight: highlight, debug: debug)
80
+ (@on_step ||= []) << extract_value(value, highlight:, debug:)
59
81
  end
60
82
 
61
83
  def self.extract_value(value, highlight: nil, debug: false)
62
84
  case value
63
85
  when String
64
- { expression: parse_rule(value), highlight: highlight, debug: debug }
86
+ { expression: parse_rule(value), highlight:, debug: }
65
87
  when Symbol
66
88
  value
67
89
  else
@@ -18,7 +18,9 @@ module Psych
18
18
  class ToRuby
19
19
  def accept(target)
20
20
  s = super(target)
21
- if target.respond_to?(:line) and ![TrueClass, FalseClass, NilClass, Integer].include? s.class
21
+
22
+ # types that we cannot monkey patch into holding line information
23
+ if target.respond_to?(:line) and ![TrueClass, FalseClass, NilClass, Integer, Float].include? s.class
22
24
  s.instance_eval do
23
25
  extend(Locatable)
24
26
  end
@@ -49,7 +51,9 @@ module Psych
49
51
  key.line = 0 if key.respond_to? :line and key.line.nil?
50
52
  key.line += 1 if key.respond_to? :line
51
53
  key.freeze
52
- if [TrueClass, FalseClass, NilClass, Integer].include? key.class
54
+
55
+ # types that we cannot monkey patch into holding line information
56
+ if [TrueClass, FalseClass, NilClass, Integer, Float].include? key.class
53
57
  val.line = 0 if val.respond_to? :line and val.line.nil?
54
58
  val.line += 1 if val.respond_to? :line
55
59
  end
@@ -0,0 +1,38 @@
1
+ module Claws
2
+ module Rule
3
+ class CheckoutWithStaticCredentials < BaseRule
4
+ description <<~DESC
5
+ Avoid using static credentials like deploy keys, SSH keys, or personal access
6
+ tokens to clone other repositories. Static credentials can be tricky to audit
7
+ and rotate, making them risky to hold onto, especially in the event of an
8
+ incident where they may be leaked.
9
+
10
+ Either grant your repository access directly to other repositories, or use a
11
+ Github App to generate a short lived access token.
12
+
13
+ For more information:
14
+ https://github.com/betterment/claws/blob/main/README.md#checkoutwithstaticcredentials
15
+ DESC
16
+
17
+ on_step %(
18
+ $step.meta.action.name == "actions/checkout" &&
19
+ (
20
+ get_key($step.with, "ssh-key") =~ "{{.*secrets\..*" ||
21
+ get_key($step.with, "ssh-key") =~ "{{.*env\..*" ||
22
+ get_key($step.with, "ssh-key") =~ "{{.*vars\..*" ||
23
+ get_key($step.with, "ssh-key") =~ ".*-----BEGIN.*"
24
+ )
25
+ ), highlight: "with.ssh-key"
26
+
27
+ on_step %(
28
+ $step.meta.action.name == "actions/checkout" &&
29
+ (
30
+ get_key($step.with, "token") =~ "{{.*secrets\..*" ||
31
+ get_key($step.with, "token") =~ "{{.*env\..*" ||
32
+ get_key($step.with, "token") =~ "{{.*vars\..*" ||
33
+ get_key($step.with, "token") =~ "gh[a-z]_.*"
34
+ )
35
+ ), highlight: "with.token"
36
+ end
37
+ end
38
+ end
@@ -8,7 +8,7 @@ module Claws
8
8
  https://github.com/betterment/claws/blob/main/README.md#commandinjection
9
9
  DESC
10
10
 
11
- on_step '$step.run =~ ".*{{[ ]+.*(github.event|inputs).*}}.*"', highlight: "run"
11
+ on_step '$step.run =~ ".*{{.*(github\.event|inputs)\..*}}.*"', highlight: "run"
12
12
  end
13
13
  end
14
14
  end
@@ -0,0 +1,34 @@
1
+ module Claws
2
+ module Rule
3
+ class GlobalPermissionsBlock < BaseRule
4
+ description <<~DESC
5
+ Permissions should be set at the job level, not globally at the workflow level.
6
+ Because jobs will often need varying permissions, it's better to specify a set
7
+ of permissions for each individual job, minimizing potential misuse from
8
+ untrusted code in a job with permissions it never needed in the first place.
9
+
10
+ This rule will flag workflows that have multiple jobs and a root level
11
+ permissions block. If there is a root level permissions block but just one job,
12
+ it will not be flagged.
13
+
14
+ For more information:
15
+ https://github.com/betterment/claws/blob/main/README.md#globalpermissionsblock
16
+ DESC
17
+
18
+ on_workflow :test_root_level_permissions
19
+
20
+ def test_root_level_permissions(workflow:, job:, step:) # rubocop:disable Lint/UnusedMethodArgument
21
+ root_permission_block_line = workflow.keys.filter { |x| x == "permissions" }.first&.line
22
+ return if root_permission_block_line.nil?
23
+
24
+ job_count = workflow["jobs"]&.count || 0
25
+ return if job_count < 2
26
+
27
+ Violation.new(
28
+ line: root_permission_block_line,
29
+ description:
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ module Claws
2
+ module Rule
3
+ class ImplicitPersistCredentials < BaseRule
4
+ description <<~DESC
5
+ By default, actions/checkout will store generated credentials to disk so that
6
+ subsequent git operations will not require reauthentication. These credentials
7
+ will be available to subsequent steps and jobs. This may be undesirable and
8
+ potentially unsafe in scenarios where these credentials may be accessible to
9
+ untrusted code. In these cases, if these credentials are stolen they can be used
10
+ externally by an attacker to clone repositories that would otherwise have been
11
+ inaccessible.
12
+
13
+ If you know you will not need to access this repository for the rest of your
14
+ workflow, consider setting `persist-credentials` to false. Conversely,
15
+ explicitly set it to true if you know you will need these credentials.
16
+
17
+ For more information:
18
+ https://github.com/betterment/claws/blob/main/README.md#implicitpersistcredentials
19
+ DESC
20
+
21
+ on_step %(
22
+ $step.meta.action.name == "actions/checkout" &&
23
+ !contains([true, false], dig($step, "with.persist-credentials"))
24
+ ), highlight: "uses"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ module Claws
2
+ module Rule
3
+ class StaticAwsCredentials < BaseRule
4
+ description <<~DESC
5
+ Avoid using long-lived AWS secret access keys in Github workflows. Static
6
+ credentials can be tricky to audit and rotate, making them risky to hold
7
+ onto, especially in the event of an incident where they may be leaked.
8
+
9
+ Use GitHub's OIDC provider and authenticate with `role-to-assume` instead.
10
+
11
+ For more information:
12
+ https://github.com/betterment/claws/blob/main/README.md#staticawscredentials
13
+ DESC
14
+
15
+ on_workflow %(
16
+ get_key($workflow.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*secrets\..*" ||
17
+ get_key($workflow.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*env\..*" ||
18
+ get_key($workflow.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*vars\..*" ||
19
+ get_key($workflow.env, "AWS_SECRET_ACCESS_KEY") =~ "^[A-Za-z0-9/+=]+$"
20
+ ), highlight: "env.AWS_SECRET_ACCESS_KEY"
21
+
22
+ on_job %(
23
+ get_key($job.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*secrets\..*" ||
24
+ get_key($job.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*env\..*" ||
25
+ get_key($job.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*vars\..*" ||
26
+ get_key($job.env, "AWS_SECRET_ACCESS_KEY") =~ "^[A-Za-z0-9/+=]+$"
27
+ ), highlight: "env.AWS_SECRET_ACCESS_KEY"
28
+
29
+ on_step %(
30
+ $step.meta.action.name == "aws-actions/configure-aws-credentials" &&
31
+ (
32
+ get_key($step.with, "aws-secret-access-key") =~ "{{.*secrets\..*" ||
33
+ get_key($step.with, "aws-secret-access-key") =~ "{{.*env\..*" ||
34
+ get_key($step.with, "aws-secret-access-key") =~ "{{.*vars\..*" ||
35
+ get_key($step.with, "aws-secret-access-key") =~ "^[A-Za-z0-9/+=]+$"
36
+ )
37
+ ), highlight: "with.aws-secret-access-key"
38
+
39
+ on_step %(
40
+ get_key($step.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*secrets\..*" ||
41
+ get_key($step.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*env\..*" ||
42
+ get_key($step.env, "AWS_SECRET_ACCESS_KEY") =~ "{{.*vars\..*" ||
43
+ get_key($step.env, "AWS_SECRET_ACCESS_KEY") =~ "^[A-Za-z0-9/+=]+$"
44
+ ), highlight: "env.AWS_SECRET_ACCESS_KEY"
45
+
46
+ on_step %(
47
+ $step.run =~ "AWS_SECRET_ACCESS_KEY=['\\x22]?\\$\\{\\{\\s*(secrets\\.|vars\\.)" ||
48
+ $step.run =~ "aws configure set aws_secret_access_key\\s+\\$\\{\\{\\s*(secrets\\.|vars\\.)" ||
49
+ $step.run =~ "AWS_SECRET_ACCESS_KEY=['\\x22]?[A-Za-z0-9/+=]+" ||
50
+ $step.run =~ "aws configure set aws_secret_access_key\\s+[A-Za-z0-9/+=]+"
51
+ ), highlight: "run"
52
+ end
53
+ end
54
+ end
data/lib/claws/rule.rb CHANGED
@@ -11,3 +11,7 @@ require "claws/rule/inherited_secrets"
11
11
  require "claws/rule/command_injection"
12
12
  require "claws/rule/bulk_permissions"
13
13
  require "claws/rule/shellcheck"
14
+ require "claws/rule/checkout_with_static_credentials"
15
+ require "claws/rule/static_aws_credentials"
16
+ require "claws/rule/implicit_persist_credentials"
17
+ require "claws/rule/global_permissions_block"
data/lib/claws/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Claws
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.5"
5
5
  end
@@ -4,7 +4,7 @@ require "claws/cli/yaml_with_lines"
4
4
  class Workflow
5
5
  extend Forwardable
6
6
 
7
- attr_accessor :data, :on, :jobs, :name, :meta, :permissions
7
+ attr_accessor :data, :on, :jobs, :name, :meta, :permissions, :env
8
8
 
9
9
  def_delegators :@workflow, :get_line, :include?, :keys
10
10
 
@@ -19,6 +19,7 @@ class Workflow
19
19
  extract_normalized_jobs(@workflow)
20
20
  extract_normalized_name(@workflow)
21
21
  extract_permissions(@workflow)
22
+ extract_env(@workflow)
22
23
 
23
24
  @raw_yaml = raw_yaml
24
25
  end
@@ -35,6 +36,7 @@ class Workflow
35
36
  return @on if key.to_s == "on"
36
37
  return @jobs if key.to_s == "jobs"
37
38
  return @name if key.to_s == "name"
39
+ return @env if key.to_s == "env"
38
40
  end
39
41
 
40
42
  def get_snippet(line, context: 3)
@@ -92,6 +94,10 @@ class Workflow
92
94
  @meta["permissions"] = normalize_permissions(input["permissions"])
93
95
  end
94
96
 
97
+ def extract_env(input)
98
+ @env = input["env"]
99
+ end
100
+
95
101
  def normalize_permissions(input) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
96
102
  permissions = {
97
103
  read: [],
@@ -173,7 +179,7 @@ class Workflow
173
179
  name, version = action.split("@", 2)
174
180
  author = name.split("/", 2)[0]
175
181
  local = author == "."
176
- { type: "action", name: name, author: author, version: version, local: local }
182
+ { type: "action", name:, author:, version:, local: }
177
183
  end
178
184
 
179
185
  def extract_container_info_from_job(job)
@@ -195,8 +201,8 @@ class Workflow
195
201
 
196
202
  {
197
203
  type: "container",
198
- image: image,
199
- version: version,
204
+ image:,
205
+ version:,
200
206
  full: "#{image}:#{version}"
201
207
  }
202
208
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claws-scan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Omar
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-08 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: equation
@@ -74,7 +74,6 @@ executables:
74
74
  extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
- - ".policy.yml"
78
77
  - ".rspec"
79
78
  - ".rubocop.yml"
80
79
  - ".ruby-version"
@@ -85,6 +84,7 @@ files:
85
84
  - Rakefile
86
85
  - bin/analyze
87
86
  - config.yml
87
+ - example-config.yml
88
88
  - lib/claws.rb
89
89
  - lib/claws/application.rb
90
90
  - lib/claws/base_rule.rb
@@ -97,13 +97,17 @@ files:
97
97
  - lib/claws/rule.rb
98
98
  - lib/claws/rule/automatic_merge.rb
99
99
  - lib/claws/rule/bulk_permissions.rb
100
+ - lib/claws/rule/checkout_with_static_credentials.rb
100
101
  - lib/claws/rule/command_injection.rb
101
102
  - lib/claws/rule/empty_name.rb
103
+ - lib/claws/rule/global_permissions_block.rb
104
+ - lib/claws/rule/implicit_persist_credentials.rb
102
105
  - lib/claws/rule/inherited_secrets.rb
103
106
  - lib/claws/rule/no_containers.rb
104
107
  - lib/claws/rule/risky_triggers.rb
105
108
  - lib/claws/rule/shellcheck.rb
106
109
  - lib/claws/rule/special_permissions.rb
110
+ - lib/claws/rule/static_aws_credentials.rb
107
111
  - lib/claws/rule/unapproved_runners.rb
108
112
  - lib/claws/rule/unpinned_action.rb
109
113
  - lib/claws/rule/unsafe_checkout.rb
@@ -123,7 +127,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
123
127
  requirements:
124
128
  - - ">="
125
129
  - !ruby/object:Gem::Version
126
- version: '3.0'
130
+ version: '3.2'
127
131
  required_rubygems_version: !ruby/object:Gem::Requirement
128
132
  requirements:
129
133
  - - ">="
data/.policy.yml DELETED
@@ -1,3 +0,0 @@
1
- remote: Betterment/policy-bot-config
2
- path: security-policy.yml
3
- ref: main