use compliance_core::error::CoreError; use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult}; use serde_json::json; use tracing::info; /// Tool that discovers and parses OpenAPI/Swagger specification files. /// /// Returns structured endpoint definitions for the LLM to use when planning /// further tests. This tool produces data rather than security findings. pub struct OpenApiParserTool { http: reqwest::Client, } /// A parsed endpoint from an OpenAPI spec. #[derive(Debug, Clone)] struct ParsedEndpoint { path: String, method: String, operation_id: Option, summary: Option, parameters: Vec, request_body_content_type: Option, response_codes: Vec, security: Vec, tags: Vec, } /// A parsed parameter from an OpenAPI spec. #[derive(Debug, Clone)] struct ParsedParameter { name: String, location: String, required: bool, param_type: Option, description: Option, } impl OpenApiParserTool { pub fn new(http: reqwest::Client) -> Self { Self { http } } /// Common paths where OpenAPI/Swagger specs are typically served. fn common_spec_paths() -> Vec<&'static str> { vec![ "/openapi.json", "/openapi.yaml", "/swagger.json", "/swagger.yaml", "/api-docs", "/api-docs.json", "/v2/api-docs", "/v3/api-docs", "/docs/openapi.json", "/api/swagger.json", "/api/openapi.json", "/api/v1/openapi.json", "/api/v2/openapi.json", "/.well-known/openapi.json", ] } /// Try to fetch a spec from a URL and return the JSON value if successful. async fn try_fetch_spec( http: &reqwest::Client, url: &str, ) -> Option<(String, serde_json::Value)> { let resp = http.get(url).send().await.ok()?; if !resp.status().is_success() { return None; } let content_type = resp .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); let body = resp.text().await.ok()?; // Try JSON first if let Ok(val) = serde_json::from_str::(&body) { // Verify it looks like an OpenAPI / Swagger spec if val.get("openapi").is_some() || val.get("swagger").is_some() || val.get("paths").is_some() { return Some((url.to_string(), val)); } } // If content type suggests YAML, we can't easily parse without a YAML dep, // so just report the URL as found if content_type.contains("yaml") || body.starts_with("openapi:") || body.starts_with("swagger:") { // Return a minimal JSON indicating YAML was found return Some(( url.to_string(), json!({ "_note": "YAML spec detected but not parsed. Fetch and convert to JSON.", "_raw_url": url, }), )); } None } /// Parse an OpenAPI 3.x or Swagger 2.x spec into structured endpoints. fn parse_spec(spec: &serde_json::Value, _base_url: &str) -> Vec { let mut endpoints = Vec::new(); // Determine base path let base_path = if let Some(servers) = spec.get("servers").and_then(|v| v.as_array()) { servers .first() .and_then(|s| s.get("url")) .and_then(|u| u.as_str()) .unwrap_or("") .to_string() } else if let Some(bp) = spec.get("basePath").and_then(|v| v.as_str()) { bp.to_string() } else { String::new() }; let paths = match spec.get("paths").and_then(|v| v.as_object()) { Some(p) => p, None => return endpoints, }; for (path, path_item) in paths { let path_obj = match path_item.as_object() { Some(o) => o, None => continue, }; // Path-level parameters let path_params = path_obj .get("parameters") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); for method in &["get", "post", "put", "patch", "delete", "head", "options"] { let operation = match path_obj.get(*method).and_then(|v| v.as_object()) { Some(o) => o, None => continue, }; let operation_id = operation .get("operationId") .and_then(|v| v.as_str()) .map(String::from); let summary = operation .get("summary") .and_then(|v| v.as_str()) .map(String::from); let tags: Vec = operation .get("tags") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|t| t.as_str().map(String::from)) .collect() }) .unwrap_or_default(); // Merge path-level and operation-level parameters let mut parameters = Vec::new(); let op_params = operation .get("parameters") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); for param_val in path_params.iter().chain(op_params.iter()) { let name = param_val .get("name") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let location = param_val .get("in") .and_then(|v| v.as_str()) .unwrap_or("query") .to_string(); let required = param_val .get("required") .and_then(|v| v.as_bool()) .unwrap_or(false); // Type from schema or direct type field let param_type = param_val .get("schema") .and_then(|s| s.get("type")) .or_else(|| param_val.get("type")) .and_then(|v| v.as_str()) .map(String::from); let description = param_val .get("description") .and_then(|v| v.as_str()) .map(String::from); parameters.push(ParsedParameter { name, location, required, param_type, description, }); } // Request body (OpenAPI 3.x) let request_body_content_type = operation .get("requestBody") .and_then(|rb| rb.get("content")) .and_then(|c| c.as_object()) .and_then(|obj| obj.keys().next().cloned()); // Response codes let response_codes: Vec = operation .get("responses") .and_then(|r| r.as_object()) .map(|obj| obj.keys().cloned().collect()) .unwrap_or_default(); // Security requirements let security: Vec = operation .get("security") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|s| s.as_object()) .flat_map(|obj| obj.keys().cloned()) .collect() }) .unwrap_or_default(); endpoints.push(ParsedEndpoint { path: format!("{}{}", base_path, path), method: method.to_uppercase(), operation_id, summary, parameters, request_body_content_type, response_codes, security, tags, }); } } endpoints } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn common_spec_paths_not_empty() { let paths = OpenApiParserTool::common_spec_paths(); assert!(paths.len() >= 5); assert!(paths.contains(&"/openapi.json")); assert!(paths.contains(&"/swagger.json")); } #[test] fn parse_spec_openapi3_basic() { let spec = json!({ "openapi": "3.0.0", "info": { "title": "Test API", "version": "1.0" }, "paths": { "/users": { "get": { "operationId": "listUsers", "summary": "List all users", "parameters": [ { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } } ], "responses": { "200": { "description": "OK" }, "401": { "description": "Unauthorized" } }, "tags": ["users"] }, "post": { "operationId": "createUser", "requestBody": { "content": { "application/json": {} } }, "responses": { "201": {} }, "security": [{ "bearerAuth": [] }] } } } }); let endpoints = OpenApiParserTool::parse_spec(&spec, "https://api.example.com"); assert_eq!(endpoints.len(), 2); let get_ep = endpoints.iter().find(|e| e.method == "GET").unwrap(); assert_eq!(get_ep.path, "/users"); assert_eq!(get_ep.operation_id.as_deref(), Some("listUsers")); assert_eq!(get_ep.summary.as_deref(), Some("List all users")); assert_eq!(get_ep.parameters.len(), 1); assert_eq!(get_ep.parameters[0].name, "limit"); assert_eq!(get_ep.parameters[0].location, "query"); assert!(!get_ep.parameters[0].required); assert_eq!(get_ep.parameters[0].param_type.as_deref(), Some("integer")); assert_eq!(get_ep.response_codes.len(), 2); assert_eq!(get_ep.tags, vec!["users"]); let post_ep = endpoints.iter().find(|e| e.method == "POST").unwrap(); assert_eq!( post_ep.request_body_content_type.as_deref(), Some("application/json") ); assert_eq!(post_ep.security, vec!["bearerAuth"]); } #[test] fn parse_spec_swagger2_with_base_path() { let spec = json!({ "swagger": "2.0", "basePath": "/api/v1", "paths": { "/items": { "get": { "parameters": [ { "name": "id", "in": "path", "required": true, "type": "string" } ], "responses": { "200": {} } } } } }); let endpoints = OpenApiParserTool::parse_spec(&spec, "https://api.example.com"); assert_eq!(endpoints.len(), 1); assert_eq!(endpoints[0].path, "/api/v1/items"); assert_eq!( endpoints[0].parameters[0].param_type.as_deref(), Some("string") ); } #[test] fn parse_spec_empty_paths() { let spec = json!({ "openapi": "3.0.0", "paths": {} }); let endpoints = OpenApiParserTool::parse_spec(&spec, "https://example.com"); assert!(endpoints.is_empty()); } #[test] fn parse_spec_no_paths_key() { let spec = json!({ "openapi": "3.0.0" }); let endpoints = OpenApiParserTool::parse_spec(&spec, "https://example.com"); assert!(endpoints.is_empty()); } #[test] fn parse_spec_servers_base_url() { let spec = json!({ "openapi": "3.0.0", "servers": [{ "url": "/api/v2" }], "paths": { "/health": { "get": { "responses": { "200": {} } } } } }); let endpoints = OpenApiParserTool::parse_spec(&spec, "https://example.com"); assert_eq!(endpoints[0].path, "/api/v2/health"); } #[test] fn parse_spec_path_level_parameters_merged() { let spec = json!({ "openapi": "3.0.0", "paths": { "/items/{id}": { "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "get": { "parameters": [ { "name": "fields", "in": "query", "schema": { "type": "string" } } ], "responses": { "200": {} } } } } }); let endpoints = OpenApiParserTool::parse_spec(&spec, "https://example.com"); assert_eq!(endpoints[0].parameters.len(), 2); assert!(endpoints[0] .parameters .iter() .any(|p| p.name == "id" && p.location == "path")); assert!(endpoints[0] .parameters .iter() .any(|p| p.name == "fields" && p.location == "query")); } } impl PentestTool for OpenApiParserTool { fn name(&self) -> &str { "openapi_parser" } fn description(&self) -> &str { "Discovers and parses OpenAPI/Swagger specifications. Tries common spec paths and \ returns structured endpoint definitions including parameters, methods, and security \ requirements. Use this to discover all API endpoints before testing." } fn input_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "base_url": { "type": "string", "description": "Base URL of the API to discover specs from" }, "spec_url": { "type": "string", "description": "Optional explicit URL of the OpenAPI/Swagger spec file" } }, "required": ["base_url"] }) } fn execute<'a>( &'a self, input: serde_json::Value, _context: &'a PentestToolContext, ) -> std::pin::Pin< Box> + Send + 'a>, > { Box::pin(async move { let base_url = input .get("base_url") .and_then(|v| v.as_str()) .ok_or_else(|| { CoreError::Dast("Missing required 'base_url' parameter".to_string()) })?; let explicit_spec_url = input.get("spec_url").and_then(|v| v.as_str()); let base_url_trimmed = base_url.trim_end_matches('/'); // If an explicit spec URL is provided, try it first let mut spec_result: Option<(String, serde_json::Value)> = None; if let Some(spec_url) = explicit_spec_url { spec_result = Self::try_fetch_spec(&self.http, spec_url).await; } // If no explicit URL or it failed, try common paths if spec_result.is_none() { for path in Self::common_spec_paths() { let url = format!("{base_url_trimmed}{path}"); if let Some(result) = Self::try_fetch_spec(&self.http, &url).await { spec_result = Some(result); break; } } } match spec_result { Some((spec_url, spec)) => { let spec_version = spec .get("openapi") .or_else(|| spec.get("swagger")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); let api_title = spec .get("info") .and_then(|i| i.get("title")) .and_then(|t| t.as_str()) .unwrap_or("Unknown API"); let api_version = spec .get("info") .and_then(|i| i.get("version")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); let endpoints = Self::parse_spec(&spec, base_url_trimmed); let endpoint_data: Vec = endpoints .iter() .map(|ep| { let params: Vec = ep .parameters .iter() .map(|p| { json!({ "name": p.name, "in": p.location, "required": p.required, "type": p.param_type, "description": p.description, }) }) .collect(); json!({ "path": ep.path, "method": ep.method, "operation_id": ep.operation_id, "summary": ep.summary, "parameters": params, "request_body_content_type": ep.request_body_content_type, "response_codes": ep.response_codes, "security": ep.security, "tags": ep.tags, }) }) .collect(); let endpoint_count = endpoints.len(); info!( spec_url = %spec_url, spec_version, api_title, endpoints = endpoint_count, "OpenAPI spec parsed" ); Ok(PentestToolResult { summary: format!( "Found OpenAPI spec ({spec_version}) at {spec_url}. \ API: {api_title} v{api_version}. \ Parsed {endpoint_count} endpoints." ), findings: Vec::new(), // This tool produces data, not findings data: json!({ "spec_url": spec_url, "spec_version": spec_version, "api_title": api_title, "api_version": api_version, "endpoint_count": endpoint_count, "endpoints": endpoint_data, "security_schemes": spec.get("components") .and_then(|c| c.get("securitySchemes")) .or_else(|| spec.get("securityDefinitions")), }), }) } None => { info!(base_url, "No OpenAPI spec found"); Ok(PentestToolResult { summary: format!( "No OpenAPI/Swagger specification found for {base_url}. \ Tried {} common paths.", Self::common_spec_paths().len() ), findings: Vec::new(), data: json!({ "spec_found": false, "paths_tried": Self::common_spec_paths(), }), }) } } }) } }