mockforge_intelligence/threat_modeling/
error_analyzer.rs1use super::types::{ThreatCategory, ThreatFinding, ThreatLevel};
10use mockforge_openapi::OpenApiSpec;
11use openapiv3::ReferenceOr;
12use std::collections::HashMap;
13
14pub struct ErrorAnalyzer {
16 enabled: bool,
18}
19
20impl ErrorAnalyzer {
21 pub fn new(enabled: bool) -> Self {
23 Self { enabled }
24 }
25
26 pub fn analyze_errors(&self, spec: &OpenApiSpec) -> Vec<ThreatFinding> {
28 if !self.enabled {
29 return Vec::new();
30 }
31
32 let mut findings = Vec::new();
33
34 for (path, path_item) in &spec.spec.paths.paths {
35 if let ReferenceOr::Item(path_item) = path_item {
36 let methods = vec![
38 ("GET", path_item.get.as_ref()),
39 ("POST", path_item.post.as_ref()),
40 ("PUT", path_item.put.as_ref()),
41 ("DELETE", path_item.delete.as_ref()),
42 ("PATCH", path_item.patch.as_ref()),
43 ("HEAD", path_item.head.as_ref()),
44 ("OPTIONS", path_item.options.as_ref()),
45 ("TRACE", path_item.trace.as_ref()),
46 ];
47
48 for (method, operation_opt) in methods {
49 let Some(operation) = operation_opt else {
50 continue;
51 };
52 let base_path = format!("{}.{}", method, path);
53
54 for (status_code, response) in &operation.responses.responses {
56 let status_num = match status_code {
57 openapiv3::StatusCode::Code(code) => *code,
58 openapiv3::StatusCode::Range(range) => {
59 match *range {
61 4 => 400,
62 5 => 500,
63 _ => continue,
64 }
65 }
66 };
67
68 if status_num >= 400 {
70 if let ReferenceOr::Item(resp) = response {
71 for (_content_type, media_type) in &resp.content {
72 if let Some(schema) = &media_type.schema {
73 findings.extend(
74 self.analyze_error_schema(
75 schema, &base_path, status_num,
76 ),
77 );
78 }
79
80 for example in media_type.examples.values() {
82 if let ReferenceOr::Item(example_item) = example {
83 if let Some(example_value) = &example_item.value {
84 findings.extend(self.analyze_error_example(
85 example_value,
86 &base_path,
87 status_num,
88 ));
89 }
90 }
91 }
92 }
93 }
94 }
95 }
96 }
97 }
98 }
99
100 findings
101 }
102
103 fn analyze_error_schema(
105 &self,
106 schema_ref: &ReferenceOr<openapiv3::Schema>,
107 base_path: &str,
108 status_code: u16,
109 ) -> Vec<ThreatFinding> {
110 let mut findings = Vec::new();
111
112 if let ReferenceOr::Item(schema) = schema_ref {
113 if let Some(description) = &schema.schema_data.description {
115 if self.contains_stack_trace_keywords(description) {
116 findings.push(ThreatFinding {
117 finding_type: ThreatCategory::StackTraceLeakage,
118 severity: ThreatLevel::High,
119 description:
120 "Error response schema description suggests stack trace exposure"
121 .to_string(),
122 field_path: Some(format!("{}.error.{}", base_path, status_code)),
123 context: HashMap::new(),
124 confidence: 0.8,
125 });
126 }
127 }
128
129 if let openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj_type)) =
131 &schema.schema_kind
132 {
133 for (prop_name, _) in &obj_type.properties {
134 let prop_lower = prop_name.to_lowercase();
135 if prop_lower.contains("stack")
136 || prop_lower.contains("trace")
137 || prop_lower.contains("exception")
138 || prop_lower.contains("error_detail")
139 {
140 findings.push(ThreatFinding {
141 finding_type: ThreatCategory::StackTraceLeakage,
142 severity: ThreatLevel::Critical,
143 description: format!(
144 "Error response contains '{}' field which may leak stack traces",
145 prop_name
146 ),
147 field_path: Some(format!(
148 "{}.error.{}.{}",
149 base_path, status_code, prop_name
150 )),
151 context: HashMap::new(),
152 confidence: 0.9,
153 });
154 }
155 }
156 }
157 }
158
159 findings
160 }
161
162 fn analyze_error_example(
164 &self,
165 example: &serde_json::Value,
166 base_path: &str,
167 status_code: u16,
168 ) -> Vec<ThreatFinding> {
169 let mut findings = Vec::new();
170
171 if let Some(obj) = example.as_object() {
172 for (key, value) in obj {
173 if let Some(str_value) = value.as_str() {
175 if self.contains_stack_trace_patterns(str_value) {
176 findings.push(ThreatFinding {
177 finding_type: ThreatCategory::StackTraceLeakage,
178 severity: ThreatLevel::Critical,
179 description:
180 "Error example contains stack trace or sensitive error details"
181 .to_string(),
182 field_path: Some(format!(
183 "{}.error.{}.{}",
184 base_path, status_code, key
185 )),
186 context: HashMap::new(),
187 confidence: 1.0,
188 });
189 }
190
191 if str_value.contains("/")
193 && (str_value.contains(".py")
194 || str_value.contains(".java")
195 || str_value.contains(".rs"))
196 {
197 findings.push(ThreatFinding {
198 finding_type: ThreatCategory::ErrorLeakage,
199 severity: ThreatLevel::Medium,
200 description:
201 "Error message contains file path which may leak internal structure"
202 .to_string(),
203 field_path: Some(format!(
204 "{}.error.{}.{}",
205 base_path, status_code, key
206 )),
207 context: HashMap::new(),
208 confidence: 0.7,
209 });
210 }
211 }
212 }
213 }
214
215 findings
216 }
217
218 fn contains_stack_trace_keywords(&self, text: &str) -> bool {
220 let text_lower = text.to_lowercase();
221 text_lower.contains("stack trace")
222 || text_lower.contains("stacktrace")
223 || text_lower.contains("exception")
224 || text_lower.contains("traceback")
225 }
226
227 fn contains_stack_trace_patterns(&self, text: &str) -> bool {
229 text.contains("at ") && (text.contains("(") || text.contains("line"))
231 || text.contains("Traceback")
232 || text.contains("Exception in thread")
233 }
234}
235
236impl Default for ErrorAnalyzer {
237 fn default() -> Self {
238 Self::new(true)
239 }
240}