agentox_core/checks/security/
error_leakage.rs1use crate::checks::runner::{Check, CheckContext};
4use crate::checks::security::constants::{
5 truncate_for_evidence, EVIDENCE_MAX_CHARS, LEAK_ALLOWLIST_CONTEXTS, LEAK_PATTERNS,
6};
7use crate::checks::types::{CheckCategory, CheckResult, Severity};
8use crate::protocol::jsonrpc::JsonRpcRequest;
9
10pub struct ErrorLeakageDetection;
11
12fn is_allowlisted_context(pattern: &str, content_lower: &str) -> bool {
13 if pattern != "api_key" {
14 return false;
15 }
16 LEAK_ALLOWLIST_CONTEXTS
17 .iter()
18 .any(|ctx| content_lower.contains(ctx))
19}
20
21#[async_trait::async_trait]
22impl Check for ErrorLeakageDetection {
23 fn id(&self) -> &str {
24 "SEC-003"
25 }
26
27 fn name(&self) -> &str {
28 "Error leakage detection"
29 }
30
31 fn category(&self) -> CheckCategory {
32 CheckCategory::Security
33 }
34
35 async fn run(&self, ctx: &mut CheckContext) -> Vec<CheckResult> {
36 let desc = "Error responses should avoid leaking internal paths, traces, or secrets";
37
38 let probes = vec![
39 (
40 "unknown method",
41 JsonRpcRequest::new(
42 ctx.session.next_id(),
43 "internal/debug_dump",
44 Some(serde_json::json!({})),
45 ),
46 ),
47 (
48 "malformed tools/call",
49 JsonRpcRequest::new(
50 ctx.session.next_id(),
51 "tools/call",
52 Some(serde_json::json!({ "name": 123, "arguments": {} })),
53 ),
54 ),
55 ];
56
57 let mut findings = Vec::new();
58 for (label, req) in probes {
59 match ctx.session.send_request(&req).await {
60 Ok(resp) => {
61 if let Some(error) = resp.error {
62 let mut haystack = error.message;
63 if let Some(data) = error.data {
64 haystack.push('\n');
65 haystack.push_str(&data.to_string());
66 }
67 let lower = haystack.to_ascii_lowercase();
68 if let Some(pattern) = LEAK_PATTERNS
69 .iter()
70 .find(|p| lower.contains(**p) && !is_allowlisted_context(p, &lower))
71 {
72 findings.push(
73 CheckResult::fail(
74 self.id(),
75 self.name(),
76 self.category(),
77 Severity::Medium,
78 desc,
79 format!("{label}: error content matched sensitive pattern \"{pattern}\""),
80 )
81 .with_evidence(serde_json::json!({
82 "probe": label,
83 "pattern": pattern,
84 "error_excerpt": truncate_for_evidence(&lower, EVIDENCE_MAX_CHARS),
85 })),
86 );
87 }
88 }
89 }
90 Err(e) => {
91 findings.push(CheckResult::fail(
92 self.id(),
93 self.name(),
94 self.category(),
95 Severity::High,
96 desc,
97 format!("{label}: failed to retrieve error response for leakage test: {e}"),
98 ));
99 }
100 }
101 }
102
103 if findings.is_empty() {
104 vec![CheckResult::pass(
105 self.id(),
106 self.name(),
107 self.category(),
108 desc,
109 )]
110 } else {
111 findings
112 }
113 }
114}