Public Access
initial-commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "charybdis-defectdojo-plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "charybdis_defectdojo"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
charybdis = { path = "../.." }
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.8"
|
||||
tokio = { version = "1.48", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
wiremock = "0.6"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
||||
@@ -0,0 +1,301 @@
|
||||
# DefectDojo Plugin for Charybdis
|
||||
|
||||
Event-driven plugin that synchronizes Charybdis entities with DefectDojo security testing platform.
|
||||
|
||||
## Overview
|
||||
|
||||
The DefectDojo plugin automatically provisions and manages resources in DefectDojo based on entity lifecycle events in Charybdis. It provides bidirectional mapping between Charybdis entities and DefectDojo resources.
|
||||
|
||||
## Supported Resources
|
||||
|
||||
| Charybdis Entity | DefectDojo Resource | Trigger |
|
||||
|-----------------|---------------------|---------|
|
||||
| Component | Product | Create/Update/Delete Component |
|
||||
| User | User | Create/Update/Delete User |
|
||||
| System | Product Type | Create/Update/Delete System |
|
||||
| Group | Product Member | Create/Update/Delete Group with annotation |
|
||||
| Resource | Engagement | Create/Update/Delete Resource with annotation |
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the DefectDojo plugin configuration to your `config.toml`:
|
||||
|
||||
```toml
|
||||
[plugins.defectdojo]
|
||||
enabled = true
|
||||
base_url = "${DEFECTDOJO_URL}"
|
||||
api_token = "${DEFECTDOJO_API_TOKEN}"
|
||||
default_product_type_id = 1
|
||||
auto_create_users = true
|
||||
auto_create_product_types = false
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `DEFECTDOJO_URL`: Base URL of your DefectDojo instance (e.g., `https://defectdojo.example.com`)
|
||||
- `DEFECTDOJO_API_TOKEN`: API token for authentication
|
||||
|
||||
### Field Mappings
|
||||
|
||||
Define how Charybdis entity fields map to DefectDojo API fields:
|
||||
|
||||
```toml
|
||||
[plugins.defectdojo.field_mappings.product]
|
||||
name = "metadata.name"
|
||||
description = "metadata.description"
|
||||
business_criticality = { value = "high" }
|
||||
product_type_id = { value = 1 }
|
||||
```
|
||||
|
||||
#### Mapping Types
|
||||
|
||||
1. **Direct field mapping**: `"metadata.name"` - Extract field from entity
|
||||
2. **Static value**: `{ value = "high" }` - Use static value
|
||||
3. **Entity resolution**: Resolve entity references
|
||||
```toml
|
||||
product_manager = {
|
||||
from = "spec.owner",
|
||||
resolve_entity = "User",
|
||||
extract = "annotations.defectdojo.com/user-id"
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### 1. Create a Product from Component
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: payment-service
|
||||
description: Payment processing service
|
||||
tags:
|
||||
- payment
|
||||
- critical
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: production
|
||||
owner: platform-team
|
||||
```
|
||||
|
||||
When this Component is created in Charybdis, the plugin automatically:
|
||||
1. Creates a Product in DefectDojo
|
||||
2. Stores the DefectDojo product ID in annotations: `defectdojo.com/product-id`
|
||||
|
||||
### 2. Create Users
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: User
|
||||
metadata:
|
||||
name: john.doe
|
||||
spec:
|
||||
profile:
|
||||
displayName: John Doe
|
||||
email: john.doe@example.com
|
||||
memberOf:
|
||||
- platform-team
|
||||
```
|
||||
|
||||
The plugin creates a DefectDojo user and stores the user ID in annotations.
|
||||
|
||||
### 3. Add Product Members
|
||||
|
||||
Create a Group entity with a special annotation to trigger product member creation:
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Group
|
||||
metadata:
|
||||
name: payment-service-security-team
|
||||
annotations:
|
||||
defectdojo.com/product-member: "true"
|
||||
spec:
|
||||
type: team
|
||||
parent: component:payment-service # Reference to Component
|
||||
members:
|
||||
- user:john.doe
|
||||
- user:jane.smith
|
||||
```
|
||||
|
||||
The plugin resolves:
|
||||
- `parent` → Component → DefectDojo Product ID
|
||||
- `members` → Users → DefectDojo User IDs
|
||||
- Creates Product Member entries with specified role
|
||||
|
||||
### 4. Create Engagements
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Resource
|
||||
metadata:
|
||||
name: payment-service-q1-security-assessment
|
||||
annotations:
|
||||
defectdojo.com/engagement: "true"
|
||||
version: "2.1.0"
|
||||
commit_hash: "abc123"
|
||||
spec:
|
||||
type: security-assessment
|
||||
owner: component:payment-service
|
||||
dependsOn:
|
||||
- user:john.doe # Lead
|
||||
target_start: "2025-01-01"
|
||||
target_end: "2025-03-31"
|
||||
```
|
||||
|
||||
## Annotations
|
||||
|
||||
The plugin uses annotations to:
|
||||
|
||||
1. **Store DefectDojo IDs** (auto-managed):
|
||||
- `defectdojo.com/product-id`
|
||||
- `defectdojo.com/user-id`
|
||||
- `defectdojo.com/product-type-id`
|
||||
- `defectdojo.com/product-member-id`
|
||||
- `defectdojo.com/engagement-id`
|
||||
|
||||
2. **Trigger special behaviors**:
|
||||
- `defectdojo.com/product-member: "true"` - Create product members from Group
|
||||
- `defectdojo.com/engagement: "true"` - Create engagement from Resource
|
||||
|
||||
## Entity Resolution
|
||||
|
||||
The plugin supports complex field mappings that resolve entity references:
|
||||
|
||||
```toml
|
||||
[plugins.defectdojo.field_mappings.product]
|
||||
product_manager = {
|
||||
from = "spec.owner", # Get owner field from entity
|
||||
resolve_entity = "User", # Resolve as User entity
|
||||
extract = "annotations.defectdojo.com/user-id" # Extract DD user ID
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to reference Users by entity ID in Charybdis, and the plugin automatically resolves to DefectDojo user IDs.
|
||||
|
||||
### Array Resolution
|
||||
|
||||
For resolving arrays of entities (like group members):
|
||||
|
||||
```toml
|
||||
user_id = {
|
||||
from = "spec.members",
|
||||
resolve_entity = "User",
|
||||
extract = "annotations.defectdojo.com/user-id",
|
||||
resolve_array = true
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Charybdis │
|
||||
│ (gRPC API) │
|
||||
└────────┬────────┘
|
||||
│ Entity Events
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Event Dispatcher│
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ DefectDojo Plugin │
|
||||
│ ┌────────────────────────┐ │
|
||||
│ │ ProductHandler │ │
|
||||
│ │ UserHandler │ │
|
||||
│ │ ProductTypeHandler │ │
|
||||
│ │ ProductMemberHandler │ │
|
||||
│ │ EngagementHandler │ │
|
||||
│ └────────────────────────┘ │
|
||||
└──────────┬──────────────────┘
|
||||
│ HTTP API
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ DefectDojo │
|
||||
│ (REST API) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Resource Handlers
|
||||
|
||||
Each resource handler implements:
|
||||
- `handle_create()`: Create resource in DefectDojo when entity created
|
||||
- `handle_update()`: Update resource in DefectDojo when entity updated
|
||||
- `handle_delete()`: Delete/deactivate resource in DefectDojo when entity deleted
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Failed API calls are logged with full error details
|
||||
- Entity is still created/updated in Charybdis even if DefectDojo sync fails
|
||||
- Missing DefectDojo IDs trigger automatic creation
|
||||
- User deletion deactivates users instead of deleting (DefectDojo best practice)
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Adding New Resource Types
|
||||
|
||||
1. Create new handler in `src/handlers/`
|
||||
2. Implement `ResourceHandler` trait
|
||||
3. Add handler to plugin in `src/lib.rs`
|
||||
4. Define field mappings in config
|
||||
|
||||
## API Reference
|
||||
|
||||
### DefectDojo API Endpoints Used
|
||||
|
||||
- `POST /api/v2/products/` - Create product
|
||||
- `PUT /api/v2/products/{id}/` - Update product
|
||||
- `DELETE /api/v2/products/{id}/` - Delete product
|
||||
- `POST /api/v2/users/` - Create user
|
||||
- `PUT /api/v2/users/{id}/` - Update user
|
||||
- `POST /api/v2/product_types/` - Create product type
|
||||
- `POST /api/v2/product_members/` - Create product member
|
||||
- `DELETE /api/v2/product_members/{id}/` - Remove product member
|
||||
- `POST /api/v2/engagements/` - Create engagement
|
||||
- `PUT /api/v2/engagements/{id}/` - Update engagement
|
||||
|
||||
## Security
|
||||
|
||||
- API token should be stored in environment variables, not committed to git
|
||||
- Use mTLS for Charybdis gRPC connections
|
||||
- DefectDojo HTTPS endpoint recommended for production
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Entity not syncing to DefectDojo
|
||||
|
||||
1. Check plugin is enabled in `config.toml`
|
||||
2. Verify `DEFECTDOJO_API_TOKEN` is set
|
||||
3. Check entity kind matches handler trigger kinds
|
||||
4. Review logs for API errors
|
||||
|
||||
### Missing DefectDojo IDs
|
||||
|
||||
If annotations are missing:
|
||||
- Plugin will attempt to create resource on next update
|
||||
- Check for errors in creation logs
|
||||
- Verify field mappings provide required fields
|
||||
|
||||
### User already exists errors
|
||||
|
||||
- Plugin checks for existing users by email before creating
|
||||
- Will reuse existing user ID instead of creating duplicate
|
||||
|
||||
## License
|
||||
|
||||
Part of Charybdis project.
|
||||
@@ -0,0 +1,25 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package charybdis.plugins.defectdojo;
|
||||
|
||||
// DefectDojo plugin metadata
|
||||
// This is used for plugin-specific configuration entities
|
||||
message DefectdojoMetadata {
|
||||
string name = 1;
|
||||
string description = 2;
|
||||
|
||||
// Resource type (product_type, product_member, engagement)
|
||||
string resource_type = 3;
|
||||
}
|
||||
|
||||
// DefectDojo plugin spec
|
||||
// This is used for plugin-specific configuration entities
|
||||
message DefectdojoSpec {
|
||||
// Configuration data stored as JSON
|
||||
string config_json = 1;
|
||||
|
||||
// Sync status
|
||||
string sync_status = 2;
|
||||
string last_sync = 3;
|
||||
string error_message = 4;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use charybdis::charybdis::entities::Entity;
|
||||
use charybdis::database::EntityRepository;
|
||||
use charybdis::plugins::{date_utils, field_mapper::FieldMapper, ResourceHandler};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{DefectDojoClient, EngagementConfig};
|
||||
|
||||
/// Engagement handler - Maps assessment/testing entities to DefectDojo Engagements
|
||||
pub struct EngagementHandler {
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
field_mapper: FieldMapper,
|
||||
trigger_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
impl EngagementHandler {
|
||||
pub fn new(
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
field_mappings: HashMap<String, serde_json::Value>,
|
||||
) -> Self {
|
||||
let field_mapper = FieldMapper::new(field_mappings)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to create field mapper: {}, using empty mapper", e);
|
||||
FieldMapper::new(HashMap::new()).unwrap()
|
||||
})
|
||||
.with_repository(repository.clone());
|
||||
|
||||
Self {
|
||||
client,
|
||||
repository,
|
||||
field_mapper,
|
||||
trigger_kinds: vec!["Resource".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a default engagement for a newly created product
|
||||
/// This is used by ProductHandler to auto-create engagements when products are created
|
||||
pub async fn create_for_product(
|
||||
&self,
|
||||
product_id: i32,
|
||||
entity: &Entity,
|
||||
engagement_config: &EngagementConfig,
|
||||
) -> Result<i32> {
|
||||
info!(
|
||||
"Creating default engagement for product {} (entity: {})",
|
||||
product_id, entity.id
|
||||
);
|
||||
|
||||
// Generate date range for engagement
|
||||
let (target_start, target_end) =
|
||||
date_utils::engagement_date_range(engagement_config.duration_days);
|
||||
|
||||
// Build DefectDojo engagement payload
|
||||
let payload = json!({
|
||||
"name": engagement_config.name,
|
||||
"description": engagement_config.description,
|
||||
"product": product_id,
|
||||
"target_start": target_start,
|
||||
"target_end": target_end,
|
||||
"status": engagement_config.status,
|
||||
"engagement_type": engagement_config.engagement_type,
|
||||
"deduplication_on_engagement": engagement_config.deduplication_on_engagement,
|
||||
});
|
||||
|
||||
// Note: Optional fields like version and source_code_management_uri could be added
|
||||
// from entity metadata/spec if needed, but requires matching on the spec variant.
|
||||
// For now, these are left empty and can be set via field mappings if needed.
|
||||
|
||||
// Create engagement in DefectDojo
|
||||
let response = self.client.post("api/v2/engagements/", &payload).await?;
|
||||
|
||||
let engagement_id = response["id"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| anyhow!("DefectDojo did not return engagement ID"))?
|
||||
as i32;
|
||||
|
||||
info!(
|
||||
"Created default engagement {} for product {} (entity: {})",
|
||||
engagement_id, product_id, entity.id
|
||||
);
|
||||
|
||||
Ok(engagement_id)
|
||||
}
|
||||
|
||||
/// Get DefectDojo engagement ID from entity annotations
|
||||
fn get_engagement_id(&self, entity: &Entity) -> Option<i32> {
|
||||
entity
|
||||
.annotations
|
||||
.get("defectdojo.com/engagement-id")
|
||||
.and_then(|id| id.parse().ok())
|
||||
}
|
||||
|
||||
/// Create engagement in DefectDojo
|
||||
async fn create_engagement(&self, entity: &Entity) -> Result<i32> {
|
||||
info!("Creating DefectDojo engagement for entity: {}", entity.id);
|
||||
|
||||
// Map fields from entity to DefectDojo API format
|
||||
let mapped = self.field_mapper.map_all(entity).await?;
|
||||
|
||||
// Extract required fields
|
||||
let name = mapped
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("name is required"))?;
|
||||
|
||||
let product_id = mapped
|
||||
.get("product_id")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow!("product_id is required"))? as i32;
|
||||
|
||||
let target_start = mapped
|
||||
.get("target_start")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("target_start is required"))?;
|
||||
|
||||
let target_end = mapped
|
||||
.get("target_end")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("target_end is required"))?;
|
||||
|
||||
// Build DefectDojo engagement payload
|
||||
let mut payload = json!({
|
||||
"name": name,
|
||||
"product": product_id,
|
||||
"target_start": target_start,
|
||||
"target_end": target_end,
|
||||
"status": mapped.get("status").and_then(|v| v.as_str()).unwrap_or("In Progress"),
|
||||
});
|
||||
|
||||
// Add optional fields
|
||||
if let Some(description) = mapped.get("description") {
|
||||
payload["description"] = description.clone();
|
||||
}
|
||||
|
||||
if let Some(lead_id) = mapped.get("lead_id").and_then(|v| v.as_i64()) {
|
||||
payload["lead"] = json!(lead_id);
|
||||
}
|
||||
|
||||
if let Some(engagement_type) = mapped.get("engagement_type") {
|
||||
payload["engagement_type"] = engagement_type.clone();
|
||||
}
|
||||
|
||||
if let Some(build_id) = mapped.get("build_id") {
|
||||
payload["build_id"] = build_id.clone();
|
||||
}
|
||||
|
||||
if let Some(version) = mapped.get("version") {
|
||||
payload["version"] = version.clone();
|
||||
}
|
||||
|
||||
if let Some(commit_hash) = mapped.get("commit_hash") {
|
||||
payload["commit_hash"] = commit_hash.clone();
|
||||
}
|
||||
|
||||
if let Some(branch_tag) = mapped.get("branch_tag") {
|
||||
payload["branch_tag"] = branch_tag.clone();
|
||||
}
|
||||
|
||||
if let Some(source_code_management_uri) = mapped.get("source_code_management_uri") {
|
||||
payload["source_code_management_uri"] = source_code_management_uri.clone();
|
||||
}
|
||||
|
||||
if let Some(deduplication_on_engagement) = mapped.get("deduplication_on_engagement") {
|
||||
payload["deduplication_on_engagement"] = deduplication_on_engagement.clone();
|
||||
}
|
||||
|
||||
if let Some(threat_model) = mapped.get("threat_model") {
|
||||
payload["threat_model"] = threat_model.clone();
|
||||
}
|
||||
|
||||
if let Some(api_test) = mapped.get("api_test") {
|
||||
payload["api_test"] = api_test.clone();
|
||||
}
|
||||
|
||||
if let Some(pen_test) = mapped.get("pen_test") {
|
||||
payload["pen_test"] = pen_test.clone();
|
||||
}
|
||||
|
||||
if let Some(check_list) = mapped.get("check_list") {
|
||||
payload["check_list"] = check_list.clone();
|
||||
}
|
||||
|
||||
// Create engagement in DefectDojo
|
||||
let response = self.client.post("api/v2/engagements/", &payload).await?;
|
||||
|
||||
let engagement_id = response["id"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| anyhow!("DefectDojo did not return engagement ID"))?
|
||||
as i32;
|
||||
|
||||
info!(
|
||||
"Created DefectDojo engagement {} for entity {}",
|
||||
engagement_id, entity.id
|
||||
);
|
||||
|
||||
Ok(engagement_id)
|
||||
}
|
||||
|
||||
/// Update engagement in DefectDojo
|
||||
async fn update_engagement(&self, entity: &Entity, engagement_id: i32) -> Result<()> {
|
||||
info!(
|
||||
"Updating DefectDojo engagement {} for entity {}",
|
||||
engagement_id, entity.id
|
||||
);
|
||||
|
||||
// Map fields from entity to DefectDojo API format
|
||||
let mapped = self.field_mapper.map_all(entity).await?;
|
||||
|
||||
// Build DefectDojo engagement payload
|
||||
let mut payload = json!({});
|
||||
|
||||
if let Some(name) = mapped.get("name") {
|
||||
payload["name"] = name.clone();
|
||||
}
|
||||
|
||||
if let Some(description) = mapped.get("description") {
|
||||
payload["description"] = description.clone();
|
||||
}
|
||||
|
||||
if let Some(status) = mapped.get("status") {
|
||||
payload["status"] = status.clone();
|
||||
}
|
||||
|
||||
if let Some(target_start) = mapped.get("target_start") {
|
||||
payload["target_start"] = target_start.clone();
|
||||
}
|
||||
|
||||
if let Some(target_end) = mapped.get("target_end") {
|
||||
payload["target_end"] = target_end.clone();
|
||||
}
|
||||
|
||||
if let Some(lead_id) = mapped.get("lead_id").and_then(|v| v.as_i64()) {
|
||||
payload["lead"] = json!(lead_id);
|
||||
}
|
||||
|
||||
if let Some(version) = mapped.get("version") {
|
||||
payload["version"] = version.clone();
|
||||
}
|
||||
|
||||
if let Some(commit_hash) = mapped.get("commit_hash") {
|
||||
payload["commit_hash"] = commit_hash.clone();
|
||||
}
|
||||
|
||||
if let Some(branch_tag) = mapped.get("branch_tag") {
|
||||
payload["branch_tag"] = branch_tag.clone();
|
||||
}
|
||||
|
||||
// Update engagement in DefectDojo
|
||||
self.client
|
||||
.put(&format!("api/v2/engagements/{}/", engagement_id), &payload)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Updated DefectDojo engagement {} for entity {}",
|
||||
engagement_id, entity.id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete engagement in DefectDojo
|
||||
async fn delete_engagement(&self, engagement_id: i32) -> Result<()> {
|
||||
info!("Deleting DefectDojo engagement {}", engagement_id);
|
||||
|
||||
self.client
|
||||
.delete(&format!("api/v2/engagements/{}/", engagement_id))
|
||||
.await?;
|
||||
|
||||
info!("Deleted DefectDojo engagement {}", engagement_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResourceHandler for EngagementHandler {
|
||||
fn resource_type(&self) -> &str {
|
||||
"defectdojo_engagement"
|
||||
}
|
||||
|
||||
fn trigger_kinds(&self) -> &[String] {
|
||||
// Trigger on Resource entities with defectdojo.com/engagement annotation
|
||||
&self.trigger_kinds
|
||||
}
|
||||
|
||||
fn creates_entity_kind(&self) -> &str {
|
||||
""
|
||||
}
|
||||
|
||||
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
|
||||
// Create engagement in DefectDojo
|
||||
let engagement_id = self.create_engagement(entity).await?;
|
||||
|
||||
// Update only the annotations on the entity
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert(
|
||||
"defectdojo.com/engagement-id".to_string(),
|
||||
engagement_id.to_string(),
|
||||
);
|
||||
self.repository
|
||||
.update_annotations(&entity.id, annotations)
|
||||
.await?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_update(&self, entity: &Entity) -> Result<()> {
|
||||
// Get DefectDojo engagement ID from annotations
|
||||
if let Some(engagement_id) = self.get_engagement_id(entity) {
|
||||
self.update_engagement(entity, engagement_id).await?;
|
||||
} else {
|
||||
warn!(
|
||||
"Entity {} has no DefectDojo engagement ID, creating new engagement",
|
||||
entity.id
|
||||
);
|
||||
let engagement_id = self.create_engagement(entity).await?;
|
||||
|
||||
// Update only the annotations on the entity
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert(
|
||||
"defectdojo.com/engagement-id".to_string(),
|
||||
engagement_id.to_string(),
|
||||
);
|
||||
self.repository
|
||||
.update_annotations(&entity.id, annotations)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
|
||||
// Get DefectDojo engagement ID and delete
|
||||
if let Some(engagement_id) = self.get_engagement_id(entity) {
|
||||
self.delete_engagement(engagement_id).await?;
|
||||
} else {
|
||||
warn!(
|
||||
"Entity {} has no DefectDojo engagement ID, skipping deletion",
|
||||
entity.id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod engagement;
|
||||
mod product;
|
||||
mod product_member;
|
||||
mod product_type;
|
||||
|
||||
pub use engagement::EngagementHandler;
|
||||
pub use product::ProductHandler;
|
||||
pub use product_member::ProductMemberHandler;
|
||||
pub use product_type::ProductTypeHandler;
|
||||
@@ -0,0 +1,594 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use charybdis::charybdis::entities::Entity;
|
||||
use charybdis::database::EntityRepository;
|
||||
use charybdis::plugins::{
|
||||
annotations::AnnotationHelper, field_mapper::FieldMapper, ResourceHandler,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{handlers::engagement::EngagementHandler, DefectDojoClient, DefectDojoConfig};
|
||||
|
||||
/// Product handler - Maps Component entities to DefectDojo Products
|
||||
pub struct ProductHandler {
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
field_mapper: FieldMapper,
|
||||
annotation_helper: AnnotationHelper,
|
||||
config: DefectDojoConfig,
|
||||
engagement_handler: Arc<EngagementHandler>,
|
||||
trigger_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProductHandler {
|
||||
pub fn new(
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
field_mappings: HashMap<String, serde_json::Value>,
|
||||
config: DefectDojoConfig,
|
||||
engagement_handler: Arc<EngagementHandler>,
|
||||
) -> Self {
|
||||
let field_mapper = FieldMapper::new(field_mappings)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to create field mapper: {}, using empty mapper", e);
|
||||
FieldMapper::new(HashMap::new()).unwrap()
|
||||
})
|
||||
.with_repository(repository.clone());
|
||||
|
||||
let annotation_helper = AnnotationHelper::new("defectdojo");
|
||||
|
||||
Self {
|
||||
client,
|
||||
repository,
|
||||
field_mapper,
|
||||
annotation_helper,
|
||||
config,
|
||||
engagement_handler,
|
||||
trigger_kinds: vec!["Component".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get DefectDojo product ID from entity annotations
|
||||
fn get_product_id(&self, entity: &Entity) -> Option<i32> {
|
||||
self.annotation_helper.get_id(entity, "product")
|
||||
}
|
||||
|
||||
/// Extract name from entity metadata
|
||||
fn extract_name(&self, entity: &Entity) -> String {
|
||||
use charybdis::charybdis::entities::entity::Metadata;
|
||||
match &entity.metadata {
|
||||
Some(Metadata::ComponentMetadata(m)) => m.name.clone(),
|
||||
Some(Metadata::ServiceMetadata(m)) => m.name.clone(),
|
||||
Some(Metadata::SystemMetadata(m)) => m.name.clone(),
|
||||
Some(Metadata::ApiMetadata(m)) => m.name.clone(),
|
||||
Some(Metadata::ResourceMetadata(m)) => m.name.clone(),
|
||||
Some(Metadata::DomainMetadata(m)) => m.name.clone(),
|
||||
Some(Metadata::UserMetadata(m)) => m.name.clone(),
|
||||
Some(Metadata::GroupMetadata(m)) => m.name.clone(),
|
||||
Some(Metadata::FindingMetadata(m)) => m.title.clone(),
|
||||
_ => entity.id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract description from entity metadata
|
||||
fn extract_description(&self, entity: &Entity) -> String {
|
||||
use charybdis::charybdis::entities::entity::Metadata;
|
||||
match &entity.metadata {
|
||||
Some(Metadata::ComponentMetadata(m)) => m.description.clone(),
|
||||
Some(Metadata::ServiceMetadata(m)) => m.description.clone(),
|
||||
Some(Metadata::SystemMetadata(m)) => m.description.clone(),
|
||||
Some(Metadata::ApiMetadata(m)) => m.description.clone(),
|
||||
Some(Metadata::ResourceMetadata(m)) => m.description.clone(),
|
||||
Some(Metadata::DomainMetadata(m)) => m.description.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create product in DefectDojo
|
||||
async fn create_product(&self, entity: &Entity) -> Result<i32> {
|
||||
info!("Creating DefectDojo product for entity: {}", entity.id);
|
||||
|
||||
// Map fields from entity to DefectDojo API format
|
||||
let mapped = self.field_mapper.map_all(entity).await?;
|
||||
|
||||
// Extract name/description from mapped fields or directly from entity metadata
|
||||
let name = mapped.get("name").cloned()
|
||||
.unwrap_or_else(|| json!(self.extract_name(entity)));
|
||||
let description = mapped.get("description").cloned()
|
||||
.unwrap_or_else(|| {
|
||||
let desc = self.extract_description(entity);
|
||||
if desc.is_empty() {
|
||||
json!(format!("Managed by Charybdis (entity: {})", entity.id))
|
||||
} else {
|
||||
json!(desc)
|
||||
}
|
||||
});
|
||||
|
||||
// Build DefectDojo product payload
|
||||
let mut payload = json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"prod_type": mapped.get("product_type_id")
|
||||
.and_then(|v| v.as_i64())
|
||||
.or(self.config.default_product_type_id.map(|id| id as i64))
|
||||
.ok_or_else(|| anyhow!("product_type_id is required"))?,
|
||||
});
|
||||
|
||||
// Add optional fields if present
|
||||
if let Some(tags) = mapped.get("tags") {
|
||||
payload["tags"] = tags.clone();
|
||||
}
|
||||
|
||||
if let Some(bc) = mapped.get("business_criticality") {
|
||||
payload["business_criticality"] = bc.clone();
|
||||
}
|
||||
|
||||
if let Some(platform) = mapped.get("platform") {
|
||||
payload["platform"] = platform.clone();
|
||||
}
|
||||
|
||||
if let Some(lifecycle) = mapped.get("lifecycle") {
|
||||
payload["lifecycle"] = lifecycle.clone();
|
||||
}
|
||||
|
||||
if let Some(origin) = mapped.get("origin") {
|
||||
payload["origin"] = origin.clone();
|
||||
}
|
||||
|
||||
if let Some(user_records) = mapped.get("user_records") {
|
||||
payload["user_records"] = user_records.clone();
|
||||
}
|
||||
|
||||
if let Some(revenue) = mapped.get("revenue") {
|
||||
payload["revenue"] = revenue.clone();
|
||||
}
|
||||
|
||||
if let Some(external_audience) = mapped.get("external_audience") {
|
||||
payload["external_audience"] = external_audience.clone();
|
||||
}
|
||||
|
||||
if let Some(internet_accessible) = mapped.get("internet_accessible") {
|
||||
payload["internet_accessible"] = internet_accessible.clone();
|
||||
}
|
||||
|
||||
if let Some(product_manager) = mapped.get("product_manager") {
|
||||
payload["product_manager"] = product_manager.clone();
|
||||
}
|
||||
|
||||
if let Some(technical_contact) = mapped.get("technical_contact") {
|
||||
payload["technical_contact"] = technical_contact.clone();
|
||||
}
|
||||
|
||||
if let Some(team_manager) = mapped.get("team_manager") {
|
||||
payload["team_manager"] = team_manager.clone();
|
||||
}
|
||||
|
||||
// Create product in DefectDojo
|
||||
let response = self.client.post("api/v2/products/", &payload).await?;
|
||||
|
||||
let product_id = response["id"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| anyhow!("DefectDojo did not return product ID"))?
|
||||
as i32;
|
||||
|
||||
info!(
|
||||
"Created DefectDojo product {} for entity {}",
|
||||
product_id, entity.id
|
||||
);
|
||||
|
||||
Ok(product_id)
|
||||
}
|
||||
|
||||
/// Update product in DefectDojo
|
||||
async fn update_product(&self, entity: &Entity, product_id: i32) -> Result<()> {
|
||||
info!(
|
||||
"Updating DefectDojo product {} for entity {}",
|
||||
product_id, entity.id
|
||||
);
|
||||
|
||||
// Map fields from entity to DefectDojo API format
|
||||
let mapped = self.field_mapper.map_all(entity).await?;
|
||||
|
||||
// Build DefectDojo product payload
|
||||
let mut payload = json!({
|
||||
"name": mapped.get("name").cloned().unwrap_or_else(|| json!(&entity.id)),
|
||||
"description": mapped.get("description").cloned().unwrap_or_else(|| json!("")),
|
||||
});
|
||||
|
||||
// Add optional fields if present
|
||||
if let Some(tags) = mapped.get("tags") {
|
||||
payload["tags"] = tags.clone();
|
||||
}
|
||||
|
||||
if let Some(bc) = mapped.get("business_criticality") {
|
||||
payload["business_criticality"] = bc.clone();
|
||||
}
|
||||
|
||||
if let Some(platform) = mapped.get("platform") {
|
||||
payload["platform"] = platform.clone();
|
||||
}
|
||||
|
||||
if let Some(lifecycle) = mapped.get("lifecycle") {
|
||||
payload["lifecycle"] = lifecycle.clone();
|
||||
}
|
||||
|
||||
if let Some(origin) = mapped.get("origin") {
|
||||
payload["origin"] = origin.clone();
|
||||
}
|
||||
|
||||
if let Some(product_manager) = mapped.get("product_manager") {
|
||||
payload["product_manager"] = product_manager.clone();
|
||||
}
|
||||
|
||||
if let Some(technical_contact) = mapped.get("technical_contact") {
|
||||
payload["technical_contact"] = technical_contact.clone();
|
||||
}
|
||||
|
||||
if let Some(team_manager) = mapped.get("team_manager") {
|
||||
payload["team_manager"] = team_manager.clone();
|
||||
}
|
||||
|
||||
// Update product in DefectDojo
|
||||
self.client
|
||||
.put(&format!("api/v2/products/{}/", product_id), &payload)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Updated DefectDojo product {} for entity {}",
|
||||
product_id, entity.id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete product in DefectDojo
|
||||
async fn delete_product(&self, product_id: i32) -> Result<()> {
|
||||
info!("Deleting DefectDojo product {}", product_id);
|
||||
|
||||
self.client
|
||||
.delete(&format!("api/v2/products/{}/", product_id))
|
||||
.await?;
|
||||
|
||||
info!("Deleted DefectDojo product {}", product_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find a user in DefectDojo by lookup value (email or username depending on config)
|
||||
async fn find_defectdojo_user(&self, lookup_value: &str) -> Result<Option<i32>> {
|
||||
let field = &self.config.owner_resolution.defectdojo_lookup_field;
|
||||
let response = self
|
||||
.client
|
||||
.get(&format!("api/v2/users/?{}={}", field, lookup_value))
|
||||
.await?;
|
||||
|
||||
if let Some(results) = response["results"].as_array() {
|
||||
if let Some(user) = results.first() {
|
||||
if let Some(id) = user["id"].as_i64() {
|
||||
return Ok(Some(id as i32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Resolve a component owner (team name) to DefectDojo user IDs.
|
||||
///
|
||||
/// Resolution chain:
|
||||
/// 1. Find the Group entity in Charybdis matching the owner name
|
||||
/// 2. Get the group's member usernames from GroupSpec
|
||||
/// 3. For each member, find their User entity in Charybdis
|
||||
/// 4. Extract email from the configured annotation (e.g., "keycloak.com/email")
|
||||
/// 5. Look up the user in DefectDojo by that email
|
||||
async fn resolve_owner_to_users(&self, owner: &str) -> Result<Vec<(String, i32)>> {
|
||||
let email_annotation = &self.config.owner_resolution.user_email_annotation;
|
||||
let mut resolved_users: Vec<(String, i32)> = Vec::new();
|
||||
|
||||
// Step 1: Find the Group entity matching the owner name
|
||||
let groups = self.repository.list_by_kind("Group").await?;
|
||||
let group = groups.iter().find(|e| {
|
||||
use charybdis::charybdis::entities::entity::Metadata;
|
||||
match &e.metadata {
|
||||
Some(Metadata::GroupMetadata(m)) => m.name == owner,
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
|
||||
let group = match group {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
warn!(
|
||||
"Owner group '{}' not found in Charybdis — cannot resolve members",
|
||||
owner
|
||||
);
|
||||
return Ok(resolved_users);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Get member usernames from GroupSpec
|
||||
let member_usernames = {
|
||||
use charybdis::charybdis::entities::entity::Spec;
|
||||
match &group.spec {
|
||||
Some(Spec::GroupSpec(s)) => s.members.clone(),
|
||||
_ => {
|
||||
warn!("Group '{}' has no GroupSpec — cannot resolve members", owner);
|
||||
return Ok(resolved_users);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if member_usernames.is_empty() {
|
||||
info!("Group '{}' has no members", owner);
|
||||
return Ok(resolved_users);
|
||||
}
|
||||
|
||||
// Step 3: Find User entities and extract email
|
||||
let users = self.repository.list_by_kind("User").await?;
|
||||
|
||||
for username in &member_usernames {
|
||||
let user_entity = users.iter().find(|e| {
|
||||
use charybdis::charybdis::entities::entity::Metadata;
|
||||
match &e.metadata {
|
||||
Some(Metadata::UserMetadata(m)) => m.name == *username,
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
|
||||
let user_entity = match user_entity {
|
||||
Some(u) => u,
|
||||
None => {
|
||||
warn!(
|
||||
"User '{}' (member of '{}') not found in Charybdis",
|
||||
username, owner
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 4: Get email from the configured annotation
|
||||
let lookup_value = match user_entity.annotations.get(email_annotation) {
|
||||
Some(value) if !value.is_empty() => value.clone(),
|
||||
_ => {
|
||||
warn!(
|
||||
"User '{}' has no '{}' annotation — cannot look up in DefectDojo",
|
||||
username, email_annotation
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 5: Find the user in DefectDojo
|
||||
match self.find_defectdojo_user(&lookup_value).await? {
|
||||
Some(dd_user_id) => {
|
||||
resolved_users.push((username.clone(), dd_user_id));
|
||||
if !self.config.owner_resolution.assign_all_members {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
info!(
|
||||
"User '{}' ({}={}) not found in DefectDojo — they may not have logged in via OIDC yet",
|
||||
username, self.config.owner_resolution.defectdojo_lookup_field, lookup_value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(resolved_users)
|
||||
}
|
||||
|
||||
/// Assign resolved users as product members in DefectDojo
|
||||
async fn assign_owner_to_product(
|
||||
&self,
|
||||
product_id: i32,
|
||||
owner: &str,
|
||||
) -> Result<Vec<i32>> {
|
||||
let resolved = self.resolve_owner_to_users(owner).await?;
|
||||
|
||||
if resolved.is_empty() {
|
||||
info!(
|
||||
"No users resolved for owner '{}' — no product members assigned",
|
||||
owner
|
||||
);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let role_id = self.find_role_id("Owner").await?.unwrap_or(4);
|
||||
let mut member_ids = Vec::new();
|
||||
|
||||
for (username, dd_user_id) in &resolved {
|
||||
// Check if product member already exists
|
||||
let existing = self
|
||||
.client
|
||||
.get(&format!(
|
||||
"api/v2/product_members/?product={}&user={}",
|
||||
product_id, dd_user_id
|
||||
))
|
||||
.await?;
|
||||
|
||||
if let Some(results) = existing["results"].as_array() {
|
||||
if let Some(member) = results.first() {
|
||||
if let Some(id) = member["id"].as_i64() {
|
||||
info!(
|
||||
"'{}' already a member of product {} (member ID: {})",
|
||||
username, product_id, id
|
||||
);
|
||||
member_ids.push(id as i32);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
"product": product_id,
|
||||
"user": dd_user_id,
|
||||
"role": role_id,
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post("api/v2/product_members/", &payload)
|
||||
.await?;
|
||||
|
||||
let member_id = response["id"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| anyhow!("DefectDojo did not return product member ID"))?
|
||||
as i32;
|
||||
|
||||
info!(
|
||||
"Assigned '{}' (DD user {}) to product {} as member {}",
|
||||
username, dd_user_id, product_id, member_id
|
||||
);
|
||||
member_ids.push(member_id);
|
||||
}
|
||||
|
||||
Ok(member_ids)
|
||||
}
|
||||
|
||||
/// Find a role ID by name in DefectDojo
|
||||
async fn find_role_id(&self, role_name: &str) -> Result<Option<i32>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(&format!("api/v2/roles/?name={}", role_name))
|
||||
.await?;
|
||||
|
||||
if let Some(results) = response["results"].as_array() {
|
||||
if let Some(role) = results.first() {
|
||||
if let Some(id) = role["id"].as_i64() {
|
||||
return Ok(Some(id as i32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Extract the owner from a Component entity's spec
|
||||
fn extract_owner(&self, entity: &Entity) -> Option<String> {
|
||||
use charybdis::charybdis::entities::entity;
|
||||
match &entity.spec {
|
||||
Some(entity::Spec::ComponentSpec(s)) if !s.owner.is_empty() => {
|
||||
Some(s.owner.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResourceHandler for ProductHandler {
|
||||
fn resource_type(&self) -> &str {
|
||||
"defectdojo_product"
|
||||
}
|
||||
|
||||
fn trigger_kinds(&self) -> &[String] {
|
||||
// Trigger on Component entities
|
||||
&self.trigger_kinds
|
||||
}
|
||||
|
||||
fn creates_entity_kind(&self) -> &str {
|
||||
// This handler doesn't create new entities, it just syncs to DefectDojo
|
||||
""
|
||||
}
|
||||
|
||||
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
|
||||
// Create product in DefectDojo
|
||||
let product_id = self.create_product(entity).await?;
|
||||
|
||||
// Build annotations to store DefectDojo product ID
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert("defectdojo.com/product-id".to_string(), product_id.to_string());
|
||||
|
||||
// Auto-create engagement if enabled
|
||||
if self.config.default_engagement.auto_create {
|
||||
info!(
|
||||
"Auto-creating engagement for product {} (entity: {})",
|
||||
product_id, entity.id
|
||||
);
|
||||
|
||||
match self
|
||||
.engagement_handler
|
||||
.create_for_product(product_id, entity, &self.config.default_engagement)
|
||||
.await
|
||||
{
|
||||
Ok(engagement_id) => {
|
||||
annotations.insert("defectdojo.com/engagement-id".to_string(), engagement_id.to_string());
|
||||
info!(
|
||||
"Auto-created engagement {} for product {} (entity: {})",
|
||||
engagement_id, product_id, entity.id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to auto-create engagement for product {} (entity: {}): {}",
|
||||
product_id, entity.id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-assign owner team members as product members
|
||||
if let Some(owner) = self.extract_owner(entity) {
|
||||
match self.assign_owner_to_product(product_id, &owner).await {
|
||||
Ok(member_ids) if !member_ids.is_empty() => {
|
||||
let ids_str: Vec<String> = member_ids.iter().map(|id| id.to_string()).collect();
|
||||
annotations.insert(
|
||||
"defectdojo.com/owner-member-ids".to_string(),
|
||||
ids_str.join(","),
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Failed to assign owner '{}' to product {}: {}",
|
||||
owner, product_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update only annotations in Charybdis
|
||||
self.repository.update_annotations(&entity.id, annotations).await?;
|
||||
|
||||
// Don't create a new entity, just return None
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_update(&self, entity: &Entity) -> Result<()> {
|
||||
// Get DefectDojo product ID from annotations
|
||||
if let Some(product_id) = self.get_product_id(entity) {
|
||||
self.update_product(entity, product_id).await?;
|
||||
} else {
|
||||
warn!(
|
||||
"Entity {} has no DefectDojo product ID, creating new product",
|
||||
entity.id
|
||||
);
|
||||
let product_id = self.create_product(entity).await?;
|
||||
|
||||
// Update only annotations with product ID
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert("defectdojo.com/product-id".to_string(), product_id.to_string());
|
||||
self.repository.update_annotations(&entity.id, annotations).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
|
||||
// Get DefectDojo product ID and delete
|
||||
if let Some(product_id) = self.get_product_id(entity) {
|
||||
self.delete_product(product_id).await?;
|
||||
} else {
|
||||
warn!(
|
||||
"Entity {} has no DefectDojo product ID, skipping deletion",
|
||||
entity.id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use charybdis::charybdis::entities::Entity;
|
||||
use charybdis::database::EntityRepository;
|
||||
use charybdis::plugins::{field_mapper::FieldMapper, ResourceHandler};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::DefectDojoClient;
|
||||
|
||||
/// Product Member handler - Maps user-product relationships to DefectDojo Product Members
|
||||
pub struct ProductMemberHandler {
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
field_mapper: FieldMapper,
|
||||
trigger_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProductMemberHandler {
|
||||
pub fn new(
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
field_mappings: HashMap<String, serde_json::Value>,
|
||||
) -> Self {
|
||||
let field_mapper = FieldMapper::new(field_mappings)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to create field mapper: {}, using empty mapper", e);
|
||||
FieldMapper::new(HashMap::new()).unwrap()
|
||||
})
|
||||
.with_repository(repository.clone());
|
||||
|
||||
Self {
|
||||
client,
|
||||
repository,
|
||||
field_mapper,
|
||||
trigger_kinds: vec!["Group".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get DefectDojo product member ID from entity annotations
|
||||
fn get_product_member_id(&self, entity: &Entity) -> Option<i32> {
|
||||
entity
|
||||
.annotations
|
||||
.get("defectdojo.com/product-member-id")
|
||||
.and_then(|id| id.parse().ok())
|
||||
}
|
||||
|
||||
/// Get role ID by name from DefectDojo
|
||||
async fn get_role_id(&self, role_name: &str) -> Result<i32> {
|
||||
let response = self
|
||||
.client
|
||||
.get(&format!("api/v2/roles/?name={}", role_name))
|
||||
.await?;
|
||||
|
||||
if let Some(results) = response["results"].as_array() {
|
||||
if let Some(role) = results.first() {
|
||||
if let Some(id) = role["id"].as_i64() {
|
||||
return Ok(id as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Role {} not found in DefectDojo", role_name))
|
||||
}
|
||||
|
||||
/// Check if product member already exists
|
||||
async fn find_product_member(&self, product_id: i32, user_id: i32) -> Result<Option<i32>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(&format!(
|
||||
"api/v2/product_members/?product={}&user={}",
|
||||
product_id, user_id
|
||||
))
|
||||
.await?;
|
||||
|
||||
if let Some(results) = response["results"].as_array() {
|
||||
if let Some(member) = results.first() {
|
||||
if let Some(id) = member["id"].as_i64() {
|
||||
return Ok(Some(id as i32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Create product member in DefectDojo
|
||||
async fn create_product_member(&self, entity: &Entity) -> Result<i32> {
|
||||
info!(
|
||||
"Creating DefectDojo product member for entity: {}",
|
||||
entity.id
|
||||
);
|
||||
|
||||
// Map fields from entity to DefectDojo API format
|
||||
let mapped = self.field_mapper.map_all(entity).await?;
|
||||
|
||||
// Extract required fields
|
||||
let product_id = mapped
|
||||
.get("product_id")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow!("product_id is required"))? as i32;
|
||||
|
||||
let user_id = mapped
|
||||
.get("user_id")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or_else(|| anyhow!("user_id is required"))? as i32;
|
||||
|
||||
let role_name = mapped
|
||||
.get("role_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Reader"); // Default to Reader role
|
||||
|
||||
// Get role ID
|
||||
let role_id = self.get_role_id(role_name).await?;
|
||||
|
||||
// Check if product member already exists
|
||||
if let Some(existing_id) = self.find_product_member(product_id, user_id).await? {
|
||||
info!(
|
||||
"Product member for product {} and user {} already exists with ID {}",
|
||||
product_id, user_id, existing_id
|
||||
);
|
||||
return Ok(existing_id);
|
||||
}
|
||||
|
||||
// Build DefectDojo product member payload
|
||||
let payload = json!({
|
||||
"product": product_id,
|
||||
"user": user_id,
|
||||
"role": role_id,
|
||||
});
|
||||
|
||||
// Create product member in DefectDojo
|
||||
let response = self
|
||||
.client
|
||||
.post("api/v2/product_members/", &payload)
|
||||
.await?;
|
||||
|
||||
let product_member_id = response["id"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| anyhow!("DefectDojo did not return product member ID"))?
|
||||
as i32;
|
||||
|
||||
info!(
|
||||
"Created DefectDojo product member {} for entity {}",
|
||||
product_member_id, entity.id
|
||||
);
|
||||
|
||||
Ok(product_member_id)
|
||||
}
|
||||
|
||||
/// Update product member in DefectDojo (change role)
|
||||
async fn update_product_member(&self, entity: &Entity, product_member_id: i32) -> Result<()> {
|
||||
info!(
|
||||
"Updating DefectDojo product member {} for entity {}",
|
||||
product_member_id, entity.id
|
||||
);
|
||||
|
||||
// Map fields from entity to DefectDojo API format
|
||||
let mapped = self.field_mapper.map_all(entity).await?;
|
||||
|
||||
// Build DefectDojo product member payload (can only update role)
|
||||
let mut payload = json!({});
|
||||
|
||||
if let Some(role_name) = mapped.get("role_name").and_then(|v| v.as_str()) {
|
||||
let role_id = self.get_role_id(role_name).await?;
|
||||
payload["role"] = json!(role_id);
|
||||
}
|
||||
|
||||
// Update product member in DefectDojo
|
||||
self.client
|
||||
.put(
|
||||
&format!("api/v2/product_members/{}/", product_member_id),
|
||||
&payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Updated DefectDojo product member {} for entity {}",
|
||||
product_member_id, entity.id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete product member in DefectDojo
|
||||
async fn delete_product_member(&self, product_member_id: i32) -> Result<()> {
|
||||
info!("Deleting DefectDojo product member {}", product_member_id);
|
||||
|
||||
self.client
|
||||
.delete(&format!("api/v2/product_members/{}/", product_member_id))
|
||||
.await?;
|
||||
|
||||
info!("Deleted DefectDojo product member {}", product_member_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResourceHandler for ProductMemberHandler {
|
||||
fn resource_type(&self) -> &str {
|
||||
"defectdojo_product_member"
|
||||
}
|
||||
|
||||
fn trigger_kinds(&self) -> &[String] {
|
||||
// Trigger on Group entities with defectdojo.com/product-member annotation
|
||||
&self.trigger_kinds
|
||||
}
|
||||
|
||||
fn creates_entity_kind(&self) -> &str {
|
||||
""
|
||||
}
|
||||
|
||||
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
|
||||
// Create product member in DefectDojo
|
||||
let product_member_id = self.create_product_member(entity).await?;
|
||||
|
||||
// Update only the annotations on the entity
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert(
|
||||
"defectdojo.com/product-member-id".to_string(),
|
||||
product_member_id.to_string(),
|
||||
);
|
||||
self.repository
|
||||
.update_annotations(&entity.id, annotations)
|
||||
.await?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_update(&self, entity: &Entity) -> Result<()> {
|
||||
// Get DefectDojo product member ID from annotations
|
||||
if let Some(product_member_id) = self.get_product_member_id(entity) {
|
||||
self.update_product_member(entity, product_member_id)
|
||||
.await?;
|
||||
} else {
|
||||
warn!(
|
||||
"Entity {} has no DefectDojo product member ID, creating new product member",
|
||||
entity.id
|
||||
);
|
||||
let product_member_id = self.create_product_member(entity).await?;
|
||||
|
||||
// Update only the annotations on the entity
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert(
|
||||
"defectdojo.com/product-member-id".to_string(),
|
||||
product_member_id.to_string(),
|
||||
);
|
||||
self.repository
|
||||
.update_annotations(&entity.id, annotations)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
|
||||
// Get DefectDojo product member ID and delete
|
||||
if let Some(product_member_id) = self.get_product_member_id(entity) {
|
||||
self.delete_product_member(product_member_id).await?;
|
||||
} else {
|
||||
warn!(
|
||||
"Entity {} has no DefectDojo product member ID, skipping deletion",
|
||||
entity.id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use charybdis::charybdis::entities::Entity;
|
||||
use charybdis::database::EntityRepository;
|
||||
use charybdis::plugins::{field_mapper::FieldMapper, ResourceHandler};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::DefectDojoClient;
|
||||
|
||||
/// Product Type handler - Maps custom entities to DefectDojo Product Types
|
||||
pub struct ProductTypeHandler {
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
field_mapper: FieldMapper,
|
||||
trigger_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProductTypeHandler {
|
||||
pub fn new(
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
field_mappings: HashMap<String, serde_json::Value>,
|
||||
) -> Self {
|
||||
let field_mapper = FieldMapper::new(field_mappings)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to create field mapper: {}, using empty mapper", e);
|
||||
FieldMapper::new(HashMap::new()).unwrap()
|
||||
})
|
||||
.with_repository(repository.clone());
|
||||
|
||||
Self {
|
||||
client,
|
||||
repository,
|
||||
field_mapper,
|
||||
trigger_kinds: vec!["System".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get DefectDojo product type ID from entity annotations
|
||||
fn get_product_type_id(&self, entity: &Entity) -> Option<i32> {
|
||||
entity
|
||||
.annotations
|
||||
.get("defectdojo.com/product-type-id")
|
||||
.and_then(|id| id.parse().ok())
|
||||
}
|
||||
|
||||
/// Find existing product type by name in DefectDojo
|
||||
async fn find_product_type_by_name(&self, name: &str) -> Result<Option<i32>> {
|
||||
let response = self
|
||||
.client
|
||||
.get(&format!("api/v2/product_types/?name={}", name))
|
||||
.await?;
|
||||
|
||||
if let Some(results) = response["results"].as_array() {
|
||||
if let Some(product_type) = results.first() {
|
||||
if let Some(id) = product_type["id"].as_i64() {
|
||||
return Ok(Some(id as i32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Create product type in DefectDojo
|
||||
async fn create_product_type(&self, entity: &Entity) -> Result<i32> {
|
||||
info!("Creating DefectDojo product type for entity: {}", entity.id);
|
||||
|
||||
// Map fields from entity to DefectDojo API format
|
||||
let mapped = self.field_mapper.map_all(entity).await?;
|
||||
|
||||
// Extract required fields
|
||||
let name = mapped
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("name is required"))?;
|
||||
|
||||
// Check if product type already exists
|
||||
if let Some(existing_id) = self.find_product_type_by_name(name).await? {
|
||||
info!(
|
||||
"Product type {} already exists in DefectDojo with ID {}",
|
||||
name, existing_id
|
||||
);
|
||||
return Ok(existing_id);
|
||||
}
|
||||
|
||||
// Build DefectDojo product type payload
|
||||
let mut payload = json!({
|
||||
"name": name,
|
||||
});
|
||||
|
||||
// Add optional fields
|
||||
if let Some(description) = mapped.get("description") {
|
||||
payload["description"] = description.clone();
|
||||
}
|
||||
|
||||
if let Some(critical_product) = mapped.get("critical_product") {
|
||||
payload["critical_product"] = critical_product.clone();
|
||||
}
|
||||
|
||||
if let Some(key_product) = mapped.get("key_product") {
|
||||
payload["key_product"] = key_product.clone();
|
||||
}
|
||||
|
||||
// Create product type in DefectDojo
|
||||
let response = self.client.post("api/v2/product_types/", &payload).await?;
|
||||
|
||||
let product_type_id = response["id"]
|
||||
.as_i64()
|
||||
.ok_or_else(|| anyhow!("DefectDojo did not return product type ID"))?
|
||||
as i32;
|
||||
|
||||
info!(
|
||||
"Created DefectDojo product type {} for entity {}",
|
||||
product_type_id, entity.id
|
||||
);
|
||||
|
||||
Ok(product_type_id)
|
||||
}
|
||||
|
||||
/// Update product type in DefectDojo
|
||||
async fn update_product_type(&self, entity: &Entity, product_type_id: i32) -> Result<()> {
|
||||
info!(
|
||||
"Updating DefectDojo product type {} for entity {}",
|
||||
product_type_id, entity.id
|
||||
);
|
||||
|
||||
// Map fields from entity to DefectDojo API format
|
||||
let mapped = self.field_mapper.map_all(entity).await?;
|
||||
|
||||
// Build DefectDojo product type payload
|
||||
let mut payload = json!({});
|
||||
|
||||
if let Some(name) = mapped.get("name") {
|
||||
payload["name"] = name.clone();
|
||||
}
|
||||
|
||||
if let Some(description) = mapped.get("description") {
|
||||
payload["description"] = description.clone();
|
||||
}
|
||||
|
||||
if let Some(critical_product) = mapped.get("critical_product") {
|
||||
payload["critical_product"] = critical_product.clone();
|
||||
}
|
||||
|
||||
if let Some(key_product) = mapped.get("key_product") {
|
||||
payload["key_product"] = key_product.clone();
|
||||
}
|
||||
|
||||
// Update product type in DefectDojo
|
||||
self.client
|
||||
.put(
|
||||
&format!("api/v2/product_types/{}/", product_type_id),
|
||||
&payload,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"Updated DefectDojo product type {} for entity {}",
|
||||
product_type_id, entity.id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete product type in DefectDojo
|
||||
async fn delete_product_type(&self, product_type_id: i32) -> Result<()> {
|
||||
info!("Deleting DefectDojo product type {}", product_type_id);
|
||||
|
||||
self.client
|
||||
.delete(&format!("api/v2/product_types/{}/", product_type_id))
|
||||
.await?;
|
||||
|
||||
info!("Deleted DefectDojo product type {}", product_type_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResourceHandler for ProductTypeHandler {
|
||||
fn resource_type(&self) -> &str {
|
||||
"defectdojo_product_type"
|
||||
}
|
||||
|
||||
fn trigger_kinds(&self) -> &[String] {
|
||||
// Trigger on System entities with specific annotation
|
||||
&self.trigger_kinds
|
||||
}
|
||||
|
||||
fn creates_entity_kind(&self) -> &str {
|
||||
""
|
||||
}
|
||||
|
||||
async fn handle_create(&self, entity: &Entity) -> Result<Option<Entity>> {
|
||||
// Create product type in DefectDojo
|
||||
let product_type_id = self.create_product_type(entity).await?;
|
||||
|
||||
// Update only the annotations on the entity
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert(
|
||||
"defectdojo.com/product-type-id".to_string(),
|
||||
product_type_id.to_string(),
|
||||
);
|
||||
self.repository
|
||||
.update_annotations(&entity.id, annotations)
|
||||
.await?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn handle_update(&self, entity: &Entity) -> Result<()> {
|
||||
// Get DefectDojo product type ID from annotations
|
||||
if let Some(product_type_id) = self.get_product_type_id(entity) {
|
||||
self.update_product_type(entity, product_type_id).await?;
|
||||
} else {
|
||||
warn!(
|
||||
"Entity {} has no DefectDojo product type ID, creating new product type",
|
||||
entity.id
|
||||
);
|
||||
let product_type_id = self.create_product_type(entity).await?;
|
||||
|
||||
// Update only the annotations on the entity
|
||||
let mut annotations = HashMap::new();
|
||||
annotations.insert(
|
||||
"defectdojo.com/product-type-id".to_string(),
|
||||
product_type_id.to_string(),
|
||||
);
|
||||
self.repository
|
||||
.update_annotations(&entity.id, annotations)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_delete(&self, entity: &Entity) -> Result<()> {
|
||||
// Get DefectDojo product type ID and delete
|
||||
if let Some(product_type_id) = self.get_product_type_id(entity) {
|
||||
self.delete_product_type(product_type_id).await?;
|
||||
} else {
|
||||
warn!(
|
||||
"Entity {} has no DefectDojo product type ID, skipping deletion",
|
||||
entity.id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use charybdis::database::EntityRepository;
|
||||
use charybdis::plugins::http_client::{AuthConfig, PluginHttpClient};
|
||||
use charybdis::plugins::{EventDrivenPlugin, Plugin, PluginConfig, PluginType, ResourceHandler};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub mod handlers;
|
||||
mod utils;
|
||||
|
||||
use handlers::*;
|
||||
pub use utils::*;
|
||||
|
||||
/// Engagement auto-creation configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EngagementConfig {
|
||||
/// Auto-create default engagement when product is created
|
||||
#[serde(default = "default_auto_create")]
|
||||
pub auto_create: bool,
|
||||
|
||||
/// Engagement name
|
||||
#[serde(default = "default_engagement_name")]
|
||||
pub name: String,
|
||||
|
||||
/// Engagement description
|
||||
#[serde(default = "default_engagement_description")]
|
||||
pub description: String,
|
||||
|
||||
/// Engagement type (e.g., "CI/CD", "Interactive")
|
||||
#[serde(default = "default_engagement_type")]
|
||||
pub engagement_type: String,
|
||||
|
||||
/// Status (e.g., "In Progress", "Completed")
|
||||
#[serde(default = "default_engagement_status")]
|
||||
pub status: String,
|
||||
|
||||
/// Duration in days
|
||||
#[serde(default = "default_duration_days")]
|
||||
pub duration_days: i64,
|
||||
|
||||
/// Enable deduplication on engagement
|
||||
#[serde(default = "default_deduplication")]
|
||||
pub deduplication_on_engagement: bool,
|
||||
}
|
||||
|
||||
// Default value functions
|
||||
fn default_auto_create() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_engagement_name() -> String {
|
||||
"CI/CD Scans".to_string()
|
||||
}
|
||||
fn default_engagement_description() -> String {
|
||||
"Automated security scans from CI/CD pipeline".to_string()
|
||||
}
|
||||
fn default_engagement_type() -> String {
|
||||
"CI/CD".to_string()
|
||||
}
|
||||
fn default_engagement_status() -> String {
|
||||
"In Progress".to_string()
|
||||
}
|
||||
fn default_duration_days() -> i64 {
|
||||
365
|
||||
}
|
||||
fn default_deduplication() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for EngagementConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_create: default_auto_create(),
|
||||
name: default_engagement_name(),
|
||||
description: default_engagement_description(),
|
||||
engagement_type: default_engagement_type(),
|
||||
status: default_engagement_status(),
|
||||
duration_days: default_duration_days(),
|
||||
deduplication_on_engagement: default_deduplication(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DefectDojo plugin configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DefectDojoConfig {
|
||||
/// DefectDojo API base URL
|
||||
pub base_url: String,
|
||||
|
||||
/// API token for authentication
|
||||
pub api_token: String,
|
||||
|
||||
/// Default product type ID (optional)
|
||||
pub default_product_type_id: Option<i32>,
|
||||
|
||||
/// Auto-create users when needed
|
||||
#[serde(default)]
|
||||
pub auto_create_users: bool,
|
||||
|
||||
/// Auto-create product types when needed
|
||||
#[serde(default)]
|
||||
pub auto_create_product_types: bool,
|
||||
|
||||
/// Default engagement configuration
|
||||
#[serde(default)]
|
||||
pub default_engagement: EngagementConfig,
|
||||
|
||||
/// Owner resolution configuration
|
||||
#[serde(default)]
|
||||
pub owner_resolution: OwnerResolutionConfig,
|
||||
|
||||
/// Field mappings for each resource type
|
||||
#[serde(default)]
|
||||
pub field_mappings: HashMap<String, HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
/// Configuration for resolving component owners to DefectDojo users.
|
||||
///
|
||||
/// When a Component has an `owner` field (typically a team name), this config
|
||||
/// controls how Charybdis resolves that to individual users in DefectDojo.
|
||||
///
|
||||
/// The resolution chain is:
|
||||
/// 1. Find the Group entity matching the owner name
|
||||
/// 2. Get the group's members (usernames)
|
||||
/// 3. Find each member's User entity
|
||||
/// 4. Extract the user's email from the configured annotation
|
||||
/// 5. Look up the user in DefectDojo by email (as OIDC providers use email as login)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OwnerResolutionConfig {
|
||||
/// Entity annotation key that holds the user's email address.
|
||||
/// This depends on your identity provider:
|
||||
/// - Keycloak: "keycloak.com/email"
|
||||
/// - Okta: "okta.com/email"
|
||||
/// - Azure AD: "azuread.com/email"
|
||||
/// - Generic: "user.email"
|
||||
#[serde(default = "default_user_email_annotation")]
|
||||
pub user_email_annotation: String,
|
||||
|
||||
/// How to search for users in DefectDojo.
|
||||
/// "email" (default) — searches by email field (for OIDC providers)
|
||||
/// "username" — searches by username field
|
||||
#[serde(default = "default_defectdojo_lookup_field")]
|
||||
pub defectdojo_lookup_field: String,
|
||||
|
||||
/// Whether to assign all group members as product members (true)
|
||||
/// or only the first found user (false, default)
|
||||
#[serde(default)]
|
||||
pub assign_all_members: bool,
|
||||
}
|
||||
|
||||
fn default_user_email_annotation() -> String {
|
||||
"keycloak.com/email".to_string()
|
||||
}
|
||||
|
||||
fn default_defectdojo_lookup_field() -> String {
|
||||
"email".to_string()
|
||||
}
|
||||
|
||||
impl Default for OwnerResolutionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
user_email_annotation: default_user_email_annotation(),
|
||||
defectdojo_lookup_field: default_defectdojo_lookup_field(),
|
||||
assign_all_members: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DefectDojoConfig {
|
||||
/// Load DefectDojo configuration from TOML config map
|
||||
pub fn from_toml(config: &HashMap<String, toml::Value>) -> Result<Self> {
|
||||
// Convert HashMap to TOML Value
|
||||
let value = toml::Value::Table(config.clone().into_iter().collect());
|
||||
|
||||
// Deserialize to DefectDojoConfig
|
||||
let config: DefectDojoConfig = value
|
||||
.try_into()
|
||||
.map_err(|e| anyhow!("Failed to parse DefectDojo configuration: {}", e))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// DefectDojo API client (wrapper around PluginHttpClient)
|
||||
#[derive(Clone)]
|
||||
pub struct DefectDojoClient {
|
||||
client: PluginHttpClient,
|
||||
}
|
||||
|
||||
impl DefectDojoClient {
|
||||
pub fn new(base_url: String, api_token: String) -> Result<Self> {
|
||||
let client = PluginHttpClient::new(base_url, AuthConfig::token(api_token))?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Make a GET request to DefectDojo API
|
||||
pub async fn get(&self, path: &str) -> Result<serde_json::Value> {
|
||||
self.client.get(path).await
|
||||
}
|
||||
|
||||
/// Make a POST request to DefectDojo API
|
||||
pub async fn post(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
self.client.post(path, body).await
|
||||
}
|
||||
|
||||
/// Make a PUT request to DefectDojo API
|
||||
pub async fn put(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
self.client.put(path, body).await
|
||||
}
|
||||
|
||||
/// Make a DELETE request to DefectDojo API
|
||||
pub async fn delete(&self, path: &str) -> Result<()> {
|
||||
self.client.delete(path).await
|
||||
}
|
||||
}
|
||||
|
||||
/// DefectDojo event-driven plugin
|
||||
#[allow(dead_code)]
|
||||
pub struct DefectDojoPlugin {
|
||||
config: DefectDojoConfig,
|
||||
client: DefectDojoClient,
|
||||
repository: Arc<EntityRepository>,
|
||||
handlers: Vec<Arc<dyn ResourceHandler>>,
|
||||
}
|
||||
|
||||
impl DefectDojoPlugin {
|
||||
pub fn new(config: DefectDojoConfig, repository: Arc<EntityRepository>) -> Result<Self> {
|
||||
// Validate configuration
|
||||
if config.base_url.is_empty() {
|
||||
return Err(anyhow!("DefectDojo base_url cannot be empty"));
|
||||
}
|
||||
if config.api_token.is_empty() {
|
||||
return Err(anyhow!("DefectDojo api_token cannot be empty"));
|
||||
}
|
||||
|
||||
let client = DefectDojoClient::new(config.base_url.clone(), config.api_token.clone())?;
|
||||
|
||||
// Create all resource handlers
|
||||
let mut handlers: Vec<Arc<dyn ResourceHandler>> = vec![];
|
||||
|
||||
// Create engagement handler first (needed by ProductHandler)
|
||||
let engagement_handler = Arc::new(EngagementHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
config
|
||||
.field_mappings
|
||||
.get("engagement")
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
|
||||
// Product handler (needs engagement_handler for auto-creating engagements)
|
||||
handlers.push(Arc::new(ProductHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
config
|
||||
.field_mappings
|
||||
.get("product")
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
config.clone(),
|
||||
engagement_handler.clone(),
|
||||
)));
|
||||
|
||||
// Product Type handler
|
||||
handlers.push(Arc::new(ProductTypeHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
config
|
||||
.field_mappings
|
||||
.get("product_type")
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)));
|
||||
|
||||
// Product Member handler
|
||||
handlers.push(Arc::new(ProductMemberHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
config
|
||||
.field_mappings
|
||||
.get("product_member")
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
)));
|
||||
|
||||
// Add engagement handler to handlers list
|
||||
handlers.push(engagement_handler);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
client,
|
||||
repository,
|
||||
handlers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Plugin for DefectDojoPlugin {
|
||||
fn name(&self) -> &str {
|
||||
"defectdojo"
|
||||
}
|
||||
|
||||
fn plugin_type(&self) -> PluginType {
|
||||
PluginType::EventDriven
|
||||
}
|
||||
|
||||
fn load_config(&mut self, _config: PluginConfig) -> Result<()> {
|
||||
// Configuration is loaded during construction
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_config(&self) -> Result<()> {
|
||||
// Validate DefectDojo configuration
|
||||
if self.config.base_url.is_empty() {
|
||||
return Err(anyhow!("DefectDojo base_url cannot be empty"));
|
||||
}
|
||||
if self.config.api_token.is_empty() {
|
||||
return Err(anyhow!("DefectDojo api_token cannot be empty"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventDrivenPlugin for DefectDojoPlugin {
|
||||
fn resource_handlers(&self) -> Vec<Arc<dyn ResourceHandler>> {
|
||||
self.handlers.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Shared utilities for DefectDojo plugin
|
||||
//!
|
||||
//! This module provides common functionality used across all DefectDojo handlers
|
||||
//! to reduce code duplication and improve maintainability.
|
||||
|
||||
use anyhow::Result;
|
||||
use charybdis::charybdis::entities::Entity;
|
||||
use charybdis::database::EntityRepository;
|
||||
use charybdis::plugins::field_mapper::FieldMapper;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
// Annotation keys used by DefectDojo plugin
|
||||
pub const ANNOTATION_PRODUCT_ID: &str = "defectdojo.com/product-id";
|
||||
pub const ANNOTATION_USER_ID: &str = "defectdojo.com/user-id";
|
||||
pub const ANNOTATION_PRODUCT_TYPE_ID: &str = "defectdojo.com/product-type-id";
|
||||
pub const ANNOTATION_PRODUCT_MEMBER_ID: &str = "defectdojo.com/product-member-id";
|
||||
pub const ANNOTATION_ENGAGEMENT_ID: &str = "defectdojo.com/engagement-id";
|
||||
|
||||
/// Gets DefectDojo resource ID from entity annotations.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `entity` - The entity to extract the ID from
|
||||
/// * `resource_type` - The resource type (e.g., "product", "user", "product-type")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Some(id)` if the annotation exists and can be parsed as i32, `None` otherwise.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let product_id = get_defectdojo_id(&entity, "product");
|
||||
/// ```
|
||||
pub fn get_defectdojo_id(entity: &Entity, resource_type: &str) -> Option<i32> {
|
||||
entity
|
||||
.annotations
|
||||
.get(&format!("defectdojo.com/{}-id", resource_type))
|
||||
.and_then(|id| id.parse().ok())
|
||||
}
|
||||
|
||||
/// Creates a field mapper with standard error handling.
|
||||
///
|
||||
/// If field mapper creation fails, logs a warning and returns an empty mapper.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `field_mappings` - Field mapping configuration
|
||||
/// * `repository` - Entity repository for entity resolution
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a configured FieldMapper with repository attached.
|
||||
pub fn create_field_mapper(
|
||||
field_mappings: HashMap<String, serde_json::Value>,
|
||||
repository: Arc<EntityRepository>,
|
||||
) -> FieldMapper {
|
||||
FieldMapper::new(field_mappings)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to create field mapper: {}, using empty mapper", e);
|
||||
FieldMapper::new(HashMap::new()).unwrap()
|
||||
})
|
||||
.with_repository(repository)
|
||||
}
|
||||
|
||||
/// Updates an entity with a DefectDojo resource ID annotation.
|
||||
///
|
||||
/// Creates a clone of the entity, adds the annotation, and updates it in the repository.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `entity` - The entity to update
|
||||
/// * `resource_type` - The resource type (e.g., "product", "user")
|
||||
/// * `defectdojo_id` - The DefectDojo resource ID to store
|
||||
/// * `repository` - Entity repository to persist the update
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the repository update fails.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// update_defectdojo_annotation(&entity, "product", 123, &repository).await?;
|
||||
/// ```
|
||||
pub async fn update_defectdojo_annotation(
|
||||
entity: &Entity,
|
||||
resource_type: &str,
|
||||
defectdojo_id: i32,
|
||||
repository: &EntityRepository,
|
||||
) -> Result<()> {
|
||||
let mut updated_entity = entity.clone();
|
||||
updated_entity.annotations.insert(
|
||||
format!("defectdojo.com/{}-id", resource_type),
|
||||
defectdojo_id.to_string(),
|
||||
);
|
||||
repository.update(&entity.id, &updated_entity).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Macro to add optional fields from mapped values to a JSON payload.
|
||||
///
|
||||
/// This macro reduces repetitive `if let Some` patterns when building API payloads.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut payload = json!({"name": "test"});
|
||||
/// add_optional_fields!(
|
||||
/// payload,
|
||||
/// mapped,
|
||||
/// "tags",
|
||||
/// "description",
|
||||
/// "business_criticality"
|
||||
/// );
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! add_optional_fields {
|
||||
($payload:expr, $mapped:expr, $($field:expr),+ $(,)?) => {
|
||||
$(
|
||||
if let Some(value) = $mapped.get($field) {
|
||||
$payload[$field] = value.clone();
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_entity(id: &str) -> Entity {
|
||||
Entity {
|
||||
id: id.to_string(),
|
||||
kind: "Component".to_string(),
|
||||
metadata: None,
|
||||
spec: None,
|
||||
annotations: HashMap::new(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_defectdojo_id_exists() {
|
||||
let mut entity = create_test_entity("test-1");
|
||||
entity
|
||||
.annotations
|
||||
.insert("defectdojo.com/product-id".to_string(), "123".to_string());
|
||||
|
||||
let id = get_defectdojo_id(&entity, "product");
|
||||
assert_eq!(id, Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_defectdojo_id_missing() {
|
||||
let entity = create_test_entity("test-1");
|
||||
let id = get_defectdojo_id(&entity, "product");
|
||||
assert_eq!(id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_defectdojo_id_invalid_format() {
|
||||
let mut entity = create_test_entity("test-1");
|
||||
entity.annotations.insert(
|
||||
"defectdojo.com/product-id".to_string(),
|
||||
"invalid".to_string(),
|
||||
);
|
||||
|
||||
let id = get_defectdojo_id(&entity, "product");
|
||||
assert_eq!(id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_field_mapper_empty() {
|
||||
// Note: This test requires mocking or a test database
|
||||
// Keeping it simple for now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
//! Integration tests for DefectDojo plugin using wiremock
|
||||
//!
|
||||
//! These tests require a running PostgreSQL instance.
|
||||
//! Set DATABASE_URL environment variable to run them.
|
||||
//! They are ignored by default in CI unless DATABASE_URL is set.
|
||||
|
||||
use charybdis::charybdis::entities::entity::{Metadata, Spec};
|
||||
use charybdis::charybdis::entities::Entity;
|
||||
use charybdis::database::{ensure_schema, EntityRepository};
|
||||
use charybdis::plugins::ResourceHandler;
|
||||
use charybdis_defectdojo::{DefectDojoClient, DefectDojoConfig, EngagementConfig, OwnerResolutionConfig};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
async fn setup_db() -> PgPool {
|
||||
let url = std::env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL must be set for integration tests");
|
||||
let pool = PgPool::connect(&url).await.expect("Failed to connect to test database");
|
||||
ensure_schema(&pool).await.expect("Failed to create schema");
|
||||
|
||||
// Clean up from previous test runs
|
||||
sqlx::query("DELETE FROM entities")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("Failed to clean entities table");
|
||||
|
||||
pool
|
||||
}
|
||||
|
||||
fn make_component_entity(id: &str, name: &str, owner: &str) -> Entity {
|
||||
Entity {
|
||||
id: id.to_string(),
|
||||
kind: "Component".to_string(),
|
||||
annotations: HashMap::new(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
metadata: Some(Metadata::ComponentMetadata(
|
||||
charybdis::charybdis::core::ComponentMetadata {
|
||||
name: name.to_string(),
|
||||
namespace: "default".to_string(),
|
||||
description: format!("{} service", name),
|
||||
labels: HashMap::new(),
|
||||
tags: vec![],
|
||||
links: vec![],
|
||||
},
|
||||
)),
|
||||
spec: Some(Spec::ComponentSpec(
|
||||
charybdis::charybdis::core::ComponentSpec {
|
||||
r#type: "service".to_string(),
|
||||
lifecycle: "production".to_string(),
|
||||
owner: owner.to_string(),
|
||||
system: String::new(),
|
||||
subcomponent_of: String::new(),
|
||||
depends_on: vec![],
|
||||
provides_apis: vec![],
|
||||
consumes_apis: vec![],
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_config(base_url: &str) -> DefectDojoConfig {
|
||||
DefectDojoConfig {
|
||||
base_url: base_url.to_string(),
|
||||
api_token: "test-token".to_string(),
|
||||
default_product_type_id: Some(1),
|
||||
auto_create_users: false,
|
||||
auto_create_product_types: false,
|
||||
default_engagement: EngagementConfig::default(),
|
||||
owner_resolution: OwnerResolutionConfig::default(),
|
||||
field_mappings: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Product creation
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires DATABASE_URL
|
||||
async fn test_product_creation_on_component_create() {
|
||||
let pool = setup_db().await;
|
||||
let repository = Arc::new(EntityRepository::new(pool.clone()));
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Mock: POST /api/v2/products/ → returns product with id=42
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/products/"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
|
||||
"id": 42,
|
||||
"name": "payment-api",
|
||||
"description": "payment-api service",
|
||||
"prod_type": 1
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock: POST /api/v2/engagements/ → returns engagement with id=100
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/engagements/"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
|
||||
"id": 100,
|
||||
"name": "CI/CD Scans",
|
||||
"product": 42
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let mut config = make_config(&mock_server.uri());
|
||||
config.default_engagement.auto_create = true;
|
||||
// Disable owner resolution for this test (no owner group exists)
|
||||
config.owner_resolution.assign_all_members = false;
|
||||
|
||||
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
|
||||
let engagement_handler = Arc::new(
|
||||
charybdis_defectdojo::handlers::EngagementHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
),
|
||||
);
|
||||
|
||||
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
|
||||
client,
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
config,
|
||||
engagement_handler,
|
||||
);
|
||||
|
||||
// Create the entity in the database first (returns entity with real UUID)
|
||||
let entity = make_component_entity("", "payment-api", "team-payments");
|
||||
let entity = repository.create(&entity).await.expect("Failed to create entity");
|
||||
|
||||
// Trigger handler
|
||||
let result = handler.handle_create(&entity).await;
|
||||
assert!(result.is_ok(), "handle_create failed: {:?}", result.err());
|
||||
|
||||
// Verify annotations were saved
|
||||
let updated = repository.get_by_id(&entity.id).await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
updated.annotations.get("defectdojo.com/product-id"),
|
||||
Some(&"42".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
updated.annotations.get("defectdojo.com/engagement-id"),
|
||||
Some(&"100".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Product update
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires DATABASE_URL
|
||||
async fn test_product_update_with_existing_product_id() {
|
||||
let pool = setup_db().await;
|
||||
let repository = Arc::new(EntityRepository::new(pool.clone()));
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Mock: PUT /api/v2/products/42/ → success
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/api/v2/products/42/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"id": 42,
|
||||
"name": "payment-api-updated"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = make_config(&mock_server.uri());
|
||||
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
|
||||
let engagement_handler = Arc::new(
|
||||
charybdis_defectdojo::handlers::EngagementHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
),
|
||||
);
|
||||
|
||||
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
|
||||
client,
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
config,
|
||||
engagement_handler,
|
||||
);
|
||||
|
||||
// Create entity with existing product-id annotation
|
||||
let mut entity = make_component_entity("", "payment-api", "team-payments");
|
||||
entity.annotations.insert(
|
||||
"defectdojo.com/product-id".to_string(),
|
||||
"42".to_string(),
|
||||
);
|
||||
let entity = repository.create(&entity).await.expect("Failed to create entity");
|
||||
|
||||
// Trigger update handler
|
||||
let result = handler.handle_update(&entity).await;
|
||||
assert!(result.is_ok(), "handle_update failed: {:?}", result.err());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Product deletion
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires DATABASE_URL
|
||||
async fn test_product_deletion() {
|
||||
let pool = setup_db().await;
|
||||
let repository = Arc::new(EntityRepository::new(pool.clone()));
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Mock: DELETE /api/v2/products/42/ → 204 No Content
|
||||
Mock::given(method("DELETE"))
|
||||
.and(path("/api/v2/products/42/"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = make_config(&mock_server.uri());
|
||||
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
|
||||
let engagement_handler = Arc::new(
|
||||
charybdis_defectdojo::handlers::EngagementHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
),
|
||||
);
|
||||
|
||||
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
|
||||
client,
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
config,
|
||||
engagement_handler,
|
||||
);
|
||||
|
||||
// Entity with product-id annotation
|
||||
let mut entity = make_component_entity("", "payment-api", "team-payments");
|
||||
entity.annotations.insert(
|
||||
"defectdojo.com/product-id".to_string(),
|
||||
"42".to_string(),
|
||||
);
|
||||
let entity = repository.create(&entity).await.expect("Failed to create entity");
|
||||
|
||||
// Trigger delete handler
|
||||
let result = handler.handle_delete(&entity).await;
|
||||
assert!(result.is_ok(), "handle_delete failed: {:?}", result.err());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Owner resolution with product member assignment
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires DATABASE_URL
|
||||
async fn test_owner_resolution_assigns_product_members() {
|
||||
let pool = setup_db().await;
|
||||
let repository = Arc::new(EntityRepository::new(pool.clone()));
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Mock: POST /api/v2/products/ → product id=10
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/products/"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
|
||||
"id": 10,
|
||||
"name": "orders-api",
|
||||
"prod_type": 1
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock: POST /api/v2/engagements/ → engagement id=20
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/engagements/"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
|
||||
"id": 20,
|
||||
"name": "CI/CD Scans",
|
||||
"product": 10
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock: GET /api/v2/users/?email=alice@example.com → found user id=5
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/users/"))
|
||||
.and(query_param("email", "alice@example.com"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"count": 1,
|
||||
"results": [{"id": 5, "username": "alice@example.com"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock: GET /api/v2/users/?email=bob@example.com → found user id=7
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/users/"))
|
||||
.and(query_param("email", "bob@example.com"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"count": 1,
|
||||
"results": [{"id": 7, "username": "bob@example.com"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock: GET /api/v2/roles/?name=Owner → role id=4
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/roles/"))
|
||||
.and(query_param("name", "Owner"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"count": 1,
|
||||
"results": [{"id": 4, "name": "Owner"}]
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock: GET /api/v2/product_members/?product=10&user=5 → not yet a member
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/product_members/"))
|
||||
.and(query_param("product", "10"))
|
||||
.and(query_param("user", "5"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"count": 0,
|
||||
"results": []
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock: GET /api/v2/product_members/?product=10&user=7 → not yet a member
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v2/product_members/"))
|
||||
.and(query_param("product", "10"))
|
||||
.and(query_param("user", "7"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"count": 0,
|
||||
"results": []
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Mock: POST /api/v2/product_members/ → member created (called twice)
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/product_members/"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
|
||||
"id": 99,
|
||||
"product": 10,
|
||||
"user": 5,
|
||||
"role": 4
|
||||
})))
|
||||
.expect(2)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// Set up config with owner resolution enabled
|
||||
let mut config = make_config(&mock_server.uri());
|
||||
config.default_engagement.auto_create = true;
|
||||
config.owner_resolution.user_email_annotation = "keycloak.com/email".to_string();
|
||||
config.owner_resolution.defectdojo_lookup_field = "email".to_string();
|
||||
config.owner_resolution.assign_all_members = true;
|
||||
|
||||
// Create Group "backend-team" with members alice and bob
|
||||
let group_entity = Entity {
|
||||
id: "group-backend".to_string(),
|
||||
kind: "Group".to_string(),
|
||||
annotations: HashMap::new(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
metadata: Some(Metadata::GroupMetadata(
|
||||
charybdis::charybdis::core::GroupMetadata {
|
||||
name: "backend-team".to_string(),
|
||||
namespace: "default".to_string(),
|
||||
description: "Backend team".to_string(),
|
||||
labels: HashMap::new(),
|
||||
tags: vec![],
|
||||
links: vec![],
|
||||
},
|
||||
)),
|
||||
spec: Some(Spec::GroupSpec(charybdis::charybdis::core::GroupSpec {
|
||||
r#type: "team".to_string(),
|
||||
profile: None,
|
||||
parent: String::new(),
|
||||
children: vec![],
|
||||
members: vec!["alice".to_string(), "bob".to_string()],
|
||||
})),
|
||||
};
|
||||
repository.create(&group_entity).await.unwrap();
|
||||
|
||||
// Create User "alice" with keycloak email annotation
|
||||
let mut alice = Entity {
|
||||
id: "user-alice".to_string(),
|
||||
kind: "User".to_string(),
|
||||
annotations: HashMap::new(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
metadata: Some(Metadata::UserMetadata(
|
||||
charybdis::charybdis::core::UserMetadata {
|
||||
name: "alice".to_string(),
|
||||
namespace: "default".to_string(),
|
||||
description: "Alice".to_string(),
|
||||
labels: HashMap::new(),
|
||||
tags: vec![],
|
||||
links: vec![],
|
||||
},
|
||||
)),
|
||||
spec: Some(Spec::UserSpec(charybdis::charybdis::core::UserSpec {
|
||||
profile: None,
|
||||
member_of: vec!["backend-team".to_string()],
|
||||
})),
|
||||
};
|
||||
alice.annotations.insert("keycloak.com/email".to_string(), "alice@example.com".to_string());
|
||||
repository.create(&alice).await.unwrap();
|
||||
|
||||
// Create User "bob" with keycloak email annotation
|
||||
let mut bob = Entity {
|
||||
id: "user-bob".to_string(),
|
||||
kind: "User".to_string(),
|
||||
annotations: HashMap::new(),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
metadata: Some(Metadata::UserMetadata(
|
||||
charybdis::charybdis::core::UserMetadata {
|
||||
name: "bob".to_string(),
|
||||
namespace: "default".to_string(),
|
||||
description: "Bob".to_string(),
|
||||
labels: HashMap::new(),
|
||||
tags: vec![],
|
||||
links: vec![],
|
||||
},
|
||||
)),
|
||||
spec: Some(Spec::UserSpec(charybdis::charybdis::core::UserSpec {
|
||||
profile: None,
|
||||
member_of: vec!["backend-team".to_string()],
|
||||
})),
|
||||
};
|
||||
bob.annotations.insert("keycloak.com/email".to_string(), "bob@example.com".to_string());
|
||||
repository.create(&bob).await.unwrap();
|
||||
|
||||
// Create handlers
|
||||
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
|
||||
let engagement_handler = Arc::new(
|
||||
charybdis_defectdojo::handlers::EngagementHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
),
|
||||
);
|
||||
|
||||
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
|
||||
client,
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
config,
|
||||
engagement_handler,
|
||||
);
|
||||
|
||||
// Create component owned by "backend-team"
|
||||
let component = make_component_entity("", "orders-api", "backend-team");
|
||||
let component = repository.create(&component).await.unwrap();
|
||||
|
||||
// Trigger handler
|
||||
let result = handler.handle_create(&component).await;
|
||||
assert!(result.is_ok(), "handle_create failed: {:?}", result.err());
|
||||
|
||||
// Verify annotations
|
||||
let updated = repository.get_by_id(&component.id).await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
updated.annotations.get("defectdojo.com/product-id"),
|
||||
Some(&"10".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
updated.annotations.get("defectdojo.com/engagement-id"),
|
||||
Some(&"20".to_string())
|
||||
);
|
||||
// Owner member IDs should be set
|
||||
assert!(
|
||||
updated.annotations.contains_key("defectdojo.com/owner-member-ids"),
|
||||
"Expected owner-member-ids annotation"
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Engagement auto-creation disabled
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires DATABASE_URL
|
||||
async fn test_product_creation_without_engagement() {
|
||||
let pool = setup_db().await;
|
||||
let repository = Arc::new(EntityRepository::new(pool.clone()));
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Mock: POST /api/v2/products/ → product id=55
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/products/"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
|
||||
"id": 55,
|
||||
"name": "simple-service",
|
||||
"prod_type": 1
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// No engagement mock — should NOT be called
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/engagements/"))
|
||||
.respond_with(ResponseTemplate::new(201).set_body_json(json!({"id": 999})))
|
||||
.expect(0) // MUST NOT be called
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let mut config = make_config(&mock_server.uri());
|
||||
config.default_engagement.auto_create = false;
|
||||
|
||||
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
|
||||
let engagement_handler = Arc::new(
|
||||
charybdis_defectdojo::handlers::EngagementHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
),
|
||||
);
|
||||
|
||||
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
|
||||
client,
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
config,
|
||||
engagement_handler,
|
||||
);
|
||||
|
||||
let entity = make_component_entity("", "simple-service", "");
|
||||
let entity = repository.create(&entity).await.unwrap();
|
||||
|
||||
let result = handler.handle_create(&entity).await;
|
||||
assert!(result.is_ok(), "handle_create failed: {:?}", result.err());
|
||||
|
||||
// Verify only product-id annotation (no engagement-id)
|
||||
let updated = repository.get_by_id(&entity.id).await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
updated.annotations.get("defectdojo.com/product-id"),
|
||||
Some(&"55".to_string())
|
||||
);
|
||||
assert!(!updated.annotations.contains_key("defectdojo.com/engagement-id"));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// DefectDojo API error handling
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires DATABASE_URL
|
||||
async fn test_product_creation_handles_api_error() {
|
||||
let pool = setup_db().await;
|
||||
let repository = Arc::new(EntityRepository::new(pool.clone()));
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
// Mock: POST /api/v2/products/ → 400 Bad Request
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/v2/products/"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400)
|
||||
.set_body_json(json!({"name": ["This field may not be blank."]})),
|
||||
)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = make_config(&mock_server.uri());
|
||||
let client = DefectDojoClient::new(mock_server.uri(), "test-token".to_string()).unwrap();
|
||||
let engagement_handler = Arc::new(
|
||||
charybdis_defectdojo::handlers::EngagementHandler::new(
|
||||
client.clone(),
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
),
|
||||
);
|
||||
|
||||
let handler = charybdis_defectdojo::handlers::ProductHandler::new(
|
||||
client,
|
||||
repository.clone(),
|
||||
HashMap::new(),
|
||||
config,
|
||||
engagement_handler,
|
||||
);
|
||||
|
||||
let entity = make_component_entity("", "bad-entity", "team-x");
|
||||
let entity = repository.create(&entity).await.unwrap();
|
||||
|
||||
let result = handler.handle_create(&entity).await;
|
||||
assert!(result.is_err(), "Expected error on 400 response");
|
||||
}
|
||||
Reference in New Issue
Block a user