rails-guarddog 0.1.11 → 0.1.13

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: 859dc0da3cfd03afa3ceea5be6d2feba0f3f49786f109fb82c0a5b1c379c0837
4
- data.tar.gz: a0251fa578fb369a59316ff36e62e4409cbfb0b08bdc4c4be1d2d75b764c1411
3
+ metadata.gz: b5176cbe7acb78424e6f30a57cb8c203d5de27d9c2e12627034ee33a723c3095
4
+ data.tar.gz: 22408c90ed568ef535fced6db89e92a96c7b79aec37cc81631abb7a0a5393498
5
5
  SHA512:
6
- metadata.gz: 3394a5d46d8ed065003722151b6f5d74361fac8e9656a3c39760e02fc25e581c474cb3246aa9bb8337530a50f70d492dfc77127b1770f7fa82bc6d01460073e5
7
- data.tar.gz: f52bea6bb7b20187c2ab4a1751267f5987116f1839657bda880dfb93e18a96a8840e4621fde4b490c4d4d318c9f42fbe453be04c6514aa0a42c26b4293a40840
6
+ metadata.gz: fc28d9a40e236b423c809aaddefed91c6de16a6108563e1d47512f8d2d5ef91a0caac2e3113c240e6d55483b272c87603a8dfa190bcdb14213604166bc547eb4
7
+ data.tar.gz: 1d0e968eaf094a70a7854dfe5223c048a02f445f475345b3d5edb0e7a12448c398c960d18b1a5f0d6fa9a9dbbd2f8a1813141739a74cf9a55fcb4281a1fea719
data/README.md CHANGED
@@ -104,14 +104,17 @@ That's it! Scan your entire Rails app for security vulnerabilities.
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
- > **False-positive protection (v0.1.10)**
108
- > - Patterns use word boundaries — `no_token:` or `reset_password_instructions:` in locale
107
+ > **False-positive protection (v0.1.10 - v0.1.13)**
108
+ > - **Secrets**: Patterns use word boundaries — `no_token:` or `reset_password_instructions:` in locale
109
109
  > files will **not** trigger the secrets checker.
110
- > - Secret values must be space-free (`[^\s'"]{6,}`) — human-readable sentences like
110
+ > - **Secrets**: Secret values must be space-free (`[^\s'"]{6,}`) — human-readable sentences like
111
111
  > `"You can't access this page..."` are never flagged.
112
- > - `config/locales/` files are skipped entirely — i18n translations are not credentials.
113
- > - `spec/` and `test/` trees suppress the secrets checker by default (configurable via
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
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
+ > - **Ignore File**: Support for `.guarddogignore` (v0.1.13) — allows excluding complete files/directories or ignoring specific checkers for specific files/directories.
115
118
 
116
119
 
117
120
  ## 📊 Example Output
@@ -119,7 +122,7 @@ That's it! Scan your entire Rails app for security vulnerabilities.
119
122
  ### Console Report
120
123
  ```
121
124
  ============================================================
122
- Rails GuardDog Security Report v0.1.10
125
+ Rails GuardDog Security Report v0.1.13
123
126
  ============================================================
124
127
 
125
128
  [CRITICAL] (5 findings)
@@ -223,6 +226,22 @@ GuardDog already silences `secrets` in `spec/` and `test/` by default. To add
223
226
  more suppressions or override the defaults, set `ignored_checks` in your
224
227
  initializer (see example above).
225
228
 
229
+ ### Ignoring findings with `.guarddogignore`
230
+
231
+ You can add a `.guarddogignore` file in the root of your Rails project to exclude entire files/directories or ignore specific checkers for specific files.
232
+
233
+ - To exclude a file or directory completely (silence all checkers), list the path:
234
+ ```
235
+ db/seeds.rb
236
+ vendor/
237
+ ```
238
+
239
+ - To skip only specific checkers for certain files or directories, separate the path and the comma-separated list of checkers with a `:`:
240
+ ```
241
+ db/seeds.rb: secrets
242
+ app/controllers/admin/*: sql_injection, csrf
243
+ ```
244
+
226
245
  ---
227
246
 
228
247
 
@@ -290,6 +309,6 @@ MIT License - Free to use and modify.
290
309
 
291
310
  ---
292
311
 
293
- **Rails GuardDog v0.1.10 — Production Ready**
312
+ **Rails GuardDog v0.1.13 — Production Ready**
294
313
 
295
314
  *Beyond brakeman. Detect what others miss.* 🐕🔒
@@ -43,9 +43,10 @@ module Rails
43
43
 
44
44
  ignored = @configuration.ignored_checks || {}
45
45
  name = checker_identifier
46
+ rel_file = relative_path(file)
46
47
 
47
48
  ignored.any? do |path_pattern, checker_names|
48
- file.include?(path_pattern.to_s) &&
49
+ rel_file.include?(path_pattern.to_s) &&
49
50
  checker_names.map(&:to_s).include?(name)
50
51
  end
51
52
  end
@@ -63,9 +64,24 @@ module Rails
63
64
  def glob_files(pattern)
64
65
  excluded = @configuration&.excluded_paths || []
65
66
  Dir.glob(File.join(@root, pattern)).reject do |file|
66
- excluded.any? { |path| file.include?(path) }
67
+ rel_file = relative_path(file)
68
+ excluded.any? { |path| rel_file.include?(path) }
67
69
  end
68
70
  end
71
+
72
+ private
73
+
74
+ def relative_path(file)
75
+ file_normalized = file.to_s.gsub('\\', '/')
76
+ root_normalized = @root.to_s.gsub('\\', '/')
77
+
78
+ if file_normalized.start_with?(root_normalized)
79
+ file_normalized.sub(/\A#{Regexp.escape(root_normalized)}\/?/, '')
80
+ else
81
+ file_normalized
82
+ end
83
+ end
84
+
69
85
  end
70
86
  end
71
87
  end
@@ -4,20 +4,19 @@ module Rails
4
4
  class DosChecker < BaseChecker
5
5
  # Matches .all only when it is the final call on an ActiveRecord-style
6
6
  # model class (PascalCase constant), e.g.:
7
- # User.all => flagged
7
+ # User.all => flagged
8
8
  # Post.where(...).all => flagged
9
9
  # But NOT:
10
- # current_user.permissions.track_manage.all => NOT flagged (chained on object)
11
- # collection.all? { } => NOT flagged (.all? method)
12
- # track_manage.all => NOT flagged (lowercase receiver)
10
+ # current_user.permissions.track_manage.all => NOT flagged (lowercase receiver)
11
+ # collection.all? { } => NOT flagged (.all? method)
13
12
  UNBOUNDED_QUERY_PATTERN = /
14
13
  (?:
15
- [A-Z][A-Za-z0-9_]* # PascalCase model class e.g. User, TrackItem
16
- (?:\.[a-z_]+\(.*?\))* # optional chained scopes e.g. .where(...).order(...)
14
+ [A-Z][A-Za-z0-9_]* # PascalCase model class e.g. User, TrackItem
15
+ (?:\.[a-z_]+\(.*?\))* # optional chained scopes e.g. .where(...).order(...)
17
16
  )
18
- \.all # the .all call
19
- (?!\?) # not .all? (Enumerable method, not AR query)
20
- \s*(?:[,)\]#\n]|$) # followed by end-of-expression
17
+ \.all # the .all call
18
+ (?!\?) # not .all? (Enumerable method, not AR query)
19
+ \s*(?:[,)\]#\n]|$) # followed by end-of-expression (not .limit etc.)
21
20
  /x.freeze
22
21
 
23
22
  # Patterns known to be dangerous nested regex (ReDoS)
@@ -26,13 +25,18 @@ module Rails
26
25
  /match\?.*\(.+\*\+/
27
26
  ].freeze
28
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
+
29
32
  def run
30
33
  glob_files('app/**/*.rb').each do |file|
31
- content = File.read(file) rescue next
32
- content.each_line.with_index do |line, idx|
34
+ lines = File.readlines(file) rescue next
35
+
36
+ lines.each_with_index do |line, idx|
33
37
  next if line.strip.start_with?('#')
34
38
 
35
- if line.match?(UNBOUNDED_QUERY_PATTERN)
39
+ if line.match?(UNBOUNDED_QUERY_PATTERN) && !limit_applied?(lines, idx, line)
36
40
  add_finding(
37
41
  severity: :high,
38
42
  message: "Potential DoS: unbounded database query without limit",
@@ -57,6 +61,46 @@ module Rails
57
61
  end
58
62
  findings
59
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
60
104
  end
61
105
  end
62
106
  end
@@ -4,11 +4,12 @@ module Rails
4
4
  attr_accessor :configuration, :findings
5
5
 
6
6
  def initialize(config = nil)
7
- @configuration = config || Configuration.new
7
+ @configuration = config || default_configuration
8
8
  @findings = []
9
9
  end
10
10
 
11
11
  def run
12
+ load_ignore_file
12
13
  load_checkers.each do |checker_class|
13
14
  checker = checker_class.new(@configuration)
14
15
  checker.run
@@ -19,6 +20,41 @@ module Rails
19
20
 
20
21
  private
21
22
 
23
+ def default_configuration
24
+ if defined?(Rails) && Rails.application && Rails.application.config.respond_to?(:guarddog)
25
+ Rails.application.config.guarddog
26
+ else
27
+ Configuration.new
28
+ end
29
+ end
30
+
31
+ def load_ignore_file
32
+ ignore_file_path = File.join(@configuration.root, '.guarddogignore')
33
+ return unless File.exist?(ignore_file_path)
34
+
35
+ @configuration.ignored_checks ||= {}
36
+ @configuration.excluded_paths ||= []
37
+
38
+ File.foreach(ignore_file_path).each_with_index do |line, idx|
39
+ line = line.strip
40
+ next if line.empty? || line.start_with?('#')
41
+
42
+ if line.include?(':')
43
+ path_pattern, checkers_str = line.split(':', 2)
44
+ path_pattern = path_pattern.strip.gsub('\\', '/')
45
+ checkers = checkers_str.split(',').map { |c| c.strip.downcase }
46
+
47
+ @configuration.ignored_checks[path_pattern] ||= []
48
+ @configuration.ignored_checks[path_pattern].concat(checkers)
49
+ @configuration.ignored_checks[path_pattern].uniq!
50
+ else
51
+ path_pattern = line.gsub('\\', '/')
52
+ @configuration.excluded_paths << path_pattern
53
+ @configuration.excluded_paths.uniq!
54
+ end
55
+ end
56
+ end
57
+
22
58
  def load_checkers
23
59
  checker_map = {
24
60
  'sql_injection' => Checkers::SqlInjectionChecker,
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Guarddog
3
- VERSION = "0.1.11"
3
+ VERSION = "0.1.13"
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.11
4
+ version: 0.1.13
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-13 00:00:00.000000000 Z
11
+ date: 2026-08-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties