Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

1. [Neeto/UnsafeTableDeletion](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/UnsafeTableDeletion)
2. [Neeto/UnsafeColumnDeletion](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/UnsafeColumnDeletion)
3. [Neeto/DirectEnvAccess](https://rubocop-neeto.neetodeployapp.com/docs/RuboCop/Cop/Neeto/DirectEnvAccess)

## Installation

Expand Down
14 changes: 14 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,17 @@ Neeto/UnsafeColumnDeletion:
VersionAdded: '0.1'
Include:
- db/**/*.rb

Neeto/DirectEnvAccess:
Description: >-
Rails had `secrets.yml` which provided a single source of truth for all
environment variables and their fallback values. Rails deprecated this in
favor of encrypted credentials, so we created Secvault to maintain
centralized configuration. Direct usage of `ENV` bypasses this system,
making it harder to track what environment variables are being used and
their defaults. Use `Secvault.secrets` instead.
Enabled: true
Severity: refactor
VersionAdded: '0.1'
Include:
- app/**/*.rb
48 changes: 48 additions & 0 deletions lib/rubocop/cop/neeto/direct_env_access.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Neeto
# Rails had `secrets.yml` which provided a single source of truth for all
# environment variables and their fallback values. Rails deprecated this in
# favor of encrypted credentials, so we created Secvault
# (https://github.com/neetozone/secvault) to maintain centralized configuration.
# Direct usage of `ENV` bypasses this system, making it harder to track what
# environment variables are being used and their defaults. This cop enforces
# that all environment variable access goes through `Secvault.secrets`.
#
# @example DirectEnvAccess: true (default)
# # Enforces the usage of `Secvault.secrets` over direct `ENV` access.
#
# # bad
# api_key = ENV['STRIPE_API_KEY']
#
# # bad
# default_timezone = ENV['DEFAULT_TIMEZONE'] || 'UTC'
#
# # good
# api_key = Secvault.secrets.stripe_api_key
#
# # good
# default_timezone = Secvault.secrets.default_timezone
#
# # good (ENV access is permitted in directories other than the app directory)
# config.log_level = ENV.fetch('LOG_LEVEL', 'info')
#
class DirectEnvAccess < Base
MSG = "Do not use ENV directly. " \
"Use Secvault.secrets to maintain a single source of truth for configuration."

def_node_matcher :env_access?, <<~PATTERN
(send (const {nil? cbase} :ENV) _ ...)
PATTERN

def on_send(node)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Using on_send catches explicit method calls like ENV['KEY'], but misses cases where ENV is aliased (e.g., x = ENV; x['KEY']) or passed as an argument. Consider implementing on_const to flag any usage of the ENV constant if strict prohibition is desired.

Copy link
Copy Markdown

@yedhink yedhink Jan 27, 2026

Choose a reason for hiding this comment

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

I feel this was a good review comment by NeetoBugWatch. I feel the following will be missed:

config = ENV
config['API_KEY']

do_something_with(ENV)

return unless env_access?(node)

add_offense(node)
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/neeto_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

require_relative "neeto/unsafe_table_deletion"
require_relative "neeto/unsafe_column_deletion"
require_relative "neeto/direct_env_access"
Binary file added rubocop-neeto-0.1.10.gem
Binary file not shown.
67 changes: 67 additions & 0 deletions spec/rubocop/cop/neeto/direct_env_access_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Neeto::DirectEnvAccess, :config do
let(:config) { RuboCop::Config.new }

it "registers an offense when ENV is accessed with bracket notation" do
snippet = <<~RUBY
api_key = ENV['STRIPE_API_KEY']
^^^^^^^^^^^^^^^^^^^^^ #{offense}
RUBY
expect_offense(snippet)
end

it "registers an offense when ENV.fetch is used" do
snippet = <<~RUBY
default_timezone = ENV.fetch('DEFAULT_TIMEZONE', 'UTC')
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
RUBY
expect_offense(snippet)
end

it "registers an offense when ENV.fetch is used without a default" do
snippet = <<~RUBY
api_key = ENV.fetch('STRIPE_API_KEY')
^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
RUBY
expect_offense(snippet)
end

it "registers multiple offenses when ENV is accessed multiple times" do
snippet = <<~RUBY
api_key = ENV['STRIPE_API_KEY']
^^^^^^^^^^^^^^^^^^^^^ #{offense}
timeout = ENV.fetch('REQUEST_TIMEOUT', '30')
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
RUBY
expect_offense(snippet)
end

it "registers an offense when ENV is accessed with :: prefix" do
snippet = <<~RUBY
api_key = ::ENV['STRIPE_API_KEY']
^^^^^^^^^^^^^^^^^^^^^^^ #{offense}
RUBY
expect_offense(snippet)
end

it "does not register an offense when Secvault.secrets is used" do
snippet = <<~RUBY
api_key = Secvault.secrets.stripe_api_key
RUBY
expect_no_offenses(snippet)
end

it "does not register an offense for non-ENV constants" do
snippet = <<~RUBY
value = SOME_CONSTANT['key']
RUBY
expect_no_offenses(snippet)
end

private

def offense
"Neeto/DirectEnvAccess: #{RuboCop::Cop::Neeto::DirectEnvAccess::MSG}"
end
end