refactor: modularize codebase and add 404 unit tests (#13)
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

This commit was merged in pull request #13.
This commit is contained in:
2026-03-13 08:03:45 +00:00
parent acc5b86aa4
commit 3bb690e5bb
89 changed files with 11884 additions and 6046 deletions
+275 -108
View File
@@ -92,7 +92,10 @@ impl OpenApiParserTool {
// 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:") {
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(),
@@ -107,7 +110,7 @@ impl OpenApiParserTool {
}
/// 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> {
fn parse_spec(spec: &serde_json::Value, _base_url: &str) -> Vec<ParsedEndpoint> {
let mut endpoints = Vec::new();
// Determine base path
@@ -258,6 +261,166 @@ impl OpenApiParserTool {
}
}
#[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"
@@ -289,134 +452,138 @@ impl PentestTool for OpenApiParserTool {
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>> {
_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 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 explicit_spec_url = input.get("spec_url").and_then(|v| v.as_str());
let base_url_trimmed = base_url.trim_end_matches('/');
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 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;
// 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");
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_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 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 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,
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();
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();
.collect();
let endpoint_count = endpoints.len();
info!(
spec_url = %spec_url,
spec_version,
api_title,
endpoints = endpoint_count,
"OpenAPI spec parsed"
);
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}. \
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");
),
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}. \
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(),
}),
})
Self::common_spec_paths().len()
),
findings: Vec::new(),
data: json!({
"spec_found": false,
"paths_tried": Self::common_spec_paths(),
}),
})
}
}
}
})
}
}