use crate::config::{self, MCPConfig, MCPConfigManager, MCPServerConfig, ScannerConfig};
use crate::constants::{messages, protocol};
use crate::mcp_client::McpClient;
use crate::security::{
cross_origin_scanner::CrossOriginScanner, BatchScannableItem, SecurityScanResult,
SecurityScanner,
};
use crate::types::{
LlmPrompt, MCPPrompt, MCPResource, MCPServerInfo, MCPTool, ScanOptions, ScanResult, ScanStatus,
YaraScanResult,
};
use crate::utils::{error_utils, performance::track_performance, Timer};
use anyhow::{anyhow, Result};
use reqwest::Client;
use std::collections::HashMap;
use std::path::Path;
use tokio::time::{timeout, Duration};
use tracing::{debug, warn};
#[cfg(feature = "yara-x-scanning")]
use yara_x::Rules;
#[cfg(feature = "yara-x-scanning")]
type YaraRules = Rules;
#[cfg(not(feature = "yara-x-scanning"))]
type YaraRules = ();
fn rule_name_to_file_name(rule_name: &str) -> Option<String> {
match rule_name {
"SecretsLeakage" | "SSHKeyExposure" | "PEMFileAccess" | "EnvironmentVariableLeakage" => {
Some("secrets_leakage".to_string())
}
"CrossOriginEscalation"
| "CrossDomainContamination"
| "DomainOutlier"
| "MixedSecuritySchemes" => Some("cross_origin_escalation".to_string()),
_ => None,
}
}
fn generate_context_message(item_type: &str, rule_name: &str) -> String {
match rule_name {
"SecretsLeakage" => format!("Potential secret exposure detected in {item_type}"),
"SSHKeyExposure" => format!("SSH key or configuration file access detected in {item_type}"),
"PEMFileAccess" => format!("PEM certificate or private key access detected in {item_type}"),
"EnvironmentVariableLeakage" => {
format!("Sensitive environment variable pattern detected in {item_type}")
}
"CrossOriginEscalation" => {
format!("Cross-origin escalation vulnerability detected in {item_type}")
}
"CrossDomainContamination" => {
format!("Cross-domain contamination detected across multiple domains in {item_type}")
}
"DomainOutlier" => {
format!("Domain outlier detected - {item_type} uses different domain than majority")
}
"MixedSecuritySchemes" => format!("Mixed HTTP/HTTPS schemes detected in {item_type}"),
"CommandInjection" => format!("Command injection vulnerability detected in {item_type}"),
_ => format!("{item_type} matched by security rule {rule_name}"),
}
}
fn is_yara_available(config_enabled: bool) -> bool {
if !config_enabled {
return false;
}
#[cfg(feature = "yara-x-scanning")]
{
true
}
#[cfg(not(feature = "yara-x-scanning"))]
{
false
}
}
fn print_yara_install_message() {
println!("📋 YARA-X Scanning Disabled");
println!();
println!("YARA-X rule scanning is enabled in your config but YARA-X is not available.");
println!("To enable YARA-X scanning, please:");
println!();
println!("1. Reinstall ramparts with YARA-X support:");
println!(" cargo install ramparts --force");
println!();
println!("2. Or disable YARA-X in your config.yaml:");
println!(" scanner:");
println!(" enable_yara: false");
println!();
println!("Continuing without YARA-X scanning...");
println!();
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ScanPhase {
PreScan,
PostScan,
}
pub trait Scanner: Send + Sync {
fn name(&self) -> &'static str;
fn phase(&self) -> ScanPhase;
fn run(&self, _scan_data: &mut ScanData) -> anyhow::Result<()>;
fn box_clone(&self) -> Box<dyn Scanner>;
}
#[derive(Default)]
pub struct ScannerChain {
pre_scan: Vec<Box<dyn Scanner>>,
post_scan: Vec<Box<dyn Scanner>>,
}
impl ScannerChain {
pub fn new() -> Self {
Self {
pre_scan: Vec::new(),
post_scan: Vec::new(),
}
}
pub fn add(&mut self, scanner: Box<dyn Scanner>) {
match scanner.phase() {
ScanPhase::PreScan => self.pre_scan.push(scanner),
ScanPhase::PostScan => self.post_scan.push(scanner),
}
}
pub fn run_pre_scan(&self, scan_data: &mut ScanData) {
for scanner in &self.pre_scan {
if let Err(e) = scanner.run(scan_data) {
tracing::warn!("Pre-scan scanner '{}' failed: {}", scanner.name(), e);
}
}
}
pub fn run_post_scan(&self, scan_data: &mut ScanData) {
for scanner in &self.post_scan {
if let Err(e) = scanner.run(scan_data) {
tracing::warn!("Post-scan scanner '{}' failed: {}", scanner.name(), e);
}
}
}
}
impl Clone for ScannerChain {
fn clone(&self) -> Self {
Self {
pre_scan: self.pre_scan.iter().map(|c| c.box_clone()).collect(),
post_scan: self.post_scan.iter().map(|c| c.box_clone()).collect(),
}
}
}
#[cfg(feature = "yara-x-scanning")]
use glob::glob;
use std::sync::Arc;
#[cfg(feature = "yara-x-scanning")]
pub struct YaraMatchInfo {
pub rule_name: String,
pub metadata: Option<crate::types::YaraRuleMetadata>,
}
#[cfg(not(feature = "yara-x-scanning"))]
pub struct YaraMatchInfo {
pub rule_name: String,
pub metadata: Option<crate::types::YaraRuleMetadata>,
}
pub struct ThreatRules {
pre_scan_rules: Vec<Arc<YaraRules>>,
post_scan_rules: Vec<Arc<YaraRules>>,
rules_dir: String,
rule_metadata: HashMap<String, RuleMetadata>,
memory_usage_bytes: usize,
last_load_time: std::time::Instant,
}
#[derive(Debug, Clone)]
pub struct RuleMetadata {
pub name: String,
}
impl ThreatRules {
pub fn new(rules_dir: &str) -> Result<Self> {
Self::with_config(rules_dir, true)
}
pub fn with_config(rules_dir: &str, enable_yara: bool) -> Result<Self> {
if !is_yara_available(enable_yara) {
if enable_yara {
print_yara_install_message();
}
return Ok(Self::new_disabled(rules_dir));
}
Self::new_enabled(rules_dir)
}
fn new_disabled(rules_dir: &str) -> Self {
let start_time = std::time::Instant::now();
Self {
pre_scan_rules: Vec::new(),
post_scan_rules: Vec::new(),
rules_dir: rules_dir.to_string(),
rule_metadata: HashMap::new(),
memory_usage_bytes: 0,
last_load_time: start_time,
}
}
#[cfg(feature = "yara-x-scanning")]
fn new_enabled(rules_dir: &str) -> Result<Self> {
let start_time = std::time::Instant::now();
let mut scanner = Self {
pre_scan_rules: Vec::new(),
post_scan_rules: Vec::new(),
rules_dir: rules_dir.to_string(),
rule_metadata: HashMap::new(),
memory_usage_bytes: 0,
last_load_time: start_time,
};
scanner.load_rules()?;
scanner.last_load_time = start_time;
Ok(scanner)
}
#[cfg(not(feature = "yara-x-scanning"))]
fn new_enabled(_rules_dir: &str) -> Result<Self> {
Err(anyhow!("YARA-X scanning feature is not available"))
}
#[cfg(feature = "yara-x-scanning")]
fn load_rules(&mut self) -> Result<()> {
let start_time = std::time::Instant::now();
let pre_dir = format!("{}/pre", self.rules_dir);
if Path::new(&pre_dir).exists() {
self.pre_scan_rules = self.load_rules_from_directory(&pre_dir, "pre")?;
}
let post_dir = format!("{}/post", self.rules_dir);
if Path::new(&post_dir).exists() {
self.post_scan_rules = self.load_rules_from_directory(&post_dir, "post")?;
}
self.calculate_memory_usage();
let load_duration = start_time.elapsed();
debug!(
"Loaded {} pre-scan rules, {} post-scan rules in {}ms (memory: {}KB)",
self.pre_scan_rules.len(),
self.post_scan_rules.len(),
load_duration.as_millis(),
self.memory_usage_bytes / 1024
);
Ok(())
}
#[cfg(feature = "yara-x-scanning")]
fn calculate_memory_usage(&mut self) {
let rule_memory = (self.pre_scan_rules.len() + self.post_scan_rules.len()) * 1024; let metadata_memory = self.rule_metadata.len() * 256;
self.memory_usage_bytes = rule_memory + metadata_memory;
}
#[cfg(test)]
pub fn memory_stats(&self) -> RuleStats {
RuleStats {
pre_scan_count: self.pre_scan_rules.len(),
post_scan_count: self.post_scan_rules.len(),
pre_scan_rules: Vec::new(), post_scan_rules: Vec::new(), }
}
#[cfg(feature = "yara-x-scanning")]
fn load_rules_from_directory(
&mut self,
dir_path: &str,
phase: &str,
) -> Result<Vec<Arc<YaraRules>>> {
let mut rules = Vec::new();
let pattern = format!("{dir_path}/*.yar");
for entry in glob(&pattern).map_err(|e| anyhow!("Glob error: {}", e))? {
match entry {
Ok(path) => {
if let Some(path_str) = path.to_str() {
let rule_content = std::fs::read_to_string(path_str)
.map_err(|e| anyhow!("Failed to read rule file {}: {}", path_str, e))?;
let mut compiler = yara_x::Compiler::new();
if let Err(e) = compiler.add_source(rule_content.as_str()) {
warn!("Failed to add rule source from {}: {}", path_str, e);
continue;
}
let rule = compiler.build();
let rule_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
debug!("Loaded YARA-X rule: {} (phase: {})", path.display(), phase);
let metadata = RuleMetadata {
name: rule_name.clone(),
};
let metadata_key = format!("{phase}:{rule_name}");
self.rule_metadata.insert(metadata_key, metadata);
rules.push(Arc::new(rule));
} else {
warn!(
"Skipping rule file with non-UTF8 characters: {}",
path.display()
);
}
}
Err(e) => warn!("Failed to read rule file: {}", e),
}
}
Ok(rules)
}
#[cfg(feature = "yara-x-scanning")]
fn extract_rule_metadata(rule_match: &yara_x::Rule) -> Option<crate::types::YaraRuleMetadata> {
let metadata_iter = rule_match.metadata();
let metadata_vec: Vec<(&str, yara_x::MetaValue)> = metadata_iter.collect();
if metadata_vec.is_empty() {
return None;
}
let mut rule_metadata = crate::types::YaraRuleMetadata {
name: None,
author: None,
date: None,
version: None,
description: None,
severity: None,
category: None,
confidence: None,
tags: Vec::new(),
};
let meta_value_to_string = |value: &yara_x::MetaValue| -> String {
match value {
yara_x::MetaValue::Integer(i) => i.to_string(),
yara_x::MetaValue::Float(f) => f.to_string(),
yara_x::MetaValue::Bool(b) => b.to_string(),
yara_x::MetaValue::String(s) => (*s).to_string(),
yara_x::MetaValue::Bytes(b) => String::from_utf8_lossy(b).to_string(),
}
};
for (key, value) in &metadata_vec {
match *key {
"name" => rule_metadata.name = Some(meta_value_to_string(value)),
"author" => rule_metadata.author = Some(meta_value_to_string(value)),
"date" => rule_metadata.date = Some(meta_value_to_string(value)),
"version" => rule_metadata.version = Some(meta_value_to_string(value)),
"description" => rule_metadata.description = Some(meta_value_to_string(value)),
"severity" => rule_metadata.severity = Some(meta_value_to_string(value)),
"category" => rule_metadata.category = Some(meta_value_to_string(value)),
"confidence" => rule_metadata.confidence = Some(meta_value_to_string(value)),
"tags" => {
let tags_str = meta_value_to_string(value);
rule_metadata.tags =
tags_str.split(',').map(|s| s.trim().to_string()).collect();
}
_ => {} }
}
Some(rule_metadata)
}
#[cfg(feature = "yara-x-scanning")]
fn scan_with_rules_enhanced_internal(
text: &str,
context: &str,
rules: &[Arc<YaraRules>],
phase: &str,
) -> Vec<YaraMatchInfo> {
let mut all_matches = Vec::new();
for (i, rule_set) in rules.iter().enumerate() {
let mut scanner = yara_x::Scanner::new(rule_set);
match scanner.scan(text.as_bytes()) {
Ok(scan_results) => {
for m in scan_results.matching_rules() {
all_matches.push(YaraMatchInfo {
rule_name: m.identifier().to_string(),
metadata: Self::extract_rule_metadata(&m),
});
}
}
Err(e) => warn!("Failed to scan with {}-rule {}: {}", phase, i, e),
}
}
if !all_matches.is_empty() {
debug!(
"{}-scan matches in {}: {} rules triggered",
phase,
context,
all_matches.len()
);
}
all_matches
}
#[cfg(feature = "yara-x-scanning")]
pub fn pre_scan(&self, text: &str, context: &str) -> Vec<YaraMatchInfo> {
Self::scan_with_rules_enhanced_internal(text, context, &self.pre_scan_rules, "pre")
}
#[cfg(feature = "yara-x-scanning")]
pub fn post_scan(&self, text: &str, context: &str) -> Vec<YaraMatchInfo> {
Self::scan_with_rules_enhanced_internal(text, context, &self.post_scan_rules, "post")
}
pub fn stats(&self) -> RuleStats {
let mut pre_scan_rules = Vec::new();
let mut post_scan_rules = Vec::new();
for (key, metadata) in &self.rule_metadata {
if key.starts_with("pre:") {
pre_scan_rules.push(metadata.name.clone());
}
}
for (key, metadata) in &self.rule_metadata {
if key.starts_with("post:") {
post_scan_rules.push(metadata.name.clone());
}
}
RuleStats {
pre_scan_count: self.pre_scan_rules.len(),
post_scan_count: self.post_scan_rules.len(),
pre_scan_rules,
post_scan_rules,
}
}
#[cfg(all(test, feature = "yara-x-scanning"))]
pub fn validate(&self) -> Result<Vec<String>> {
let mut issues = Vec::new();
for (i, rules) in self.pre_scan_rules.iter().enumerate() {
let mut scanner = yara_x::Scanner::new(rules);
if let Err(e) = scanner.scan(b"test") {
issues.push(format!("Pre-scan rule {i}: {e}"));
}
}
for (i, rules) in self.post_scan_rules.iter().enumerate() {
let mut scanner = yara_x::Scanner::new(rules);
if let Err(e) = scanner.scan(b"test") {
issues.push(format!("Post-scan rule {i}: {e}"));
}
}
if issues.is_empty() {
Ok(issues)
} else {
Err(anyhow!("Rule validation failed: {}", issues.join(", ")))
}
}
#[cfg(all(test, not(feature = "yara-x-scanning")))]
pub fn validate(&self) -> Result<Vec<String>> {
Ok(Vec::new()) }
}
#[derive(Debug)]
pub struct RuleStats {
pub pre_scan_count: usize,
pub post_scan_count: usize,
pub pre_scan_rules: Vec<String>,
pub post_scan_rules: Vec<String>,
}
impl Clone for ThreatRules {
fn clone(&self) -> Self {
Self {
pre_scan_rules: self.pre_scan_rules.clone(), post_scan_rules: self.post_scan_rules.clone(),
rules_dir: self.rules_dir.clone(),
rule_metadata: self.rule_metadata.clone(),
memory_usage_bytes: self.memory_usage_bytes,
last_load_time: self.last_load_time,
}
}
}
pub struct YaraScanner {
scanner: ThreatRules,
phase: ScanPhase,
}
impl YaraScanner {
pub fn new(rules_dir: &str, phase: ScanPhase) -> Result<Self> {
let scanner = ThreatRules::new(rules_dir)?;
Ok(Self { scanner, phase })
}
fn scan_items_with_yara<T>(&self, items: &[T], phase: ScanPhase) -> Vec<YaraScanResult>
where
T: crate::security::BatchScannableItem,
{
let mut results = Vec::new();
for item in items {
let item_text = Self::format_item_for_yara_scan(item);
let context = format!("{} '{}'", T::item_type(), item.name());
#[cfg(feature = "yara-x-scanning")]
let enhanced_matches = match phase {
ScanPhase::PreScan => self.scanner.pre_scan(&item_text, &context),
ScanPhase::PostScan => self.scanner.post_scan(&item_text, &context),
};
#[cfg(not(feature = "yara-x-scanning"))]
let enhanced_matches: Vec<YaraMatchInfo> = Vec::new();
if !enhanced_matches.is_empty() {
warn!(
"Security issue detected in {} '{}': {} rules matched",
T::item_type(),
item.name(),
enhanced_matches.len()
);
for match_info in enhanced_matches {
let yara_result =
Self::create_yara_result_with_metadata::<T>(item, &match_info);
results.push(yara_result);
}
}
}
results
}
fn format_item_for_yara_scan<T>(item: &T) -> String
where
T: crate::security::BatchScannableItem,
{
format!("{}: {}", T::item_type().to_uppercase(), item.name())
}
fn create_yara_result_with_metadata<T>(item: &T, match_info: &YaraMatchInfo) -> YaraScanResult
where
T: crate::security::BatchScannableItem,
{
YaraScanResult {
target_type: T::item_type().to_string(),
target_name: item.name().to_string(),
rule_name: match_info.rule_name.clone(),
rule_file: rule_name_to_file_name(&match_info.rule_name),
matched_text: None,
context: generate_context_message(T::item_type(), &match_info.rule_name),
rule_metadata: match_info.metadata.clone(),
phase: None,
rules_executed: None,
security_issues_detected: None,
total_items_scanned: None,
total_matches: None,
status: Some("warning".to_string()),
}
}
}
impl Scanner for YaraScanner {
fn name(&self) -> &'static str {
"yara"
}
fn phase(&self) -> ScanPhase {
self.phase
}
#[allow(clippy::too_many_lines)]
fn run(&self, scan_data: &mut ScanData) -> anyhow::Result<()> {
match self.phase {
ScanPhase::PreScan => {
let stats = self.scanner.stats();
debug!("Running pre-scan with {} rules", stats.pre_scan_count);
let tool_results = self.scan_items_with_yara(&scan_data.tools, ScanPhase::PreScan);
let prompt_results =
self.scan_items_with_yara(&scan_data.prompts, ScanPhase::PreScan);
let resource_results =
self.scan_items_with_yara(&scan_data.resources, ScanPhase::PreScan);
let total_matches =
tool_results.len() + prompt_results.len() + resource_results.len();
let total_items =
scan_data.tools.len() + scan_data.prompts.len() + scan_data.resources.len();
let mut triggered_file_names = std::collections::HashSet::new();
let mut triggered_rules = std::collections::HashSet::new();
for result in &tool_results {
triggered_rules.insert(result.rule_name.clone());
if let Some(file_name) = rule_name_to_file_name(&result.rule_name) {
triggered_file_names.insert(file_name);
} else {
triggered_file_names.insert(result.rule_name.clone());
}
}
for result in &prompt_results {
triggered_rules.insert(result.rule_name.clone());
if let Some(file_name) = rule_name_to_file_name(&result.rule_name) {
triggered_file_names.insert(file_name);
} else {
triggered_file_names.insert(result.rule_name.clone());
}
}
for result in &resource_results {
triggered_rules.insert(result.rule_name.clone());
if let Some(file_name) = rule_name_to_file_name(&result.rule_name) {
triggered_file_names.insert(file_name);
} else {
triggered_file_names.insert(result.rule_name.clone());
}
}
scan_data.yara_results.extend(tool_results);
scan_data.yara_results.extend(prompt_results);
scan_data.yara_results.extend(resource_results);
let summary_result = YaraScanResult {
target_type: "summary".to_string(),
target_name: "pre-scan".to_string(),
rule_name: "YARA_PRE_SCAN_SUMMARY".to_string(),
rule_file: None,
matched_text: None,
context: format!(
"Pre-scan completed: {} rules executed on {} items",
stats.pre_scan_count, total_items
),
rule_metadata: None,
phase: Some("pre-scan".to_string()),
rules_executed: if stats.pre_scan_rules.is_empty() {
None
} else {
Some(
stats
.pre_scan_rules
.iter()
.map(|f| format!("{f}:*"))
.collect(),
)
},
security_issues_detected: if total_matches > 0 {
let triggered_vec: Vec<String> = triggered_rules
.into_iter()
.map(|rule_name| {
if let Some(file_name) = rule_name_to_file_name(&rule_name) {
format!("{file_name}:{rule_name}")
} else {
rule_name
}
})
.collect();
debug!("Pre-scan triggered rules: {:?}", triggered_vec);
Some(triggered_vec)
} else {
None
},
total_items_scanned: Some(total_items),
total_matches: Some(total_matches),
status: Some(if total_matches == 0 {
"passed".to_string()
} else {
"warning".to_string()
}),
};
scan_data.yara_results.push(summary_result);
}
ScanPhase::PostScan => {
let stats = self.scanner.stats();
debug!("Running post-scan with {} rules", stats.post_scan_count);
let tool_results = self.scan_items_with_yara(&scan_data.tools, ScanPhase::PostScan);
let prompt_results =
self.scan_items_with_yara(&scan_data.prompts, ScanPhase::PostScan);
let resource_results =
self.scan_items_with_yara(&scan_data.resources, ScanPhase::PostScan);
let total_matches =
tool_results.len() + prompt_results.len() + resource_results.len();
let total_items =
scan_data.tools.len() + scan_data.prompts.len() + scan_data.resources.len();
let mut triggered_file_names = std::collections::HashSet::new();
let mut triggered_rules = std::collections::HashSet::new();
for result in &tool_results {
triggered_rules.insert(result.rule_name.clone());
if let Some(file_name) = rule_name_to_file_name(&result.rule_name) {
triggered_file_names.insert(file_name);
} else {
triggered_file_names.insert(result.rule_name.clone());
}
}
for result in &prompt_results {
triggered_rules.insert(result.rule_name.clone());
if let Some(file_name) = rule_name_to_file_name(&result.rule_name) {
triggered_file_names.insert(file_name);
} else {
triggered_file_names.insert(result.rule_name.clone());
}
}
for result in &resource_results {
triggered_rules.insert(result.rule_name.clone());
if let Some(file_name) = rule_name_to_file_name(&result.rule_name) {
triggered_file_names.insert(file_name);
} else {
triggered_file_names.insert(result.rule_name.clone());
}
}
scan_data.yara_results.extend(tool_results);
scan_data.yara_results.extend(prompt_results);
scan_data.yara_results.extend(resource_results);
let summary_result = YaraScanResult {
target_type: "summary".to_string(),
target_name: "post-scan".to_string(),
rule_name: "YARA_POST_SCAN_SUMMARY".to_string(),
rule_file: None,
matched_text: None,
context: format!(
"Post-scan completed: {} rules executed on {} items",
stats.post_scan_count, total_items
),
rule_metadata: None,
phase: Some("post-scan".to_string()),
rules_executed: if stats.post_scan_rules.is_empty() {
None
} else {
Some(
stats
.post_scan_rules
.iter()
.map(|f| format!("{f}:*"))
.collect(),
)
},
security_issues_detected: if total_matches > 0 {
let triggered_vec: Vec<String> = triggered_rules
.into_iter()
.map(|rule_name| {
if let Some(file_name) = rule_name_to_file_name(&rule_name) {
format!("{file_name}:{rule_name}")
} else {
rule_name
}
})
.collect();
debug!("Post-scan triggered rules: {:?}", triggered_vec);
Some(triggered_vec)
} else {
None
},
total_items_scanned: Some(total_items),
total_matches: Some(total_matches),
status: Some(if total_matches == 0 {
"passed".to_string()
} else {
"warning".to_string()
}),
};
scan_data.yara_results.push(summary_result);
}
}
Ok(())
}
fn box_clone(&self) -> Box<dyn Scanner> {
Box::new(Self {
scanner: self.scanner.clone(),
phase: self.phase,
})
}
}
pub struct MCPScanner {
client: Client,
http_timeout: u64,
middleware_chain: ScannerChain, mcp_client: McpClient, }
impl MCPScanner {
pub fn with_timeout(http_timeout: u64) -> Result<Self> {
let client = Client::builder()
.timeout(Duration::from_secs(http_timeout))
.user_agent(protocol::USER_AGENT)
.build()
.map_err(|e| anyhow!("Failed to create HTTP client: {}", e))?;
let mut middleware_chain = ScannerChain::new();
let rules_dir = "rules".to_string();
if let Ok(pre_cap) = YaraScanner::new(&rules_dir, ScanPhase::PreScan) {
middleware_chain.add(Box::new(pre_cap));
debug!("{}", messages::YARA_PRE_SCAN_LOADED);
} else {
warn!("{}", messages::YARA_PRE_SCAN_FAILED);
}
if let Ok(post_cap) = YaraScanner::new(&rules_dir, ScanPhase::PostScan) {
middleware_chain.add(Box::new(post_cap));
debug!("{}", messages::YARA_POST_SCAN_LOADED);
} else {
warn!("{}", messages::YARA_POST_SCAN_FAILED);
}
let cross_origin_scanner = CrossOriginScanner::new(ScanPhase::PreScan);
middleware_chain.add(Box::new(cross_origin_scanner));
debug!("Cross-origin scanner loaded");
Ok(Self {
client,
http_timeout,
middleware_chain,
mcp_client: McpClient::new(),
})
}
pub async fn scan_single(&self, url: &str, options: ScanOptions) -> Result<ScanResult> {
let mut result = ScanResult::new(url.to_string());
debug!("Scanning {}", url);
if url.starts_with("stdio:") {
return self.scan_stdio_url(url, options).await;
}
let normalized_url = Self::normalize_url(url);
result.url.clone_from(&normalized_url);
let scan_result = track_performance("MCP server scan", || async {
let scan_future = self.perform_scan_with_rmcp(&normalized_url, &options);
match timeout(Duration::from_secs(options.timeout), scan_future).await {
Ok(result) => result,
Err(_) => Err(anyhow!("Scan operation timed out")),
}
})
.await;
match scan_result {
Ok(mut scan_data) => {
self.middleware_chain.run_pre_scan(&mut scan_data);
result.status = ScanStatus::Success;
result.server_info.clone_from(&scan_data.server_info);
result.tools.clone_from(&scan_data.tools);
result.resources.clone_from(&scan_data.resources);
result.prompts.clone_from(&scan_data.prompts);
result.yara_results.clone_from(&scan_data.yara_results);
result.errors.extend(scan_data.fetch_errors.clone());
let config_manager = crate::config::ScannerConfigManager::new();
let scanner_config = match config_manager.load_config() {
Ok(config) => config,
Err(e) => {
warn!("Failed to load scanner config, using defaults: {}", e);
result.errors.push(format!("Config loading failed: {e}"));
ScannerConfig::default()
}
};
if options.return_prompts {
let mut prompts: Vec<LlmPrompt> = Vec::new();
if !scan_data.tools.is_empty() {
let batch_size = scanner_config.scanner.llm_batch_size as usize;
for (batch_index, chunk) in scan_data.tools.chunks(batch_size).enumerate() {
let tools_info = chunk
.iter()
.enumerate()
.map(|(i, tool)| tool.format_for_analysis(i))
.collect::<String>();
let prompt_text =
SecurityScanner::create_tools_analysis_prompt(&tools_info);
let item_names = chunk.iter().map(|t| t.name.clone()).collect();
let request_body = SecurityScanner::with_config(scanner_config.clone())
.build_llm_request_body(&prompt_text);
let endpoint =
SecurityScanner::with_config(scanner_config.clone()).get_endpoint();
prompts.push(LlmPrompt {
target_type: "tool".to_string(),
batch_index,
prompt: prompt_text,
request_body: Some(request_body),
endpoint,
item_names,
});
}
}
if !scan_data.prompts.is_empty() {
let batch_size = scanner_config.scanner.llm_batch_size as usize;
for (batch_index, chunk) in scan_data.prompts.chunks(batch_size).enumerate()
{
let prompts_info = chunk
.iter()
.enumerate()
.map(|(i, p)| p.format_for_analysis(i))
.collect::<String>();
let prompt_text =
SecurityScanner::create_prompts_analysis_prompt(&prompts_info);
let item_names = chunk.iter().map(|p| p.name.clone()).collect();
let request_body = SecurityScanner::with_config(scanner_config.clone())
.build_llm_request_body(&prompt_text);
let endpoint =
SecurityScanner::with_config(scanner_config.clone()).get_endpoint();
prompts.push(LlmPrompt {
target_type: "prompt".to_string(),
batch_index,
prompt: prompt_text,
request_body: Some(request_body),
endpoint,
item_names,
});
}
}
if !scan_data.resources.is_empty() {
let batch_size = scanner_config.scanner.llm_batch_size as usize;
for (batch_index, chunk) in
scan_data.resources.chunks(batch_size).enumerate()
{
let resources_info = chunk
.iter()
.enumerate()
.map(|(i, r)| r.format_for_analysis(i))
.collect::<String>();
let prompt_text =
SecurityScanner::create_resources_analysis_prompt(&resources_info);
let item_names = chunk.iter().map(|r| r.name.clone()).collect();
let request_body = SecurityScanner::with_config(scanner_config.clone())
.build_llm_request_body(&prompt_text);
let endpoint =
SecurityScanner::with_config(scanner_config.clone()).get_endpoint();
prompts.push(LlmPrompt {
target_type: "resource".to_string(),
batch_index,
prompt: prompt_text,
request_body: Some(request_body),
endpoint,
item_names,
});
}
}
result.llm_prompts = Some(prompts);
} else {
let security_scanner = if scanner_config.security.enabled {
SecurityScanner::with_config(scanner_config)
} else {
SecurityScanner::default()
};
let mut security_result = SecurityScanResult::new();
match security_scanner
.scan_tools_batch(&scan_data.tools, options.detailed)
.await
{
Ok((tool_issues, analysis_details)) => {
security_result.add_tool_issues(tool_issues);
for (tool_name, details) in analysis_details {
security_result.add_tool_analysis_details(tool_name, details);
}
}
Err(e) => warn!("Failed to batch scan tools for security issues: {}", e),
}
if !scan_data.prompts.is_empty() {
match security_scanner
.scan_prompts_batch(&scan_data.prompts, options.detailed)
.await
{
Ok(prompt_issues) => security_result.add_prompt_issues(prompt_issues),
Err(e) => {
warn!("Failed to batch scan prompts for security issues: {}", e)
}
}
}
if !scan_data.resources.is_empty() {
match security_scanner
.scan_resources_batch(&scan_data.resources, options.detailed)
.await
{
Ok(resource_issues) => {
security_result.add_resource_issues(resource_issues)
}
Err(e) => {
warn!("Failed to batch scan resources for security issues: {}", e);
}
}
}
if !options.return_prompts {
result.security_issues = Some(security_result);
}
self.middleware_chain.run_post_scan(&mut scan_data);
result.yara_results.clone_from(&scan_data.yara_results);
result.response_time_ms = Timer::start().elapsed_ms(); debug!("Scan completed in {}ms", result.response_time_ms);
}
}
Err(e) => {
result.status = ScanStatus::Failed(e.to_string());
result.add_error(error_utils::format_error("Scan operation", &e.to_string()));
warn!("Scan failed: [\x1b[1m{}\x1b[0m]", e);
}
}
Ok(result)
}
async fn scan_stdio_url(&self, stdio_url: &str, options: ScanOptions) -> Result<ScanResult> {
let parts: Vec<&str> = stdio_url.splitn(3, ':').collect();
if parts.len() < 2 {
return Err(anyhow!(
"Invalid STDIO URL format. Expected: stdio:command or stdio:command:args"
));
}
let command = parts[1].trim_start_matches("//");
let args: Vec<String> = if parts.len() > 2 && !parts[2].is_empty() {
parts[2]
.split(':')
.map(std::string::ToString::to_string)
.collect()
} else {
Vec::new()
};
let server_config = MCPServerConfig {
name: Some(format!("STDIO-{command}")),
url: None,
command: Some(command.to_string()),
args: Some(args),
env: None,
description: Some(format!("STDIO server from URL: {stdio_url}")),
auth_headers: None,
options: None,
};
self.scan_stdio_server(&server_config, options).await
}
async fn scan_stdio_server(
&self,
server_config: &MCPServerConfig,
options: ScanOptions,
) -> Result<ScanResult> {
let command = server_config
.command
.as_ref()
.ok_or_else(|| anyhow!("STDIO server missing command"))?;
let args = server_config.args.as_deref().unwrap_or(&[]);
let display_url = server_config.to_display_url();
debug!("Scanning STDIO MCP server: {}", display_url);
let mut result = ScanResult::new(display_url.clone());
let session = self
.mcp_client
.connect_subprocess(command, args, server_config.env.as_ref())
.await
.map_err(|e| anyhow!("Failed to connect to STDIO server {}: {}", command, e))?;
let scan_result = track_performance("STDIO MCP server scan", || async {
self.perform_scan_with_session(&session, &options).await
})
.await;
match scan_result {
Ok(mut scan_data) => {
self.middleware_chain.run_pre_scan(&mut scan_data);
#[allow(clippy::single_match_else)]
let scanner_config = match config::ScannerConfigManager::new().load_config() {
Ok(config) => config,
Err(_) => {
debug!("Failed to load scanner config for STDIO security analysis, using defaults");
ScannerConfig::default()
}
};
let security_scanner = if scanner_config.security.enabled {
SecurityScanner::with_config(scanner_config)
} else {
SecurityScanner::default()
};
let mut security_result = SecurityScanResult::new();
match security_scanner
.scan_tools_batch(&scan_data.tools, options.detailed)
.await
{
Ok((tool_issues, analysis_details)) => {
security_result.add_tool_issues(tool_issues);
for (tool_name, details) in analysis_details {
security_result.add_tool_analysis_details(tool_name, details);
}
}
Err(e) => warn!(
"Failed to batch scan STDIO tools for security issues: {}",
e
),
}
if !scan_data.prompts.is_empty() {
match security_scanner
.scan_prompts_batch(&scan_data.prompts, options.detailed)
.await
{
Ok(prompt_issues) => security_result.add_prompt_issues(prompt_issues),
Err(e) => warn!(
"Failed to batch scan STDIO prompts for security issues: {}",
e
),
}
}
if !scan_data.resources.is_empty() {
match security_scanner
.scan_resources_batch(&scan_data.resources, options.detailed)
.await
{
Ok(resource_issues) => security_result.add_resource_issues(resource_issues),
Err(e) => {
warn!(
"Failed to batch scan STDIO resources for security issues: {}",
e
);
}
}
}
self.middleware_chain.run_post_scan(&mut scan_data);
result.status = ScanStatus::Success;
result.server_info.clone_from(&session.server_info);
result.tools = scan_data.tools;
result.resources = scan_data.resources;
result.prompts = scan_data.prompts;
result.yara_results = scan_data.yara_results;
result.security_issues = Some(security_result);
debug!("Successfully scanned STDIO server: {}", display_url);
}
Err(e) => {
result.status = ScanStatus::Failed(e.to_string());
result.add_error(format!("STDIO scan failed: {e}"));
warn!("STDIO scan failed for {}: {}", display_url, e);
}
}
Ok(result)
}
pub async fn scan_config_by_ide(&self, options: ScanOptions) -> Result<Vec<ScanResult>> {
let config_manager = MCPConfigManager::new();
if !config_manager.has_config_files() {
return Err(anyhow!("No MCP IDE configuration files found"));
}
let config = config_manager.load_config();
println!(
"🔍 Loaded MCP configuration with {} servers",
config.servers.as_ref().map(|s| s.len()).unwrap_or(0)
);
use std::collections::HashMap as StdHashMap;
let mut server_config_yara: StdHashMap<String, Vec<YaraScanResult>> = StdHashMap::new();
fn get_baseline_path() -> std::path::PathBuf {
dirs::home_dir()
.map(|mut p| {
p.push(".ramparts");
p.push("mcp-baseline.json");
p
})
.unwrap_or_else(|| std::path::PathBuf::from(".ramparts/mcp-baseline.json"))
}
fn compute_server_fingerprint(server: &MCPServerConfig) -> String {
use std::hash::{Hash, Hasher};
let mut s = String::new();
if let Some(name) = &server.name {
s.push_str(name);
}
if let Some(url) = &server.url {
s.push_str(url);
}
if let Some(cmd) = &server.command {
s.push_str(cmd);
}
if let Some(args) = &server.args {
s.push_str(&args.join(" "));
}
if let Some(env) = &server.env {
let mut kv: Vec<_> = env.iter().collect();
kv.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in kv {
s.push_str(k);
s.push('=');
s.push_str(v);
}
}
let mut hasher = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
let baseline_path = get_baseline_path();
let mut baseline_map: StdHashMap<String, String> = StdHashMap::new();
if baseline_path.exists() {
if let Ok(content) = std::fs::read_to_string(&baseline_path) {
if let Ok(map) = serde_json::from_str::<StdHashMap<String, String>>(&content) {
baseline_map = map;
}
}
}
#[cfg(feature = "yara-x-scanning")]
let pre_rules_engine = ThreatRules::new("rules").ok();
if let Some(ref servers) = config.servers {
for server in servers {
let key = server.dedup_key();
let mut prefindings: Vec<YaraScanResult> = Vec::new();
#[cfg(feature = "yara-x-scanning")]
if let Some(engine) = &pre_rules_engine {
let mut text = String::new();
if let Some(name) = &server.name {
text.push_str(&format!("NAME: {name}\n"));
}
if let Some(url) = &server.url {
text.push_str(&format!("URL: {url}\n"));
}
if let Some(cmd) = &server.command {
text.push_str(&format!("COMMAND: {cmd}\n"));
}
if let Some(args) = &server.args {
text.push_str(&format!("ARGS: {}\n", args.join(" ")));
}
if let Some(env) = &server.env {
let mut kv: Vec<_> = env.iter().collect();
kv.sort_by(|a, b| a.0.cmp(b.0));
for (_k, v) in kv {
let val = v.trim();
if val.is_empty() {
continue;
}
let is_placeholder = val.starts_with("${")
|| val.starts_with("$(")
|| val.contains("{{")
|| val.contains('<')
|| val.eq_ignore_ascii_case("true")
|| val.eq_ignore_ascii_case("false");
if is_placeholder || val.len() < 8 {
continue;
}
text.push_str(&format!("ENV_VALUE:{val} "));
}
text.push('\n');
}
if let Some(desc) = &server.description {
text.push_str(&format!("DESCRIPTION: {desc}\n"));
}
let context = format!(
"server '{}'",
server.name.as_deref().unwrap_or(&server.to_display_url())
);
let matches = engine.pre_scan(&text, &context);
for m in matches {
prefindings.push(YaraScanResult {
target_type: "server".to_string(),
target_name: server
.name
.clone()
.unwrap_or_else(|| server.to_display_url()),
rule_name: m.rule_name.clone(),
rule_file: rule_name_to_file_name(&m.rule_name),
matched_text: None,
context: generate_context_message("server", &m.rule_name),
rule_metadata: m.metadata.clone(),
phase: Some("pre-config".to_string()),
rules_executed: None,
security_issues_detected: None,
total_items_scanned: None,
total_matches: None,
status: Some("warning".to_string()),
});
}
}
let fp = compute_server_fingerprint(server);
match baseline_map.get(&key) {
Some(stored) if stored == &fp => { }
Some(_different) => {
prefindings.push(YaraScanResult {
target_type: "server".to_string(),
target_name: server
.name
.clone()
.unwrap_or_else(|| server.to_display_url()),
rule_name: "MCPConfigChanged".to_string(),
rule_file: None,
matched_text: None,
context: "MCP server configuration changed since last baseline"
.to_string(),
rule_metadata: Some(crate::types::YaraRuleMetadata {
name: Some("Baseline Change".to_string()),
author: Some("Ramparts".to_string()),
date: None,
version: None,
description: Some(
"Server command/args/env fingerprint differs from baseline."
.to_string(),
),
severity: Some("HIGH".to_string()),
category: Some("supply-chain".to_string()),
confidence: Some("MEDIUM".to_string()),
tags: vec!["baseline".to_string()],
}),
phase: Some("pre-config".to_string()),
rules_executed: None,
security_issues_detected: None,
total_items_scanned: None,
total_matches: None,
status: Some("warning".to_string()),
});
}
None => {
if !baseline_path.exists() {
if let Some(parent) = baseline_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
}
baseline_map.insert(key.clone(), fp.clone());
if let Ok(serialized) = serde_json::to_string_pretty(&baseline_map) {
let _ = std::fs::write(&baseline_path, serialized);
}
}
}
if !prefindings.is_empty() {
server_config_yara.insert(key, prefindings);
}
}
}
let server_config_yara = std::sync::Arc::new(server_config_yara);
let mut results = Vec::new();
if let Some(ref servers) = config.servers {
debug!(
"Found [\x1b[1m{}\x1b[0m] MCP servers to scan",
servers.len()
);
use futures::future::join_all;
let scan_tasks: Vec<_> = servers
.iter()
.map(|server| {
let server = server.clone();
let config = config.clone();
let options = options.clone();
let scanner = self.clone();
let cfg_yara = server_config_yara.clone();
tokio::spawn(async move {
debug!(
"Scanning MCP server: [\x1b[1m{}\x1b[0m] ({})",
server.name.as_deref().unwrap_or("unnamed"),
server.to_display_url()
);
let ide_source = server
.description
.as_ref()
.and_then(|desc| {
if let Some(start) = desc.rfind("[IDE:") {
if let Some(end) = desc[start..].find(']') {
let ide_name = &desc[start + 5..start + end];
return Some(ide_name.to_string());
}
}
None
})
.unwrap_or_else(|| "IDE Configs".to_string());
let server_options =
MCPScanner::build_server_options(&options, &config, &server);
let attach_findings = |res: &mut ScanResult| {
if let Some(findings) = cfg_yara.get(&server.dedup_key()) {
res.yara_results.extend(findings.clone());
}
};
let result = if let Some(url) = server.scan_url() {
match scanner.scan_single(url, server_options).await {
Ok(mut result) => {
result.ide_source = Some(ide_source);
attach_findings(&mut result);
result
}
Err(e) => {
let mut failed_result = ScanResult::new(url.to_string());
failed_result.status = ScanStatus::Failed(e.to_string());
failed_result.ide_source = Some(ide_source);
attach_findings(&mut failed_result);
failed_result
}
}
} else if server.command.is_some() {
match scanner.scan_stdio_server(&server, server_options).await {
Ok(mut result) => {
result.ide_source = Some(ide_source);
attach_findings(&mut result);
result
}
Err(e) => {
let mut failed_result =
ScanResult::new(server.to_display_url());
failed_result.status = ScanStatus::Failed(e.to_string());
failed_result.ide_source = Some(ide_source);
attach_findings(&mut failed_result);
failed_result
}
}
} else {
let mut failed_result = ScanResult::new("unknown".to_string());
failed_result.status =
ScanStatus::Failed("Invalid server configuration".to_string());
failed_result.ide_source = Some(ide_source);
attach_findings(&mut failed_result);
failed_result
};
result
})
})
.collect();
println!(
"🚀 Starting parallel scan of {} servers...",
scan_tasks.len()
);
let scan_results = tokio::time::timeout(
std::time::Duration::from_secs(300), join_all(scan_tasks),
)
.await
.unwrap_or_else(|_| {
warn!("Parallel scan tasks timed out after 5 minutes");
vec![] });
for task_result in scan_results {
match task_result {
Ok(scan_result) => results.push(scan_result),
Err(e) => {
let mut failed_result = ScanResult::new("task_failed".to_string());
failed_result.status = ScanStatus::Failed(format!("Scan task failed: {e}"));
failed_result.ide_source = Some("IDE Configs".to_string());
results.push(failed_result);
}
}
}
if let Err(e) = self.mcp_client.cleanup_all_sessions().await {
warn!("Failed to clean up main scanner sessions after parallel scan: {e}");
}
}
Ok(results)
}
fn build_server_options(
options: &ScanOptions,
config: &MCPConfig,
server: &MCPServerConfig,
) -> ScanOptions {
let mut server_options = options.clone();
if let Some(global_options) = &config.options {
if let Some(timeout) = global_options.timeout {
server_options.timeout = timeout;
}
if let Some(http_timeout) = global_options.http_timeout {
server_options.http_timeout = http_timeout;
}
if let Some(format) = &global_options.format {
server_options.format.clone_from(format);
}
if let Some(detailed) = global_options.detailed {
server_options.detailed = detailed;
}
}
if let Some(server_specific_options) = &server.options {
if let Some(timeout) = server_specific_options.timeout {
server_options.timeout = timeout;
}
if let Some(http_timeout) = server_specific_options.http_timeout {
server_options.http_timeout = http_timeout;
}
if let Some(format) = &server_specific_options.format {
server_options.format.clone_from(format);
}
if let Some(detailed) = server_specific_options.detailed {
server_options.detailed = detailed;
}
}
server_options.auth_headers = Self::build_auth_headers(options, config, server);
server_options
}
fn build_auth_headers(
options: &ScanOptions,
config: &MCPConfig,
server: &MCPServerConfig,
) -> Option<HashMap<String, String>> {
let mut auth_headers = options.auth_headers.clone();
if let Some(global_auth_headers) = &config.auth_headers {
match &mut auth_headers {
Some(headers) => {
for (key, value) in global_auth_headers {
headers.insert(key.clone(), value.clone());
}
}
None => {
auth_headers = Some(global_auth_headers.clone());
}
}
}
if let Some(server_auth_headers) = &server.auth_headers {
match &mut auth_headers {
Some(headers) => {
for (key, value) in server_auth_headers {
headers.insert(key.clone(), value.clone());
}
}
None => {
auth_headers = Some(server_auth_headers.clone());
}
}
}
auth_headers
}
async fn perform_scan_with_rmcp(&self, url: &str, options: &ScanOptions) -> Result<ScanData> {
let mut scan_data = ScanData::new();
let session = self
.mcp_client
.connect_smart(url, options.auth_headers.clone())
.await?;
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
debug!("Starting to fetch tools, resources, and prompts after rmcp connection");
if let Some(ref server_info) = session.server_info {
scan_data.server_info = Some(server_info.clone());
}
let mut fetch_errors = Vec::new();
scan_data.tools = match self.mcp_client.list_tools(&session).await {
Ok(tools) => {
debug!("Successfully fetched {} tools via rmcp", tools.len());
tools
}
Err(e) => {
let error_msg = format!("Failed to fetch tools via rmcp: {e}");
warn!("{}", error_msg);
fetch_errors.push(error_msg);
Vec::new()
}
};
scan_data.resources = match self.mcp_client.list_resources(&session).await {
Ok(resources) => {
debug!(
"Successfully fetched {} resources via rmcp",
resources.len()
);
resources
}
Err(e) => {
let error_msg = format!("Failed to fetch resources via rmcp: {e}");
warn!("{}", error_msg);
fetch_errors.push(error_msg);
Vec::new()
}
};
scan_data.prompts = match self.mcp_client.list_prompts(&session).await {
Ok(prompts) => {
debug!("Successfully fetched {} prompts via rmcp", prompts.len());
prompts
}
Err(e) => {
let error_msg = format!("Failed to fetch prompts via rmcp: {e}");
warn!("{}", error_msg);
fetch_errors.push(error_msg);
Vec::new()
}
};
scan_data.fetch_errors = fetch_errors;
if let Err(e) = self.mcp_client.cleanup_session(&session).await {
warn!("Failed to clean up MCP session: {}", e);
}
Ok(scan_data)
}
async fn perform_scan_with_session(
&self,
session: &crate::types::MCPSession,
_options: &ScanOptions,
) -> Result<ScanData> {
let mut scan_data = ScanData::new();
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
debug!("Starting to fetch tools, resources, and prompts from existing session");
if let Some(ref server_info) = session.server_info {
scan_data.server_info = Some(server_info.clone());
}
let mut fetch_errors = Vec::new();
scan_data.tools = match self.mcp_client.list_tools(session).await {
Ok(tools) => {
debug!("Successfully fetched {} tools from session", tools.len());
tools
}
Err(e) => {
let error_msg = format!("Failed to fetch tools from session: {e}");
warn!("{}", error_msg);
fetch_errors.push(error_msg);
Vec::new()
}
};
scan_data.resources = match self.mcp_client.list_resources(session).await {
Ok(resources) => {
debug!(
"Successfully fetched {} resources from session",
resources.len()
);
resources
}
Err(e) => {
let error_msg = format!("Failed to fetch resources from session: {e}");
warn!("{}", error_msg);
fetch_errors.push(error_msg);
Vec::new()
}
};
scan_data.prompts = match self.mcp_client.list_prompts(session).await {
Ok(prompts) => {
debug!(
"Successfully fetched {} prompts from session",
prompts.len()
);
prompts
}
Err(e) => {
let error_msg = format!("Failed to fetch prompts from session: {e}");
warn!("{}", error_msg);
fetch_errors.push(error_msg);
Vec::new()
}
};
scan_data.fetch_errors = fetch_errors;
if let Err(e) = self.mcp_client.cleanup_session(session).await {
warn!("Failed to clean up MCP session: {}", e);
}
Ok(scan_data)
}
fn normalize_url(url: &str) -> String {
let mut normalized_url = url.to_string();
if !normalized_url.contains("://") {
normalized_url = format!("http://{normalized_url}");
}
normalized_url
}
#[allow(dead_code)] pub async fn connect_to_server(
&self,
url: &str,
auth_headers: Option<HashMap<String, String>>,
) -> Result<crate::types::MCPSession> {
self.mcp_client.connect_smart(url, auth_headers).await
}
#[allow(dead_code)] pub async fn list_tools_from_session(
&self,
session: &crate::types::MCPSession,
) -> Result<Vec<crate::types::MCPTool>> {
self.mcp_client.list_tools(session).await
}
}
impl Clone for MCPScanner {
fn clone(&self) -> Self {
Self {
client: self.client.clone(),
http_timeout: self.http_timeout,
middleware_chain: self.middleware_chain.clone(),
mcp_client: McpClient::new(),
}
}
}
pub(crate) struct ScanData {
pub server_info: Option<MCPServerInfo>,
pub tools: Vec<MCPTool>,
pub resources: Vec<MCPResource>,
pub prompts: Vec<MCPPrompt>,
pub yara_results: Vec<YaraScanResult>,
pub fetch_errors: Vec<String>,
}
impl ScanData {
fn new() -> Self {
Self {
server_info: None,
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
yara_results: Vec::new(),
fetch_errors: Vec::new(),
}
}
}
impl Drop for MCPScanner {
fn drop(&mut self) {
debug!("MCPScanner dropped");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_creates_threat_rules_with_and_without_yara_x() {
#[cfg(feature = "yara-x-scanning")]
let scanner = ThreatRules::new("rules");
#[cfg(not(feature = "yara-x-scanning"))]
let scanner = ThreatRules::with_config("rules", false);
assert!(
scanner.is_ok(),
"ThreatRules creation should succeed even when YARA is disabled"
);
let scanner = scanner.expect("Scanner creation should have succeeded");
let stats = scanner.stats();
#[cfg(feature = "yara-x-scanning")]
assert!(stats.pre_scan_count > 0);
#[cfg(not(feature = "yara-x-scanning"))]
assert_eq!(stats.pre_scan_count, 0);
println!("Loaded {} pre-scan rules", stats.pre_scan_count);
}
#[test]
fn test_creates_yara_capability_with_correct_phase() {
let scanner = YaraScanner::new("rules", ScanPhase::PreScan);
assert!(
scanner.is_ok(),
"YaraScanner creation should succeed with valid rules directory"
);
let scanner_instance = scanner.expect("Scanner creation should have succeeded");
assert_eq!(scanner_instance.name(), "yara");
assert_eq!(scanner_instance.phase(), ScanPhase::PreScan);
}
#[test]
fn test_reports_memory_usage_statistics() {
#[cfg(feature = "yara-x-scanning")]
let scanner = ThreatRules::new("rules")
.expect("Should be able to create ThreatRules with rules directory");
#[cfg(not(feature = "yara-x-scanning"))]
let scanner = ThreatRules::with_config("rules", false)
.expect("Should be able to create ThreatRules with YARA disabled");
let memory_stats = scanner.memory_stats();
#[cfg(feature = "yara-x-scanning")]
assert!(memory_stats.pre_scan_count + memory_stats.post_scan_count > 0);
#[cfg(not(feature = "yara-x-scanning"))]
assert_eq!(
memory_stats.pre_scan_count + memory_stats.post_scan_count,
0
);
println!("Memory stats: {memory_stats:?}");
}
#[test]
fn test_shares_rules_efficiently_via_arc_cloning() {
let scanner1 = ThreatRules::new("rules").unwrap();
let memory1 = scanner1.memory_stats();
let scanner2 = scanner1.clone();
let memory2 = scanner2.memory_stats();
assert_eq!(memory1.pre_scan_count, memory2.pre_scan_count);
assert_eq!(memory1.post_scan_count, memory2.post_scan_count);
assert_eq!(
memory1.pre_scan_count + memory1.post_scan_count,
memory2.pre_scan_count + memory2.post_scan_count
);
println!("Cache test passed - cloned scanner has identical memory usage");
}
#[test]
fn test_validates_loaded_rules_successfully() {
let scanner = ThreatRules::new("rules")
.expect("Should be able to create ThreatRules with rules directory");
let validation_result = scanner.validate();
assert!(
validation_result.is_ok(),
"YARA rule validation should pass for well-formed rules"
);
println!("Rule validation passed");
}
#[test]
#[cfg(feature = "yara-x-scanning")]
fn test_compiles_and_scans_with_yara_x_rules() {
let test_rule = r#"
rule TestRule {
meta:
name = "Test Rule"
description = "A test rule for YARA-X integration"
severity = "MEDIUM"
strings:
$test_string = "MALICIOUS_PATTERN"
$api_key = /[Aa][Pp][Ii].*[Kk][Ee][Yy]/
condition:
$test_string or $api_key
}
"#;
let mut compiler = yara_x::Compiler::new();
assert!(
compiler.add_source(test_rule).is_ok(),
"Rule compilation should succeed"
);
let rules = compiler.build();
let mut scanner = yara_x::Scanner::new(&rules);
let result = scanner.scan(b"This contains MALICIOUS_PATTERN text");
assert!(result.is_ok(), "Scanning should succeed");
let scan_results = result.expect("YARA-X scan should have succeeded");
let matching_rules: Vec<_> = scan_results.matching_rules().collect();
assert!(
!matching_rules.is_empty(),
"Should have matches for test pattern"
);
let mut scanner2 = yara_x::Scanner::new(&rules);
let result2 = scanner2.scan(b"Clean text with no malicious content");
assert!(result2.is_ok(), "Scanning clean text should succeed");
let scan_results2 = result2.expect("YARA-X scan on clean text should have succeeded");
let matching_rules2: Vec<_> = scan_results2.matching_rules().collect();
assert!(
matching_rules2.is_empty(),
"Should have no matches for clean text"
);
}
#[test]
#[cfg(feature = "yara-x-scanning")]
fn test_extracts_metadata_from_yara_x_matches() {
let test_rule = r#"
rule MetadataTest {
meta:
name = "Metadata Test Rule"
author = "Test Author"
version = "2.0"
severity = "HIGH"
confidence = 0.95
tags = "test,metadata"
strings:
$test = "test_pattern"
condition:
$test
}
"#;
let mut compiler = yara_x::Compiler::new();
compiler
.add_source(test_rule)
.expect("Test rule should compile successfully with YARA-X");
let rules = compiler.build();
let mut scanner = yara_x::Scanner::new(&rules);
let result = scanner.scan(b"test_pattern");
assert!(
result.is_ok(),
"YARA-X scanning should succeed on test pattern"
);
let scan_results = result.expect("YARA-X scan should have succeeded");
let matching_rules: Vec<_> = scan_results.matching_rules().collect();
assert!(!matching_rules.is_empty());
let rule = &matching_rules[0];
assert_eq!(rule.identifier(), "MetadataTest");
let metadata: std::collections::HashMap<_, _> = rule.metadata().collect();
assert!(metadata.contains_key("name"));
assert!(metadata.contains_key("severity"));
assert!(metadata.contains_key("confidence"));
}
#[test]
#[cfg(feature = "yara-x-scanning")]
fn test_rejects_malformed_yara_rules() {
let malformed_rule = r#"
rule MalformedRule {
invalid_section:
this_is_not_valid_yara_syntax = "error"
condition:
undefined_variable
}
"#;
let mut compiler = yara_x::Compiler::new();
let result = compiler.add_source(malformed_rule);
assert!(
result.is_err(),
"Malformed rule should cause compilation error"
);
}
#[test]
fn test_loads_rules_from_filesystem() {
let scanner = ThreatRules::new("rules")
.expect("Should be able to create ThreatRules with rules directory");
let stats = scanner.memory_stats();
#[cfg(feature = "yara-x-scanning")]
{
assert!(stats.pre_scan_count + stats.post_scan_count > 0);
assert!(stats.pre_scan_count > 0);
}
#[cfg(not(feature = "yara-x-scanning"))]
{
assert_eq!(stats.pre_scan_count + stats.post_scan_count, 0);
}
#[cfg(feature = "yara-x-scanning")]
{
let rule_content = std::fs::read_to_string("rules/pre/secrets_leakage.yar")
.expect("Should be able to read secrets_leakage.yar test rule file");
let mut compiler = yara_x::Compiler::new();
compiler
.add_source(rule_content.as_str())
.expect("Should be able to compile secrets_leakage.yar rule");
let rules = compiler.build();
let mut scanner = yara_x::Scanner::new(&rules);
let scan_result = scanner.scan(b"test data");
assert!(
scan_result.is_ok(),
"YARA-X scanning should succeed on test data"
);
}
}
#[test]
fn test_runs_post_scan_capability_without_errors() {
let post_scanner = YaraScanner::new("rules", ScanPhase::PostScan);
assert!(
post_scanner.is_ok(),
"YaraScanner creation should succeed for post-scan phase"
);
let scanner_instance =
post_scanner.expect("Post-scan scanner creation should have succeeded");
assert_eq!(scanner_instance.name(), "yara");
assert_eq!(scanner_instance.phase(), ScanPhase::PostScan);
let mut scan_data = ScanData {
server_info: None,
tools: vec![],
resources: vec![],
prompts: vec![],
yara_results: vec![],
fetch_errors: vec![],
};
let result = scanner_instance.run(&mut scan_data);
assert!(
result.is_ok(),
"Post-scan scanner should run successfully on scan data"
);
println!("Post-scan scanner test passed");
}
#[test]
fn test_tracks_separate_pre_and_post_scan_statistics() {
#[cfg(feature = "yara-x-scanning")]
let scanner = ThreatRules::new("rules")
.expect("Should be able to create ThreatRules with rules directory");
#[cfg(not(feature = "yara-x-scanning"))]
let scanner = ThreatRules::with_config("rules", false)
.expect("Should be able to create ThreatRules with YARA disabled");
let stats = scanner.stats();
println!("Pre-scan rules: {}", stats.pre_scan_count);
println!("Post-scan rules: {}", stats.post_scan_count);
#[cfg(feature = "yara-x-scanning")]
assert!(stats.pre_scan_count > 0);
#[cfg(not(feature = "yara-x-scanning"))]
assert_eq!(stats.pre_scan_count, 0);
if stats.post_scan_count > 0 {
println!("Post-scan rules detected: {}", stats.post_scan_count);
} else {
println!("No post-scan rules found (this is expected if none were created)");
}
}
#[test]
fn test_collects_rule_names_from_loaded_files() {
let scanner = ThreatRules::new("rules")
.expect("Should be able to create ThreatRules with rules directory");
let stats = scanner.stats();
#[cfg(feature = "yara-x-scanning")]
{
assert!(!stats.pre_scan_rules.is_empty());
assert!(stats
.pre_scan_rules
.contains(&"command_injection".to_string()));
assert!(stats.pre_scan_rules.contains(&"path_traversal".to_string()));
assert!(stats
.pre_scan_rules
.contains(&"secrets_leakage".to_string()));
println!("Pre-scan rules: {:?}", stats.pre_scan_rules);
}
#[cfg(not(feature = "yara-x-scanning"))]
{
assert!(stats.pre_scan_rules.is_empty());
}
assert!(stats.post_scan_rules.is_empty());
println!("Post-scan rules: {:?}", stats.post_scan_rules);
}
}