mockforge_intelligence/threat_modeling/
schema_analyzer.rs1use super::types::{ThreatCategory, ThreatFinding, ThreatLevel};
9use mockforge_openapi::OpenApiSpec;
10use openapiv3::ReferenceOr;
11use std::collections::HashMap;
12
13pub struct SchemaAnalyzer {
15 max_optional_fields: usize,
17}
18
19impl SchemaAnalyzer {
20 pub fn new(max_optional_fields: usize) -> Self {
22 Self {
23 max_optional_fields,
24 }
25 }
26
27 pub fn analyze_schemas(&self, spec: &OpenApiSpec) -> Vec<ThreatFinding> {
29 let mut findings = Vec::new();
30
31 for (path, path_item) in &spec.spec.paths.paths {
32 if let ReferenceOr::Item(path_item) = path_item {
33 let methods = vec![
35 ("GET", path_item.get.as_ref()),
36 ("POST", path_item.post.as_ref()),
37 ("PUT", path_item.put.as_ref()),
38 ("DELETE", path_item.delete.as_ref()),
39 ("PATCH", path_item.patch.as_ref()),
40 ("HEAD", path_item.head.as_ref()),
41 ("OPTIONS", path_item.options.as_ref()),
42 ("TRACE", path_item.trace.as_ref()),
43 ];
44
45 for (method, operation_opt) in methods {
46 let Some(operation) = operation_opt else {
47 continue;
48 };
49 let base_path = format!("{}.{}", method, path);
50
51 if let Some(request_body) = &operation.request_body {
53 if let Some(ref_or_item) = request_body.as_item() {
54 for media_type in ref_or_item.content.values() {
55 if let Some(schema) = &media_type.schema {
56 findings.extend(
57 self.analyze_schema_design(schema, &base_path, "request"),
58 );
59 }
60 }
61 }
62 }
63
64 for (status_code, response) in &operation.responses.responses {
66 if let ReferenceOr::Item(resp) = response {
67 for media_type in resp.content.values() {
68 if let Some(schema) = &media_type.schema {
69 findings.extend(self.analyze_schema_design(
70 schema,
71 &base_path,
72 &format!("response.{}", status_code),
73 ));
74 }
75 }
76 }
77 }
78 }
79 }
80 }
81
82 findings
83 }
84
85 fn analyze_schema_design(
87 &self,
88 schema_ref: &ReferenceOr<openapiv3::Schema>,
89 base_path: &str,
90 context: &str,
91 ) -> Vec<ThreatFinding> {
92 let mut findings = Vec::new();
93
94 if let ReferenceOr::Item(schema) = schema_ref {
95 if let openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj_type)) =
96 &schema.schema_kind
97 {
98 let required = obj_type.required.len();
99 let total_fields = obj_type.properties.len();
100 let optional_fields = total_fields.saturating_sub(required);
101
102 if optional_fields > self.max_optional_fields {
103 findings.push(ThreatFinding {
104 finding_type: ThreatCategory::ExcessiveOptionalFields,
105 severity: ThreatLevel::Medium,
106 description: format!(
107 "Schema has {} optional fields (threshold: {}), which may indicate inconsistent backend behavior",
108 optional_fields,
109 self.max_optional_fields
110 ),
111 field_path: Some(format!("{}.{}", base_path, context)),
112 context: HashMap::new(),
113 confidence: 0.7,
114 });
115 }
116
117 for (prop_name, prop_schema) in &obj_type.properties {
119 if let ReferenceOr::Item(prop_schema_item) = prop_schema {
120 if let openapiv3::SchemaKind::Type(openapiv3::Type::String(string_type)) =
122 &prop_schema_item.as_ref().schema_kind
123 {
124 let has_format = !matches!(
126 string_type.format,
127 openapiv3::VariantOrUnknownOrEmpty::Empty
128 );
129 let has_pattern = string_type.pattern.is_some();
130 let has_max_length = string_type.max_length.is_some();
131
132 if !has_format && !has_pattern && !has_max_length {
133 findings.push(ThreatFinding {
134 finding_type: ThreatCategory::MissingValidation,
135 severity: ThreatLevel::Low,
136 description: format!(
137 "String field '{}' lacks validation constraints (format, pattern, or maxLength)",
138 prop_name
139 ),
140 field_path: Some(format!("{}.{}.{}", base_path, context, prop_name)),
141 context: HashMap::new(),
142 confidence: 0.6,
143 });
144 }
145 }
146 }
147 }
148 }
149 }
150
151 findings
152 }
153}
154
155impl Default for SchemaAnalyzer {
156 fn default() -> Self {
157 Self::new(10)
158 }
159}