Skip to main content

agentox_core/checks/security/
error_leakage.rs

1//! SEC-003: Detect sensitive details leaking through error messages.
2
3use 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}