Skip to content
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,121 @@ This rule only looks for user supplied branches being checked out for `pull_requ
|-------------|------------------------|---------------------------------------------------------------|
| `risky_events` | ["pull_request_target", "workflow_dispatch"] | An array of Github events you consider risky. |

### ImplicitPersistCredentials

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.

For example, take the following workflow:

```yaml
name: Ruby CI (PR)

on: [pull_request]

jobs:
bundle-install:
name: Bundle install
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up Ruby
uses: ruby/setup-ruby@v1

- name: Install dependencies
run: bundle install
```

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`:
Comment thread
6f6d6172 marked this conversation as resolved.
Comment thread
6f6d6172 marked this conversation as resolved.

```yaml
# ...
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
```

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.

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.
Comment thread
6f6d6172 marked this conversation as resolved.
Comment thread
6f6d6172 marked this conversation as resolved.

### GlobalPermissionsBlock

[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.
Copy link

Copilot AI Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation URL appears to use an older or incorrect path structure. Based on other GitHub Actions documentation links in this file (e.g., line 304), the URL should likely be https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#permissions instead of using the /actions/reference/workflows-and-actions/ path.

Suggested change
[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.
[The permissions block](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the suggested url literally redirects to my url. what r u doin


For example, take the following workflow:

```yaml
name: Build and Deploy

on: [push]

permissions:
id-token: write

jobs:
build:
name: Build
Comment thread
6f6d6172 marked this conversation as resolved.

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Install dependencies
run: bundle install

- name: Upload build artifact to GitHub
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}-${{ steps.meta.outputs.version }}
path: ${{ env.ARTIFACT_NAME }}.tgz

deploy:
name: Deploy
needs: build
Comment thread
6f6d6172 marked this conversation as resolved.

steps:
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.ROLE_TO_ASSUME }}
aws-region: ${{ env.AWS_REGION }}

- name: Upload release artifact to S3
run: |
aws s3 cp "${ARTIFACT_NAME}.tgz" "s3://${S3_BUCKET}/${{ needs.build.outputs.s3_key }}"
```

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.

To remedy this, we should move the `permissions:` block into the specific job that actually needs this permission:

```yaml
# ...
deploy:
name: Deploy
needs: build
permissions:
id-token: write

steps:
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.ROLE_TO_ASSUME }}
aws-region: ${{ env.AWS_REGION }}

- name: Upload release artifact to S3
run: |
aws s3 cp "${ARTIFACT_NAME}.tgz" "s3://${S3_BUCKET}/${{ needs.build.outputs.s3_key }}"
```

This limits AWS OIDC access to just `deploy`.

## Walkthrough

Let's start with a minimal configuration file that enables some basic Rules.
Expand Down
27 changes: 24 additions & 3 deletions lib/claws/base_rule.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
class BaseRule
attr_accessor :on_workflow, :on_job, :on_step, :configuration

def self.parse_rule(rule) # rubocop:disable Metrics/AbcSize
def self.parse_rule(rule) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
ExpressionParser.parse_expression(rule).tap do |expression|
expression.instance_eval do
def ctx # rubocop:disable Metrics/AbcSize
def ctx # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@ctx ||= Context.new(
default: {},
methods: {
Expand All @@ -15,7 +15,28 @@ def ctx # rubocop:disable Metrics/AbcSize
difference: ->(arr1, arr2) { arr1.difference arr2 },
intersection: ->(arr1, arr2) { arr1.intersection arr2 },
get_key: ->(arr, key) { (arr || {}).fetch(key, nil) },
count: ->(n) { n.length }
count: ->(n) { n.length },
dig: lambda { |object, path, default = nil|
# sometimes we might want to traverse the object as if it were a hash
# sometimes we might want to traverse it as a Ruby object
# annoying up front, but the edge cases are few and keeps expressions simple
path.to_s.split(".").reduce(object) do |current, part|
return default if current.nil?

if current.is_a?(Hash)
# Prefer exact string key, then symbol key
if current.key?(part)
current[part]
elsif current.key?(part.to_sym)
current[part.to_sym]
else
default
end
else
current.respond_to?(part) ? current.public_send(part) : default
end
end
}
}
)
end
Expand Down
2 changes: 2 additions & 0 deletions lib/claws/rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@
require "claws/rule/bulk_permissions"
require "claws/rule/shellcheck"
require "claws/rule/checkout_with_static_credentials"
require "claws/rule/implicit_persist_credentials"
require "claws/rule/global_permissions_block"
34 changes: 34 additions & 0 deletions lib/claws/rule/global_permissions_block.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module Claws
module Rule
class GlobalPermissionsBlock < BaseRule
description <<~DESC
Permissions should be set at the job level, not globally at the workflow level.
Because jobs will often need varying permissions, it's better to specify a set
of permissions for each individual job, minimizing potential misuse from
untrusted code in a job with permissions it never needed in the first place.

This rule will flag workflows that have multiple jobs and a root level
permissions block. If there is a root level permissions block but just one job,
it will not be flagged.

For more information:
https://github.com/betterment/claws/blob/main/README.md#globalpermissionsblock
DESC

on_workflow :test_root_level_permissions

def test_root_level_permissions(workflow:, job:, step:) # rubocop:disable Lint/UnusedMethodArgument
Comment thread
6f6d6172 marked this conversation as resolved.
root_permission_block_line = workflow.keys.filter { |x| x == "permissions" }.first&.line
return if root_permission_block_line.nil?

job_count = workflow["jobs"]&.count || 0
return if job_count < 2

Violation.new(
line: root_permission_block_line,
description:
)
end
end
end
end
27 changes: 27 additions & 0 deletions lib/claws/rule/implicit_persist_credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module Claws
module Rule
class ImplicitPersistCredentials < BaseRule
description <<~DESC
By default, actions/checkout will store generated credentials to disk so that
subsequent git operations will not require reauthentication. These credentials
will be available to subsequent steps and jobs. This may be undesirable and
potentially unsafe in scenarios where these credentials may be accessible to
untrusted code. In these cases, if these credentials are stolen they can be used
externally by an attacker to clone repositories that would otherwise have been
inaccessible.

If you know you will not need to access this repository for the rest of your
workflow, consider setting `persist-credentials` to false. Conversely,
explicitly set it to true if you know you will need these credentials.

For more information:
https://github.com/betterment/claws/blob/main/README.md#implicitpersistcredentials
DESC

on_step %(
$step.meta.action.name == "actions/checkout" &&
!contains([true, false], dig($step, "with.persist-credentials"))
), highlight: "uses"
end
end
end
145 changes: 145 additions & 0 deletions spec/claws/rule/global_permissions_block_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
RSpec.describe Claws::Rule::GlobalPermissionsBlock do
before do
load_detection
end

context "with default configuration" do
it "flags a workflow with a top level permissions block if there is more than one job" do
violations = analyze(<<~YAML)
name: publish docs

on:
push:
branches:
- main

permissions:
contents: read
pages: write
id-token: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
YAML

expect(violations.count).to eq(1)
expect(violations[0].line).to eq(8)
Comment thread
6f6d6172 marked this conversation as resolved.
expect(violations[0].name).to eq("GlobalPermissionsBlock")
end

it "flags a workflow for global permissions even if some jobs specify their own" do
violations = analyze(<<~YAML)
name: publish docs

on:
push:
branches:
- main

# default for all jobs
permissions:
contents: read
pages: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

deploy:
needs: build
runs-on: ubuntu-latest
# override defaults
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
YAML

expect(violations.count).to eq(1)
expect(violations[0].line).to eq(9)
Comment thread
6f6d6172 marked this conversation as resolved.
expect(violations[0].name).to eq("GlobalPermissionsBlock")
end

it "does not flag a workflow with a top level permissions block if there is just one job" do
violations = analyze(<<~YAML)
name: pretend to publish docs

on:
push:
branches:
- main

permissions:
contents: read
pages: write
id-token: write

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
YAML

expect(violations.count).to eq(0)
end

it "does not flag a workflow if there is no top level permissions block" do
violations = analyze(<<~YAML)
name: publish docs

on:
push:
branches:
- main

jobs:
build:
permissions:
contents: read
pages: write
id-token: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
YAML

expect(violations.count).to eq(0)
end
end
end
Comment thread
6f6d6172 marked this conversation as resolved.
Loading