## 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>
554 lines
18 KiB
Rust
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());
|
|
}
|
|
}
|