Files
compliance-scanner-agent/compliance-dast/src/tools/openapi_parser.rs
sharang 3bb690e5bb
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m15s
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
refactor: modularize codebase and add 404 unit tests (#13)
2026-03-13 08:03:45 +00:00

590 lines
21 KiB
Rust

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<String>,
summary: Option<String>,
parameters: Vec<ParsedParameter>,
request_body_content_type: Option<String>,
response_codes: Vec<String>,
security: Vec<String>,
tags: Vec<String>,
}
/// A parsed parameter from an OpenAPI spec.
#[derive(Debug, Clone)]
struct ParsedParameter {
name: String,
location: String,
required: bool,
param_type: Option<String>,
description: Option<String>,
}
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::<serde_json::Value>(&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<ParsedEndpoint> {
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<String> = 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<String> = operation
.get("responses")
.and_then(|r| r.as_object())
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default();
// Security requirements
let security: Vec<String> = 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<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + 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<serde_json::Value> = endpoints
.iter()
.map(|ep| {
let params: Vec<serde_json::Value> = 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(),
}),
})
}
}
})
}
}