1use async_trait::async_trait;
6use serde_json::Value;
7use tracing::debug;
8
9use crate::{
10 config::Config,
11 error::CapturedError,
12 http_client::HttpClient,
13 reports::{Finding, Severity},
14};
15
16use super::Scanner;
17
18pub struct OpenApiScanner;
19
20impl OpenApiScanner {
21 pub fn new(_config: &Config) -> Self {
22 Self
23 }
24}
25
26static SPEC_PATHS: &[&str] = &[
28 "/swagger.json",
29 "/swagger.yaml",
30 "/swagger/v1/swagger.json",
31 "/swagger/v2/swagger.json",
32 "/openapi.json",
33 "/openapi.yaml",
34 "/api-docs",
35 "/api-docs.json",
36 "/api-docs.yaml",
37 "/api/swagger.json",
38 "/api/openapi.json",
39 "/api/v1/swagger.json",
40 "/api/v2/swagger.json",
41 "/v1/swagger.json",
42 "/v2/swagger.json",
43 "/v3/api-docs",
44 "/v3/api-docs.yaml",
45];
46
47#[async_trait]
48impl Scanner for OpenApiScanner {
49 fn name(&self) -> &'static str {
50 "openapi"
51 }
52
53 async fn scan(
54 &self,
55 url: &str,
56 client: &HttpClient,
57 _config: &Config,
58 ) -> (Vec<Finding>, Vec<CapturedError>) {
59 let mut findings = Vec::new();
60 let mut errors = Vec::new();
61
62 let base = url.trim_end_matches('/');
63
64 for spec_path in SPEC_PATHS {
65 let spec_url = format!("{base}{spec_path}");
66 let body = if let Some(cached) = client.get_cached_spec(&spec_url) {
67 cached
68 } else {
69 let resp = match client.get(&spec_url).await {
70 Ok(r) if r.status < 400 => r,
71 Ok(_) => continue,
72 Err(e) => {
73 errors.push(e);
74 continue;
75 }
76 };
77 client.cache_spec(&spec_url, &resp.body);
78 resp.body
79 };
80
81 debug!("[openapi] found spec at {spec_url}");
82
83 match parse_spec(&body) {
84 Ok(spec) => analyze_spec(&spec_url, &spec, &mut findings),
85 Err(e) => errors.push(CapturedError::parse("openapi/parse", e)),
86 }
87 }
88
89 (findings, errors)
90 }
91}
92
93fn parse_spec(body: &str) -> Result<Value, String> {
94 let trimmed = body.trim_start();
95 if trimmed.starts_with('{') || trimmed.starts_with('[') {
96 serde_json::from_str::<Value>(trimmed).map_err(|e| e.to_string())
97 } else {
98 serde_yml::from_str::<Value>(trimmed).map_err(|e| e.to_string())
99 }
100}
101
102fn analyze_spec(spec_url: &str, spec: &Value, findings: &mut Vec<Finding>) {
103 let mut unsecured_ops = Vec::new();
104 let mut deprecated_ops = Vec::new();
105 let mut upload_ops = Vec::new();
106
107 let security_schemes = spec
108 .get("components")
109 .and_then(|c| c.get("securitySchemes"))
110 .or_else(|| spec.get("securityDefinitions"));
111
112 let has_security_schemes = security_schemes
113 .and_then(|v| v.as_object())
114 .map(|o| !o.is_empty())
115 .unwrap_or(false);
116
117 let global_security = spec
118 .get("security")
119 .and_then(|v| v.as_array())
120 .map(|v| !v.is_empty())
121 .unwrap_or(false);
122
123 if !has_security_schemes {
124 findings.push(
125 Finding::new(
126 spec_url,
127 "openapi/no-security-schemes",
128 "OpenAPI spec missing security schemes",
129 Severity::Medium,
130 "No securitySchemes (OAS3) or securityDefinitions (Swagger v2) were defined.",
131 "openapi",
132 )
133 .with_remediation(
134 "Define authentication schemes in the spec (e.g., OAuth2, API key, JWT).",
135 ),
136 );
137 }
138
139 let paths = spec.get("paths").and_then(|v| v.as_object());
140 if let Some(paths) = paths {
141 for (path, item) in paths {
142 let item_obj = match item.as_object() {
143 Some(v) => v,
144 None => continue,
145 };
146
147 for (method, op) in item_obj {
148 if !is_http_method(method) {
149 continue;
150 }
151
152 let op_obj = match op.as_object() {
153 Some(v) => v,
154 None => continue,
155 };
156
157 let op_security = op_obj.get("security").and_then(|v| v.as_array());
158 let secured = op_security
159 .map(|v| !v.is_empty())
160 .unwrap_or(global_security);
161
162 if !secured {
163 unsecured_ops.push(format!("{} {}", method.to_uppercase(), path));
164 }
165
166 if op_obj.get("deprecated").and_then(|v| v.as_bool()) == Some(true) {
167 deprecated_ops.push(format!("{} {}", method.to_uppercase(), path));
168 }
169
170 if looks_like_file_upload(op_obj) {
171 upload_ops.push(format!("{} {}", method.to_uppercase(), path));
172 }
173 }
174 }
175 }
176
177 if !unsecured_ops.is_empty() {
178 let sample = unsecured_ops
179 .iter()
180 .take(10)
181 .cloned()
182 .collect::<Vec<_>>()
183 .join(", ");
184 findings.push(
185 Finding::new(
186 spec_url,
187 "openapi/unauthenticated-operations",
188 "OpenAPI operations without security requirements",
189 Severity::Medium,
190 format!(
191 "{} operation(s) do not declare security requirements.",
192 unsecured_ops.len()
193 ),
194 "openapi",
195 )
196 .with_evidence(format!("Sample: {sample}"))
197 .with_remediation("Apply security requirements globally or per-operation in the spec."),
198 );
199 }
200
201 if !upload_ops.is_empty() {
202 let sample = upload_ops
203 .iter()
204 .take(10)
205 .cloned()
206 .collect::<Vec<_>>()
207 .join(", ");
208 findings.push(
209 Finding::new(
210 spec_url,
211 "openapi/file-upload",
212 "OpenAPI file upload endpoints",
213 Severity::Medium,
214 format!("{} operation(s) accept file uploads.", upload_ops.len()),
215 "openapi",
216 )
217 .with_evidence(format!("Sample: {sample}"))
218 .with_remediation(
219 "Harden file upload endpoints with size limits, content-type validation, and auth.",
220 ),
221 );
222 }
223
224 if !deprecated_ops.is_empty() {
225 let sample = deprecated_ops
226 .iter()
227 .take(10)
228 .cloned()
229 .collect::<Vec<_>>()
230 .join(", ");
231 findings.push(
232 Finding::new(
233 spec_url,
234 "openapi/deprecated-operations",
235 "Deprecated OpenAPI operations still present",
236 Severity::Info,
237 format!(
238 "{} deprecated operation(s) listed in the spec.",
239 deprecated_ops.len()
240 ),
241 "openapi",
242 )
243 .with_evidence(format!("Sample: {sample}"))
244 .with_remediation("Remove deprecated endpoints or add explicit sunset timelines."),
245 );
246 }
247}
248
249fn is_http_method(method: &str) -> bool {
250 matches!(
251 method,
252 "get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "trace"
253 )
254}
255
256fn looks_like_file_upload(op_obj: &serde_json::Map<String, Value>) -> bool {
257 if let Some(req) = op_obj.get("requestBody") {
258 if let Some(content) = req.get("content").and_then(|v| v.as_object()) {
259 for key in content.keys() {
260 let ct = key.to_ascii_lowercase();
261 if ct.contains("multipart/form-data") || ct.contains("application/octet-stream") {
262 return true;
263 }
264 }
265 }
266 }
267
268 if let Some(params) = op_obj.get("parameters").and_then(|v| v.as_array()) {
269 for p in params {
270 if let Some(obj) = p.as_object() {
271 let loc = obj.get("in").and_then(|v| v.as_str()).unwrap_or("");
272 let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
273 if loc == "formData" && typ == "file" {
274 return true;
275 }
276 }
277 }
278 }
279
280 false
281}