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