feat: AI-driven automated penetration testing (#12)
CI / Format (push) Failing after 42s
CI / Clippy (push) Failing after 1m51s
CI / Security Audit (push) Successful in 2m1s
CI / Tests (push) Has been skipped
CI / Detect Changes (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped

This commit was merged in pull request #12.
This commit is contained in:
2026-03-12 14:42:54 +00:00
parent 3ec1456b0d
commit acc5b86aa4
52 changed files with 11729 additions and 98 deletions
+422
View File
@@ -0,0 +1,422 @@
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
}
}
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(),
}),
})
}
}
})
}
}