railsforge 2.1.0 → 2.2.0
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/lib/railsforge/analyzers/database_analyzer.rb +83 -19
- data/lib/railsforge/analyzers/performance_analyzer.rb +61 -25
- data/lib/railsforge/analyzers/refactor_analyzer.rb +45 -59
- data/lib/railsforge/analyzers/security_analyzer.rb +65 -22
- data/lib/railsforge/analyzers/spec_analyzer.rb +14 -4
- data/lib/railsforge/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97a2aa54dd6324b6a8237f33d2d45abdff8e33903e0cc63f1def08b6cea5f39b
|
|
4
|
+
data.tar.gz: 8a0ca64024fa1eec71cc892aba1ac8e66903e0e801259999fd9b4cfac5484e12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d4da0f8ee77c37302c61c3a72fcee87bd786c710d40d2b9da0c94c818df150b6f8f8f2ea66b78786c2be415f4e07f06eb2c2cc130187b86d4cc5b4489a1d935
|
|
7
|
+
data.tar.gz: 63410692a4e0dd42712e543c4aff3ba19cd1230c17cabd3b12122183fa63c0017e4ae6699ae1b9b3935d5550cb66a61b1c2f1c2dd74f1ea622c25b3dff9892e9
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Database analyzer for RailsForge
|
|
2
|
-
# Scans database schema for issues
|
|
2
|
+
# Scans database schema for structural and index issues
|
|
3
3
|
|
|
4
4
|
require_relative "base_analyzer"
|
|
5
5
|
|
|
@@ -15,26 +15,90 @@ module RailsForge
|
|
|
15
15
|
schema_file = File.join(base_path, "db", "schema.rb")
|
|
16
16
|
return [] unless File.exist?(schema_file)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
18
|
+
new(schema_file).find_issues
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(schema_file)
|
|
22
|
+
@schema_file = schema_file
|
|
23
|
+
@content = File.read(schema_file)
|
|
24
|
+
@issues = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def find_issues
|
|
28
|
+
check_missing_fk_indexes
|
|
29
|
+
check_unindexed_datetime_queries
|
|
30
|
+
@issues
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Check for foreign key columns (_id suffix) without a corresponding index
|
|
36
|
+
def check_missing_fk_indexes
|
|
37
|
+
@content.scan(/create_table\s+"(\w+)"(.*?)(?=\s*create_table|\z)/m) do |table, body|
|
|
38
|
+
fk_columns = body.scan(/t\.(?:integer|bigint|string|uuid)\s+"(\w+_id)"/).flatten
|
|
39
|
+
indexed = indexed_columns_in(table, body)
|
|
40
|
+
|
|
41
|
+
fk_columns.each do |col|
|
|
42
|
+
next if indexed.include?(col)
|
|
43
|
+
|
|
44
|
+
@issues << RailsForge::Issue.new(
|
|
45
|
+
analyzer: :database,
|
|
46
|
+
type: :missing_fk_index,
|
|
47
|
+
severity: "high",
|
|
48
|
+
file: "db/schema.rb",
|
|
49
|
+
line: nil,
|
|
50
|
+
message: "Table `#{table}` has foreign key `#{col}` without an index",
|
|
51
|
+
suggestion: "add_index :#{table}, :#{col}"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
35
54
|
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Flag datetime columns that are queried by range but lack an index
|
|
58
|
+
# Specifically: tables that have a datetime column with no index at all on that column
|
|
59
|
+
def check_unindexed_datetime_queries
|
|
60
|
+
@content.scan(/create_table\s+"(\w+)"(.*?)(?=\s*create_table|\z)/m) do |table, body|
|
|
61
|
+
# Collect explicitly named datetime columns (not created_at/updated_at which Rails rarely queries by range)
|
|
62
|
+
dt_columns = body.scan(/t\.datetime\s+"(\w+)"/).flatten
|
|
63
|
+
.reject { |c| c == "created_at" || c == "updated_at" }
|
|
64
|
+
|
|
65
|
+
indexed = indexed_columns_in(table, body)
|
|
36
66
|
|
|
37
|
-
|
|
67
|
+
dt_columns.each do |col|
|
|
68
|
+
next if indexed.include?(col)
|
|
69
|
+
|
|
70
|
+
@issues << RailsForge::Issue.new(
|
|
71
|
+
analyzer: :database,
|
|
72
|
+
type: :missing_datetime_index,
|
|
73
|
+
severity: "medium",
|
|
74
|
+
file: "db/schema.rb",
|
|
75
|
+
line: nil,
|
|
76
|
+
message: "Table `#{table}` has datetime column `#{col}` without an index",
|
|
77
|
+
suggestion: "add_index :#{table}, :#{col} if you query or order by this column"
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Collect all indexed columns for a given table from the schema body and
|
|
84
|
+
# any trailing add_index lines at the bottom of the schema
|
|
85
|
+
def indexed_columns_in(table, body)
|
|
86
|
+
cols = []
|
|
87
|
+
# t.index ["col"] or t.index ["col1", "col2"]
|
|
88
|
+
body.scan(/t\.index\s+\[([^\]]+)\]/).each do |match|
|
|
89
|
+
match[0].scan(/"(\w+)"/).each { |m| cols << m[0] }
|
|
90
|
+
end
|
|
91
|
+
# t.index "col" (single column, no brackets)
|
|
92
|
+
body.scan(/t\.index\s+"(\w+)"/).each { |m| cols << m[0] }
|
|
93
|
+
# add_index "table", ["col"] or add_index "table", "col"
|
|
94
|
+
@content.scan(/add_index\s+"#{Regexp.escape(table)}",\s+(?:\[([^\]]+)\]|"(\w+)")/).each do |arr, single|
|
|
95
|
+
if arr
|
|
96
|
+
arr.scan(/"(\w+)"/).each { |m| cols << m[0] }
|
|
97
|
+
elsif single
|
|
98
|
+
cols << single
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
cols
|
|
38
102
|
end
|
|
39
103
|
end
|
|
40
104
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Performance analyzer for RailsForge
|
|
2
|
-
# Checks for common performance issues
|
|
2
|
+
# Checks for common performance issues in app code.
|
|
3
|
+
# Schema-level index issues are handled by DatabaseAnalyzer.
|
|
3
4
|
|
|
4
5
|
require "fileutils"
|
|
5
6
|
|
|
@@ -8,22 +9,19 @@ module RailsForge
|
|
|
8
9
|
class PerformanceAnalyzer < BaseAnalyzer
|
|
9
10
|
ISSUE_TYPES = {
|
|
10
11
|
n_plus_one: "N+1 Query",
|
|
11
|
-
missing_index: "Missing Database Index",
|
|
12
12
|
slow_method: "Slow Method",
|
|
13
|
-
inefficient_query: "Inefficient Query"
|
|
14
|
-
cache_miss: "Cache Miss",
|
|
15
|
-
eager_loading: "Unnecessary Eager Loading"
|
|
13
|
+
inefficient_query: "Inefficient Query"
|
|
16
14
|
}.freeze
|
|
17
15
|
|
|
18
16
|
SUGGESTIONS = {
|
|
19
17
|
n_plus_one: "Use eager loading: includes(:association) or preload(:association)",
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
inefficient_query: "Refactor query to avoid loading unnecessary records",
|
|
23
|
-
cache_miss: "Consider caching with Rails.cache or HTTP caching",
|
|
24
|
-
eager_loading: "Remove unnecessary includes() if association is not used"
|
|
18
|
+
slow_method: "Consider async processing (ActiveJob) or caching for this operation",
|
|
19
|
+
inefficient_query: "Refactor query to avoid loading unnecessary records"
|
|
25
20
|
}.freeze
|
|
26
21
|
|
|
22
|
+
# AR query methods that signal a DB hit inside a loop
|
|
23
|
+
QUERY_METHODS = /\.(find|find_by|find_by!|where|first|last|count|sum|pluck|select|exists\?)\s*[(\s]/
|
|
24
|
+
|
|
27
25
|
def self.analyze(base_path = nil)
|
|
28
26
|
base_path ||= find_rails_app_path
|
|
29
27
|
return [] unless base_path
|
|
@@ -38,7 +36,6 @@ module RailsForge
|
|
|
38
36
|
|
|
39
37
|
def find_issues
|
|
40
38
|
check_n_plus_one
|
|
41
|
-
check_missing_indexes
|
|
42
39
|
check_slow_methods
|
|
43
40
|
check_inefficient_queries
|
|
44
41
|
@issues
|
|
@@ -47,23 +44,59 @@ module RailsForge
|
|
|
47
44
|
private
|
|
48
45
|
|
|
49
46
|
def check_n_plus_one
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
# Sliding window: if a line has an iteration method and the next 5 lines
|
|
48
|
+
# contain a raw ActiveRecord query, flag it as a potential N+1.
|
|
49
|
+
loop_pattern = /\.(each|map|each_with_object|flat_map|collect|each_with_index|each_slice)\b/
|
|
50
|
+
|
|
51
|
+
["app/controllers/**/*.rb", "app/views/**/*.erb"].each do |glob|
|
|
52
|
+
Dir.glob(File.join(@base_path, glob)).each do |file|
|
|
53
|
+
lines = File.read(file).lines
|
|
54
|
+
lines.each_with_index do |line, i|
|
|
55
|
+
next unless line =~ loop_pattern
|
|
56
|
+
next if line =~ /^\s*#/
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
window = lines[i + 1, 5].to_a.join
|
|
59
|
+
next unless window =~ QUERY_METHODS
|
|
60
|
+
# Skip if eager loading is already set up nearby
|
|
61
|
+
context = lines[[i - 5, 0].max, 15].to_a.join
|
|
62
|
+
next if context =~ /\.(includes|preload|eager_load)\s*\(/
|
|
63
|
+
|
|
64
|
+
@issues << RailsForge::Issue.new(
|
|
65
|
+
analyzer: :performance,
|
|
66
|
+
type: :n_plus_one,
|
|
67
|
+
severity: "high",
|
|
68
|
+
file: file.delete_prefix("#{@base_path}/"),
|
|
69
|
+
line: i + 1,
|
|
70
|
+
message: "N+1 Query: #{line.strip}",
|
|
71
|
+
suggestion: SUGGESTIONS[:n_plus_one]
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
58
76
|
end
|
|
59
77
|
|
|
60
78
|
def check_slow_methods
|
|
61
|
-
|
|
79
|
+
# Target synchronous external HTTP calls and process spawning
|
|
80
|
+
patterns = [
|
|
81
|
+
/Net::HTTP\.(get|post|start)\b/,
|
|
82
|
+
/HTTParty\.(get|post|put|delete|patch)\b/,
|
|
83
|
+
/RestClient\.(get|post|put|delete|patch)\b/,
|
|
84
|
+
/Faraday\.new.*\.(?:get|post|put|delete)\b/,
|
|
85
|
+
/\bProcess\.spawn\b/,
|
|
86
|
+
/`[^`]{4,}`/, # non-trivial backtick shell commands
|
|
87
|
+
/IO\.read\b/,
|
|
88
|
+
/File\.read\b/ # reading entire files synchronously in request cycle
|
|
89
|
+
]
|
|
62
90
|
scan_files("app/**/*.rb", patterns, :slow_method)
|
|
63
91
|
end
|
|
64
92
|
|
|
65
93
|
def check_inefficient_queries
|
|
66
|
-
patterns = [
|
|
94
|
+
patterns = [
|
|
95
|
+
/\.all\.each\b/, # loads all records to iterate
|
|
96
|
+
/\.all\.to_a\b/, # .to_a forces load; .all is redundant
|
|
97
|
+
/\.where\b.*\.count\b/, # consider .size or a counter cache
|
|
98
|
+
/\.select\([^)]+\)\.map\b/ # select + map can often be a single query
|
|
99
|
+
]
|
|
67
100
|
scan_files("app/models/**/*.rb", patterns, :inefficient_query)
|
|
68
101
|
scan_files("app/controllers/**/*.rb", patterns, :inefficient_query)
|
|
69
102
|
end
|
|
@@ -72,8 +105,11 @@ module RailsForge
|
|
|
72
105
|
Dir.glob(File.join(@base_path, glob)).each do |file|
|
|
73
106
|
lines = File.read(file).lines
|
|
74
107
|
lines.each_with_index do |line, index|
|
|
108
|
+
stripped = line.strip
|
|
109
|
+
next if stripped.start_with?("#")
|
|
110
|
+
|
|
75
111
|
patterns.each do |pattern|
|
|
76
|
-
next unless
|
|
112
|
+
next unless stripped =~ pattern
|
|
77
113
|
|
|
78
114
|
@issues << RailsForge::Issue.new(
|
|
79
115
|
analyzer: :performance,
|
|
@@ -81,7 +117,7 @@ module RailsForge
|
|
|
81
117
|
severity: severity_for(issue_type),
|
|
82
118
|
file: file.delete_prefix("#{@base_path}/"),
|
|
83
119
|
line: index + 1,
|
|
84
|
-
message: "#{ISSUE_TYPES[issue_type]}: #{
|
|
120
|
+
message: "#{ISSUE_TYPES[issue_type]}: #{stripped}",
|
|
85
121
|
suggestion: SUGGESTIONS[issue_type]
|
|
86
122
|
)
|
|
87
123
|
end
|
|
@@ -91,9 +127,9 @@ module RailsForge
|
|
|
91
127
|
|
|
92
128
|
def severity_for(issue_type)
|
|
93
129
|
case issue_type
|
|
94
|
-
when :n_plus_one
|
|
95
|
-
when :inefficient_query
|
|
96
|
-
else
|
|
130
|
+
when :n_plus_one then "high"
|
|
131
|
+
when :inefficient_query then "medium"
|
|
132
|
+
else "low"
|
|
97
133
|
end
|
|
98
134
|
end
|
|
99
135
|
end
|
|
@@ -8,10 +8,7 @@ module RailsForge
|
|
|
8
8
|
class RefactorAnalyzer < BaseAnalyzer
|
|
9
9
|
class RefactorError < StandardError; end
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
CONTROLLER_MAX_METHODS = 10
|
|
13
|
-
MODEL_MAX_LINES = 200
|
|
14
|
-
MODEL_MAX_METHOD_LINES = 15
|
|
11
|
+
MAX_METHOD_LINES = 15
|
|
15
12
|
|
|
16
13
|
def self.analyze(base_path = nil)
|
|
17
14
|
analyze_controllers(base_path) + analyze_models(base_path)
|
|
@@ -48,51 +45,14 @@ module RailsForge
|
|
|
48
45
|
|
|
49
46
|
def self.analyze_file(file_path, type)
|
|
50
47
|
content = File.read(file_path)
|
|
51
|
-
lines = content.lines.count
|
|
52
48
|
methods = extract_methods(content)
|
|
53
49
|
basename = File.basename(file_path)
|
|
54
50
|
issues = []
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
issues << RailsForge::Issue.new(
|
|
59
|
-
analyzer: :refactor,
|
|
60
|
-
type: :oversized_controller,
|
|
61
|
-
severity: "medium",
|
|
62
|
-
file: basename,
|
|
63
|
-
line: nil,
|
|
64
|
-
message: "#{basename} has #{lines} lines (max #{CONTROLLER_MAX_LINES})",
|
|
65
|
-
suggestion: "Extract business logic to Service objects"
|
|
66
|
-
)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
if methods.count > CONTROLLER_MAX_METHODS
|
|
70
|
-
issues << RailsForge::Issue.new(
|
|
71
|
-
analyzer: :refactor,
|
|
72
|
-
type: :too_many_methods,
|
|
73
|
-
severity: "low",
|
|
74
|
-
file: basename,
|
|
75
|
-
line: nil,
|
|
76
|
-
message: "#{basename} has #{methods.count} methods (max #{CONTROLLER_MAX_METHODS})",
|
|
77
|
-
suggestion: "Consider splitting into focused controllers"
|
|
78
|
-
)
|
|
79
|
-
end
|
|
80
|
-
else
|
|
81
|
-
if lines > MODEL_MAX_LINES
|
|
82
|
-
issues << RailsForge::Issue.new(
|
|
83
|
-
analyzer: :refactor,
|
|
84
|
-
type: :oversized_model,
|
|
85
|
-
severity: "medium",
|
|
86
|
-
file: basename,
|
|
87
|
-
line: nil,
|
|
88
|
-
message: "#{basename} has #{lines} lines (max #{MODEL_MAX_LINES})",
|
|
89
|
-
suggestion: "Consider extracting concerns or service objects"
|
|
90
|
-
)
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
52
|
+
# Only flag individual methods that are too long.
|
|
53
|
+
# File-level size and method-count checks are owned by ControllerAnalyzer / ModelAnalyzer.
|
|
94
54
|
methods.each do |method|
|
|
95
|
-
next unless method[:lines] >
|
|
55
|
+
next unless method[:lines] > MAX_METHOD_LINES
|
|
96
56
|
|
|
97
57
|
issues << RailsForge::Issue.new(
|
|
98
58
|
analyzer: :refactor,
|
|
@@ -112,25 +72,51 @@ module RailsForge
|
|
|
112
72
|
methods = []
|
|
113
73
|
in_method = false
|
|
114
74
|
method_name = nil
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
in_method =
|
|
128
|
-
method_name =
|
|
129
|
-
|
|
75
|
+
method_start = 0
|
|
76
|
+
depth = 0
|
|
77
|
+
|
|
78
|
+
content.lines.each_with_index do |line, idx|
|
|
79
|
+
stripped = line.strip
|
|
80
|
+
next if stripped.start_with?("#")
|
|
81
|
+
|
|
82
|
+
if stripped =~ /\bdef\s+([\w?!]+)/
|
|
83
|
+
if in_method
|
|
84
|
+
# Nested def opens a new block that needs its own end
|
|
85
|
+
depth += 1
|
|
86
|
+
else
|
|
87
|
+
in_method = true
|
|
88
|
+
method_name = Regexp.last_match(1)
|
|
89
|
+
method_start = idx
|
|
90
|
+
depth = 1
|
|
130
91
|
end
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
next unless in_method
|
|
96
|
+
|
|
97
|
+
# Count block-opening keywords that each require a matching `end`
|
|
98
|
+
opens = 0
|
|
99
|
+
opens += stripped.scan(/\b(?:class|module)\b/).count
|
|
100
|
+
# if/unless/case/while/until/for/begin only open a block when they start
|
|
101
|
+
# the statement (not when used as trailing modifiers after other code)
|
|
102
|
+
opens += stripped.scan(/(?:^|;)\s*(?:if|unless|case|while|until|for|begin)\b/).count
|
|
103
|
+
# `do` at end of line (iterator block)
|
|
104
|
+
opens += stripped.scan(/\bdo\b(?:\s*(?:\|[^|]*\|))?\s*(?:#.*)?$/).count
|
|
105
|
+
|
|
106
|
+
closes = stripped.scan(/\bend\b/).count
|
|
107
|
+
|
|
108
|
+
depth += opens - closes
|
|
109
|
+
|
|
110
|
+
if depth <= 0
|
|
111
|
+
methods << { name: method_name, lines: idx - method_start + 1 }
|
|
112
|
+
in_method = false
|
|
113
|
+
depth = 0
|
|
131
114
|
end
|
|
132
115
|
end
|
|
133
116
|
|
|
117
|
+
# Capture method that reaches end of file without an explicit `end`
|
|
118
|
+
methods << { name: method_name, lines: content.lines.count - method_start } if in_method && method_name
|
|
119
|
+
|
|
134
120
|
methods
|
|
135
121
|
end
|
|
136
122
|
end
|
|
@@ -7,21 +7,21 @@ module RailsForge
|
|
|
7
7
|
module Analyzers
|
|
8
8
|
class SecurityAnalyzer < BaseAnalyzer
|
|
9
9
|
ISSUE_TYPES = {
|
|
10
|
-
sql_injection:
|
|
11
|
-
mass_assignment:
|
|
12
|
-
xss:
|
|
13
|
-
sensitive_data:
|
|
14
|
-
weak_crypto:
|
|
10
|
+
sql_injection: "SQL Injection",
|
|
11
|
+
mass_assignment: "Unsafe Mass Assignment",
|
|
12
|
+
xss: "Cross-Site Scripting (XSS)",
|
|
13
|
+
sensitive_data: "Sensitive Data Exposure",
|
|
14
|
+
weak_crypto: "Weak Cryptography",
|
|
15
15
|
command_injection: "Command Injection"
|
|
16
16
|
}.freeze
|
|
17
17
|
|
|
18
18
|
SUGGESTIONS = {
|
|
19
|
-
sql_injection:
|
|
20
|
-
mass_assignment:
|
|
21
|
-
xss:
|
|
22
|
-
sensitive_data:
|
|
23
|
-
weak_crypto:
|
|
24
|
-
command_injection: "Avoid shell interpolation; use array form of system()"
|
|
19
|
+
sql_injection: "Use parameterized queries: Model.where(column: value)",
|
|
20
|
+
mass_assignment: "Replace permit! with an explicit list: params.require(:model).permit(:field1, :field2)",
|
|
21
|
+
xss: "Avoid raw/html_safe; use Rails' built-in escaping",
|
|
22
|
+
sensitive_data: "Move secrets to environment variables or Rails credentials",
|
|
23
|
+
weak_crypto: "Use bcrypt for passwords; SHA-256 or better for hashing",
|
|
24
|
+
command_injection: "Avoid shell interpolation; use array form of system() or Open3"
|
|
25
25
|
}.freeze
|
|
26
26
|
|
|
27
27
|
def self.analyze(base_path = nil)
|
|
@@ -42,6 +42,7 @@ module RailsForge
|
|
|
42
42
|
check_xss
|
|
43
43
|
check_sensitive_data
|
|
44
44
|
check_weak_crypto
|
|
45
|
+
check_command_injection
|
|
45
46
|
@issues
|
|
46
47
|
end
|
|
47
48
|
|
|
@@ -49,42 +50,84 @@ module RailsForge
|
|
|
49
50
|
|
|
50
51
|
def check_sql_injection
|
|
51
52
|
patterns = [
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
# String interpolation inside where/having/order/group — the classic injection vector
|
|
54
|
+
# Use [^"]*#\{ so single-quoted content inside the string doesn't break the match
|
|
55
|
+
/\.(where|having|order|group)\s*\(.*#\{/,
|
|
56
|
+
# .order(params[...]) or .where(params[...]) — passing params directly
|
|
57
|
+
/\.(order|where|group)\s*\(\s*params[\[.]/,
|
|
58
|
+
# Raw execute with interpolation
|
|
59
|
+
/\.execute\s*\(.*#\{/,
|
|
60
|
+
# find_by with interpolation
|
|
61
|
+
/find_by[(!]?\(.*#\{/
|
|
55
62
|
]
|
|
56
63
|
scan_files("app/models/**/*.rb", patterns, :sql_injection)
|
|
57
64
|
scan_files("app/controllers/**/*.rb", patterns, :sql_injection)
|
|
58
65
|
end
|
|
59
66
|
|
|
60
67
|
def check_mass_assignment
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
# params.permit! bypasses strong parameters entirely — the modern Rails vulnerability
|
|
69
|
+
patterns = [
|
|
70
|
+
/params\.permit!\s*(?!\(.*\))/,
|
|
71
|
+
/\.permit!\s*$/
|
|
72
|
+
]
|
|
73
|
+
scan_files("app/controllers/**/*.rb", patterns, :mass_assignment)
|
|
63
74
|
end
|
|
64
75
|
|
|
65
76
|
def check_xss
|
|
66
|
-
patterns = [
|
|
77
|
+
patterns = [
|
|
78
|
+
/\braw\s*\(/,
|
|
79
|
+
/\.html_safe\b/, # .html_safe anywhere, not just end of line
|
|
80
|
+
/\bContent-Security-Policy.*unsafe-inline/
|
|
81
|
+
]
|
|
67
82
|
scan_files("app/views/**/*.erb", patterns, :xss)
|
|
68
83
|
scan_files("app/helpers/**/*.rb", patterns, :xss)
|
|
69
84
|
end
|
|
70
85
|
|
|
71
86
|
def check_sensitive_data
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
# Look for hardcoded secret values (string literals), not just field names
|
|
88
|
+
# Matches: password = "abc123", api_key: "sk-...", secret: 'hardcoded'
|
|
89
|
+
# Excludes: password: <%= ENV[...] %>, password: Rails.application.credentials...
|
|
90
|
+
patterns = [
|
|
91
|
+
# Case-insensitive: catches API_KEY = "...", password: "...", SECRET_TOKEN = '...'
|
|
92
|
+
/(?:password|secret|token|api_key|private_key)\s*(?:=|:)\s*['"][^'"<\s]{6,}['"]/i
|
|
93
|
+
]
|
|
74
94
|
scan_files("config/**/*.rb", patterns, :sensitive_data)
|
|
95
|
+
scan_files("config/**/*.yml", patterns, :sensitive_data)
|
|
96
|
+
# Skip initializers that intentionally assign from env — still scan for raw literals
|
|
97
|
+
scan_files("app/models/**/*.rb", patterns, :sensitive_data)
|
|
75
98
|
end
|
|
76
99
|
|
|
77
100
|
def check_weak_crypto
|
|
78
|
-
patterns = [
|
|
101
|
+
patterns = [
|
|
102
|
+
/\bMD5\b/,
|
|
103
|
+
/\bSHA1\b/,
|
|
104
|
+
/Digest::SHA1/,
|
|
105
|
+
/\.encrypt\s*\(/,
|
|
106
|
+
/DES\.encrypt/
|
|
107
|
+
]
|
|
79
108
|
scan_files("app/**/*.rb", patterns, :weak_crypto)
|
|
80
109
|
end
|
|
81
110
|
|
|
111
|
+
def check_command_injection
|
|
112
|
+
patterns = [
|
|
113
|
+
/`[^`]*#\{/, # backtick shell execution with interpolation
|
|
114
|
+
/system\s*\([^)]*#\{/, # system() with interpolation
|
|
115
|
+
/exec\s*\([^)]*#\{/, # exec() with interpolation
|
|
116
|
+
/Open3\.\w+\s*\([^)]*#\{/, # Open3 with interpolation
|
|
117
|
+
/IO\.popen\s*\([^)]*#\{/ # IO.popen with interpolation
|
|
118
|
+
]
|
|
119
|
+
scan_files("app/**/*.rb", patterns, :command_injection)
|
|
120
|
+
end
|
|
121
|
+
|
|
82
122
|
def scan_files(glob, patterns, issue_type)
|
|
83
123
|
Dir.glob(File.join(@base_path, glob)).each do |file|
|
|
84
124
|
lines = File.read(file).lines
|
|
85
125
|
lines.each_with_index do |line, index|
|
|
126
|
+
stripped = line.strip
|
|
127
|
+
next if stripped.start_with?('#')
|
|
128
|
+
|
|
86
129
|
patterns.each do |pattern|
|
|
87
|
-
next unless
|
|
130
|
+
next unless stripped =~ pattern
|
|
88
131
|
|
|
89
132
|
@issues << RailsForge::Issue.new(
|
|
90
133
|
analyzer: :security,
|
|
@@ -92,7 +135,7 @@ module RailsForge
|
|
|
92
135
|
severity: severity_for(issue_type),
|
|
93
136
|
file: file.delete_prefix("#{@base_path}/"),
|
|
94
137
|
line: index + 1,
|
|
95
|
-
message: "#{ISSUE_TYPES[issue_type]}: #{
|
|
138
|
+
message: "#{ISSUE_TYPES[issue_type]}: #{stripped}",
|
|
96
139
|
suggestion: SUGGESTIONS[issue_type]
|
|
97
140
|
)
|
|
98
141
|
end
|
|
@@ -6,7 +6,16 @@ require_relative "base_analyzer"
|
|
|
6
6
|
module RailsForge
|
|
7
7
|
module Analyzers
|
|
8
8
|
class SpecAnalyzer < BaseAnalyzer
|
|
9
|
-
|
|
9
|
+
# Explicit singular mapping — .chop fails for irregular plurals like queries→querie, policies→policie
|
|
10
|
+
SPEC_TYPES = {
|
|
11
|
+
"services" => "service",
|
|
12
|
+
"queries" => "query",
|
|
13
|
+
"jobs" => "job",
|
|
14
|
+
"forms" => "form",
|
|
15
|
+
"presenters" => "presenter",
|
|
16
|
+
"policies" => "policy",
|
|
17
|
+
"serializers" => "serializer"
|
|
18
|
+
}.freeze
|
|
10
19
|
|
|
11
20
|
def self.analyze(base_path = nil)
|
|
12
21
|
base_path ||= find_rails_app_path
|
|
@@ -14,14 +23,15 @@ module RailsForge
|
|
|
14
23
|
|
|
15
24
|
issues = []
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
singular = type.chop
|
|
26
|
+
SPEC_TYPES.each do |type, singular|
|
|
19
27
|
app_path = File.join(base_path, "app", singular)
|
|
20
28
|
spec_path = File.join(base_path, "spec", type)
|
|
21
29
|
|
|
22
30
|
next unless Dir.exist?(app_path)
|
|
23
31
|
|
|
24
|
-
app_files = Dir.glob(File.join(app_path, "**", "*.rb"))
|
|
32
|
+
app_files = Dir.glob(File.join(app_path, "**", "*.rb"))
|
|
33
|
+
.reject { |f| File.basename(f) =~ /^application_/ }
|
|
34
|
+
.map { |f| File.basename(f, ".rb") }
|
|
25
35
|
spec_files = Dir.exist?(spec_path) ? Dir.glob(File.join(spec_path, "**", "*_spec.rb")).map { |f| File.basename(f, "_spec.rb") } : []
|
|
26
36
|
|
|
27
37
|
(app_files - spec_files).each do |missing|
|
data/lib/railsforge/version.rb
CHANGED