Skip to main content

api_scanner/scanner/
openapi.rs

1// src/scanner/openapi.rs
2//
3// OpenAPI / Swagger spec security analysis.
4
5use 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
26/// Well-known OpenAPI / Swagger spec locations to probe.
27static 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}