//! Ruby on Rails security extractor. //! //! Detects security misconfigurations in Rails applications: //! - Force SSL disabled //! - CSRF protection skipped //! - SQL injection via string interpolation //! - Mass assignment vulnerabilities //! - Unsafe rendering (html_safe, raw) use regex::Regex; use stemedb_core::types::ObjectValue; use super::traits::build_claim; use super::Extractor; use crate::types::{ExtractedClaim, Language}; /// Extractor for Rails security misconfigurations. pub struct RailsSecurityExtractor { // Config patterns (production.rb) force_ssl_false: Regex, cookies_same_site_none: Regex, session_secure_false: Regex, session_httponly_false: Regex, forgery_protection_false: Regex, log_level_debug: Regex, // Code patterns skip_verify_authenticity: Regex, protect_from_forgery_null: Regex, where_interpolation: Regex, where_concat: Regex, find_by_sql_interpolation: Regex, html_safe: Regex, render_inline_params: Regex, render_html_params: Regex, permit_all: Regex, mass_assignment_new: Regex, secret_key_hardcoded: Regex, } impl Default for RailsSecurityExtractor { fn default() -> Self { Self::new() } } impl RailsSecurityExtractor { /// Create a new Rails security extractor. /// /// # Panics /// Panics if any regex pattern is invalid (programmer error). #[allow(clippy::expect_used)] pub fn new() -> Self { Self { // Config patterns force_ssl_false: Regex::new(r"config\.force_ssl\s*=\s*false").expect("valid regex"), cookies_same_site_none: Regex::new(r"cookies_same_site_protection\s*=\s*:none") .expect("valid regex"), session_secure_false: Regex::new(r"session_store\s*:[^,]+,\s*secure:\s*false") .expect("valid regex"), session_httponly_false: Regex::new(r"session_store\s*:[^,]+,\s*httponly:\s*false") .expect("valid regex"), forgery_protection_false: Regex::new(r"allow_forgery_protection\s*=\s*false") .expect("valid regex"), log_level_debug: Regex::new(r"config\.log_level\s*=\s*:debug").expect("valid regex"), // Code patterns skip_verify_authenticity: Regex::new( r"skip_before_action\s*:verify_authenticity_token", ) .expect("valid regex"), protect_from_forgery_null: Regex::new(r"protect_from_forgery\s+with:\s*:null_session") .expect("valid regex"), where_interpolation: Regex::new(r#"\.where\s*\(.*#\{.*params"#).expect("valid regex"), where_concat: Regex::new(r#"\.where\s*\(\s*['"][^'"]*['"]\s*\+[^)]*params"#) .expect("valid regex"), find_by_sql_interpolation: Regex::new(r#"find_by_sql\s*\(.*#\{.*params"#) .expect("valid regex"), html_safe: Regex::new(r"\.html_safe").expect("valid regex"), render_inline_params: Regex::new(r"render\s+inline:\s*params").expect("valid regex"), render_html_params: Regex::new(r"render\s+html:\s*params").expect("valid regex"), permit_all: Regex::new(r"params\.permit!").expect("valid regex"), mass_assignment_new: Regex::new(r"\.\s*new\s*\(\s*params\s*\[\s*:") .expect("valid regex"), secret_key_hardcoded: Regex::new(r#"secret_key_base\s*=\s*['"][^'"]{10,}['"]"#) .expect("valid regex"), } } fn check_config_patterns( &self, path_segments: &[String], content: &str, file: &str, ) -> Vec { let mut claims = Vec::new(); for (line_idx, line) in content.lines().enumerate() { let line_num = line_idx + 1; // Force SSL false if let Some(m) = self.force_ssl_false.find(line) { claims.push(build_claim( path_segments, &["rails", "force_ssl"], "enabled", ObjectValue::Boolean(false), file, line_num, m.as_str(), 1.0, "Rails force_ssl disabled - HTTPS not enforced", )); } // Cookies same site none if let Some(m) = self.cookies_same_site_none.find(line) { claims.push(build_claim( path_segments, &["rails", "cookies", "same_site"], "config_value", ObjectValue::Text("none".to_string()), file, line_num, m.as_str(), 0.9, "Rails cookies same_site set to none", )); } // Session secure false if let Some(m) = self.session_secure_false.find(line) { claims.push(build_claim( path_segments, &["rails", "session", "secure"], "enabled", ObjectValue::Boolean(false), file, line_num, m.as_str(), 1.0, "Rails session cookie not marked secure", )); } // Session httponly false if let Some(m) = self.session_httponly_false.find(line) { claims.push(build_claim( path_segments, &["rails", "session", "httponly"], "enabled", ObjectValue::Boolean(false), file, line_num, m.as_str(), 1.0, "Rails session cookie accessible to JavaScript", )); } // Forgery protection false if let Some(m) = self.forgery_protection_false.find(line) { claims.push(build_claim( path_segments, &["rails", "csrf"], "enabled", ObjectValue::Boolean(false), file, line_num, m.as_str(), 1.0, "Rails CSRF protection disabled globally", )); } // Log level debug in production if file.contains("production") { if let Some(m) = self.log_level_debug.find(line) { claims.push(build_claim( path_segments, &["rails", "log_level"], "config_value", ObjectValue::Text("debug".to_string()), file, line_num, m.as_str(), 0.8, "Rails log level set to debug in production", )); } } } claims } fn check_code_patterns( &self, path_segments: &[String], content: &str, file: &str, ) -> Vec { let mut claims = Vec::new(); for (line_idx, line) in content.lines().enumerate() { let line_num = line_idx + 1; // Skip verify authenticity token if let Some(m) = self.skip_verify_authenticity.find(line) { claims.push(build_claim( path_segments, &["rails", "csrf"], "skipped", ObjectValue::Boolean(true), file, line_num, m.as_str(), 1.0, "Rails CSRF protection skipped via skip_before_action", )); } // Protect from forgery null session if let Some(m) = self.protect_from_forgery_null.find(line) { claims.push(build_claim( path_segments, &["rails", "csrf"], "null_session", ObjectValue::Boolean(true), file, line_num, m.as_str(), 0.9, "Rails CSRF protection using null_session strategy", )); } // Where interpolation if let Some(m) = self.where_interpolation.find(line) { claims.push(build_claim( path_segments, &["rails", "sql_injection"], "vulnerable", ObjectValue::Boolean(true), file, line_num, m.as_str(), 0.95, "Rails SQL injection via .where() with string interpolation", )); } // Where concatenation if let Some(m) = self.where_concat.find(line) { claims.push(build_claim( path_segments, &["rails", "sql_injection"], "vulnerable", ObjectValue::Boolean(true), file, line_num, m.as_str(), 0.95, "Rails SQL injection via .where() with string concatenation", )); } // Find by SQL interpolation if let Some(m) = self.find_by_sql_interpolation.find(line) { claims.push(build_claim( path_segments, &["rails", "sql_injection"], "vulnerable", ObjectValue::Boolean(true), file, line_num, m.as_str(), 0.95, "Rails SQL injection via find_by_sql with interpolation", )); } // html_safe if let Some(m) = self.html_safe.find(line) { claims.push(build_claim( path_segments, &["rails", "xss"], "html_safe_used", ObjectValue::Boolean(true), file, line_num, m.as_str(), 0.7, "Rails .html_safe used - potential XSS if user input", )); } // Render inline params if let Some(m) = self.render_inline_params.find(line) { claims.push(build_claim( path_segments, &["rails", "xss"], "vulnerable", ObjectValue::Boolean(true), file, line_num, m.as_str(), 1.0, "Rails XSS via render inline with params", )); } // Render html params if let Some(m) = self.render_html_params.find(line) { claims.push(build_claim( path_segments, &["rails", "xss"], "vulnerable", ObjectValue::Boolean(true), file, line_num, m.as_str(), 1.0, "Rails XSS via render html with params", )); } // params.permit! if let Some(m) = self.permit_all.find(line) { claims.push(build_claim( path_segments, &["rails", "mass_assignment"], "permit_all", ObjectValue::Boolean(true), file, line_num, m.as_str(), 1.0, "Rails mass assignment via params.permit!", )); } // Mass assignment via new if let Some(m) = self.mass_assignment_new.find(line) { claims.push(build_claim( path_segments, &["rails", "mass_assignment"], "vulnerable", ObjectValue::Boolean(true), file, line_num, m.as_str(), 0.8, "Rails potential mass assignment via .new(params[...])", )); } // Hardcoded secret key if let Some(m) = self.secret_key_hardcoded.find(line) { // Skip if using ENV if !line.contains("ENV[") && !line.contains("Rails.application.credentials") { claims.push(build_claim( path_segments, &["rails", "secret_key"], "hardcoded", ObjectValue::Boolean(true), file, line_num, &m.as_str()[..m.as_str().len().min(50)], 0.9, "Rails secret_key_base appears hardcoded", )); } } } claims } } impl Extractor for RailsSecurityExtractor { fn name(&self) -> &str { "rails_security" } fn languages(&self) -> &[Language] { &[Language::Ruby, Language::Yaml] } fn extract( &self, path_segments: &[String], content: &str, language: Language, file: &str, ) -> Vec { let mut claims = Vec::new(); // Check if this looks like a Rails file let is_rails = content.contains("Rails") || content.contains("rails") || content.contains("ActionController") || content.contains("ApplicationController") || content.contains("ActiveRecord") || content.contains("< Controller") || content.contains("class ") && content.contains("Controller") || content.contains("class ") && content.contains("Helper") || file.contains("config/environments") || file.contains("app/controllers") || file.contains("app/helpers"); if !is_rails { return claims; } match language { Language::Ruby => { claims.extend(self.check_config_patterns(path_segments, content, file)); claims.extend(self.check_code_patterns(path_segments, content, file)); } Language::Yaml => { // secrets.yml patterns could be added here } _ => {} } claims } } #[cfg(test)] mod tests { use super::*; #[test] fn test_force_ssl_false() { let extractor = RailsSecurityExtractor::new(); let content = r#" Rails.application.configure do config.force_ssl = false end "#; let claims = extractor.extract( &["ruby".to_string()], content, Language::Ruby, "config/environments/production.rb", ); assert!(claims.iter().any(|c| c.concept_path.contains("force_ssl"))); } #[test] fn test_skip_verify_authenticity_token() { let extractor = RailsSecurityExtractor::new(); let content = r#" class ApiController < ApplicationController skip_before_action :verify_authenticity_token end "#; let claims = extractor.extract( &["ruby".to_string()], content, Language::Ruby, "app/controllers/api_controller.rb", ); assert!(claims.iter().any(|c| c.concept_path.contains("csrf"))); } #[test] fn test_sql_injection_where() { let extractor = RailsSecurityExtractor::new(); let content = r#" class UsersController < ApplicationController def search User.where("name = '#{params[:name]}'") end end "#; let claims = extractor.extract( &["ruby".to_string()], content, Language::Ruby, "app/controllers/users_controller.rb", ); assert!(claims.iter().any(|c| c.concept_path.contains("sql_injection"))); } #[test] fn test_html_safe() { let extractor = RailsSecurityExtractor::new(); let content = r#" class ApplicationHelper def render_content(content) content.html_safe end end "#; let claims = extractor.extract( &["ruby".to_string()], content, Language::Ruby, "app/helpers/application_helper.rb", ); assert!(claims.iter().any(|c| c.concept_path.contains("xss"))); } #[test] fn test_permit_all() { let extractor = RailsSecurityExtractor::new(); let content = r#" class UsersController < ApplicationController def create User.create(params.permit!) end end "#; let claims = extractor.extract( &["ruby".to_string()], content, Language::Ruby, "app/controllers/users_controller.rb", ); assert!(claims.iter().any(|c| c.concept_path.contains("mass_assignment"))); } #[test] fn test_non_rails_file_skipped() { let extractor = RailsSecurityExtractor::new(); let content = r#" class MyClass def html_safe true end end "#; let claims = extractor.extract(&["ruby".to_string()], content, Language::Ruby, "lib/my_class.rb"); // Should not detect since file doesn't look like Rails assert!(claims.is_empty()); } }