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
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:
@@ -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(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user