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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b004345e61120afc6f11432c72baeddb6981aee6e504d50b5eedf5b0334f71d6
4
- data.tar.gz: 51bae87bc37e99da845f89ea3e9af014a2d9b6d85128417223978e5ffef14958
3
+ metadata.gz: 97a2aa54dd6324b6a8237f33d2d45abdff8e33903e0cc63f1def08b6cea5f39b
4
+ data.tar.gz: 8a0ca64024fa1eec71cc892aba1ac8e66903e0e801259999fd9b4cfac5484e12
5
5
  SHA512:
6
- metadata.gz: 1bc07580544ae7d06679dd03986cf6268b7598ead40a495a54ff8b2f8d0da1730871d01738ec419455998992f85cbda3619dbd60ccf77fb6ea73cce53ce7179c
7
- data.tar.gz: 7b1b7c639d4da0968d6db7ea4e2a9d98a28398186f93ffeaddab9ee86c60c9481296d72ab64c0c9d552e795f635de3d146664e7cc6415d9c919db16bfe68aa18
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
- content = File.read(schema_file)
19
- tables = content.scan(/create_table\s+"(\w+)"/).flatten
20
- issues = []
21
-
22
- tables.each do |table|
23
- table_section = content[/create_table\s+"#{table}".*?(?=create_table|\z)/m]
24
- next unless table_section&.include?("t.datetime")
25
-
26
- issues << RailsForge::Issue.new(
27
- analyzer: :database,
28
- type: :missing_index,
29
- severity: "medium",
30
- file: "db/schema.rb",
31
- line: nil,
32
- message: "Table `#{table}` has datetime columns without an index",
33
- suggestion: "add_index :#{table}, :created_at"
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
- issues
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
- missing_index: "Add a database index to the foreign key column",
21
- slow_method: "Consider async processing or caching for slow operations",
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
- patterns = [/\.each\s+do.*\.find/, /\.map.*\.where/, /@.*\.all\.each/]
51
- scan_files("app/controllers/**/*.rb", patterns, :n_plus_one)
52
- scan_files("app/views/**/*.erb", patterns, :n_plus_one)
53
- end
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
- def check_missing_indexes
56
- patterns = [/belongs_to\s+:\w+/, /add_reference/]
57
- scan_files("db/migrate/**/*.rb", patterns, :missing_index)
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
- patterns = [/\.each\s+do\s*\n.*\.save/, /Process\.spawn/, /\.read/, /\.open\(.*\)/]
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 = [/\.order\(.*\)\.last/, /\.where\(.*\)\.first\! /, /\.all\.to_a/, /\.select\(.+\)\.map\//]
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 line =~ pattern
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]}: #{line.strip}",
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, :missing_index then "high"
95
- when :inefficient_query then "medium"
96
- else "low"
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
- CONTROLLER_MAX_LINES = 150
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
- if type == :controller
57
- if lines > CONTROLLER_MAX_LINES
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] > MODEL_MAX_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
- method_lines = []
116
-
117
- content.lines.each do |line|
118
- if line =~ /\bdef\s+(\w+)/
119
- methods << { name: method_name, lines: method_lines.count } if in_method && method_name
120
- in_method = true
121
- method_name = $1
122
- method_lines = [line]
123
- elsif in_method
124
- method_lines << line
125
- if line.strip == "end" && method_lines.count > 1
126
- methods << { name: method_name, lines: method_lines.count }
127
- in_method = false
128
- method_name = nil
129
- method_lines = []
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: "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",
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: "Use parameterized queries: Model.where(column: value)",
20
- mass_assignment: "Use strong parameters with permit() instead of attr_accessible",
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()"
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
- /\.where\s*\(\s*params\./,
53
- /find_by\(.*\#\{.*\}/,
54
- /execute\s*\(.*\#\{.*\}/
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
- patterns = [/attr_accessible/, /attr_protected/]
62
- scan_files("app/models/**/*.rb", patterns, :mass_assignment)
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 = [/raw\s*\(/, /html_safe\s*$/, /\<\%=\s*[^>]+without/]
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
- patterns = [/password/, /secret/, /token/, /api_key/, /private_key/]
73
- scan_files("app/models/**/*.rb", patterns, :sensitive_data)
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 = [/MD5/, /SHA1/, /DES\.encrypt/, /\.encrypt\s+[^:]+$/]
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 line =~ pattern
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]}: #{line.strip}",
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
- GENERATOR_TYPES = %w[services queries jobs forms presenters policies serializers].freeze
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
- GENERATOR_TYPES.each do |type|
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")).map { |f| File.basename(f, ".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|
@@ -1,5 +1,5 @@
1
1
  # Version module for RailsForge gem
2
2
  # Defines the current version of the gem
3
3
  module RailsForge
4
- VERSION = "2.1.0"
4
+ VERSION = "2.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railsforge
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - RailsForge Contributors