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 +4 -4
- data/README.md +26 -7
- data/lib/rails/guarddog/checkers/base_checker.rb +18 -2
- data/lib/rails/guarddog/checkers/dos_checker.rb +56 -12
- data/lib/rails/guarddog/scanner.rb +37 -1
- data/lib/rails/guarddog/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5176cbe7acb78424e6f30a57cb8c203d5de27d9c2e12627034ee33a723c3095
|
|
4
|
+
data.tar.gz: 22408c90ed568ef535fced6db89e92a96c7b79aec37cc81631abb7a0a5393498
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7
|
+
# User.all => flagged
|
|
8
8
|
# Post.where(...).all => flagged
|
|
9
9
|
# But NOT:
|
|
10
|
-
# current_user.permissions.track_manage.all
|
|
11
|
-
# collection.all? { }
|
|
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_]*
|
|
16
|
-
(?:\.[a-z_]+\(.*?\))*
|
|
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
|
|
19
|
-
(?!\?)
|
|
20
|
-
\s*(?:[,)\]#\n]|$)
|
|
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
|
-
|
|
32
|
-
|
|
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 ||
|
|
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,
|
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.
|
|
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-
|
|
11
|
+
date: 2026-08-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: railties
|