stemedb/applications/aphoria/src/extractors/rails_security.rs
jordan 157dbbb9eb feat: Complete Aphoria Phase 8-9 + UAT suite (90/90 tests passing)
## Phase 8: Enterprise Extractor Improvements 
- 14 security extractors (TLS, JWT, SQL injection, XSS, etc.)
- 10 framework-specific extractors (Spring, Django, Rails, etc.)
- Config file security detection (YAML, TOML)

## Phase 9: Autonomous Extractor Generation 
- Shadow mode executor with TP/FP tracking
- Graduation pipeline with confidence thresholds
- Auto-rollback on regression detection
- Cross-project pattern syncing

## UAT Suite Complete (14 scripts, 90 tests)
- test-core-detection.sh (6 tests)
- test-declarative-extractors.sh (5 tests)
- test-domain-frameworks.sh (5 tests)
- test-domain-unreal.sh (3 tests)
- test-llm-extraction.sh (6 tests)
- test-eval-harness.sh (5 tests)
- test-cross-language.sh (3 tests)
- test-precommit-performance.sh (4 tests)
- test-output-formats.sh (8 tests)
- test-drift-detection.sh (6 tests)
- test-exit-codes.sh (12 tests)
+ 3 more scripts

## Other Changes
- Updated roadmap to mark Phase 8-9 complete
- Added .gitignore entries for build artifacts
- Updated pre-commit: 800 line limit, exclude tests/data/cmd

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:50:55 -07:00

554 lines
18 KiB
Rust

//! 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<ExtractedClaim> {
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<ExtractedClaim> {
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<ExtractedClaim> {
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());
}
}