rails-guarddog 0.1.10 → 0.1.12

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: 13a4a099ed1043cb4c5169191304022c1e9f75e8ee28b977fd4a4968a1f458ac
4
- data.tar.gz: b62273a1dcaa5d126f19545e8f4c51716decd8e1d7b50ab32aa84d047bf9388b
3
+ metadata.gz: 97cdcd83511d9b89a1786584d4a21dcf403123b3ea3911e7341a85a3bda88446
4
+ data.tar.gz: e6634444d93c30e0ddf147cb51db25ee7f04f6f2ee9a17f09d9c1de7b59d7da0
5
5
  SHA512:
6
- metadata.gz: 3c18887f0434640f5092cc2fe2f13ce706ca10d121f0ffb64a8601f86f673596ad7dd232b62c744e5610cdbd37368a8b2413f95bd8fd0c92a50bc545e703ef03
7
- data.tar.gz: 485c37f61a02f85cc9a78710e03441ed7271badb21a3f05818d511e6f3cded152260c9463f05e1fbb427a076d322685122e6dfb76551337ed235c7ffb3246fc0
6
+ metadata.gz: c1e72af9e429215d30599e42276ced36f320f052002274827beb6b4060adfa29f907c42af0a88da4a13eead365ef84e32cbb079994534084112e60151671a09f
7
+ data.tar.gz: 2af6a3569f894d95fc2e944ebe14bce13696bc89e0120f13aa5c3cdd9e305e44ecbaba77a6e212dc1911ae1bc2efba267c58093e141bb5c544e59c39979aa3d3
data/README.md CHANGED
@@ -98,20 +98,30 @@ That's it! Scan your entire Rails app for security vulnerabilities.
98
98
  ### Data Protection
99
99
  - **CSRF Protection** — Disabled without documented reason
100
100
  - **Mass Assignment** — `permit!` vulnerabilities ⭐ IMPROVED in v0.1.8
101
- - **Hardcoded Secrets** — API keys, tokens, passwords in code (ALWAYS-ON)
101
+ - **Hardcoded Secrets** — API keys, tokens, passwords in code (ALWAYS-ON, false-positive-safe)
102
102
 
103
103
  ### Resource Management
104
104
  - **DoS/ReDoS** — Unbounded queries, dangerous regex patterns ⭐ ENHANCED in v0.1.8
105
105
  - **Supply Chain** — Typosquatted gems using Levenshtein distance ⭐ IMPROVED in v0.1.8
106
106
 
107
- ---
107
+ > **False-positive protection (v0.1.10 - v0.1.12)**
108
+ > - **Secrets**: Patterns use word boundaries — `no_token:` or `reset_password_instructions:` in locale
109
+ > files will **not** trigger the secrets checker.
110
+ > - **Secrets**: Secret values must be space-free (`[^\s'"]{6,}`) — human-readable sentences like
111
+ > `"You can't access this page..."` are never flagged.
112
+ > - **Secrets**: `config/locales/` files are skipped entirely — i18n translations are not credentials.
113
+ > - **Secrets**: `spec/` and `test/` trees suppress the secrets checker by default (configurable via
114
+ > `ignored_checks`).
115
+ > - **DoS**: Smart multiline `.limit()` check detection (v0.1.12) — lookahead up to 3 lines handles chained
116
+ > `.limit()` calls on subsequent lines or reassignment to the same variable, e.g. `things = Thing.all; things = things.limit(100)` or `.limit(...)` on the next line.
117
+
108
118
 
109
119
  ## 📊 Example Output
110
120
 
111
121
  ### Console Report
112
122
  ```
113
123
  ============================================================
114
- Rails GuardDog Security Report v0.1.8
124
+ Rails GuardDog Security Report v0.1.12
115
125
  ============================================================
116
126
 
117
127
  [CRITICAL] (5 findings)
@@ -165,25 +175,23 @@ Create `config/initializers/guarddog.rb`:
165
175
 
166
176
  ```ruby
167
177
  Rails.application.config.after_initialize do
168
- # Enable only specific checkers
178
+ # --- Option A: Enable only specific checkers ---
169
179
  Rails.application.config.guarddog.enabled_checkers = %w[
170
180
  sql_injection xss csrf mass_assignment secrets
171
181
  ai_injection idor dos rate_limit supply_chain
172
182
  ]
173
183
 
184
+ # --- Option B: Disable only specific checkers (keep everything else) ---
185
+ # Easier when you want "all but a couple". Takes priority over enabled_checkers.
186
+ Rails.application.config.guarddog.disabled_checkers = %w[dos rate_limit]
187
+
174
188
  # Directories to skip entirely (all checks silenced).
175
189
  # Default: %w[vendor node_modules]
176
190
  Rails.application.config.guarddog.excluded_paths = %w[vendor node_modules]
177
191
 
178
192
  # Suppress specific checkers for files under certain path substrings.
179
193
  # Keys are path fragments; values are arrays of checker names.
180
- # This is useful to avoid false positives in test/spec trees where
181
- # dummy passwords and tokens are intentional.
182
- #
183
- # Default (built-in):
184
- # { "spec" => %w[secrets], "test" => %w[secrets] }
185
- #
186
- # Override or extend:
194
+ # Default: { "spec" => %w[secrets], "test" => %w[secrets] }
187
195
  Rails.application.config.guarddog.ignored_checks = {
188
196
  "spec" => %w[secrets], # no secrets alerts in spec/
189
197
  "test" => %w[secrets], # no secrets alerts in test/
@@ -198,16 +206,20 @@ Rails.application.config.after_initialize do
198
206
  end
199
207
  ```
200
208
 
209
+ ### Disabling specific checkers
201
210
 
202
- ### Suppressing checks per directory
203
-
204
- If you see false positives like dummy passwords flagged in test/spec files:
211
+ If you want to keep all checkers **except a couple**, use `disabled_checkers`
212
+ instead of maintaining a long `enabled_checkers` list:
205
213
 
214
+ ```ruby
215
+ # All 12 checkers run, except dos and rate_limit
216
+ Rails.application.config.guarddog.disabled_checkers = %w[dos rate_limit]
206
217
  ```
207
- Secrets — Hardcoded secret detected
208
- /app/spec/controllers/api/v1/users_controller_spec.rb:1975
209
- Fix: Use ENV variables or Rails credentials
210
- ```
218
+
219
+ This is the recommended approach when you hit false positives in one checker
220
+ but still want full coverage from all others.
221
+
222
+ ### Suppressing checks per directory
211
223
 
212
224
  GuardDog already silences `secrets` in `spec/` and `test/` by default. To add
213
225
  more suppressions or override the defaults, set `ignored_checks` in your
@@ -215,6 +227,7 @@ initializer (see example above).
215
227
 
216
228
  ---
217
229
 
230
+
218
231
  ## 🔄 CI/CD Integration
219
232
 
220
233
  ### GitHub Actions
@@ -279,6 +292,6 @@ MIT License - Free to use and modify.
279
292
 
280
293
  ---
281
294
 
282
- **Rails GuardDog v0.1.8 — Production Ready**
295
+ **Rails GuardDog v0.1.12 — Production Ready**
283
296
 
284
297
  *Beyond brakeman. Detect what others miss.* 🐕🔒
@@ -1,38 +1,107 @@
1
- module Rails
2
- module Guarddog
3
- module Checkers
4
- class DosChecker < BaseChecker
5
- def run
6
- glob_files('app/**/*.rb').each do |file|
7
- content = File.read(file)
8
- content.each_line.with_index do |line, idx|
9
- # Check for unbounded queries
10
- if line.match?(/\.where\(.*\)\.all/) || line.match?(/\.all\s*$/)
11
- add_finding(
12
- severity: :high,
13
- message: "Potential DoS: unbounded database query without limit",
14
- file: file,
15
- line: idx + 1,
16
- snippet: line.strip,
17
- remediation: "Add .limit() to control result size"
18
- )
19
- end
20
- # Check for regex vulnerabilities
21
- if line.match?(/\/.+\*\+.*\*\+.+\//) || line.match?(/match\?.*\(.+\*\+/)
22
- add_finding(
23
- severity: :high,
24
- message: "Potential ReDoS vulnerability: dangerous regex pattern",
25
- file: file,
26
- line: idx + 1,
27
- snippet: line.strip,
28
- remediation: "Simplify regex or use timeout mechanisms"
29
- )
30
- end
31
- end
32
- end
33
- findings
34
- end
35
- end
36
- end
37
- end
38
- end
1
+ module Rails
2
+ module Guarddog
3
+ module Checkers
4
+ class DosChecker < BaseChecker
5
+ # Matches .all only when it is the final call on an ActiveRecord-style
6
+ # model class (PascalCase constant), e.g.:
7
+ # User.all => flagged
8
+ # Post.where(...).all => flagged
9
+ # But NOT:
10
+ # current_user.permissions.track_manage.all => NOT flagged (lowercase receiver)
11
+ # collection.all? { } => NOT flagged (.all? method)
12
+ UNBOUNDED_QUERY_PATTERN = /
13
+ (?:
14
+ [A-Z][A-Za-z0-9_]* # PascalCase model class e.g. User, TrackItem
15
+ (?:\.[a-z_]+\(.*?\))* # optional chained scopes e.g. .where(...).order(...)
16
+ )
17
+ \.all # the .all call
18
+ (?!\?) # not .all? (Enumerable method, not AR query)
19
+ \s*(?:[,)\]#\n]|$) # followed by end-of-expression (not .limit etc.)
20
+ /x.freeze
21
+
22
+ # Patterns known to be dangerous nested regex (ReDoS)
23
+ REDOS_PATTERNS = [
24
+ /\/.+\*\+.*\*\+.+\//,
25
+ /match\?.*\(.+\*\+/
26
+ ].freeze
27
+
28
+ # How many lines ahead to search for a .limit() call.
29
+ # Covers both chained-on-next-line and separate-assignment styles.
30
+ LOOKAHEAD_LINES = 3
31
+
32
+ def run
33
+ glob_files('app/**/*.rb').each do |file|
34
+ lines = File.readlines(file) rescue next
35
+
36
+ lines.each_with_index do |line, idx|
37
+ next if line.strip.start_with?('#')
38
+
39
+ if line.match?(UNBOUNDED_QUERY_PATTERN) && !limit_applied?(lines, idx, line)
40
+ add_finding(
41
+ severity: :high,
42
+ message: "Potential DoS: unbounded database query without limit",
43
+ file: file,
44
+ line: idx + 1,
45
+ snippet: line.strip,
46
+ remediation: "Add .limit() to control result size"
47
+ )
48
+ end
49
+
50
+ if REDOS_PATTERNS.any? { |pat| line.match?(pat) }
51
+ add_finding(
52
+ severity: :high,
53
+ message: "Potential ReDoS vulnerability: dangerous regex pattern",
54
+ file: file,
55
+ line: idx + 1,
56
+ snippet: line.strip,
57
+ remediation: "Simplify regex or use timeout mechanisms"
58
+ )
59
+ end
60
+ end
61
+ end
62
+ findings
63
+ end
64
+
65
+ private
66
+
67
+ # Returns true if a .limit() call is found either:
68
+ # 1. On the same line as .all (e.g. Thing.all.limit(100))
69
+ # 2. Chained on the very next line (e.g. Thing.all\n .limit(100))
70
+ # 3. Applied to the assigned variable within LOOKAHEAD_LINES lines
71
+ # (e.g. things = Thing.all \n things = things.limit(100))
72
+ def limit_applied?(lines, current_idx, current_line)
73
+ # 1. Same-line limit (Thing.all.limit(100)) — already excluded by
74
+ # UNBOUNDED_QUERY_PATTERN's end-of-expression anchor, but double-check.
75
+ return true if current_line.match?(/\.limit\s*\(/)
76
+
77
+ # Extract the variable being assigned, supporting local vars, instance
78
+ # vars (@foo) and class-instance vars (@@foo).
79
+ assigned_var = current_line.match(/([@]{0,2}\w+)\s*=\s*[A-Z]/)&.[](1)
80
+
81
+ lookahead = lines[(current_idx + 1), LOOKAHEAD_LINES] || []
82
+
83
+ lookahead.each do |next_line|
84
+ stripped = next_line.lstrip
85
+
86
+ # 2. Chained on next line: the continuation starts with .limit
87
+ return true if stripped.start_with?('.limit')
88
+
89
+ # 3. Assigned variable subsequently limited:
90
+ # things = things.limit(100) => local var (word boundary ok)
91
+ # @things = @things.limit(100) => instance var (@ is non-word, use look-behind)
92
+ # things.limit(100)
93
+ if assigned_var
94
+ # Build an anchor that works for both `@var` and plain `var`:
95
+ # require that the var is preceded by start-of-line, whitespace or `=`.
96
+ anchor = '(?:^|[\s=(])'
97
+ escaped = Regexp.escape(assigned_var)
98
+ return true if next_line.match?(/#{anchor}#{escaped}(?:\s*=\s*#{escaped})?\.limit\s*\(/)
99
+ end
100
+ end
101
+
102
+ false
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -2,11 +2,16 @@ module Rails
2
2
  module Guarddog
3
3
  class Configuration
4
4
  attr_writer :root
5
- attr_accessor :enabled_checkers, :excluded_paths, :output_format, :ignored_checks
5
+ attr_accessor :enabled_checkers, :disabled_checkers, :excluded_paths, :output_format, :ignored_checks
6
6
 
7
7
  def initialize
8
8
  @root = nil
9
9
  @enabled_checkers = all_checkers
10
+ # Checkers to turn off without having to list every other checker in
11
+ # enabled_checkers. Takes priority over enabled_checkers.
12
+ # Example: disable just dos and rate_limit:
13
+ # config.disabled_checkers = %w[dos rate_limit]
14
+ @disabled_checkers = []
10
15
  @excluded_paths = %w[vendor node_modules]
11
16
  # Per-directory checker suppression: keys are path substrings, values are
12
17
  # arrays of checker names (same format as enabled_checkers).
@@ -19,6 +24,12 @@ module Rails
19
24
  @output_format = :console
20
25
  end
21
26
 
27
+ # The final resolved list of checkers to run:
28
+ # enabled_checkers minus anything in disabled_checkers.
29
+ def active_checkers
30
+ enabled_checkers - Array(@disabled_checkers).map(&:to_s)
31
+ end
32
+
22
33
  def root
23
34
  @root || Rails.root.to_s
24
35
  end
@@ -20,20 +20,21 @@ module Rails
20
20
  private
21
21
 
22
22
  def load_checkers
23
- [
24
- Checkers::SqlInjectionChecker,
25
- Checkers::XssChecker,
26
- Checkers::CsrfChecker,
27
- Checkers::MassAssignmentChecker,
28
- Checkers::OpenRedirectChecker,
29
- Checkers::SecretsChecker,
30
- Checkers::DosChecker,
31
- Checkers::IdorChecker,
32
- Checkers::AiInjectionChecker,
33
- Checkers::RateLimitChecker,
34
- Checkers::DependencyChecker,
35
- Checkers::GraphqlChecker
36
- ]
23
+ checker_map = {
24
+ 'sql_injection' => Checkers::SqlInjectionChecker,
25
+ 'xss' => Checkers::XssChecker,
26
+ 'csrf' => Checkers::CsrfChecker,
27
+ 'mass_assignment'=> Checkers::MassAssignmentChecker,
28
+ 'open_redirect' => Checkers::OpenRedirectChecker,
29
+ 'secrets' => Checkers::SecretsChecker,
30
+ 'dos' => Checkers::DosChecker,
31
+ 'idor' => Checkers::IdorChecker,
32
+ 'ai_injection' => Checkers::AiInjectionChecker,
33
+ 'rate_limit' => Checkers::RateLimitChecker,
34
+ 'dependency' => Checkers::DependencyChecker,
35
+ 'graphql' => Checkers::GraphqlChecker
36
+ }
37
+ @configuration.active_checkers.filter_map { |name| checker_map[name] }
37
38
  end
38
39
 
39
40
  def severity_order(severity)
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Guarddog
3
- VERSION = "0.1.10"
3
+ VERSION = "0.1.12"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-guarddog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.10
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Security Team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-12 00:00:00.000000000 Z
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties