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
590 lines
21 KiB
Rust
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(),
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|