ez_logs_agent 0.1.10 → 0.2.1

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.
@@ -60,37 +60,9 @@ module EzLogsAgent
60
60
  # Previously we filtered them out, but this loses important context.
61
61
  # FOREIGN_KEY_PATTERN = /_id\z/ # Removed January 2026
62
62
 
63
- # Patterns for sensitive data to ignore.
64
- #
65
- # The first source of truth is `record.class.encrypted_attributes`
66
- # (Rails 7+ `encrypts :foo` declaration) — see encrypted_attribute?.
67
- # If the host app encrypted it, we never capture it.
68
- #
69
- # This list is the secondary defense: column names that frequently
70
- # carry sensitive material even when the host app didn't declare
71
- # `encrypts` (legacy code, manual hashing, externally-generated
72
- # material). Matching is substring + case-insensitive.
73
- SENSITIVE_PATTERNS = %w[
74
- password
75
- token
76
- secret
77
- api_key
78
- credit_card
79
- ssn
80
- social_security
81
- encrypted
82
- private_key
83
- public_key
84
- signing_key
85
- pem
86
- cipher
87
- nonce
88
- salt
89
- digest
90
- signature
91
- hmac
92
- ].freeze
93
-
63
+ # Sensitive-attribute name pattern denylist (secondary defense after
64
+ # `encrypts :foo` introspection) lives in EzLogsAgent::SensitivePatterns —
65
+ # see sensitive_attribute? below.
94
66
 
95
67
  @installed = false
96
68
  @callbacks_registered = false
@@ -106,6 +78,33 @@ module EzLogsAgent
106
78
  return unless defined?(ActiveRecord::Base)
107
79
  return if @installed
108
80
 
81
+ # Memoize config values that the per-row hot path reads on
82
+ # every callback. Without this, each captured create / update
83
+ # / destroy pays a method dispatch into the configuration
84
+ # object (`EzLogsAgent.configuration.capture_database`) plus
85
+ # an `all_excluded_tables.include?` Hash dispatch. On a request
86
+ # that touches dozens of rows the overhead is real.
87
+ # Runtime mutations require uninstall! + install to take effect
88
+ # (acceptable — nobody flips capture_database at runtime).
89
+ @capture_enabled =
90
+ begin
91
+ EzLogsAgent.configuration.capture_database
92
+ rescue StandardError
93
+ false
94
+ end
95
+ @excluded_tables =
96
+ begin
97
+ EzLogsAgent.configuration.all_excluded_tables.dup.freeze
98
+ rescue StandardError
99
+ [].freeze
100
+ end
101
+ @display_name_for =
102
+ begin
103
+ (EzLogsAgent.configuration.display_name_for || {}).dup.freeze
104
+ rescue StandardError
105
+ {}.freeze
106
+ end
107
+
109
108
  # Only register callbacks once per Ruby process
110
109
  unless @callbacks_registered
111
110
  ActiveRecord::Base.class_eval do
@@ -165,24 +164,24 @@ module EzLogsAgent
165
164
 
166
165
  private
167
166
 
168
- # Checks if database capture is enabled
167
+ # Checks if database capture is enabled. Reads the memoized
168
+ # value set at install time (no config-object dispatch on the
169
+ # hot path).
169
170
  #
170
171
  # @return [Boolean]
171
172
  def capture_enabled?
172
- EzLogsAgent.configuration.capture_database
173
- rescue StandardError
174
- false
173
+ @capture_enabled
175
174
  end
176
175
 
177
- # Checks if the model's table is in the excluded_tables list
178
- # Uses all_excluded_tables which combines defaults with user-configured
176
+ # Checks if the model's table is in the excluded_tables list.
177
+ # Reads the memoized list set at install time.
179
178
  #
180
179
  # @param model [ActiveRecord::Base] The model instance
181
180
  # @return [Boolean]
182
181
  def table_excluded?(model)
183
182
  return false unless model.class.respond_to?(:table_name)
184
183
 
185
- EzLogsAgent.configuration.all_excluded_tables.include?(model.class.table_name)
184
+ @excluded_tables.include?(model.class.table_name)
186
185
  rescue StandardError
187
186
  false
188
187
  end
@@ -247,9 +246,8 @@ module EzLogsAgent
247
246
  # @param model [ActiveRecord::Base] The model instance
248
247
  # @return [String, nil] The display name, or nil if no meaningful name found
249
248
  def resolve_display_name(model)
250
- # Check for configured custom field
251
- display_name_config = EzLogsAgent.configuration.display_name_for || {}
252
- custom_field = display_name_config[model.class.name]
249
+ # Check for configured custom field (memoized list, see install).
250
+ custom_field = @display_name_for[model.class.name]
253
251
 
254
252
  if custom_field && model.respond_to?(custom_field)
255
253
  value = model.public_send(custom_field)
@@ -386,35 +384,25 @@ module EzLogsAgent
386
384
  end
387
385
 
388
386
  # Checks whether the host app declared `encrypts :<attribute>` on
389
- # this model's class. Available since Rails 7.0 via
390
- # ActiveRecord::Encryption::EncryptableRecord#encrypted_attributes.
391
- #
392
- # Safe across host Rails versions: returns false if the API isn't
393
- # present (older Rails, non-AR records).
387
+ # this model's class. Delegates to EncryptedAttributes (single
388
+ # source of truth shared with BulkDatabaseCapturer, which only has
389
+ # the class — no instance — for bulk operations).
394
390
  #
395
391
  # @param attribute [String] The attribute name (already to_s'd)
396
392
  # @param model [ActiveRecord::Base] The model instance
397
393
  # @return [Boolean]
398
394
  def encrypted_attribute?(attribute, model)
399
- klass = model.class
400
- return false unless klass.respond_to?(:encrypted_attributes)
401
-
402
- encrypted = klass.encrypted_attributes
403
- return false if encrypted.nil? || encrypted.empty?
404
-
405
- encrypted.map(&:to_s).include?(attribute)
406
- rescue StandardError
407
- false
395
+ EzLogsAgent::EncryptedAttributes.attribute?(model.class, attribute)
408
396
  end
409
397
 
410
398
  # Checks if attribute name contains sensitive patterns.
411
- # Secondary check see SENSITIVE_PATTERNS comment.
399
+ # Delegates to SensitivePatterns (single source of truth shared
400
+ # with Sanitizer and BulkDatabaseCapturer).
412
401
  #
413
402
  # @param attribute [String] The attribute name
414
403
  # @return [Boolean]
415
404
  def sensitive_attribute?(attribute)
416
- attr_lower = attribute.downcase
417
- SENSITIVE_PATTERNS.any? { |pattern| attr_lower.include?(pattern) }
405
+ EzLogsAgent::SensitivePatterns.match?(attribute)
418
406
  end
419
407
 
420
408
  # Checks if both values are scalar types
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # Primary defense against capturing encrypted columns: read the host
5
+ # app's `encrypts :foo` declarations (Rails 7+ ActiveRecord::Encryption)
6
+ # and drop those attributes from anywhere we'd ship them on the wire.
7
+ #
8
+ # Two callers:
9
+ # - DatabaseCapturer (per-record callbacks) — has a model INSTANCE,
10
+ # used to also work from `record.class`.
11
+ # - BulkDatabaseCapturer (AS::Notifications path) — has only the model
12
+ # CLASS (the bulk SQL never instantiated a record). So this module
13
+ # takes a class, not an instance — both call sites converge.
14
+ #
15
+ # The Rails API is `ModelClass.encrypted_attributes` (Symbol array).
16
+ # Available since Rails 7.0; older Rails or non-AR classes return false
17
+ # from this module, which is the fail-open default for the encrypts
18
+ # check. The pattern-based fallback in SensitivePatterns is the second
19
+ # layer of defense for hosts that don't (or can't) declare encrypts.
20
+ module EncryptedAttributes
21
+ module_function
22
+
23
+ # @param model_class [Class, nil] The AR class
24
+ # @param attribute [String, Symbol, nil] The attribute name
25
+ # @return [Boolean] true iff the host app declared `encrypts :<attribute>`
26
+ # on `model_class` (or an ancestor). False on any error, missing API,
27
+ # or empty list — see comment about fail-open semantics above.
28
+ def attribute?(model_class, attribute)
29
+ return false if model_class.nil? || attribute.nil?
30
+ return false unless model_class.respond_to?(:encrypted_attributes)
31
+
32
+ declared = model_class.encrypted_attributes
33
+ return false if declared.nil? || declared.empty?
34
+
35
+ attribute_str = attribute.to_s
36
+ declared.any? { |declared_attr| declared_attr.to_s == attribute_str }
37
+ rescue StandardError
38
+ # Same rescue policy as the previous inline check in DatabaseCapturer:
39
+ # if introspection raises (host app monkey-patched the API, weird
40
+ # AR class hierarchy), fall through to the pattern-based fallback
41
+ # rather than crash the capture path.
42
+ false
43
+ end
44
+ end
45
+ end
@@ -24,7 +24,10 @@ module EzLogsAgent
24
24
  SENSITIVE_KEYS = %w[password token secret api_key credit_card].freeze
25
25
 
26
26
  # Valid source types
27
- VALID_SOURCE_TYPES = %w[http_request background_job database_callback].freeze
27
+ # bulk_database covers AR bulk ops that bypass per-row callbacks
28
+ # (delete_all, update_all, insert_all, upsert_all) captured via
29
+ # ActiveSupport::Notifications. See Capturers::BulkDatabaseCapturer.
30
+ VALID_SOURCE_TYPES = %w[http_request background_job database_callback bulk_database].freeze
28
31
 
29
32
  # Valid outcome values
30
33
  VALID_OUTCOMES = %w[success failure].freeze
@@ -218,10 +218,13 @@ module EzLogsAgent
218
218
  EzLogsAgent::Logger.error("[Railtie] Failed to install ActiveJob capturer: #{e.class} - #{e.message}")
219
219
  end
220
220
 
221
- # Install Database capturer
221
+ # Install Database capturers (per-row + bulk).
222
222
  #
223
- # Database capturer installs ActiveRecord lifecycle callbacks
224
- # (after_create, after_update, after_destroy) for all models.
223
+ # Two capturers, one switch (`capture_database`):
224
+ # - DatabaseCapturer: per-row CRUD via after_create / _update / _destroy.
225
+ # - BulkDatabaseCapturer: bulk SQL via ActiveSupport::Notifications
226
+ # ("sql.active_record"), narrowly filtered to delete_all / update_all /
227
+ # insert_all / upsert_all. Catches what callbacks can't see.
225
228
  #
226
229
  # @return [void]
227
230
  def self.install_database_capturer
@@ -240,8 +243,9 @@ module EzLogsAgent
240
243
  return if @database_capturer_installed
241
244
 
242
245
  EzLogsAgent::Capturers::DatabaseCapturer.install
246
+ EzLogsAgent::Capturers::BulkDatabaseCapturer.install
243
247
  @database_capturer_installed = true
244
- EzLogsAgent::Logger.debug("[Railtie] Database capture installed")
248
+ EzLogsAgent::Logger.debug("[Railtie] Database capture installed (per-row + bulk)")
245
249
  rescue StandardError => e
246
250
  EzLogsAgent::Logger.error("[Railtie] Failed to install database capturer: #{e.class} - #{e.message}")
247
251
  end
@@ -21,18 +21,11 @@ module EzLogsAgent
21
21
  # The module is pure (no I/O, no state), so it's safe to call from
22
22
  # any thread.
23
23
  module Sanitizer
24
- # Default sensitive-key patterns. Matched case-insensitively as
25
- # SUBSTRINGS of the key, so `customer_password` matches `password`.
26
- SENSITIVE_PATTERNS = %w[
27
- password passwd pwd
28
- token access_token refresh_token api_token auth_token
29
- secret api_secret client_secret
30
- api_key apikey private_key privatekey secret_key secretkey
31
- credential auth authorization
32
- encrypted encrypted_data
33
- ssn social_security
34
- credit_card card_number cvv cvc
35
- ].freeze
24
+ # Sensitive-key pattern list. Delegates to SensitivePatterns (single
25
+ # source of truth shared with DatabaseCapturer / BulkDatabaseCapturer).
26
+ # Kept as a constant alias for backwards compatibility — code that
27
+ # used `Sanitizer::SENSITIVE_PATTERNS` continues to work.
28
+ SENSITIVE_PATTERNS = EzLogsAgent::SensitivePatterns::PATTERNS
36
29
 
37
30
  # Hard ceiling for nested object recursion. Deeper structures
38
31
  # collapse to the literal string "[Object]".
@@ -79,18 +72,13 @@ module EzLogsAgent
79
72
 
80
73
  # Check whether a key matches a sensitive pattern. Public so the
81
74
  # HTTP middleware can short-circuit early on identical keys.
75
+ # Delegates to SensitivePatterns (single source of truth — also
76
+ # consulted by DatabaseCapturer and BulkDatabaseCapturer).
82
77
  #
83
78
  # @param key [String, Symbol]
84
79
  # @return [Boolean]
85
80
  def sensitive_key?(key)
86
- key_lower = key.to_s.downcase
87
- return true if SENSITIVE_PATTERNS.any? { |pattern| key_lower.include?(pattern) }
88
-
89
- user_patterns = EzLogsAgent.configuration.excluded_graphql_variable_keys || []
90
- user_patterns.any? { |pattern| key_lower.include?(pattern.to_s.downcase) }
91
- rescue
92
- # Defensive: when in doubt, treat as sensitive.
93
- true
81
+ EzLogsAgent::SensitivePatterns.match?(key)
94
82
  end
95
83
 
96
84
  private
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # Single source of truth for the agent's sensitive-key denylist. Used by
5
+ # every capture path that needs to mask a value based on its column /
6
+ # parameter / argument name (Sanitizer for HTTP params + job args,
7
+ # DatabaseCapturer for AR attributes, BulkDatabaseCapturer for SQL
8
+ # WHERE binds + SET values).
9
+ #
10
+ # This is a NAME-pattern denylist — the secondary defense, separate from
11
+ # the primary defense (Rails `encrypts :foo` introspection via
12
+ # `model.class.encrypted_attributes`, handled in EncryptedAttributes).
13
+ # Use both together: the encrypts check catches what the host app
14
+ # declared, this list catches what got past the declaration (legacy
15
+ # columns, manual hashing, externally-generated material).
16
+ #
17
+ # Matching rules:
18
+ # - Case-insensitive
19
+ # - Substring (so `customer_password` matches `password`)
20
+ # - User-extensible via `EzLogsAgent.configuration.excluded_graphql_variable_keys`
21
+ module SensitivePatterns
22
+ # Union of every column / key name we treat as sensitive. Curated
23
+ # from RFC 7468 / OWASP top sensitive-data categories plus
24
+ # ActiveRecord conventions. Keep this list narrow but defensive —
25
+ # adding a pattern is cheap; removing one is a backwards-incompatible
26
+ # behavior change for customer data on the wire.
27
+ PATTERNS = %w[
28
+ password passwd pwd
29
+ token access_token refresh_token api_token auth_token
30
+ secret api_secret client_secret
31
+ api_key apikey private_key privatekey secret_key secretkey
32
+ public_key signing_key
33
+ credential auth authorization
34
+ encrypted encrypted_data
35
+ pem cipher nonce salt digest signature hmac
36
+ ssn social_security
37
+ credit_card card_number cvv cvc
38
+ ].freeze
39
+
40
+ module_function
41
+
42
+ # @param key [String, Symbol, nil]
43
+ # @return [Boolean] true if the key matches a sensitive pattern OR
44
+ # matches a user-configured pattern in `excluded_graphql_variable_keys`
45
+ def match?(key)
46
+ return false if key.nil?
47
+
48
+ key_lower = key.to_s.downcase
49
+ return true if PATTERNS.any? { |pattern| key_lower.include?(pattern) }
50
+
51
+ user_patterns = user_patterns_cache
52
+ user_patterns.any? { |pattern| key_lower.include?(pattern) }
53
+ rescue StandardError
54
+ # Defensive: if configuration access raises, treat as sensitive.
55
+ # Better to over-mask than to leak.
56
+ true
57
+ end
58
+
59
+ # Memoized lookup of user-configured patterns, already lowercased.
60
+ # Called per HTTP param, per DB attribute, per job arg key. Without
61
+ # the cache it would dispatch into EzLogsAgent.configuration on
62
+ # every check.
63
+ #
64
+ # The cache is keyed by `object_id` of the configured array, so
65
+ # `EzLogsAgent.configure { |c| c.excluded_graphql_variable_keys = [...] }`
66
+ # invalidates it naturally (assigning a new array changes its id).
67
+ # No explicit invalidation needed.
68
+ def user_patterns_cache
69
+ configured = EzLogsAgent.configuration.excluded_graphql_variable_keys
70
+ return @cached_user_patterns if configured.nil? && @cached_source_id.nil?
71
+
72
+ source_id = configured&.object_id
73
+ if source_id != @cached_source_id
74
+ @cached_user_patterns =
75
+ (configured || []).map { |p| p.to_s.downcase }.freeze
76
+ @cached_source_id = source_id
77
+ end
78
+ @cached_user_patterns
79
+ end
80
+ private_class_method :user_patterns_cache
81
+ end
82
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EzLogsAgent
4
- VERSION = "0.1.10"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/ez_logs_agent.rb CHANGED
@@ -8,6 +8,9 @@ require_relative "ez_logs_agent/correlation"
8
8
  require_relative "ez_logs_agent/actor_validator"
9
9
  require_relative "ez_logs_agent/actor"
10
10
  require_relative "ez_logs_agent/user_agent_detector"
11
+ require_relative "ez_logs_agent/sensitive_patterns"
12
+ require_relative "ez_logs_agent/encrypted_attributes"
13
+ require_relative "ez_logs_agent/bulk_sql_parser"
11
14
  require_relative "ez_logs_agent/sanitizer"
12
15
  require_relative "ez_logs_agent/event_builder"
13
16
  require_relative "ez_logs_agent/resource_extractor"
@@ -19,6 +22,7 @@ require_relative "ez_logs_agent/middleware/http_request"
19
22
  require_relative "ez_logs_agent/capturers/job_capturer"
20
23
  require_relative "ez_logs_agent/capturers/active_job_capturer"
21
24
  require_relative "ez_logs_agent/capturers/database_capturer"
25
+ require_relative "ez_logs_agent/capturers/bulk_database_capturer"
22
26
 
23
27
  # Load Railtie only when Rails is present
24
28
  require_relative "ez_logs_agent/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ez_logs_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.10
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - dezsirazvan
@@ -123,12 +123,15 @@ files:
123
123
  - lib/ez_logs_agent/actor.rb
124
124
  - lib/ez_logs_agent/actor_validator.rb
125
125
  - lib/ez_logs_agent/buffer.rb
126
+ - lib/ez_logs_agent/bulk_sql_parser.rb
126
127
  - lib/ez_logs_agent/capturers/active_job_capturer.rb
128
+ - lib/ez_logs_agent/capturers/bulk_database_capturer.rb
127
129
  - lib/ez_logs_agent/capturers/database_capturer.rb
128
130
  - lib/ez_logs_agent/capturers/job_capturer.rb
129
131
  - lib/ez_logs_agent/configuration.rb
130
132
  - lib/ez_logs_agent/configuration_validator.rb
131
133
  - lib/ez_logs_agent/correlation.rb
134
+ - lib/ez_logs_agent/encrypted_attributes.rb
132
135
  - lib/ez_logs_agent/event_builder.rb
133
136
  - lib/ez_logs_agent/flush_scheduler.rb
134
137
  - lib/ez_logs_agent/logger.rb
@@ -137,6 +140,7 @@ files:
137
140
  - lib/ez_logs_agent/resource_extractor.rb
138
141
  - lib/ez_logs_agent/retry_sender.rb
139
142
  - lib/ez_logs_agent/sanitizer.rb
143
+ - lib/ez_logs_agent/sensitive_patterns.rb
140
144
  - lib/ez_logs_agent/transport.rb
141
145
  - lib/ez_logs_agent/user_agent_detector.rb
142
146
  - lib/ez_logs_agent/version.rb