fix(security): fuse command scanner signals (#11440)

Signed-off-by: Jasper Hugo <jasper@spiral.xyz>
This commit is contained in:
Jasper
2026-09-01 05:11:55 +00:00
committed by GitHub
parent 9a5319a6e8
commit e05f5d4348
+248 -23
View File
@@ -26,6 +26,7 @@ pub struct ScanResult {
struct DetailedScanResult {
confidence: f32,
pattern_confidence: f32,
pattern_matches: Vec<PatternMatch>,
ml_confidence: Option<f32>,
used_pattern_detection: bool,
@@ -166,8 +167,9 @@ impl PromptInjectionScanner {
threshold
);
let final_confidence =
self.combine_confidences(tool_result.confidence, context_result.ml_confidence);
let final_confidence = self
.combine_confidences(tool_result.confidence, context_result.ml_confidence)
.max(tool_result.pattern_confidence);
tracing::info!(
security.event_type = "prompt_injection_scan",
@@ -184,6 +186,7 @@ impl PromptInjectionScanner {
let final_result = DetailedScanResult {
confidence: final_confidence,
pattern_confidence: tool_result.pattern_confidence,
pattern_matches: tool_result.pattern_matches,
ml_confidence: tool_result.ml_confidence,
used_pattern_detection: tool_result.used_pattern_detection,
@@ -198,25 +201,19 @@ impl PromptInjectionScanner {
}
async fn analyze_text(&self, text: &str) -> Result<DetailedScanResult> {
if let Some(classifier) = self.command_classifier.as_ref() {
if let Some(ml_confidence) = self
.scan_with_classifier(text, classifier, ClassifierType::Command)
.await
{
return Ok(DetailedScanResult {
confidence: ml_confidence,
pattern_matches: Vec::new(),
ml_confidence: Some(ml_confidence),
used_pattern_detection: false,
});
}
}
let (pattern_confidence, pattern_matches) = self.pattern_based_scanning(text);
let ml_confidence = if let Some(classifier) = self.command_classifier.as_ref() {
self.scan_with_classifier(text, classifier, ClassifierType::Command)
.await
} else {
None
};
Ok(DetailedScanResult {
confidence: pattern_confidence,
confidence: ml_confidence.map_or(pattern_confidence, |ml| ml.max(pattern_confidence)),
pattern_confidence,
pattern_matches,
ml_confidence: None,
ml_confidence,
used_pattern_detection: true,
})
}
@@ -227,6 +224,7 @@ impl PromptInjectionScanner {
let Some(classifier) = self.prompt_classifier.as_ref() else {
return Ok(DetailedScanResult {
confidence: 0.0,
pattern_confidence: 0.0,
pattern_matches: Vec::new(),
ml_confidence: None,
used_pattern_detection: false,
@@ -236,6 +234,7 @@ impl PromptInjectionScanner {
if user_messages.is_empty() {
return Ok(DetailedScanResult {
confidence: 0.0,
pattern_confidence: 0.0,
pattern_matches: Vec::new(),
ml_confidence: None,
used_pattern_detection: false,
@@ -255,6 +254,7 @@ impl PromptInjectionScanner {
Ok(DetailedScanResult {
confidence: max_confidence,
pattern_confidence: 0.0,
pattern_matches: Vec::new(),
ml_confidence: Some(max_confidence),
used_pattern_detection: false,
@@ -329,12 +329,34 @@ impl PromptInjectionScanner {
.map_or(tool_content, |(_, args)| args);
let command_preview = safe_truncate(text_to_preview, 300);
if let Some(top_match) = result.pattern_matches.first() {
let preview = safe_truncate(&top_match.matched_text, 50);
return format!(
let decisive_ml = result
.ml_confidence
.filter(|confidence| *confidence >= threshold);
let top_match = result
.pattern_matches
.iter()
.max_by_key(|pattern_match| &pattern_match.threat.risk_level);
let decisive_pattern_match = top_match.filter(|_| result.pattern_confidence >= threshold);
let pattern_explanation = |pattern_match: &PatternMatch| {
let preview = safe_truncate(&pattern_match.matched_text, 50);
format!(
"Pattern-based detection: {} (Risk: {:?})\nFound: '{}'\n\nCommand:\n{}",
top_match.threat.description, top_match.threat.risk_level, preview, command_preview
);
pattern_match.threat.description,
pattern_match.threat.risk_level,
preview,
command_preview
)
};
if let Some(decisive_pattern_match) = decisive_pattern_match {
let mut explanation = pattern_explanation(decisive_pattern_match);
if let Some(ml_confidence) = decisive_ml {
explanation.push_str(&format!(
"\n\nClassifier detection confidence: {:.1}%",
ml_confidence * 100.0
));
}
return explanation;
}
if let Some(ml_conf) = result.ml_confidence {
@@ -343,6 +365,8 @@ impl PromptInjectionScanner {
ml_conf * 100.0,
command_preview
)
} else if let Some(top_match) = top_match {
pattern_explanation(top_match)
} else {
format!("Security threat detected\n\nCommand:\n{}", command_preview)
}
@@ -408,6 +432,64 @@ impl Default for PromptInjectionScanner {
mod tests {
use super::*;
use rmcp::object;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn classifier_with_confidence(
injection_confidence: f32,
) -> (MockServer, ClassificationClient) {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!([[{
"label": "INJECTION",
"score": injection_confidence
}, {
"label": "SAFE",
"score": 1.0 - injection_confidence
}]])),
)
.mount(&mock_server)
.await;
let classifier =
ClassificationClient::from_endpoint(mock_server.uri(), None, None).unwrap();
(mock_server, classifier)
}
async fn scanner_with_command_confidence(
injection_confidence: f32,
) -> (MockServer, PromptInjectionScanner) {
let (mock_server, command_classifier) =
classifier_with_confidence(injection_confidence).await;
let scanner = PromptInjectionScanner {
pattern_matcher: PatternMatcher::new(),
command_classifier: Some(command_classifier),
prompt_classifier: None,
};
(mock_server, scanner)
}
async fn scanner_with_classifier_confidences(
command_confidence: f32,
prompt_confidence: f32,
) -> (MockServer, MockServer, PromptInjectionScanner) {
let (command_server, command_classifier) =
classifier_with_confidence(command_confidence).await;
let (prompt_server, prompt_classifier) =
classifier_with_confidence(prompt_confidence).await;
let scanner = PromptInjectionScanner {
pattern_matcher: PatternMatcher::new(),
command_classifier: Some(command_classifier),
prompt_classifier: Some(prompt_classifier),
};
(command_server, prompt_server, scanner)
}
#[tokio::test]
async fn test_text_pattern_detection() {
@@ -418,6 +500,149 @@ mod tests {
assert!(!result.pattern_matches.is_empty());
}
#[tokio::test]
async fn command_classifier_false_negative_preserves_pattern_detection() {
let (_mock_server, scanner) = scanner_with_command_confidence(0.0).await;
let result = scanner.analyze_text("rm -rf /").await.unwrap();
assert_eq!(result.confidence, 0.95);
assert_eq!(result.ml_confidence, Some(0.0));
assert!(result.used_pattern_detection);
assert_eq!(result.pattern_matches[0].threat.name, "rm_rf_root_bare");
}
#[tokio::test]
async fn command_classifier_legitimate_result_remains_safe() {
let (_mock_server, scanner) = scanner_with_command_confidence(0.0).await;
let result = scanner.analyze_text("printf 'hello\\n'").await.unwrap();
assert_eq!(result.confidence, 0.0);
assert_eq!(result.ml_confidence, Some(0.0));
assert!(result.used_pattern_detection);
assert!(result.pattern_matches.is_empty());
}
#[tokio::test]
async fn command_classifier_stronger_signal_preserves_pattern_evidence() {
let (_mock_server, scanner) = scanner_with_command_confidence(0.9).await;
let result = scanner.analyze_text("chmod +s /tmp/tool").await.unwrap();
assert_eq!(result.confidence, 0.9);
assert_eq!(result.ml_confidence, Some(0.9));
assert!(result.used_pattern_detection);
assert_eq!(
result.pattern_matches[0].threat.name,
"suid_binary_creation"
);
}
#[tokio::test]
async fn classifier_explanation_wins_when_low_pattern_is_not_decisive() {
let (_mock_server, scanner) = scanner_with_command_confidence(0.9).await;
let command = "echo $(printf $(date))";
let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({
"command": command
}));
let detailed = scanner.analyze_text(command).await.unwrap();
assert_eq!(detailed.pattern_confidence, 0.45);
assert_eq!(
detailed.pattern_matches[0].threat.name,
"indirect_command_execution"
);
let result = scanner
.analyze_tool_call_with_context(&tool_call, &[])
.await
.unwrap();
assert_eq!(result.confidence, 0.9);
assert!(result.is_malicious);
assert!(result.explanation.contains("confidence: 90.0%"));
assert!(!result.explanation.contains("Pattern-based detection"));
assert!(!result.explanation.contains("Risk: Low"));
}
#[tokio::test]
async fn decisive_pattern_explanation_keeps_classifier_evidence() {
let (_mock_server, scanner) = scanner_with_command_confidence(0.9).await;
let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({
"command": "rm -rf /"
}));
let result = scanner
.analyze_tool_call_with_context(&tool_call, &[])
.await
.unwrap();
assert_eq!(result.confidence, 0.95);
assert!(result.is_malicious);
assert!(result.explanation.contains("Pattern-based detection"));
assert!(result.explanation.contains("Risk: Critical"));
assert!(result
.explanation
.contains("Classifier detection confidence: 90.0%"));
}
#[tokio::test]
async fn low_pattern_and_low_classifier_remain_safe() {
let (_mock_server, scanner) = scanner_with_command_confidence(0.2).await;
let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({
"command": "echo $(printf $(date))"
}));
let result = scanner
.analyze_tool_call_with_context(&tool_call, &[])
.await
.unwrap();
assert_eq!(result.confidence, 0.45);
assert!(!result.is_malicious);
assert_eq!(result.explanation, "No security threats detected");
}
#[tokio::test]
async fn context_fusion_preserves_pattern_confidence_floor() {
let _env = env_lock::lock_env([("SECURITY_PROMPT_THRESHOLD", Some("0.9"))]);
let (_command_server, _prompt_server, scanner) =
scanner_with_classifier_confidences(0.0, 0.0).await;
let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({
"command": "rm -rf /"
}));
let messages = vec![Message::user().with_text("Please clean the build directory")];
let result = scanner
.analyze_tool_call_with_context(&tool_call, &messages)
.await
.unwrap();
assert_eq!(result.confidence, 0.95);
assert!(result.is_malicious);
assert!(result.explanation.contains("Pattern-based detection"));
}
#[tokio::test]
async fn context_fusion_keeps_legitimate_command_safe() {
let (_command_server, _prompt_server, scanner) =
scanner_with_classifier_confidences(0.0, 0.0).await;
let tool_call = CallToolRequestParams::new("shell").with_arguments(object!({
"command": "printf 'hello\\n'"
}));
let messages = vec![Message::user().with_text("Print a greeting")];
let result = scanner
.analyze_tool_call_with_context(&tool_call, &messages)
.await
.unwrap();
assert_eq!(result.confidence, 0.0);
assert!(!result.is_malicious);
assert_eq!(result.explanation, "No security threats detected");
}
#[tokio::test]
async fn test_conversation_scan_without_ml() {
let scanner = PromptInjectionScanner::new();