From 051a080dfae4ed19ac4817168fcfad6e5c407e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20GRAB=C3=89?= Date: Tue, 12 May 2026 17:06:43 +0200 Subject: [PATCH] initial-commit --- .dockerignore | 13 + .env.example | 50 + .github/workflows/ci.yml | 76 + .gitignore | 28 + CONTRIBUTING.md | 102 + Cargo.lock | 4434 +++++++++++++++++ Cargo.toml | 89 + Charybdis.png | Bin 0 -> 5343 bytes Dockerfile | 72 + README.md | 248 + TODO.md | 67 + VISION.md | 249 + build.rs | 222 + charybdis-server/Cargo.toml | 22 + charybdis-server/build.rs | 7 + charybdis-server/src/main.rs | 419 ++ config.toml | 78 + config.toml.example | 267 + deploy/DEMO.md | 297 ++ deploy/backstage/README.md | 102 + deploy/backstage/app-config.yaml | 92 + deploy/demo.sh | 222 + deploy/docker-compose.demo.yml | 233 + deploy/docker-compose.telemetry.yml | 78 + deploy/scripts/generate-client-cert.sh | 125 + deploy/scripts/generate-dev-certs.sh | 134 + deploy/scripts/keycloak/charybdis-realm.json | 82 + docs/PLUGIN_CONFIGURATION_GUIDE.md | 1009 ++++ docs/architecture.md | 985 ++++ docs/core-concepts.md | 628 +++ docs/getting-started.md | 508 ++ docs/index.md | 125 + docs/security.md | 503 ++ plugins.toml | 56 + plugins/README.md | 340 ++ plugins/defectdojo/Cargo.toml | 24 + plugins/defectdojo/README.md | 301 ++ plugins/defectdojo/proto/defectdojo.proto | 25 + plugins/defectdojo/src/handlers/engagement.rs | 351 ++ plugins/defectdojo/src/handlers/mod.rs | 9 + plugins/defectdojo/src/handlers/product.rs | 594 +++ .../defectdojo/src/handlers/product_member.rs | 272 + .../defectdojo/src/handlers/product_type.rs | 253 + plugins/defectdojo/src/lib.rs | 332 ++ plugins/defectdojo/src/utils.rs | 182 + plugins/defectdojo/tests/integration_tests.rs | 607 +++ plugins/dependencytrack/Cargo.toml | 22 + .../proto/dependencytrack.proto | 23 + plugins/dependencytrack/src/handlers/mod.rs | 3 + .../dependencytrack/src/handlers/project.rs | 172 + plugins/dependencytrack/src/lib.rs | 162 + plugins/keycloak/Cargo.toml | 24 + plugins/keycloak/proto/keycloak.proto | 27 + plugins/keycloak/src/lib.rs | 355 ++ plugins/keycloak/src/sync.rs | 545 ++ proto/core/api.proto | 50 + proto/core/common.proto | 19 + proto/core/component.proto | 55 + proto/core/domain.proto | 34 + proto/core/finding.proto | 114 + proto/core/group.proto | 60 + proto/core/resource.proto | 44 + proto/core/service.proto | 55 + proto/core/system.proto | 37 + proto/core/user.proto | 49 + proto/entities.proto | 200 + proto/entities.proto.template | 191 + proto/ingestion.proto | 127 + src/adapters/mod.rs | 1 + src/adapters/yaml/backstage.rs | 540 ++ src/adapters/yaml/mod.rs | 154 + src/config.rs | 325 ++ src/database.rs | 1112 +++++ src/error.rs | 60 + src/events/backends/memory.rs | 240 + src/events/backends/mod.rs | 13 + src/events/bus.rs | 138 + src/events/handler.rs | 25 + src/events/mod.rs | 14 + src/events/types.rs | 85 + src/findings/fingerprint.rs | 132 + src/findings/ingestion.rs | 246 + src/findings/mod.rs | 3 + src/findings/reconciler.rs | 362 ++ src/lib.rs | 396 ++ src/main.rs | 337 ++ src/plugins/annotations.rs | 377 ++ src/plugins/date_utils.rs | 236 + src/plugins/dispatcher.rs | 252 + src/plugins/field_mapper.rs | 727 +++ src/plugins/http_client.rs | 424 ++ src/plugins/manager.rs | 265 + src/plugins/mod.rs | 138 + src/plugins/sync_scheduler.rs | 220 + src/scanners/mod.rs | 90 + src/scanners/sarif.rs | 323 ++ src/security/audit.rs | 97 + src/security/config.rs | 175 + src/security/identity.rs | 122 + src/security/interceptor.rs | 244 + src/security/middleware.rs | 72 + src/security/mod.rs | 13 + src/security/rbac.rs | 290 ++ src/security/tls.rs | 157 + src/telemetry/config.rs | 131 + src/telemetry/metrics.rs | 136 + src/telemetry/mod.rs | 7 + src/telemetry/setup.rs | 184 + tests/entity_crud.rs | 203 + tests/harness.rs | 31 + 110 files changed, 26377 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Charybdis.png create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 TODO.md create mode 100644 VISION.md create mode 100644 build.rs create mode 100644 charybdis-server/Cargo.toml create mode 100644 charybdis-server/build.rs create mode 100644 charybdis-server/src/main.rs create mode 100644 config.toml create mode 100644 config.toml.example create mode 100644 deploy/DEMO.md create mode 100644 deploy/backstage/README.md create mode 100644 deploy/backstage/app-config.yaml create mode 100755 deploy/demo.sh create mode 100644 deploy/docker-compose.demo.yml create mode 100644 deploy/docker-compose.telemetry.yml create mode 100755 deploy/scripts/generate-client-cert.sh create mode 100755 deploy/scripts/generate-dev-certs.sh create mode 100644 deploy/scripts/keycloak/charybdis-realm.json create mode 100644 docs/PLUGIN_CONFIGURATION_GUIDE.md create mode 100644 docs/architecture.md create mode 100644 docs/core-concepts.md create mode 100644 docs/getting-started.md create mode 100644 docs/index.md create mode 100644 docs/security.md create mode 100644 plugins.toml create mode 100644 plugins/README.md create mode 100644 plugins/defectdojo/Cargo.toml create mode 100644 plugins/defectdojo/README.md create mode 100644 plugins/defectdojo/proto/defectdojo.proto create mode 100644 plugins/defectdojo/src/handlers/engagement.rs create mode 100644 plugins/defectdojo/src/handlers/mod.rs create mode 100644 plugins/defectdojo/src/handlers/product.rs create mode 100644 plugins/defectdojo/src/handlers/product_member.rs create mode 100644 plugins/defectdojo/src/handlers/product_type.rs create mode 100644 plugins/defectdojo/src/lib.rs create mode 100644 plugins/defectdojo/src/utils.rs create mode 100644 plugins/defectdojo/tests/integration_tests.rs create mode 100644 plugins/dependencytrack/Cargo.toml create mode 100644 plugins/dependencytrack/proto/dependencytrack.proto create mode 100644 plugins/dependencytrack/src/handlers/mod.rs create mode 100644 plugins/dependencytrack/src/handlers/project.rs create mode 100644 plugins/dependencytrack/src/lib.rs create mode 100644 plugins/keycloak/Cargo.toml create mode 100644 plugins/keycloak/proto/keycloak.proto create mode 100644 plugins/keycloak/src/lib.rs create mode 100644 plugins/keycloak/src/sync.rs create mode 100644 proto/core/api.proto create mode 100644 proto/core/common.proto create mode 100644 proto/core/component.proto create mode 100644 proto/core/domain.proto create mode 100644 proto/core/finding.proto create mode 100644 proto/core/group.proto create mode 100644 proto/core/resource.proto create mode 100644 proto/core/service.proto create mode 100644 proto/core/system.proto create mode 100644 proto/core/user.proto create mode 100644 proto/entities.proto create mode 100644 proto/entities.proto.template create mode 100644 proto/ingestion.proto create mode 100644 src/adapters/mod.rs create mode 100644 src/adapters/yaml/backstage.rs create mode 100644 src/adapters/yaml/mod.rs create mode 100644 src/config.rs create mode 100644 src/database.rs create mode 100644 src/error.rs create mode 100644 src/events/backends/memory.rs create mode 100644 src/events/backends/mod.rs create mode 100644 src/events/bus.rs create mode 100644 src/events/handler.rs create mode 100644 src/events/mod.rs create mode 100644 src/events/types.rs create mode 100644 src/findings/fingerprint.rs create mode 100644 src/findings/ingestion.rs create mode 100644 src/findings/mod.rs create mode 100644 src/findings/reconciler.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/plugins/annotations.rs create mode 100644 src/plugins/date_utils.rs create mode 100644 src/plugins/dispatcher.rs create mode 100644 src/plugins/field_mapper.rs create mode 100644 src/plugins/http_client.rs create mode 100644 src/plugins/manager.rs create mode 100644 src/plugins/mod.rs create mode 100644 src/plugins/sync_scheduler.rs create mode 100644 src/scanners/mod.rs create mode 100644 src/scanners/sarif.rs create mode 100644 src/security/audit.rs create mode 100644 src/security/config.rs create mode 100644 src/security/identity.rs create mode 100644 src/security/interceptor.rs create mode 100644 src/security/middleware.rs create mode 100644 src/security/mod.rs create mode 100644 src/security/rbac.rs create mode 100644 src/security/tls.rs create mode 100644 src/telemetry/config.rs create mode 100644 src/telemetry/metrics.rs create mode 100644 src/telemetry/mod.rs create mode 100644 src/telemetry/setup.rs create mode 100644 tests/entity_crud.rs create mode 100644 tests/harness.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5a1f7fb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +target/ +.git/ +.github/ +.env +*.md +docs/ +.vscode/ +.idea/ +*.log +.DS_Store +docker-compose*.yml +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5f02c32 --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Charybdis Environment Variables +# ================================ +# +# PREFERRED: Use config.toml for configuration (copy from config.toml.example) +# Environment variables are used ONLY when referenced in config.toml with ${VAR_NAME} +# or when config.toml is not present (legacy mode) +# +# See config.toml.example for the recommended configuration approach + +# Database Connection (Required) +# =============================== +# Used in config.toml as: url = "${DATABASE_URL}" +DATABASE_URL=postgresql://charybdis:CHANGE_ME@localhost:5432/charybdis + +# Logging Configuration +# ===================== +RUST_LOG=info,charybdis=debug + +# API Keys and Secrets (for plugins) +# =================================== +# These should NEVER be stored in config.toml +# Reference them in config.toml using ${VARIABLE_NAME} + +# DefectDojo API Key (if using DefectDojo plugin) +# DEFECTDOJO_API_KEY=your-secret-api-key + +# Dependency-Track API Key (if using Dependency-Track plugin) +# DEPENDENCYTRACK_API_KEY=your-secret-api-key + +# LEGACY: Direct Environment Variable Configuration +# ================================================== +# The following variables are only used if config.toml is not found +# We recommend using config.toml instead for better organization + +# Server Configuration +# GRPC_HOST=[::1] +# GRPC_PORT=50051 + +# OpenTelemetry +# OTEL_SERVICE_NAME=charybdis +# OTEL_SERVICE_VERSION=0.1.0 +# OTEL_ENABLE_CONSOLE=true +# ENVIRONMENT=development + +# Security (mTLS + RBAC) +# SECURITY_MTLS_ENABLED=false +# SECURITY_MTLS_SERVER_CERT=./certs/server-cert.pem +# SECURITY_MTLS_SERVER_KEY=./certs/server-key.pem +# SECURITY_MTLS_CLIENT_CA=./certs/ca.pem +# SECURITY_RBAC_ENABLED=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..666a7b0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + version: "25.x" + - run: cargo check --workspace + + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + version: "25.x" + - run: cargo clippy --workspace -- -D warnings + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + version: "25.x" + - run: cargo test --workspace + + build: + name: Build Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + version: "25.x" + - run: cargo build --workspace --release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e6f7a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Build +/target + +# Environment +.env + +# Development certificates (never commit) +/certs/ +*.pem +*.csr +*.srl + +# Data +/.entities/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dd3c28a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing to Charybdis + +Contributions are welcome! Whether it's a bug report, feature idea, documentation improvement, or code contribution. + +## Getting Started + +### Prerequisites + +- Rust stable (1.79+) +- PostgreSQL 14+ +- protoc (Protocol Buffers compiler) + +### Development Setup + +```bash +# Clone the repo +git clone https://github.com/YOUR-ORG/charybdis.git +cd charybdis + +# Copy config +cp config.toml.example config.toml +cp .env.example .env +# Edit .env with your PostgreSQL credentials + +# Build +cargo build --workspace + +# Run tests +cargo test -p charybdis + +# Run the server +cargo run -p charybdis-server +``` + +### Project Structure + +``` +src/ Core library (catalog, findings, scanners, security) +charybdis-server/ Deployable binary (links core + plugins) +plugins/ External integrations (DefectDojo, Keycloak, etc.) +proto/ Protocol Buffer definitions +``` + +## How to Contribute + +### Reporting Bugs + +Open an issue with: +- What you expected to happen +- What actually happened +- Steps to reproduce +- Rust version (`rustc --version`) and OS + +### Proposing Features + +Open an issue describing: +- The problem you're trying to solve +- Your proposed approach +- Any alternatives you considered + +### Submitting Code + +1. Fork the repo and create a branch from `main` +2. Make your changes +3. Ensure `cargo fmt`, `cargo clippy`, and `cargo test` pass +4. Write a clear commit message explaining the *why* +5. Open a PR against `main` + +### Adding a Scanner Parser + +To add support for a new scan format: + +1. Create `src/scanners/your_format.rs` +2. Implement the `ScannerParser` trait +3. Register it in `ParserRegistry::with_builtins()` (or via plugin `contributed_parsers()`) +4. Add tests with sample data + +```rust +pub struct YourFormatParser; + +impl ScannerParser for YourFormatParser { + fn format_id(&self) -> &str { "your-format" } + fn description(&self) -> &str { "Description of the format" } + fn parse(&self, data: &[u8]) -> Result { + // Parse and normalize findings + } +} +``` + +## Code Style + +- Run `cargo fmt` before committing +- Run `cargo clippy` and address warnings +- No comments unless the *why* is non-obvious +- Prefer exhaustive matches over wildcards for proto enums + +## Architecture Decisions + +- Security features belong in `src/` (core), not in `plugins/` +- Plugins are for external integrations (Slack, Jira, DefectDojo sync) +- All API is gRPC-first, other interfaces are adapters +- Database schema never changes (protobuf handles evolution) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..409fd34 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4434 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "107a4e9d9cab9963e04e84bb8dee0e25f2a987f9a8bad5ed054abd439caa8f8c" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core 0.5.5", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bollard" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.47.1-rc.27.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "charybdis" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.7.9", + "base64", + "chrono", + "cron", + "hex", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "prost", + "prost-build", + "prost-types", + "regex", + "reqwest", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "sqlx", + "testcontainers", + "testcontainers-modules", + "thiserror", + "tokio", + "tokio-cron-scheduler", + "tokio-rustls", + "toml", + "tonic", + "tonic-build", + "tonic-prost", + "tonic-prost-build", + "tonic-reflection", + "tower", + "tower-http", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "uuid", + "wildmatch", + "x509-parser", +] + +[[package]] +name = "charybdis-defectdojo-plugin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "charybdis", + "reqwest", + "serde", + "serde_json", + "sqlx", + "tokio", + "tokio-test", + "toml", + "tracing", + "wiremock", +] + +[[package]] +name = "charybdis-dependencytrack-plugin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "charybdis", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-test", + "toml", + "tracing", +] + +[[package]] +name = "charybdis-keycloak-plugin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "charybdis", + "chrono", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-test", + "toml", + "tracing", + "uuid", +] + +[[package]] +name = "charybdis-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "charybdis", + "charybdis-defectdojo-plugin", + "charybdis-dependencytrack-plugin", + "charybdis-keycloak-plugin", + "rustls", + "tokio", + "tonic", + "tonic-reflection", + "tracing", +] + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "cron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" +dependencies = [ + "chrono", + "nom", + "once_cell", +] + +[[package]] +name = "croner" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843" +dependencies = [ + "chrono", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "docker_credential" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4564c274ebf369f501de192b02a0b81a5c4bda375abfe526aa70fc702fa6fa0" +dependencies = [ + "base64", + "serde", + "serde_json", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.9.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.3", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown 0.15.3", + "serde", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.176" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.11.1", + "libc", + "redox_syscall 0.5.17", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.59.0", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.2", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.17", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.9.0", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dee91521343f4c5c6a63edd65e54f31f5c92fe8978c40a4282f8372194c6a7d" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags 2.11.1", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5b6a0769a491a08b31ea5c62494a8f144ee0987d86d670a8af4df1e1b7cde75" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.9.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.9.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.3", + "hashlink", + "indexmap 2.9.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags 2.11.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags 2.11.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "testcontainers" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a4f01f39bb10fc2a5ab23eb0d888b1e2bb168c157f61a1b98e6c501c639c74" +dependencies = [ + "async-trait", + "bollard", + "bollard-stubs", + "bytes", + "docker_credential", + "either", + "etcetera", + "futures", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tokio", + "tokio-stream", + "tokio-tar", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d43ed4e8f58424c3a2c6c56dbea6643c3c23e8666a34df13c54f0a184e6c707" +dependencies = [ + "testcontainers", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-cron-scheduler" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5597b569b4712cf78aa0c9ae29742461b7bda1e49c2a5fdad1d79bf022f8f0" +dependencies = [ + "chrono", + "croner", + "num-derive", + "num-traits", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tar" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5714c010ca3e5c27114c1cdeb9d14641ace49874aa5626d7149e47aedace75" +dependencies = [ + "filetime", + "futures-core", + "libc", + "redox_syscall 0.3.5", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +dependencies = [ + "async-trait", + "axum 0.8.6", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-reflection" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34da53e8387581d66db16ff01f98a70b426b091fdf76856e289d5c1bd386ed7b" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.9.0", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6e5658463dd88089aba75c7791e1d3120633b1bfde22478b28f625a9bb1b8e" +dependencies = [ + "js-sys", + "opentelemetry", + "opentelemetry_sdk", + "rustversion", + "smallvec", + "thiserror", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +dependencies = [ + "redox_syscall 0.5.17", + "wasite", +] + +[[package]] +name = "wildmatch" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b7d07a236abaef6607536ccfaf19b396dbe3f5110ddb73d39f4562902ed382" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.0", + "windows-strings 0.5.0", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "x509-parser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..74bc130 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,89 @@ +[workspace] +members = [ + ".", + "plugins/defectdojo", + "plugins/dependencytrack", + "plugins/keycloak", + "charybdis-server", +] +default-members = ["charybdis-server"] + +[package] +name = "charybdis" +version = "0.1.0" +edition = "2024" +description = "Security-native platform engineering tool — software catalog, vulnerability management, and compliance in one binary" +authors = ["Guillaume GRABÉ"] +keywords = ["platform-engineering", "security", "vulnerability-management", "devsecops", "catalog"] +categories = ["development-tools"] + +[dependencies] +prost = "0.14" +prost-types = "0.14" +tonic = { version = "0.14.2", features = ["tls-ring", "tls-connect-info"] } +tonic-prost = "0.14" +tokio = { version = "1.48", features = ["full"] } +uuid = { version = "1.18", features = [ + "v4", + "serde", +] } # Used for generating placeholder IDs +tonic-reflection = "0.14" # Required for gRPC reflection (used by tools like grpcurl) +sqlx = { version = "0.8.6", features = [ + "runtime-tokio-rustls", + "postgres", + "uuid", + "chrono", + "json", +] } +chrono = { version = "0.4.42", features = ["serde"] } +anyhow = "1.0.100" +serde = "1.0.228" +serde_json = "1.0.145" +serde_yaml = "0.9" +async-trait = "0.1" +thiserror = "2.0" +toml = "0.8" +regex = "1.10" + +# HTTP server for YAML adapter +axum = "0.7" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } + +# Plugin system - scheduling and cron +tokio-cron-scheduler = "0.13" +cron = "0.12" + +# Findings - fingerprint hashing +sha2 = "0.10" +hex = "0.4" + +# HTTP client for plugins +reqwest = { version = "0.12", features = ["json"] } + +# Security - mTLS and RBAC +x509-parser = "0.18" +rustls = "0.23" +rustls-pemfile = "2.2" +tokio-rustls = "0.26" +wildmatch = "2.5" +base64 = "0.22" + +# OpenTelemetry - Observability stack +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-opentelemetry = "0.32" +opentelemetry = { version = "0.31", features = ["trace", "metrics", "logs"] } +opentelemetry_sdk = { version = "0.31", features = ["rt-tokio", "trace", "metrics", "logs"] } +opentelemetry-otlp = { version = "0.31", features = ["trace", "metrics", "logs", "grpc-tonic"] } +opentelemetry-semantic-conventions = "0.31" + + +[build-dependencies] +prost-build = "0.14" +tonic-build = "0.14" +tonic-prost-build = "0.14" + +[dev-dependencies] +testcontainers = "0.23" +testcontainers-modules = { version = "0.11", features = ["postgres"] } diff --git a/Charybdis.png b/Charybdis.png new file mode 100644 index 0000000000000000000000000000000000000000..92b4a58468e260f4072eefdb48c690134a1a5f16 GIT binary patch literal 5343 zcmYjVc{r5a{~nBGhQvH$sc#EodOS!GqU_@tW|(2dQnJKQqQ&w?q0KUrneob?Wg3bZ zWeYE5YbC~(*Dgwyj8sabRVjY+e&6eN{eIVVpYyrz`+TnJ{B_Q`&ds5@yR1R4M>fydB~CKh(J_lD*s~0OSMiY$&-pe zB!UP;5)*;=DYcT`A`o#V2*kT!1Y$QIfzXQ1yGOT?E}&=>Z=$sBHNX4w=WjFnMzU1M z#Cp1WAsk*m3;RcexVgXxw){rYKkd<^ea8`qZF>I@vqBO2r#y2LcanxvmA;Xf8B&9edQMel+Fx61N z>Ejo0L7e$cw#W+AjVgl-30|p&Wm$a~N9;j3Tv))B#nMO zbvTf)nwZY-H~8r6j}2SK^VJis!POQ|)*F&UqjqFb2cG`DI`60kmfq#W!8&7R9FhWU z*tpWVEjpjPA2>@gz-fjC>g$9;b0L(NUh)u0z*M}^J^f?_$yty4S$;ViX0pSMyv0u$ zi>BJR&+)g3=HthlFvs@TtUly#(lWaYAI@e|WVaW-&A~VOpWjQ5)y*0TLIe%jV77CX zqB!|Z{i*P$4v8_Aak6N(@AzLzg|t`_hWJ`1dc?9F#&8H`UQq=llaWJ$%F_s90_N-M zs^Y>DcS4tVf2l$N`>ypqa&uJZd@%MiiMi!|DFoL3NBxJ(%TiG_X8&EIq#a>ET&RwabY5HVIU57(6$ zaE!2*Ff40SE^5*MAEhyH`)g9&2G10DKGm@Y8qG?|b@+pQesS5GMOVS8e;K%OM|Pt| z{drXQA>8q(xVQIf!U{|BzyL=vc76z!$NFN(5zU{RjpMfGn!90xF$Un!`UKEb)N}&G zT<@Zj8ZLjVS?UDu6{A?hpUtFo(w2eCumQFd>@#*LN7W`8FZZNSuy4(EJMrawHZF;I z{^^0Shy3xxrbap$pJe&!D=gyyFuuqS(5|Grs}3`XDsM12bs*Q=tid~5S@ATnhvnT#1)TDGgoJXKpYGd04C`TFqNnW zu9jQ{6Bies`{3-fgj+K9=)r=b{oyW)~v;<<;=gV$#Mo&s&i9aA60x4zw>YWpztxdc%B9DbedqE@?eCgl|0GQ9*l_o49 zhisSA*~6xRd7IsDtImdEh-}nm%yRvi7tE+sS-xr!CpBe?iuDuU%xZnFX1s+bc6RMir+fXMBSs^`vqcgGr2p=aXxt>F{5eQ%%^ikvo(-u^fGiV~SR37KsoM$GFj1lYn^OoaG;NXt z74h!&71Xj(S+T$&d+DyuBil-)fGsTr&_T(o(TL^?lH0)=gW~N#gK7I-m+Bj+_kWfB zz@r6A&mO;j&?3tqI8mWC=IL4=5`f(b@^t9?03_rK(xdu}>4_+hMU`Vi%) z)??39XTU$L8%{Q(Kbp15OL}Bd9rsjG)CZYxLTCc3z)MTZ(;#sF;3pULzId!#@$5VSPYQFL#C8t-X_FY(A1S{@bDk`*J2GL)mY|wnGUB74TmBivw*aN!)Qt%I` zV=5yj-}HE#sLa#qQfJ+>+$2xQyY_p|==aIx*kDOnKrq%k#`F6rBhH~+@50s@qW@_D zG3z}fpEVy-1FqbL_qzO#v0YZ?et~MYEAq-H)Ky(sktVk}Rd3xqUS}jiUr$in| za}xy}B2HY5#n{s*mN9WF_ZfdPp3_)d3mFIcUF#S#>l+c2OkRR+u7J1(mKFj1rW z4%^(OGOu+_GKVa6Ybnp(IqGsm*{G#l`01x(1_xx%v~IoYe1jMBI4_K|cY%>% zUKJ4c>PL?$>+}E)^ld~E?NyPe?F_U}a!TUzGjLBqx+c*c$aTx#2Yu5z)L4h^7yo$b zzp|y7S$et$I-21U2z2Dfb?7d}$(zTV{V;M(^&Ifp=pz*y1F9;7Rt7fo_H*h{Nrxw4 z(@t@x(2O#7$#8RPFp&Q=pQlgLie8NMlm~MCWv2BntS&EjZgqc>e+BvohD?s?U6aI4 zhI@(})>?;4<|@gm+T9eK4|aXENPs7o8cW=T<&`nlS6H%8!Wrm`B6&Kc2$h z(n8;72()*el!x9;x%{Gb)S@PfzX%GFE)M9^|2A8E)4ErmvHQtZVEvx=ab(1W!Ety@ zZ(@epRGA4@9(I4tN-XFw)!i6&Q6LPDB821g=zU1)n6P6G>yp+{Z46LNN@P@OB;~2)U8MXqoKCB zQuP*p1KviiCpb@|yeK?XtNxJ7VKw^H!McTnHI1^ColGbna<183NKuog{(A8yKDAh& z!!43t6L}QuFV4%eh0kI@?MhAx7>*kf@6*qL!lg7N!zIT(YzqK80_Oew%q-XpV0rKM zR-tYfp_c~CRNrdga$1tpz>z<;10#(;yq?3sqPO2(X1YY2w7*qL39sXn**5P6Us(Wg zX((Gtc>-vdxiQF8JuT#I>Wne9+zx!_Vb%{b( zu2CvhSw@ATQjR8vaAOO4I_CC0>8o0^G*Y3;MFOALsH`HW?5b~9g_H1RmOI=5JBywfkzf0M9%<~n18$%XU_u@mD#}&6j;ON z{L<*c-3skFsLdb%#32(IV^%+N$V8ZcE8{m^3VqWQ6CsKgmDDajsfD~K;g{K-U`-0j zAe4{;9@H6l`5u z`G5GdlR$L@4o`=Am>mz`{vISkeg{3+J;rN~CGajhq+$scKITXkJYL>*{L(kQ9Niz$ z98Sp4$S@K50_~Y&9mhcggk?$E8}&}Dsm{5o3Ap3SIXFyf=DG9V@J?E`c56$%QV_Ha z!{hR(a#XjuEUG#MfScjO2=Py3S+9x9gdO&?qXa$DT47AP`ylSto7npqs+;7U~#R9y^51w+|P|T@a zuTR%-#-D$VvFkRMam7YLJ;lK4ug|l{ta#_CB01@842cKD?pL<;!jXceEoepTC$i*L zXz&i<8fv+ZAw9mpDrnOS3*lau%vtvX#i#<*foHVS005VKl)v#`)_YW!-OeSMH$GJgX*^%-M`ocY! zdC&nG*ax@2G>?gryzOq9_Acp!7tN)Q%LTNB#l{)(*hw>wbLbeg^oMRSy6Pda>9UK7 zuQ%Z%so}rP84$UIge?S!u;e&?R zAbb*Y0cyJ|{R_;HeErtep02y59Onvn_A;%x1|~bZb|u3q+?;nI0+K#s2O}}0(Cc~0qGk-IF1V3e%GQNtedWO0yf za1@4ymG{lNoQnTN50_UDBn?@}I16WS^JIu@CF}!n0i+HUj9VXM?tvtW3)L#xCG#~M zC+~LaO{Ee5*p~#6kp)V@F=uvXlboiWG)EgMVs>OO_0am7MR$0}O{`vPKT5Ga=L8hJ z?eF#p>A{n3ga|;W-v6CYe9Q01Hm;k-$TkN>JSQ8firRzCE@3v`ko_W1%zwn*JM5nh zJ#ZqWKwE7A8&pDy*c2UMOpwcggD_ND(s|>oBd3qpG70y%eNFiOTsQ%H)+^8zV}v}W?@9#^8WYd1tzj^RZUYT4#BV9{wTXn}+%KEkF!K}eK?;d`9cV@#WpTq8f zq-Hr*!^EE{>5;uW_TJ<~UJmZCsAKZ;=3VuVH0ghP>0%u<8t99Du&FzX2Unr5sz~VS zC$Kv-#4UJ)Arr?I*(f*3SG)<$Mhn?Hpu<&a*-(*aM0qQOb7j>iII7!0n1CjGF@C40^vN{DYXDtT5v z6@;F6?`MccMwv?ksQ-gBP5WOdjn)43rKC`Zp4fIumsCQ@{+ zR35gFW^z&-RFgZ_Cp#@xe9!)WIo`i src/main.rs && \ + mkdir -p charybdis-server/src && \ + echo "fn main() {}" > charybdis-server/src/main.rs && \ + mkdir -p plugins/defectdojo/src && \ + echo "fn main() {}" > plugins/defectdojo/src/lib.rs && \ + mkdir -p plugins/dependencytrack/src && \ + echo "fn main() {}" > plugins/dependencytrack/src/lib.rs && \ + mkdir -p plugins/keycloak/src && \ + echo "fn main() {}" > plugins/keycloak/src/lib.rs + +# Build dependencies (cached layer) +RUN cargo build --release && \ + rm -rf src charybdis-server/src plugins/defectdojo/src plugins/dependencytrack/src plugins/keycloak/src + +# Copy source code +COPY . . + +# Build real application +RUN cargo build --release --bin charybdis + +# Runtime stage +FROM debian:bookworm-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/target/release/charybdis /usr/local/bin/charybdis + +# Copy config template +COPY config.toml.example /app/config.toml.example + +# Create non-root user +RUN useradd -m -u 1000 charybdis && \ + chown -R charybdis:charybdis /app + +USER charybdis + +# Expose ports +EXPOSE 50051 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD test -f /tmp/charybdis.pid || exit 1 + +ENTRYPOINT ["charybdis"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..11e2cfe --- /dev/null +++ b/README.md @@ -0,0 +1,248 @@ +# Charybdis + +**The security-native platform engineering tool.** + +One platform for your software catalog, vulnerability management, and compliance posture. Event-driven. Single binary. No YAML files to maintain. + +## Why Charybdis? + +Platform engineers today run Backstage for the catalog, DefectDojo for vulnerabilities, Dependency-Track for SBOMs, a license scanner, and a compliance spreadsheet. Five tools, five sources of truth, none of them talking to each other. + +Charybdis unifies this: + +- **Software Catalog** — Event-driven, gRPC-native. Services register from CI/CD or IaC. No static YAML, no polling, always accurate. +- **Vulnerability Management** — Ingest scan results natively (SARIF, CycloneDX, SPDX). Triage, assess, track. No external vuln tool needed. +- **Compliance & Licenses** — Security gates, license policies, compliance framework mappings. Built-in, not bolted on. + +All of this in a **single Rust binary** that uses ~50MB of RAM. + +## How It Works + +``` +CI/CD Pipeline ──gRPC──> Charybdis +Scanner results ───────> ├── Catalogs the service (event-driven) +IaC tools ─────────────> ├── Ingests vulnerabilities natively + ├── Evaluates security gates + └── Fires events to plugins (Slack, Jira, ...) +``` + +**One platform. Your services are cataloged. Your vulns are tracked. Your compliance is visible. In real-time.** + +## Quick Start + +```bash +# 1. Start PostgreSQL +docker run -d \ + -e POSTGRES_PASSWORD=mysecretpassword \ + -p 5432:5432 \ + postgres:15 + +# 2. Configure +cp config.toml.example config.toml +echo 'DATABASE_URL=postgresql://postgres:mysecretpassword@localhost:5432/postgres' > .env + +# 3. Run +cargo run + +# 4. Register your first service +grpcurl -plaintext -d '{ + "entity": { + "kind": "Component", + "component_metadata": { + "name": "payment-api", + "description": "Payment processing service", + "tags": ["api", "critical"] + }, + "component_spec": { + "type": "service", + "lifecycle": "production", + "owner": "team-payments" + } + } +}' localhost:50051 charybdis.entities.EntityService/CreateEntity +``` + +The entity is stored, events are fired, and plugins react automatically. + +## Key Features + +### Software Catalog +- **gRPC API** for programmatic entity management from CI/CD, scripts, or any tool +- **Event-driven plugins** auto-provision external tools when entities change +- **Zero-migration storage** — PostgreSQL with protobuf + JSONB. No schema changes, ever +- **Rich entity model** — Component, System, API, User, Group, Domain, Resource +- **Backstage compatible** — Built-in YAML adapter for migration or coexistence + +### Security (Native — Phase 1) +Security is a first-class concept in Charybdis, not a plugin. These features are under active development: +- **Vulnerability ingestion** — Push scan results via gRPC (SARIF, CycloneDX, SPDX) +- **Assessment workflow** — Triage, accept risk, remediate, auto-assess via rules +- **Security gates** — Severity thresholds per product, block deployments on violations +- **License compliance** — Track licenses, enforce policies, flag violations +- **Compliance frameworks** — Map vulnerabilities to NIS2, SOC2, DORA requirements + +### Platform +- **mTLS + RBAC** — Certificate-based auth with fine-grained permissions +- **OpenTelemetry** — Full observability (traces, metrics, logs) out of the box +- **Single binary** — Deploy one Rust binary + PostgreSQL. That's it. +- **Sub-millisecond latency** — Tested at 170k+ entities + +## Architecture + +```mermaid +graph TB + subgraph "Sources" + A1[CI/CD Pipelines] + A2[IaC Tools] + A3[Security Scanners] + end + + subgraph "Charybdis Core" + B1[gRPC API] + B2[Software Catalog] + B3[Vuln Management] + B4[Security Gates] + B5[Event Bus] + end + + subgraph "Integrations - Plugins" + C1[Slack / Teams] + C2[Jira / GitHub Issues] + C3[Custom Plugins] + end + + subgraph "Frontend" + D2[Backstage - optional] + end + + A1 -->|gRPC| B1 + A2 -->|gRPC| B1 + A3 -->|Scan Results| B1 + B1 --> B2 + B1 --> B3 + B3 --> B4 + B2 --> B5 + B3 --> B5 + B5 --> C1 + B5 --> C2 + B5 --> C3 + B2 -.->|YAML Adapter| D2 +``` + +## Entity Model + +| Kind | Description | Example | +|------|-------------|---------| +| **Service** | Individual microservices or applications | `payment-api`, `auth-service` | +| **Component** | Reusable libraries, SDKs, modules | `auth-sdk`, `logging-lib` | +| **System** | Collections of components working together | `payment-system` | +| **API** | Interfaces exposed by components | `payments-rest-api` | +| **User** | Individual people | `john.doe` | +| **Group** | Teams and organizational units | `team-payments` | +| **Domain** | Business domains | `payments`, `shipping` | +| **Resource** | Infrastructure resources | `payments-db`, `cache-cluster` | + +Each entity will carry its security posture natively once Phase 1 is complete: vulnerabilities, license status, compliance state, and assessment history. + +## Plugin System + +Core features (catalog, vulns, compliance) are **native**. Plugins handle **integrations** with external systems: + +``` +EntityCreated ──> Event Bus ──> DefectDojo Plugin ──> Creates product + ──> Slack Plugin ──> Notifies channel (planned) + ──> Custom Plugin ──> Your logic +``` + +Two plugin types: +- **Event-Driven** — React to entity/vulnerability events in real-time +- **Sync** — Pull data from external sources on a schedule (e.g., sync users from Keycloak) + +**Current plugins:** +- DefectDojo — Auto-create products, engagements, and assign owners (Done) +- Keycloak — Sync users and groups with annotations (Done) +- Dependency-Track — Scaffolded +- Jira, Slack, GitHub — Planned + +Build your own with the `EventDrivenPlugin` or `SyncPlugin` traits. + +## Backstage Compatibility + +Already using Backstage? Charybdis works as a **drop-in dynamic backend**. Point Backstage at Charybdis's YAML adapter and stop maintaining `catalog-info.yaml` files: + +```yaml +# backstage app-config.yaml +catalog: + locations: + - type: url + target: http://charybdis:8080/yaml/locations + rules: + - allow: [Component, System, Service, API, User, Group] +``` + +Or use Charybdis standalone via its gRPC API — no Backstage needed. + +## vs. Alternatives + +| | Charybdis | Backstage | Port / Cortex / OpsLevel | +|---|---|---|---| +| **Type** | Open source | Open source | Commercial SaaS | +| **Catalog** | Event-driven, real-time | Static YAML, polling | Varies | +| **Security** | Native (first-class) | Plugins (fragmented) | Limited / add-on | +| **Deployment** | Single binary + PG | Node.js cluster + PG + plugins | Hosted | +| **Performance** | ~50MB RAM, sub-ms | ~1GB+ RAM | N/A | +| **Compliance** | Native frameworks | Manual | Some | +| **Cost** | Free | Free (+ operational cost) | $$$$ | + +## Project Status + +| Module | Status | Phase | +|--------|--------|-------| +| Core gRPC API | Done | 0 | +| PostgreSQL storage | Done | 0 | +| Event bus system | Done | 0 | +| Plugin framework | Done | 0 | +| mTLS + RBAC | Done | 0 | +| OpenTelemetry | Done | 0 | +| Backstage YAML adapter | Done | 0 | +| DefectDojo plugin | Done | 0 | +| Keycloak plugin | Done | 0 | +| Vulnerability ingestion | Planned | 1 | +| SARIF / CycloneDX / SPDX parsers | Planned | 1 | +| Security gates & rules engine | Planned | 1 | +| Compliance frameworks | Planned | 3 | + +See [VISION.md](VISION.md) for the full roadmap. + +## Documentation + +- [Vision & Roadmap](VISION.md) — Where Charybdis is going +- [Getting Started](docs/getting-started.md) — Installation and first steps +- [Core Concepts](docs/core-concepts.md) — Entities, events, and architecture +- [Security](docs/security.md) — mTLS and RBAC configuration +- [Architecture](docs/architecture.md) — Deep dive into design decisions +- [Plugin Guide](plugins/README.md) — Building and using plugins +- [Demo](deploy/DEMO.md) — Full stack demo with DefectDojo + +## Technology Stack + +| | | +|---|---| +| **Language** | Rust | +| **API** | gRPC + Protocol Buffers | +| **Database** | PostgreSQL 14+ (protobuf + JSONB, zero-migration) | +| **Frontend** | Backstage YAML adapter (compatible) | +| **Security** | mTLS (rustls) + RBAC | +| **Observability** | OpenTelemetry (traces, metrics, logs) | + +## Contributing + +Contributions are welcome! Whether it's a new plugin, a security parser, or documentation improvements — we'd love your help. + +## License + +[Apache-2.0](LICENSE) + +--- + +**One platform. Catalog. Security. Compliance. Built in Rust.** diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..52ffc92 --- /dev/null +++ b/TODO.md @@ -0,0 +1,67 @@ +# Charybdis - TODO List + +**Last Updated**: 2026-05-06 +**Status**: Phase 0 done. Phase 1 (Security Core) in progress — assessment & gates remaining. + +> Aligned with [VISION.md](VISION.md) roadmap. + +--- + +## Phase 1: Security Core (In Progress) + +> **Goal**: Native vulnerability management and scan ingestion. Replace DefectDojo for finding lifecycle management. +> **Architecture**: Core features (reconciliation, dedup, dry-run) in `src/`. Parsers extensible via `ScannerParser` trait. Plugins contribute parsers via `contributed_parsers()`. + +### Remaining + +- [ ] CycloneDX VEX parser (vulnerability data from SBOMs) +- [ ] `Plugin::contributed_parsers()` default impl on base trait +- [ ] Publish events: FindingCreated, FindingResolved, FindingReopened (for downstream plugins) +- [ ] Assessment workflow (triage, accept risk, remediate) +- [ ] Rules engine for auto-assessment +- [ ] Security gates (severity thresholds per product) +- [ ] License tracking and policy engine + +--- + +## Phase 2: Compliance & Integrations + +> **Goal**: Compliance frameworks and integration plugins. + +- [ ] Compliance framework mappings (NIS2, SOC2, DORA) +- [ ] VEX document support (CSAF, OpenVEX) +- [ ] Export/reporting (PDF, Excel) +- [ ] Notification plugins (Slack, Teams, email) +- [ ] Issue tracker plugins (Jira, GitHub, GitLab) +- [ ] Redis event bus backend (production) + +--- + +## Phase 3: Scaffolder & Ecosystem + +> **Goal**: Service scaffolding and community growth. + +- [ ] Service scaffolder (Git-native templates, not Nunjucks) +- [ ] Event-driven provisioning on scaffold +- [ ] Plugin SDK documentation +- [ ] Helm chart & 1-click deploy +- [ ] Community plugin registry + +--- + +## Infrastructure & Quality + +- [ ] Performance benchmarks (criterion) +- [ ] Dependency-Track plugin implementation (follow DefectDojo pattern) + +--- + +## Non-Goals (per VISION.md) + +These will **not** be implemented: + +- ~~REST API~~ — gRPC only. Teams can add REST via grpc-gateway or Envoy. +- ~~GraphQL API~~ — Same. gRPC is the single API surface. +- ~~Hot-swappable plugins~~ — Plugins are compile-time integrated for type safety. +- ~~Scanner execution~~ — Charybdis ingests results, it doesn't run scanners. +- ~~SIEM features~~ — Not an incident response tool. diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..c79ed1e --- /dev/null +++ b/VISION.md @@ -0,0 +1,249 @@ +# Charybdis Vision + +**The security-native platform engineering tool.** + +## The Thesis + +Backstage was built in 2020, at the dawn of microservices and Kubernetes adoption. It solved a real problem: "where do we catalog all these services?" But its approach — static YAML files, polling-based discovery, and a bolted-on plugin ecosystem — reflects the constraints of its era. + +In 2026, the landscape has changed: + +- **Infrastructure as Code is the norm.** Services are declared, not discovered. Events are emitted, not polled. +- **Security and compliance are non-negotiable.** NIS2, DORA, SOC2, FedRAMP — every organization needs a clear picture of their security posture, not just a service catalog. +- **Platform engineers want fewer tools, not more.** Running Backstage + DefectDojo + Dependency-Track + a license scanner + a compliance dashboard is unsustainable. +- **The plugin ecosystem failed its promise.** Most Backstage plugins are thin wrappers, poorly maintained, and break between versions. The 1000+ plugin count is vanity — teams use 3 features. + +Charybdis is what a platform engineering tool looks like when you start from these realities. + +## What Charybdis Is + +A **security-native platform engineering tool** that unifies software catalog, vulnerability management, and compliance posture in a single, event-driven platform. + +Not "a catalog with security plugins." Not "a security dashboard with a catalog bolted on." A single tool where **every service in your catalog has its security posture, vulnerabilities, licenses, and compliance status as first-class data** — because they were never separate concerns to begin with. + +### The Three Pillars + +#### 1. Dynamic Software Catalog + +Your software catalog should reflect reality, not YAML files that were accurate three months ago. + +- **Event-driven** — Services register via gRPC from CI/CD pipelines, IaC tools, or Kubernetes controllers. No YAML files to maintain. +- **Real-time** — Changes propagate instantly to all consumers. No polling, no stale data. +- **Rich entity model** — Components, Systems, APIs, Users, Groups, Domains, Resources. Compatible with Backstage's descriptor format for migration. + +#### 2. Native Security Posture + +Every entity in the catalog carries its security context natively. + +- **Vulnerability management** — Ingest scan results (SARIF, CycloneDX, SPDX) directly. No external vuln management tool needed. +- **Assessment workflow** — Triage, accept risk, or remediate. Rules engine for auto-assessment based on severity, component, scanner. +- **Security gates** — Define thresholds per product. Block deployments when critical vulnerabilities exceed limits. +- **License compliance** — Track licenses across your dependency tree. Enforce policies. Flag violations. + +### Future: Scaffolder + +A modern scaffolding system that goes beyond template rendering: + +- **Event-driven provisioning** — Create a service from a template and the entire toolchain is provisioned automatically: repo, CI/CD, security scanning, monitoring, catalog entry. +- **Git-native templates** — No Nunjucks. Templates are real repositories with real code. +- **Policy-driven** — Templates enforce organizational standards by default. + +## Why This Matters + +### For Platform Engineers + +**Before Charybdis:** +``` +New service → Create repo → Add catalog-info.yaml → PR to catalog → +Wait for merge → Wait for Backstage poll → Manually create DefectDojo product → +Manually create DT project → Manually configure scanner → Hope someone updates +the YAML when things change +``` + +**With Charybdis:** +``` +New service → One gRPC call → Cataloged, security scanning configured, +vulnerability tracking active, compliance monitored. Real-time. Always accurate. +``` + +### For Security Engineers + +**Before Charybdis:** +- Vulnerability data scattered across DefectDojo, Dependency-Track, Snyk, SonarQube +- No link between "this service" and "its vulnerabilities" +- Compliance evidence assembled manually from 5 different tools +- Security posture visibility requires stitching together multiple dashboards + +**With Charybdis:** +- One dashboard: every service, its vulnerabilities, its licenses, its compliance status +- Native scan ingestion — SARIF covers 60%+ of modern scanners +- Assessment workflows built-in, not bolted on +- Compliance frameworks (NIS2, SOC2) mapped to actual vulnerability data + +### For Engineering Leadership + +- **Single pane of glass** for software inventory AND security posture +- **Compliance reporting** that pulls from real data, not spreadsheets +- **Risk visibility** per service, per team, per domain +- **One tool to deploy and maintain** instead of a fragmented toolchain + +## Technical Differentiators + +| | Backstage | Charybdis | +|---|---|---| +| **Architecture** | Static YAML + polling | Event-driven + gRPC | +| **Catalog updates** | PR → merge → poll (minutes to hours) | API call → instant | +| **Security** | Plugin ecosystem (fragmented) | Native (first-class) | +| **Deployment** | Node.js cluster + PostgreSQL + plugins | Single Rust binary + PostgreSQL | +| **Performance** | Degrades at scale (>5k entities) | Sub-millisecond p99, tested at 170k entities | +| **Plugin quality** | Variable (many abandoned) | Core features native, integrations as plugins | +| **Language** | TypeScript | Rust (memory-safe, high-performance) | +| **Resource usage** | ~1GB+ RAM | ~50MB RAM | +| **Vulnerability management** | Requires external tools | Built-in | +| **Compliance** | Manual / external | Native frameworks | + +## Architecture + +``` + Charybdis + ┌─────────────────────────────────────────┐ + │ │ +CI/CD ──gRPC──────▶│ Software Catalog (event-driven) │ +IaC tools ────────▶│ Vulnerability Management (native) │ +K8s controllers ──▶│ License Compliance (native) │ +Scanners ─────────▶│ Assessment & Rules Engine │ + │ Security Gates │ + │ │ + │ ┌─────────────────────────────────┐ │ + │ │ Event Bus │ │ + │ │ EntityCreated → Plugins react │ │ + │ │ VulnIngested → Rules evaluate │ │ + │ │ GateFailed → Notifications fire │ │ + │ └─────────────────────────────────┘ │ + │ │ + │ ┌─────────────────────────────────┐ │ + │ │ Plugins (integrations only) │ │ + │ │ Jira · Slack · Teams · GitHub │ │ + │ │ GitLab · PagerDuty · Custom │ │ + │ └─────────────────────────────────┘ │ + │ │ + └─────────────────────────────────────────┘ +``` + +**Key insight:** Security features are in the core, not in plugins. Plugins handle integrations with external systems (notifications, issue trackers). This is the opposite of Backstage's model. + +## Roadmap + +### Phase 0: Foundation (Done) +- [x] gRPC API with full entity CRUD +- [x] PostgreSQL storage (zero-migration, protobuf + JSONB) +- [x] Event bus system +- [x] Plugin framework (EventDriven + Sync) +- [x] mTLS + RBAC security +- [x] OpenTelemetry observability +- [x] Backstage YAML adapter +- [x] DefectDojo plugin (products, engagements, owner resolution) +- [x] Keycloak plugin (user/group sync with annotations) + +### Phase 1: Security Core + +Native vulnerability management — the feature that makes Charybdis a DefectDojo/Dependency-Track replacement, not just a catalog. + +#### Architecture: Core + Extensible Parsers + +Security features live in the **core**, not in plugins. Plugins remain for external integrations (Slack, Jira, DefectDojo sync). The parser system is extensible without touching reconciliation logic. + +``` +┌────────────────────────────────────────────────────────────────┐ +│ gRPC IngestionService │ +│ - ImportScan(component, lifecycle, format, data, options) │ +│ - DryRunScan(component, lifecycle, format, data) │ +└───────────────────────────┬────────────────────────────────────┘ + │ +┌───────────────────────────▼────────────────────────────────────┐ +│ Parser Registry (extensible) │ +│ - ScannerParser trait │ +│ - Built-in: SARIF, CycloneDX, ... │ +│ - Plugin-contributed: exotic formats via contributed_parsers()│ +└───────────────────────────┬────────────────────────────────────┘ + │ Vec +┌───────────────────────────▼────────────────────────────────────┐ +│ Reconciliation Engine (core) │ +│ - Fingerprint calculation (scanner + rule_id + file + line) │ +│ - Match existing findings by (component_id, lifecycle) │ +│ - Produce diff: New / Unchanged / Resolved / Reopened │ +└───────────────────────────┬────────────────────────────────────┘ + │ ReconciliationResult +┌───────────────────────────▼────────────────────────────────────┐ +│ Lifecycle Manager (core) │ +│ - Apply: create new, update last_seen, close resolved │ +│ - DryRun: return diff without persisting │ +│ - Publish events (FindingCreated, FindingResolved, etc.) │ +└────────────────────────────────────────────────────────────────┘ +``` + +**Key design decisions:** +- **Parsers are extensible, reconciliation is core.** Adding a scanner = implement one trait (~50-100 lines). Dedup/close logic is written and tested once. +- **SARIF as primary format.** Covers 70%+ of modern scanners (Trivy, Semgrep, CodeQL, Checkov, etc.) with zero per-scanner code. +- **Findings scoped to (component, lifecycle).** Allows per-environment tracking (prod vs integration) without duplicating catalog entities. +- **Dry-run is native.** Enables MR-level diff: "this MR introduces X new vulnerabilities" — a feature DefectDojo still doesn't have. + +#### Milestones + +- [ ] Finding entity kind (proto + storage) +- [ ] IngestionService gRPC endpoint (ImportScan + DryRun) +- [ ] ScannerParser trait + ParserRegistry +- [ ] SARIF parser (built-in, covers majority of modern scanners) +- [ ] Fingerprint-based deduplication +- [ ] Reconciliation engine (new/unchanged/resolved/reopened) +- [ ] CycloneDX VEX parser +- [ ] Assessment workflow (triage, accept, remediate) +- [ ] Rules engine for auto-assessment +- [ ] Security gates (severity thresholds per product) + +### Phase 2: Compliance & Integrations +- [ ] Compliance framework mappings (NIS2, SOC2, DORA) +- [ ] License policy engine +- [ ] VEX document support (CSAF, OpenVEX) +- [ ] Export/reporting (PDF, Excel) +- [ ] Notification plugins (Slack, Teams, email) +- [ ] Issue tracker plugins (Jira, GitHub, GitLab) + +### Phase 3: Scaffolder & Ecosystem +- [ ] Service scaffolder (Git-native templates) +- [ ] Event-driven provisioning on scaffold +- [ ] Plugin SDK documentation +- [ ] Community plugin registry +- [ ] Helm chart & 1-click deploy + +## Target Audience + +**Primary:** Platform engineering teams and security engineers at companies with 50-5000 engineers who need both a software catalog and security posture visibility, and are tired of stitching together 5+ tools. + +**Secondary:** Organizations evaluating Backstage but hesitant about the operational complexity, or currently running Backstage and frustrated with stale data and plugin maintenance. + +## Open Source Strategy + +Charybdis is and will remain **fully open source** (Apache-2.0). + +The value proposition is clear enough that adoption will be driven by the product itself: +- Zero-cost alternative to commercial platforms (Port, Cortex, OpsLevel) +- Dramatically simpler than self-hosting Backstage + security tools +- Security-native approach that no other open-source tool offers + +Community growth will come from: +1. **Security engineers** frustrated with fragmented tooling +2. **Platform engineers** looking for a lighter alternative to Backstage +3. **Small-to-mid companies** that can't justify 5 separate tools +4. **Compliance-driven organizations** that need integrated security posture + +## Non-Goals + +- **Not a SIEM.** Charybdis manages software catalog and vulnerability posture, not security events or incident response. +- **Not a scanner.** Charybdis ingests scan results. It doesn't run scanners itself. Use your existing scanners (Trivy, Semgrep, ZAP, etc.) and push results to Charybdis. +- **Not a CI/CD platform.** Charybdis integrates with your CI/CD. It doesn't replace it. +- **Not trying to have 1000 plugins.** Core features are native. Plugins are for integrations with external systems. Quality over quantity. + +--- + +*Built with Rust. Secured by design. One binary to rule them all.* diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..cf873b7 --- /dev/null +++ b/build.rs @@ -0,0 +1,222 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=plugins.toml"); + println!("cargo:rerun-if-changed=proto/entities.proto.template"); + println!("cargo:rerun-if-changed=proto/core/"); + println!("cargo:rerun-if-changed=proto/ingestion.proto"); + println!("cargo:rerun-if-changed=plugins/"); + + // Parse plugins.toml to get enabled plugins + let plugins_config = fs::read_to_string("plugins.toml").expect("Failed to read plugins.toml"); + + let plugins = parse_plugins_config(&plugins_config)?; + + // Generate entities.proto from template + generate_entities_proto(&plugins)?; + + // Compile all proto files + let out_dir = PathBuf::from(std::env::var("OUT_DIR")?); + + // Collect all proto files to compile + let mut proto_files = vec!["proto/entities.proto".to_string()]; + + // Add core proto files + proto_files.push("proto/core/service.proto".to_string()); + proto_files.push("proto/core/system.proto".to_string()); + proto_files.push("proto/core/component.proto".to_string()); + proto_files.push("proto/core/api.proto".to_string()); + proto_files.push("proto/core/user.proto".to_string()); + proto_files.push("proto/core/group.proto".to_string()); + proto_files.push("proto/core/domain.proto".to_string()); + proto_files.push("proto/core/resource.proto".to_string()); + proto_files.push("proto/core/finding.proto".to_string()); + proto_files.push("proto/ingestion.proto".to_string()); + + // Add enabled plugin proto files + for plugin in &plugins { + if plugin.enabled { + let proto_path = format!("{}/{}.proto", plugin.proto_path, plugin.name); + if std::path::Path::new(&proto_path).exists() { + proto_files.push(proto_path.clone()); + println!("cargo:rerun-if-changed={}", proto_path); + } + } + } + + // Compile protos with tonic (0.14+ uses tonic-prost-build) + let includes = vec!["proto".to_string(), ".".to_string()]; + tonic_prost_build::configure() + .file_descriptor_set_path(out_dir.join("entity_descriptor.bin")) + .compile_protos(&proto_files, &includes)?; + + println!( + "✓ Generated entities.proto with {} plugins", + plugins.iter().filter(|p| p.enabled).count() + ); + + Ok(()) +} + +#[derive(Debug)] +struct PluginConfig { + name: String, + enabled: bool, + proto_path: String, + metadata_field_number: u32, + spec_field_number: u32, +} + +fn parse_plugins_config(config: &str) -> Result, Box> { + let mut plugins = Vec::new(); + let mut current_section = String::new(); + let mut current_plugin: Option> = None; + + for line in config.lines() { + let line = line.trim(); + + // Skip comments and empty lines + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Section header [plugins.name] + if line.starts_with('[') && line.ends_with(']') { + // Save previous plugin if exists + if let Some(plugin_data) = current_plugin.take() { + if let Some(plugin) = build_plugin_config(¤t_section, plugin_data) { + plugins.push(plugin); + } + } + + current_section = line[1..line.len() - 1].to_string(); + + // Start collecting data for plugins.* sections + if current_section.starts_with("plugins.") { + current_plugin = Some(HashMap::new()); + } + continue; + } + + // Key = value + if let Some(eq_pos) = line.find('=') { + let key = line[..eq_pos].trim(); + let value = line[eq_pos + 1..] + .trim() + .trim_matches('"') + .trim_matches('\''); + + if let Some(ref mut plugin_data) = current_plugin { + plugin_data.insert(key.to_string(), value.to_string()); + } + } + } + + // Don't forget the last plugin + if let Some(plugin_data) = current_plugin.take() { + if let Some(plugin) = build_plugin_config(¤t_section, plugin_data) { + plugins.push(plugin); + } + } + + Ok(plugins) +} + +fn build_plugin_config(section: &str, data: HashMap) -> Option { + if !section.starts_with("plugins.") { + return None; + } + + let name = section.strip_prefix("plugins.")?.to_string(); + + let enabled = data + .get("enabled") + .and_then(|v| v.parse::().ok()) + .unwrap_or(false); + + let proto_path = data.get("proto_path")?.clone(); + + let metadata_field_number = data + .get("metadata_field_number") + .and_then(|v| v.parse::().ok())?; + + let spec_field_number = data + .get("spec_field_number") + .and_then(|v| v.parse::().ok())?; + + Some(PluginConfig { + name, + enabled, + proto_path, + metadata_field_number, + spec_field_number, + }) +} + +fn generate_entities_proto(plugins: &[PluginConfig]) -> Result<(), Box> { + let template = fs::read_to_string("proto/entities.proto.template") + .expect("Failed to read proto/entities.proto.template"); + + // Generate import statements for enabled plugins + let mut imports = String::new(); + for plugin in plugins { + if plugin.enabled { + let import_line = format!( + "import \"plugins/{}/proto/{}.proto\";\n", + plugin.name, plugin.name + ); + imports.push_str(&import_line); + } + } + + // Generate metadata oneof fields for enabled plugins + let mut metadata_fields = String::new(); + for plugin in plugins { + if plugin.enabled { + // Convert plugin name to PascalCase for message type + let pascal_name = to_pascal_case(&plugin.name); + let field_line = format!( + " charybdis.plugins.{}.{}Metadata {}_metadata = {};\n", + plugin.name, pascal_name, plugin.name, plugin.metadata_field_number + ); + metadata_fields.push_str(&field_line); + } + } + + // Generate spec oneof fields for enabled plugins + let mut spec_fields = String::new(); + for plugin in plugins { + if plugin.enabled { + let pascal_name = to_pascal_case(&plugin.name); + let field_line = format!( + " charybdis.plugins.{}.{}Spec {}_spec = {};\n", + plugin.name, pascal_name, plugin.name, plugin.spec_field_number + ); + spec_fields.push_str(&field_line); + } + } + + // Replace placeholders in template + let mut output = template.replace("{{PLUGIN_IMPORTS}}", &imports); + output = output.replace("{{PLUGIN_METADATA_FIELDS}}", &metadata_fields); + output = output.replace("{{PLUGIN_SPEC_FIELDS}}", &spec_fields); + + // Write generated file + fs::write("proto/entities.proto", output).expect("Failed to write proto/entities.proto"); + + Ok(()) +} + +fn to_pascal_case(s: &str) -> String { + s.split('_') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect() +} diff --git a/charybdis-server/Cargo.toml b/charybdis-server/Cargo.toml new file mode 100644 index 0000000..22340eb --- /dev/null +++ b/charybdis-server/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "charybdis-server" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "charybdis" +path = "src/main.rs" + +[dependencies] +charybdis = { path = ".." } +charybdis_defectdojo = { package = "charybdis-defectdojo-plugin", path = "../plugins/defectdojo" } +charybdis_dependencytrack = { package = "charybdis-dependencytrack-plugin", path = "../plugins/dependencytrack" } +charybdis_keycloak = { package = "charybdis-keycloak-plugin", path = "../plugins/keycloak" } + +# Re-export core dependencies needed by main +tonic = { version = "0.14.2", features = ["tls-ring", "tls-connect-info"] } +tonic-reflection = "0.14" +tokio = { version = "1.48", features = ["full"] } +tracing = "0.1" +anyhow = "1.0" +rustls = "0.23" diff --git a/charybdis-server/build.rs b/charybdis-server/build.rs new file mode 100644 index 0000000..6034f85 --- /dev/null +++ b/charybdis-server/build.rs @@ -0,0 +1,7 @@ +// This is a stub build.rs that just ensures OUT_DIR is available +// The actual protobuf compilation happens in the main charybdis crate + +fn main() { + // Nothing to do - we just need this to exist so OUT_DIR is defined + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/charybdis-server/src/main.rs b/charybdis-server/src/main.rs new file mode 100644 index 0000000..ec169b0 --- /dev/null +++ b/charybdis-server/src/main.rs @@ -0,0 +1,419 @@ +use std::sync::Arc; +use std::time::Duration; +use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; +use tonic_reflection::server::Builder as ReflectionBuilder; +use tracing::{info, warn}; + +// Plugin imports +extern crate charybdis_defectdojo; +extern crate charybdis_keycloak; +extern crate charybdis_dependencytrack; + +// Import MyEntityService and EntityServiceServer from the library crate +use charybdis::{ + MyEntityService, + charybdis::entities::entity_service_server::EntityServiceServer, + charybdis::ingestion::ingestion_service_server::IngestionServiceServer, + config::Config, + database, + events::{EventBus, backends::MemoryEventBus}, + findings::ingestion::MyIngestionService, + scanners::ParserRegistry, + security::{ + audit::AuditLogger, + config::{RoleMapping, RoleRule, SecurityConfig, SubjectMatch}, + interceptor::AuthInterceptor, + rbac::RbacEngine, + tls, + }, + telemetry::{init_telemetry, shutdown_telemetry}, +}; +use std::collections::HashMap; + +// Use the descriptor from the charybdis library crate +// The descriptor is compiled by the main charybdis crate's build.rs +use charybdis::ENTITY_DESCRIPTOR_SET; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Install default crypto provider for rustls (required in rustls 0.23+) + // Using ring as the crypto backend since we have tls-ring feature enabled + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Load configuration from file or environment + info!("Loading configuration..."); + let config = Config::load()?; + info!("Configuration loaded successfully"); + + // Initialize OpenTelemetry (traces, metrics, logs) + let metrics = init_telemetry(config.telemetry.clone())?; + + let addr = format!("{}:{}", config.server.grpc_host, config.server.grpc_port).parse()?; + + info!("Connecting to database..."); + let pool = database::create_connection_pool(&config.database.url).await?; + + info!("Ensuring database schema exists..."); + database::ensure_schema(&pool).await?; + info!("Database ready"); + + // Initialize event bus + info!("Initializing event bus..."); + let event_bus: Arc = Arc::new(MemoryEventBus::new()); + event_bus.start().await?; + info!("Event bus started successfully"); + + // Initialize plugin system + info!("Initializing plugin system..."); + let entity_repository = Arc::new(database::EntityRepository::new(pool.clone())); + let mut plugin_manager = charybdis::plugins::manager::PluginManager::new(); + + // Load DefectDojo plugin if configured + if let Some(dd_config) = &config.plugins.defectdojo { + let enabled = dd_config.get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + info!("DefectDojo plugin is disabled"); + } else { + info!("Loading DefectDojo plugin..."); + match charybdis_defectdojo::DefectDojoConfig::from_toml(dd_config) { + Ok(dd_config) => { + match charybdis_defectdojo::DefectDojoPlugin::new( + dd_config, + entity_repository.clone(), + ) { + Ok(plugin) => { + plugin_manager.register_event_driven(Arc::new(plugin)); + info!("DefectDojo plugin registered successfully"); + } + Err(e) => { + warn!("Failed to initialize DefectDojo plugin: {}", e); + } + } + } + Err(e) => { + warn!("Failed to parse DefectDojo configuration: {}", e); + } + } + } + } + + // Load Keycloak plugin if configured + if let Some(kc_config) = &config.plugins.keycloak { + let enabled = kc_config.get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + info!("Keycloak plugin is disabled"); + } else { + info!("Loading Keycloak plugin..."); + match charybdis_keycloak::KeycloakConfig::from_toml(kc_config) { + Ok(kc_config) => { + match charybdis_keycloak::KeycloakPlugin::new( + kc_config, + entity_repository.clone(), + ) { + Ok(plugin) => { + plugin_manager.register_sync(Arc::new(plugin)); + info!("Keycloak plugin registered successfully"); + } + Err(e) => { + warn!("Failed to initialize Keycloak plugin: {}", e); + } + } + } + Err(e) => { + warn!("Failed to parse Keycloak configuration: {}", e); + } + } + } + } + + // Load Dependency-Track plugin if configured + if let Some(dt_config) = &config.plugins.dependencytrack { + let enabled = dt_config.get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + info!("Dependency-Track plugin is disabled"); + } else { + info!("Loading Dependency-Track plugin..."); + match charybdis_dependencytrack::DependencyTrackConfig::from_toml(dt_config) { + Ok(dt_config) => { + match charybdis_dependencytrack::DependencyTrackPlugin::new( + dt_config, + entity_repository.clone(), + ) { + Ok(plugin) => { + plugin_manager.register_event_driven(Arc::new(plugin)); + info!("Dependency-Track plugin registered successfully"); + } + Err(e) => { + warn!("Failed to initialize Dependency-Track plugin: {}", e); + } + } + } + Err(e) => { + warn!("Failed to parse Dependency-Track configuration: {}", e); + } + } + } + } + + // Create event dispatcher and subscribe to event bus + let event_plugins = plugin_manager.event_driven_plugins().to_vec(); + if !event_plugins.is_empty() { + let dispatcher = Arc::new(charybdis::plugins::dispatcher::EventDispatcher::new( + event_plugins, + entity_repository.clone(), + )); + + // Subscribe dispatcher to all entity events + event_bus.subscribe(dispatcher).await?; + + info!("Event dispatcher subscribed to event bus"); + } + + info!( + "Plugin system initialized with {} event-driven plugins", + plugin_manager.event_driven_plugins().len() + ); + + // Load security configuration + let mut security_config = config.security.clone(); + + // Initialize security components if RBAC is enabled + let auth_interceptor = if security_config.rbac.enabled { + info!("Security (RBAC) is enabled"); + + // Configure default roles if none are defined + if security_config.rbac.role_mappings.is_empty() { + info!("No role mappings configured, setting up defaults"); + security_config = setup_default_rbac_config(); + } + + let rbac_engine = Arc::new(RbacEngine::new(security_config.rbac.clone())); + let audit_logger = Arc::new(AuditLogger::new(security_config.rbac.audit.enabled)); + Some(AuthInterceptor::new(rbac_engine, audit_logger)) + } else { + warn!("Security (RBAC) is disabled - running in insecure mode"); + None + }; + + // Clone repository for YAML adapter (EntityRepository is Clone) + let yaml_repository = entity_repository.clone(); + + let my_entity_service = MyEntityService::new( + (*entity_repository).clone(), + event_bus, + metrics, + auth_interceptor, + ); + + // Initialize parser registry and ingestion service + let parser_registry = Arc::new(ParserRegistry::with_builtins()); + let my_ingestion_service = + MyIngestionService::new(entity_repository.clone(), parser_registry); + + // Configure and build the reflection service using the embedded descriptor set. + // In tonic-reflection 0.14+, use build_v1() instead of build() + let reflection_service = ReflectionBuilder::configure() + .register_encoded_file_descriptor_set(ENTITY_DESCRIPTOR_SET) + .build_v1()?; + + // Configure server with optional mTLS + let mut server_builder = Server::builder() + .http2_keepalive_interval(Some(Duration::from_secs(60))) + .initial_connection_window_size(1048576) + .initial_stream_window_size(1048576) + .http2_max_header_list_size(64 * 1024); // 64KB, generous for gRPC metadata + + if security_config.mtls.enabled { + info!("mTLS is enabled - configuring TLS"); + + // Validate certificate files exist + tls::validate_cert_files(&security_config.mtls)?; + + // Load certificate files + let (server_cert, server_key, ca_cert) = tls::load_tls_files(&security_config.mtls)?; + + // Create TLS identity and CA certificate + let identity = Identity::from_pem(&server_cert, &server_key); + let client_ca = Certificate::from_pem(&ca_cert); + + // Configure TLS with client certificate verification + let tls_config = ServerTlsConfig::new() + .identity(identity) + .client_ca_root(client_ca); + + server_builder = server_builder.tls_config(tls_config)?; + info!("mTLS configuration complete"); + } else { + warn!("mTLS is disabled - running without transport security"); + } + + info!("EntityService server listening on {}", addr); + + // Start YAML adapter if enabled + let yaml_adapter_handle = if config.server.yaml_adapter.enabled { + let yaml_host = config.server.yaml_adapter.host.clone(); + let yaml_port = config.server.yaml_adapter.port; + let yaml_repo = yaml_repository.clone(); + let base_url = format!("http://{}:{}", yaml_host, yaml_port); + + info!("Starting YAML adapter on {}:{}", yaml_host, yaml_port); + + Some(tokio::spawn(async move { + if let Err(e) = charybdis::adapters::yaml::start_yaml_adapter( + yaml_host, yaml_port, yaml_repo, base_url, + ) + .await + { + tracing::error!("YAML adapter error: {}", e); + } + })) + } else { + info!("YAML adapter is disabled"); + None + }; + + // Run the server with graceful shutdown + let server = server_builder + .add_service(EntityServiceServer::new(my_entity_service)) + .add_service(IngestionServiceServer::new(my_ingestion_service)) + .add_service(reflection_service) + .serve(addr); + + // Handle shutdown + tokio::select! { + result = server => { + if let Err(e) = result { + tracing::error!("Server error: {}", e); + } + } + _ = tokio::signal::ctrl_c() => { + info!("Received shutdown signal"); + } + } + + // Abort YAML adapter if it's running + if let Some(handle) = yaml_adapter_handle { + handle.abort(); + } + + // Gracefully shutdown telemetry + shutdown_telemetry().await; + info!("Shutdown complete"); + + Ok(()) +} + +/// Setup default RBAC configuration for testing/development +/// Maps certificate attributes to roles based on OU (Organizational Unit) +fn setup_default_rbac_config() -> SecurityConfig { + use charybdis::security::config::{AuditConfig, RbacConfig}; + + let mut permissions = HashMap::new(); + + // Admin role: full access + permissions.insert( + "admin".to_string(), + vec![ + "entity:create".to_string(), + "entity:read".to_string(), + "entity:update".to_string(), + "entity:delete".to_string(), + "entity:list".to_string(), + ], + ); + + // Platform team: full access (mapped from platform-team OU) + permissions.insert( + "platform".to_string(), + vec![ + "entity:create".to_string(), + "entity:read".to_string(), + "entity:update".to_string(), + "entity:delete".to_string(), + "entity:list".to_string(), + ], + ); + + // Automation/CI: create, read, update, list + permissions.insert( + "automation".to_string(), + vec![ + "entity:create".to_string(), + "entity:read".to_string(), + "entity:update".to_string(), + "entity:list".to_string(), + ], + ); + + // Plugins: read and list only + permissions.insert( + "plugin".to_string(), + vec!["entity:read".to_string(), "entity:list".to_string()], + ); + + let role_mappings = vec![ + // Map platform-team OU to platform role + RoleMapping { + role: "platform".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: None, + ou: Some("platform-team".to_string()), + o: None, + }, + }], + }, + // Map automation OU to automation role + RoleMapping { + role: "automation".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: None, + ou: Some("automation".to_string()), + o: None, + }, + }], + }, + // Map plugins OU to plugin role + RoleMapping { + role: "plugin".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: None, + ou: Some("plugins".to_string()), + o: None, + }, + }], + }, + ]; + + SecurityConfig { + mtls: charybdis::security::config::MtlsConfig { + enabled: std::env::var("SECURITY_MTLS_ENABLED") + .unwrap_or_else(|_| "false".to_string()) + .parse() + .unwrap_or(false), + server_cert: std::env::var("SECURITY_MTLS_SERVER_CERT").unwrap_or_default(), + server_key: std::env::var("SECURITY_MTLS_SERVER_KEY").unwrap_or_default(), + client_ca_cert: std::env::var("SECURITY_MTLS_CLIENT_CA").unwrap_or_default(), + require_client_cert: true, + crl_file: None, + }, + rbac: RbacConfig { + enabled: true, + role_mappings, + permissions, + audit: AuditConfig { + enabled: true, + log_all_requests: true, + log_denied_requests: true, + }, + }, + } +} diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..5913107 --- /dev/null +++ b/config.toml @@ -0,0 +1,78 @@ +# Charybdis Configuration +# Test configuration with security disabled + +[server] +grpc_host = "0.0.0.0" # Listen on all interfaces for Docker +grpc_port = 50051 + +[server.yaml_adapter] +enabled = true +host = "0.0.0.0" +port = 8080 + +[database] +url = "${DATABASE_URL}" +max_connections = 10 +connection_timeout_secs = 30 + +[security.mtls] +enabled = false + +[security.rbac] +enabled = false + +[security.rbac.audit] +enabled = true +log_all_requests = true +log_denied_requests = true + +[telemetry] +service_name = "charybdis" +service_version = "0.1.0" +environment = "development" +enable_console = true + +[telemetry.otlp] +enabled = false + +[telemetry.traces] +sampler_ratio = 1.0 + +[telemetry.metrics] +export_interval_secs = 60 + +[telemetry.logs] +level = "info" +json_format = false + +[plugins.defectdojo] +enabled = true +base_url = "${DEFECTDOJO_API_URL:-http://defectdojo-uwsgi:8081}" +api_token = "${DEFECTDOJO_API_TOKEN:-changeme}" +default_product_type_id = 1 + +[plugins.defectdojo.owner_resolution] +user_email_annotation = "keycloak.com/email" +defectdojo_lookup_field = "email" +assign_all_members = true + +[plugins.defectdojo.default_engagement] +auto_create = true +name = "CI/CD Pipeline" +engagement_type = "CI/CD" + +[plugins.keycloak] +enabled = true +base_url = "${KEYCLOAK_URL:-http://keycloak:8080}" +realm = "charybdis" +client_id = "charybdis-sync" +client_secret = "charybdis-sync-secret" + +[plugins.keycloak.sync] +on_startup = true +sync_users = true +sync_groups = true +namespace = "keycloak" + +[plugins.dependencytrack] +enabled = false diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..f7cac59 --- /dev/null +++ b/config.toml.example @@ -0,0 +1,267 @@ +# Charybdis Configuration File +# ============================= +# This file uses TOML format and supports environment variable substitution +# using ${VAR_NAME} syntax to avoid storing secrets in the config file. +# +# Copy this file to 'config.toml' and customize for your environment. + +# Server Configuration +# ==================== +[server] +# gRPC server bind address +# Default: "[::1]" (IPv6 localhost) +# Use "0.0.0.0" to listen on all interfaces +grpc_host = "[::1]" + +# gRPC server port +# Default: 50051 +grpc_port = 50051 + +# YAML Adapter for Backstage Integration +[server.yaml_adapter] +# Enable YAML adapter (for Backstage catalog integration) +enabled = true + +# YAML adapter bind address +host = "0.0.0.0" + +# YAML adapter port +port = 8080 + + +# Database Configuration +# ====================== +[database] +# PostgreSQL connection URL +# Use environment variable substitution to avoid storing credentials +# Example: postgresql://user:password@localhost:5432/dbname +url = "${DATABASE_URL}" + +# Maximum number of connections in the pool +# Default: 10 +max_connections = 10 + +# Connection timeout in seconds +# Default: 30 +connection_timeout_secs = 30 + + +# Security Configuration +# ====================== +[security.mtls] +# Enable mutual TLS authentication +# When enabled, all clients must present valid certificates +# Default: false +enabled = false + +# Server certificate (PEM format) +# Example: "./certs/server-cert.pem" +server_cert = "./certs/server-cert.pem" + +# Server private key (PEM format) +# Example: "./certs/server-key.pem" +server_key = "./certs/server-key.pem" + +# Client CA certificate for verification (PEM format) +# Example: "./certs/ca.pem" +client_ca_cert = "./certs/ca.pem" + +# Require client certificate (recommended: true) +# Default: true +require_client_cert = true + +# Optional: Certificate Revocation List (CRL) file +# crl_file = "./certs/crl.pem" + +[security.rbac] +# Enable role-based access control +# Default: false +enabled = false + +# Audit logging configuration +[security.rbac.audit] +# Enable audit logging for security events +# Default: true +enabled = true + +# Log all requests (including successful ones) +# Default: true +log_all_requests = true + +# Log denied requests +# Default: true +log_denied_requests = true + +# Role mappings: Certificate attributes -> RBAC roles +# Note: Default roles are configured in code if this section is empty +# Uncomment to customize role mappings +# +# [[security.rbac.role_mappings]] +# role = "admin" +# [[security.rbac.role_mappings.rules]] +# [security.rbac.role_mappings.rules.subject] +# ou = "administrators" +# +# [[security.rbac.role_mappings]] +# role = "developer" +# [[security.rbac.role_mappings.rules]] +# [security.rbac.role_mappings.rules.subject] +# ou = "engineering" + +# Role permissions: role -> list of permissions +# Uncomment to customize permissions +# +# [security.rbac.permissions] +# admin = ["entity:create", "entity:read", "entity:update", "entity:delete", "entity:list"] +# developer = ["entity:create", "entity:read", "entity:update", "entity:list"] +# viewer = ["entity:read", "entity:list"] + + +# OpenTelemetry Configuration +# ============================ +[telemetry] +# Service name for telemetry +# Default: "charybdis" +service_name = "charybdis" + +# Service version +# Default: "0.1.0" +service_version = "0.1.0" + +# Environment name (development, staging, production) +# Default: "development" +environment = "development" + +# Console output for traces/metrics (useful for development) +# Default: true +# Set to false in production +enable_console = true + +# OTLP exporter configuration (for production) +[telemetry.otlp] +# Enable OTLP exporter (sends telemetry to collector) +# Default: false +enabled = false + +# OTLP endpoint (without protocol prefix) +# Example: "localhost:4317" +# endpoint = "localhost:4317" + +# Traces configuration +[telemetry.traces] +# Sampling ratio (0.0 to 1.0) +# 1.0 = sample all traces (development) +# 0.1 = sample 10% of traces (production) +# Default: 1.0 +sampler_ratio = 1.0 + +# Metrics configuration +[telemetry.metrics] +# Export interval in seconds +# Default: 60 +export_interval_secs = 60 + +# Logs configuration +[telemetry.logs] +# Log level filter +# Options: trace, debug, info, warn, error +# Default: "info" +level = "info" + +# Enable JSON formatting +# Default: false +json_format = false + + +# Plugin Configuration +# ==================== +# Plugins extend Charybdis functionality by reacting to entity lifecycle events + +# DefectDojo Integration Plugin +[plugins.defectdojo] +# Enable DefectDojo plugin +enabled = false + +# DefectDojo API URL +# api_url = "https://defectdojo.company.com" + +# DefectDojo API key (use environment variable) +# api_key = "${DEFECTDOJO_API_KEY}" + +# Request timeout in seconds +# timeout_secs = 30 + +# Event handlers +# [[plugins.defectdojo.on_entity_created]] +# kind = "Service" +# action = "create_product" +# auto_create_engagement = true +# +# [[plugins.defectdojo.on_entity_updated]] +# kind = "Service" +# action = "update_product" +# sync_metadata = true + +# Dependency-Track Integration Plugin +[plugins.dependencytrack] +# Enable Dependency-Track plugin +enabled = false + +# Dependency-Track API URL +# base_url = "https://dependencytrack.company.com" + +# Dependency-Track API key (use environment variable) +# api_key = "${DEPENDENCYTRACK_API_KEY}" + +# Request timeout in seconds +# timeout_secs = 30 + +# Auto-create projects for new Component entities +# auto_create_project = true + +# Default team UUID to assign to new projects (optional) +# default_team_uuid = "" + +# Keycloak Identity Provider Sync Plugin +[plugins.keycloak] +# Enable Keycloak plugin +enabled = false + +# Keycloak server URL +# base_url = "https://keycloak.company.com" + +# Keycloak realm to sync users and groups from +# realm = "master" + +# Service account client credentials (use environment variables) +# client_id = "charybdis-sync" +# client_secret = "${KEYCLOAK_CLIENT_SECRET}" + +# Sync options +# [plugins.keycloak.sync] +# Cron schedule (e.g., every 5 minutes) +# schedule = "0 */5 * * * *" + +# Run sync on Charybdis startup +# on_startup = true + +# Allow manual trigger via API +# manual_trigger = true + +# Sync users from Keycloak +# sync_users = true + +# Sync groups from Keycloak +# sync_groups = true + +# Namespace to assign to synced entities +# namespace = "keycloak" + +# Max results per API page +# page_size = 100 + +# Custom Plugin Example +# [plugins.custom_plugin] +# enabled = false +# setting1 = "value1" +# setting2 = "${CUSTOM_PLUGIN_API_KEY}" diff --git a/deploy/DEMO.md b/deploy/DEMO.md new file mode 100644 index 0000000..515f41d --- /dev/null +++ b/deploy/DEMO.md @@ -0,0 +1,297 @@ +# Charybdis Demo + +See the full flow in action: register a service via one gRPC call and watch DefectDojo auto-provision a product — no YAML files, no manual steps. Optionally, see how Backstage can consume entities from Charybdis in real-time via the YAML adapter. + +## Prerequisites + +- Docker & Docker Compose +- 8GB RAM recommended +- Ports available: 5432, 8080, 8081, 50051 (and 3000 if using Backstage) + +## Quick Start (5 minutes) + +### 1. Start the demo stack + +```bash +./demo.sh +``` + +This will start: +- ✅ Charybdis (gRPC on :50051, YAML adapter on :8081) +- ✅ DefectDojo (UI on :8080) +- ✅ PostgreSQL instances for each service +- ℹ️ Backstage (UI on :3000) — optional, see [Backstage Integration](#optional-backstage-integration) below + +### 2. Create your first service + +Using grpcurl (if installed): + +```bash +grpcurl -plaintext -d '{ + "entity": { + "kind": "Component", + "component_metadata": { + "name": "payment-api", + "description": "Payment processing service", + "labels": { + "team": "payments", + "env": "production" + }, + "tags": ["api", "critical"] + }, + "component_spec": { + "type": "service", + "lifecycle": "production", + "owner": "team-payments" + } + } +}' localhost:50051 charybdis.entities.EntityService/CreateEntity +``` + +Or using the included grpcurl container: + +```bash +docker-compose -f docker-compose.demo.yml run --rm grpcurl \ + -plaintext -d '{ + "entity": { + "kind": "Component", + "component_metadata": { + "name": "payment-api", + "description": "Payment processing service" + }, + "component_spec": { + "type": "service", + "lifecycle": "production", + "owner": "team-payments" + } + } + }' charybdis:50051 charybdis.entities.EntityService/CreateEntity +``` + +### 3. See the magic happen! ✨ + +**In DefectDojo** (http://localhost:8080): +- Login: `admin` / `admin` +- Go to "Products" +- You'll see "payment-api" automatically created! 🎉 + +**In Charybdis** (via gRPC): +- Query the entity back — it now has annotations: + - `defectdojo.com/product-id` + - `defectdojo.com/engagement-id` (if auto-create enabled) + +**In the YAML adapter** (http://localhost:8081): +- Visit `http://localhost:8081/yaml/locations` — entities are served in Backstage-compatible YAML format +- Any Backstage instance pointed at this URL will pick up entities automatically + +## What just happened? + +``` +1. You created a Component entity in Charybdis (gRPC API) +2. Charybdis stored it in PostgreSQL +3. Event published: EntityCreated(Component) +4. DefectDojo plugin received the event +5. Plugin created a Product in DefectDojo via API +6. Plugin stored the product_id in entity annotations +7. Entity is now queryable via gRPC and available via YAML adapter +``` + +**30 seconds instead of 30 minutes of manual provisioning — no YAML files, no manual tool setup.** + +## Try more operations + +### List all entities + +```bash +grpcurl -plaintext -d '{}' localhost:50051 \ + charybdis.entities.EntityService/ListEntities +``` + +### Get a specific entity + +```bash +grpcurl -plaintext -d '{"id": "YOUR_ENTITY_ID"}' localhost:50051 \ + charybdis.entities.EntityService/GetEntity +``` + +### Update an entity + +```bash +grpcurl -plaintext -d '{ + "id": "YOUR_ENTITY_ID", + "entity": { + "kind": "Component", + "component_metadata": { + "name": "payment-api", + "description": "Updated description" + } + } +}' localhost:50051 charybdis.entities.EntityService/UpdateEntity +``` + +→ Check DefectDojo: Product description updated automatically! + +### Delete an entity + +```bash +grpcurl -plaintext -d '{"id": "YOUR_ENTITY_ID"}' localhost:50051 \ + charybdis.entities.EntityService/DeleteEntity +``` + +→ Check DefectDojo: Product deleted automatically! + +## Explore the stack + +### Check Charybdis logs + +```bash +docker-compose -f docker-compose.demo.yml logs -f charybdis +``` + +You'll see: +- Entity CRUD operations +- Event dispatching +- Plugin execution +- DefectDojo API calls + +### Check DefectDojo + +Open http://localhost:8080 +- Login: `admin` / `admin` +- Products: See auto-created products +- Engagements: See auto-created CI/CD engagements + +### Check the YAML Adapter + +```bash +# List all entity locations (Backstage-compatible format) +curl http://localhost:8081/yaml/locations +``` + +This endpoint serves entities as Backstage-compatible YAML — useful for Backstage integration or any tool that consumes this format. + +### Access databases + +**Charybdis database:** +```bash +docker-compose -f docker-compose.demo.yml exec postgres-charybdis \ + psql -U charybdis -d charybdis +``` + +**Query entities:** +```sql +SELECT id, kind, annotations FROM entities; +``` + +## Troubleshooting + +### Services not starting + +Check logs: +```bash +docker-compose -f docker-compose.demo.yml logs +``` + +### DefectDojo API token issue + +Manually get a token: +1. Open http://localhost:8080 +2. Login: `admin` / `admin` +3. Go to Settings → API Key +4. Copy the token +5. Update `.env`: `DEFECTDOJO_API_TOKEN=your-token` +6. Restart: `docker-compose -f docker-compose.demo.yml restart charybdis` + +### Port conflicts + +If ports are already in use, edit `docker-compose.demo.yml` to change: +- `3000:3000` → `3001:3000` (Backstage) +- `8080:8080` → `8082:8080` (DefectDojo) +- etc. + +## Clean up + +### Stop services + +```bash +docker-compose -f docker-compose.demo.yml down +``` + +### Remove all data + +```bash +docker-compose -f docker-compose.demo.yml down -v +``` + +## Optional: Backstage Integration + +If you use Backstage, point it at the Charybdis YAML adapter to replace static `catalog-info.yaml` files: + +```yaml +# backstage app-config.yaml +catalog: + locations: + - type: url + target: http://charybdis:8081/yaml/locations + rules: + - allow: [Component, System, Service, API, User, Group] +``` + +Backstage will discover all entities from Charybdis automatically. See the `docker-compose.demo.yml` file for the commented-out Backstage service if you want to run it as part of the demo stack. + +## Next Steps + +1. **Read the docs**: [Getting Started](../docs/getting-started.md) +2. **Understand the architecture**: [Architecture](../docs/architecture.md) +3. **Create your own plugin**: [Plugin Guide](../plugins/README.md) + +## Demo Architecture + +``` +┌─────────────┐ +│ Client │ (grpcurl / CI/CD) +│ (gRPC) │ +└──────┬──────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Charybdis │ +│ ┌─────────────┐ ┌──────────────┐ │ +│ │ gRPC API │ │ YAML Adapter │ │ +│ │ :50051 │ │ :8081 │ │ +│ └──────┬──────┘ └──────┬───────┘ │ +│ │ │ │ +│ ┌──────▼────────────────▼───────┐ │ +│ │ Entity Repository │ │ +│ │ (PostgreSQL) │ │ +│ └──────┬────────────────────────┘ │ +│ │ │ +│ ┌──────▼────────┐ │ +│ │ Event Bus │ │ +│ └──────┬────────┘ │ +│ │ │ +│ ┌──────▼────────────────┐ │ +│ │ Plugin Dispatcher │ │ +│ │ ┌─────────────────┐ │ │ +│ │ │ DefectDojo │ │ │ +│ │ │ Plugin │ │ │ +│ │ └─────────────────┘ │ │ +│ └───────────────────────┘ │ +└──────┬──────────────┬───────────────┘ + │ │ + ▼ ▼ (YAML Adapter) +┌──────────────┐ ┌────────────────────┐ +│ DefectDojo │ │ Backstage / Any │ +│ :8080 │ │ compatible UI │ +└──────────────┘ └────────────────────┘ +``` + +## Support + +- GitHub Issues: [github.com/charybdis-catalog/charybdis/issues](https://github.com/charybdis-catalog/charybdis/issues) +- Documentation: [docs/](docs/) +- Discord: [Join our community](https://discord.gg/...) + +--- + +**🎉 Welcome to automated DevSecOps orchestration with Charybdis!** diff --git a/deploy/backstage/README.md b/deploy/backstage/README.md new file mode 100644 index 0000000..d1b8933 --- /dev/null +++ b/deploy/backstage/README.md @@ -0,0 +1,102 @@ +# Adding Backstage to Charybdis Demo + +Backstage requires a custom build and cannot be run from a pre-built Docker image. + +## Option 1: Use the YAML Adapter Directly + +You can read entities from Charybdis without Backstage: + +```bash +# List all entities +curl http://localhost:8081/yaml/locations + +# Get specific entity +curl http://localhost:8081/yaml/entities/YOUR_ENTITY_ID +``` + +## Option 2: Build and Run Backstage + +### 1. Create a Backstage App + +```bash +# Install Node.js 18+ first +npx @backstage/create-app@latest + +# Follow prompts, name it "backstage-app" +cd backstage-app +``` + +### 2. Configure Charybdis Integration + +Edit `app-config.yaml` and add: + +```yaml +catalog: + locations: + # Point to Charybdis YAML adapter + - type: url + target: http://charybdis:8080/yaml/locations + rules: + - allow: [Component, System, API, Resource, User, Group, Domain] +``` + +### 3. Build Docker Image + +Create `backstage-app/Dockerfile`: + +```dockerfile +FROM node:18-bookworm-slim + +WORKDIR /app + +# Copy package files +COPY package.json yarn.lock ./ +COPY packages packages + +# Install dependencies +RUN yarn install --frozen-lockfile --production + +# Build +RUN yarn tsc +RUN yarn build:backend + +# Run +EXPOSE 7007 +CMD ["node", "packages/backend", "--config", "app-config.yaml"] +``` + +### 4. Update docker-compose.demo.yml + +Uncomment the Backstage section in `docker-compose.demo.yml`. + +### 5. Start + +```bash +docker-compose -f docker-compose.demo.yml up -d backstage +``` + +Access Backstage at http://localhost:3000 + +## Option 3: Run Backstage Locally (Recommended for Development) + +```bash +# In backstage-app directory +yarn dev +``` + +Update `app-config.local.yaml`: + +```yaml +catalog: + locations: + - type: url + target: http://localhost:8081/yaml/locations + rules: + - allow: [Component, System, API, Resource, User, Group, Domain] +``` + +Access at http://localhost:3000 + +--- + +For more info: https://backstage.io/docs/getting-started/ diff --git a/deploy/backstage/app-config.yaml b/deploy/backstage/app-config.yaml new file mode 100644 index 0000000..54b716d --- /dev/null +++ b/deploy/backstage/app-config.yaml @@ -0,0 +1,92 @@ +app: + title: Charybdis Demo + baseUrl: http://localhost:3000 + +organization: + name: Charybdis + +backend: + baseUrl: http://localhost:7007 + listen: + port: 7007 + csp: + connect-src: ["'self'", 'http:', 'https:'] + cors: + origin: http://localhost:3000 + methods: [GET, HEAD, PATCH, POST, PUT, DELETE] + credentials: true + database: + client: pg + connection: + host: ${POSTGRES_HOST} + port: ${POSTGRES_PORT} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + database: backstage + +integrations: + github: + - host: github.com + # Optional: token for private repos + # token: ${GITHUB_TOKEN} + +proxy: + '/charybdis': + target: ${CHARYBDIS_URL} + changeOrigin: true + +# Catalog configuration - Point to Charybdis +catalog: + import: + entityFilename: catalog-info.yaml + pullRequestBranchName: backstage-integration + + rules: + - allow: [Component, System, API, Resource, Location] + + # Charybdis as a location provider + locations: + # Dynamic location from Charybdis YAML adapter + - type: url + target: ${CHARYBDIS_URL}/yaml/locations + rules: + - allow: [Component, System, API, Resource, User, Group, Domain] + + # Processors + processors: + githubOrg: + providers: + - target: https://github.com + # Optional: token for private orgs + # token: ${GITHUB_TOKEN} + +auth: + # For demo purposes, we use guest authentication + # In production, configure proper authentication + providers: + guest: + dangerouslyAllowOutsideDevelopment: true + +# Scaffolder configuration +scaffolder: + defaultAuthor: + name: Charybdis + email: charybdis@example.com + +# TechDocs configuration (optional) +techdocs: + builder: 'local' + generator: + runIn: 'local' + publisher: + type: 'local' + +# Search configuration (optional) +search: + pg: + highlightOptions: + useHighlight: true + maxWord: 35 + minWord: 15 + shortWord: 3 + highlightAll: false diff --git a/deploy/demo.sh b/deploy/demo.sh new file mode 100755 index 0000000..549829a --- /dev/null +++ b/deploy/demo.sh @@ -0,0 +1,222 @@ +#!/bin/bash +set -e + +# Run from the deploy/ directory +cd "$(dirname "$0")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}" +cat << "EOF" + ______ __ __ ___ + / ____// /_ ____ _ _____ __ __ / /_ ____/ (_)____ + / / / __ \ / __ `// ___// / / // __ \ / __ // // __ \ +/ /___ / / / // /_/ // / / /_/ // /_/ // /_/ // // /_/ / +\____//_/ /_/ \__,_//_/ \__, //_.___/ \__,_//_/ \____/ + /____/ +EOF +echo -e "${NC}" + +echo -e "${GREEN}Charybdis Demo${NC}\n" + +# Detect gRPC client +GRPC_CMD="" +if command -v buf &> /dev/null; then + GRPC_CMD="buf" +elif command -v grpcurl &> /dev/null; then + GRPC_CMD="grpcurl" +else + echo -e "${RED}Error: Neither 'buf' nor 'grpcurl' found. Install one:${NC}" + echo -e " brew install bufbuild/buf/buf" + echo -e " brew install grpcurl" + exit 1 +fi +echo -e "${BLUE}Using gRPC client: ${GRPC_CMD}${NC}\n" + +# Helper function for gRPC calls +grpc_call() { + local service_method="$1" + local data="$2" + local host="${3:-localhost:50051}" + + if [ "$GRPC_CMD" = "buf" ]; then + buf curl --protocol grpc --http2-prior-knowledge \ + -d "$data" \ + "http://${host}/${service_method}" 2>&1 + else + grpcurl -plaintext -d "$data" "$host" "$service_method" 2>&1 + fi +} + +# Check if Docker is running +if ! docker info > /dev/null 2>&1; then + echo -e "${RED}Error: Docker is not running. Please start Docker first.${NC}" + exit 1 +fi + +echo -e "${BLUE}Starting services...${NC}\n" + +# Start core services (Charybdis + Postgres only for quick demo) +docker compose -f docker-compose.demo.yml up -d postgres-charybdis 2>/dev/null +echo -e "${GREEN} Postgres started${NC}" + +# Wait for Postgres +for i in {1..15}; do + if docker compose -f docker-compose.demo.yml exec -T postgres-charybdis pg_isready -U charybdis > /dev/null 2>&1; then + break + fi + sleep 1 +done + +docker compose -f docker-compose.demo.yml up -d charybdis 2>/dev/null +echo -e "${GREEN} Charybdis started${NC}" + +# Wait for Charybdis gRPC +echo -e "\n${BLUE}Waiting for Charybdis to be ready...${NC}" +for i in {1..30}; do + if timeout 2 bash -c "echo > /dev/tcp/localhost/50051" 2>/dev/null; then + echo -e "${GREEN} gRPC server ready on :50051${NC}" + break + fi + if [ $i -eq 30 ]; then + echo -e "${RED} Charybdis failed to start${NC}" + docker compose -f docker-compose.demo.yml logs charybdis | tail -10 + exit 1 + fi + sleep 1 +done + +sleep 2 # Allow service to fully initialize + +# --- Demo: Entity Management --- +echo -e "\n${BLUE}=== Entity Management ===${NC}\n" + +echo -e "${YELLOW}1. Creating a Component...${NC}" +RESULT=$(grpc_call "charybdis.entities.EntityService/CreateEntity" '{ + "entity": { + "kind": "Component", + "component_metadata": { + "name": "payment-service", + "namespace": "default", + "description": "Payment processing microservice" + }, + "component_spec": { + "type": "service", + "lifecycle": "production", + "owner": "team-payments" + } + } +}') +echo "$RESULT" | head -5 +ENTITY_ID=$(echo "$RESULT" | grep '"id"' | head -1 | sed 's/.*"id": *"//;s/".*//') +echo -e "${GREEN} Created entity: ${ENTITY_ID}${NC}\n" + +echo -e "${YELLOW}2. Listing entities...${NC}" +grpc_call "charybdis.entities.EntityService/ListEntities" '{}' | head -5 +echo -e "" + +# --- Demo: Vulnerability Ingestion --- +echo -e "\n${BLUE}=== Vulnerability Ingestion ===${NC}\n" + +# Create a SARIF report +SARIF_REPORT=$(cat << 'SARIF' +{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "semgrep", "version": "1.0.0", "rules": [ + {"id": "sql-injection", "shortDescription": {"text": "SQL Injection"}, "defaultConfiguration": {"level": "error"}, "properties": {"tags": ["CWE-89"]}}, + {"id": "xss-reflected", "shortDescription": {"text": "Reflected XSS"}, "defaultConfiguration": {"level": "warning"}, "properties": {"tags": ["CWE-79"]}}, + {"id": "hardcoded-secret", "shortDescription": {"text": "Hardcoded Secret"}, "defaultConfiguration": {"level": "error"}} + ]}}, + "results": [ + {"ruleId": "sql-injection", "level": "error", "message": {"text": "User input in SQL query without parameterization"}, "locations": [{"physicalLocation": {"artifactLocation": {"uri": "src/db/queries.rs"}, "region": {"startLine": 42}}}], "partialFingerprints": {"primaryLocationLineHash": "fp-sql-001"}}, + {"ruleId": "xss-reflected", "level": "warning", "message": {"text": "User input reflected in response without encoding"}, "locations": [{"physicalLocation": {"artifactLocation": {"uri": "src/handlers/search.rs"}, "region": {"startLine": 18}}}]}, + {"ruleId": "hardcoded-secret", "level": "error", "message": {"text": "AWS secret key found in source code"}, "locations": [{"physicalLocation": {"artifactLocation": {"uri": "src/config.rs"}, "region": {"startLine": 7}}}]} + ] + }] +} +SARIF +) + +SARIF_B64=$(echo "$SARIF_REPORT" | base64) + +echo -e "${YELLOW}3. Dry-run scan (preview without persisting)...${NC}" +grpc_call "charybdis.ingestion.IngestionService/DryRunScan" "{ + \"component_ref\": \"payment-service\", + \"lifecycle\": \"production\", + \"format\": \"sarif\", + \"data\": \"${SARIF_B64}\" +}" +echo -e "" + +echo -e "${YELLOW}4. Importing scan (persisting findings)...${NC}" +grpc_call "charybdis.ingestion.IngestionService/ImportScan" "{ + \"component_ref\": \"payment-service\", + \"lifecycle\": \"production\", + \"format\": \"sarif\", + \"data\": \"${SARIF_B64}\" +}" +echo -e "" + +echo -e "${YELLOW}5. Re-importing same scan (testing deduplication)...${NC}" +grpc_call "charybdis.ingestion.IngestionService/ImportScan" "{ + \"component_ref\": \"payment-service\", + \"lifecycle\": \"production\", + \"format\": \"sarif\", + \"data\": \"${SARIF_B64}\" +}" +echo -e "" + +# Simulate a fix: remove the SQL injection finding +SARIF_FIXED=$(cat << 'SARIF' +{ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "semgrep", "version": "1.0.0", "rules": [ + {"id": "xss-reflected", "shortDescription": {"text": "Reflected XSS"}, "defaultConfiguration": {"level": "warning"}, "properties": {"tags": ["CWE-79"]}}, + {"id": "hardcoded-secret", "shortDescription": {"text": "Hardcoded Secret"}, "defaultConfiguration": {"level": "error"}} + ]}}, + "results": [ + {"ruleId": "xss-reflected", "level": "warning", "message": {"text": "User input reflected in response without encoding"}, "locations": [{"physicalLocation": {"artifactLocation": {"uri": "src/handlers/search.rs"}, "region": {"startLine": 18}}}]}, + {"ruleId": "hardcoded-secret", "level": "error", "message": {"text": "AWS secret key found in source code"}, "locations": [{"physicalLocation": {"artifactLocation": {"uri": "src/config.rs"}, "region": {"startLine": 7}}}]} + ] + }] +} +SARIF +) + +SARIF_FIXED_B64=$(echo "$SARIF_FIXED" | base64) + +echo -e "${YELLOW}6. Importing scan after fix (SQL injection resolved)...${NC}" +grpc_call "charybdis.ingestion.IngestionService/ImportScan" "{ + \"component_ref\": \"payment-service\", + \"lifecycle\": \"production\", + \"format\": \"sarif\", + \"data\": \"${SARIF_FIXED_B64}\" +}" +echo -e "" + +# --- Summary --- +echo -e "\n${GREEN}=== Demo Complete ===${NC}\n" +echo -e "${BLUE}What we demonstrated:${NC}" +echo -e " 1. Created a Component entity via gRPC" +echo -e " 2. Dry-run scan: preview findings without persisting" +echo -e " 3. Import scan: persist 3 findings (SQL injection, XSS, hardcoded secret)" +echo -e " 4. Deduplication: re-import same scan -> all unchanged" +echo -e " 5. Auto-resolution: import without SQL injection -> marked as resolved" +echo "" +echo -e "${BLUE}Access:${NC}" +echo -e " gRPC: localhost:50051" +echo -e " YAML Adapter: http://localhost:8081" +echo "" +echo -e "${BLUE}Stop:${NC}" +echo -e " docker compose -f docker-compose.demo.yml down" +echo "" +echo -e "${BLUE}Clean up:${NC}" +echo -e " docker compose -f docker-compose.demo.yml down -v" +echo "" diff --git a/deploy/docker-compose.demo.yml b/deploy/docker-compose.demo.yml new file mode 100644 index 0000000..752f085 --- /dev/null +++ b/deploy/docker-compose.demo.yml @@ -0,0 +1,233 @@ +version: '3.8' + +# Demo stack secrets — override via .env file or environment variables. +# These are NOT production credentials. Generate your own before deploying. +x-dd-secrets: &dd-secrets + DD_SECRET_KEY: ${DD_SECRET_KEY:-CHANGE_ME_generate_a_random_secret_key} + DD_CREDENTIAL_AES_256_KEY: ${DD_CREDENTIAL_AES_256_KEY:-CHANGE_ME_generate_a_base64_aes_key} + +services: + # PostgreSQL for Charybdis + postgres-charybdis: + image: postgres:15-alpine + container_name: charybdis-postgres + environment: + POSTGRES_DB: charybdis + POSTGRES_USER: charybdis + POSTGRES_PASSWORD: ${CHARYBDIS_DB_PASSWORD:-charybdis} + ports: + - "5432:5432" + volumes: + - charybdis-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U charybdis"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - charybdis-network + + # PostgreSQL for DefectDojo + postgres-defectdojo: + image: postgres:15-alpine + container_name: defectdojo-postgres + environment: + POSTGRES_DB: defectdojo + POSTGRES_USER: defectdojo + POSTGRES_PASSWORD: ${DD_DB_PASSWORD:-defectdojo} + volumes: + - defectdojo-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U defectdojo"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - charybdis-network + + # Redis for DefectDojo + redis-defectdojo: + image: redis:7-alpine + container_name: defectdojo-redis + networks: + - charybdis-network + + # DefectDojo + defectdojo-uwsgi: + image: defectdojo/defectdojo-django:2.38.1 + platform: linux/amd64 # Force x86_64 emulation for ARM64 (Mac Silicon) + container_name: defectdojo-uwsgi + depends_on: + postgres-defectdojo: + condition: service_healthy + redis-defectdojo: + condition: service_started + environment: + DD_DEBUG: 'True' + DD_ALLOWED_HOSTS: '*' + DD_DATABASE_URL: postgresql://defectdojo:${DD_DB_PASSWORD:-defectdojo}@postgres-defectdojo:5432/defectdojo + DD_CELERY_BROKER_URL: redis://redis-defectdojo:6379/0 + <<: *dd-secrets + DD_INITIALIZE: 'true' + DD_ADMIN_USER: ${DD_ADMIN_USER:-admin} + DD_ADMIN_PASSWORD: ${DD_ADMIN_PASSWORD:-admin} + DD_ADMIN_MAIL: 'admin@example.com' + ports: + - "8080:8081" # DefectDojo HTTP endpoint is on 8081 inside container + volumes: + - defectdojo-media:/app/media + networks: + - charybdis-network + # Healthcheck disabled - DefectDojo takes time to initialize + # Access will be available after ~2 minutes + # healthcheck: + # test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/').read()"] + # interval: 30s + # timeout: 10s + # retries: 5 + # start_period: 120s + + # DefectDojo Celery Worker + defectdojo-celery: + image: defectdojo/defectdojo-django:2.38.1 + platform: linux/amd64 # Force x86_64 emulation for ARM64 (Mac Silicon) + container_name: defectdojo-celery + depends_on: + - defectdojo-uwsgi + - redis-defectdojo + environment: + DD_DATABASE_URL: postgresql://defectdojo:${DD_DB_PASSWORD:-defectdojo}@postgres-defectdojo:5432/defectdojo + DD_CELERY_BROKER_URL: redis://redis-defectdojo:6379/0 + <<: *dd-secrets + entrypoint: ['/entrypoint-celery-worker.sh'] + networks: + - charybdis-network + + # DefectDojo Celery Beat + defectdojo-beat: + image: defectdojo/defectdojo-django:2.38.1 + platform: linux/amd64 # Force x86_64 emulation for ARM64 (Mac Silicon) + container_name: defectdojo-beat + depends_on: + - defectdojo-celery + environment: + DD_DATABASE_URL: postgresql://defectdojo:${DD_DB_PASSWORD:-defectdojo}@postgres-defectdojo:5432/defectdojo + DD_CELERY_BROKER_URL: redis://redis-defectdojo:6379/0 + <<: *dd-secrets + entrypoint: ['/entrypoint-celery-beat.sh'] + networks: + - charybdis-network + + # Keycloak (Identity Provider) + keycloak: + image: quay.io/keycloak/keycloak:24.0 + container_name: charybdis-keycloak + command: start-dev --import-realm + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + ports: + - "8082:8080" + volumes: + - ./scripts/keycloak/charybdis-realm.json:/opt/keycloak/data/import/charybdis-realm.json:ro + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\n\r\n' >&3 && cat <&3 | grep -q '200'"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s + networks: + - charybdis-network + + # Charybdis + charybdis: + build: + context: .. + dockerfile: Dockerfile + container_name: charybdis + depends_on: + postgres-charybdis: + condition: service_healthy + keycloak: + condition: service_healthy + defectdojo-uwsgi: + condition: service_started # Changed from service_healthy since healthcheck is disabled + environment: + DATABASE_URL: postgresql://charybdis:${CHARYBDIS_DB_PASSWORD:-charybdis}@postgres-charybdis:5432/charybdis + RUST_LOG: info + DEFECTDOJO_API_URL: http://defectdojo-uwsgi:8081 + DEFECTDOJO_API_TOKEN: ${DEFECTDOJO_API_TOKEN:-changeme} + ports: + - "50051:50051" # gRPC + - "8081:8080" # YAML adapter + volumes: + - ./config.toml:/app/config.toml:ro + networks: + - charybdis-network + restart: unless-stopped + + # Backstage (commented out - requires custom build) + # To enable Backstage: + # 1. Follow https://backstage.io/docs/getting-started/create-an-app + # 2. Build Docker image: cd backstage-app && yarn install && yarn build + # 3. Uncomment this section and postgres-backstage below + + # backstage: + # build: + # context: ./backstage-app + # dockerfile: Dockerfile + # container_name: backstage + # depends_on: + # - charybdis + # - postgres-backstage + # environment: + # POSTGRES_HOST: postgres-backstage + # POSTGRES_PORT: 5432 + # POSTGRES_USER: backstage + # POSTGRES_PASSWORD: backstage + # CHARYBDIS_URL: http://charybdis:8080 + # ports: + # - "3000:3000" + # - "7007:7007" + # volumes: + # - ./backstage/app-config.yaml:/app/app-config.yaml:ro + # networks: + # - charybdis-network + # restart: unless-stopped + + # postgres-backstage: + # image: postgres:15-alpine + # container_name: backstage-postgres + # environment: + # POSTGRES_DB: backstage + # POSTGRES_USER: backstage + # POSTGRES_PASSWORD: backstage + # volumes: + # - backstage-postgres-data:/var/lib/postgresql/data + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U backstage"] + # interval: 10s + # timeout: 5s + # retries: 5 + # networks: + # - charybdis-network + + # Helper: grpcurl for testing + grpcurl: + image: fullstorydev/grpcurl:latest + container_name: charybdis-grpcurl + network_mode: service:charybdis + entrypoint: ["/bin/sh"] + command: ["-c", "sleep infinity"] + profiles: + - tools + +volumes: + charybdis-postgres-data: + defectdojo-postgres-data: + defectdojo-media: + # backstage-postgres-data: # Uncomment if enabling Backstage + +networks: + charybdis-network: + driver: bridge diff --git a/deploy/docker-compose.telemetry.yml b/deploy/docker-compose.telemetry.yml new file mode 100644 index 0000000..a188e5e --- /dev/null +++ b/deploy/docker-compose.telemetry.yml @@ -0,0 +1,78 @@ +# Docker Compose for OpenTelemetry Testing +# This sets up a complete observability stack for local development + +version: '3.8' + +services: + # OpenTelemetry Collector + # Receives telemetry data and exports to backends + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + container_name: charybdis-otel-collector + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./docker/otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP HTTP receiver + - "8888:8888" # Prometheus metrics exposed by the collector + - "8889:8889" # Prometheus exporter metrics + - "13133:13133" # health_check extension + networks: + - charybdis-telemetry + + # Jaeger - Distributed Tracing UI + jaeger: + image: jaegertracing/all-in-one:latest + container_name: charybdis-jaeger + environment: + - COLLECTOR_OTLP_ENABLED=true + ports: + - "16686:16686" # Jaeger UI + - "14250:14250" # Jaeger gRPC + networks: + - charybdis-telemetry + + # Prometheus - Metrics Storage + prometheus: + image: prom/prometheus:latest + container_name: charybdis-prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + volumes: + - ./docker/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + ports: + - "9090:9090" + networks: + - charybdis-telemetry + + # Grafana - Visualization + grafana: + image: grafana/grafana:latest + container_name: charybdis-grafana + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_SECURITY_ADMIN_USER=admin + volumes: + - ./docker/grafana/provisioning:/etc/grafana/provisioning + - grafana-data:/var/lib/grafana + ports: + - "3000:3000" + networks: + - charybdis-telemetry + depends_on: + - prometheus + - jaeger + +networks: + charybdis-telemetry: + driver: bridge + +volumes: + prometheus-data: + grafana-data: diff --git a/deploy/scripts/generate-client-cert.sh b/deploy/scripts/generate-client-cert.sh new file mode 100755 index 0000000..3f2337c --- /dev/null +++ b/deploy/scripts/generate-client-cert.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# scripts/generate-client-cert.sh +# Flexible client certificate generation for any use case +# Usage: ./scripts/generate-client-cert.sh [days-valid] + +set -e + +NAME=$1 +OU=$2 +DAYS_VALID=${3:-365} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CERT_DIR="${SCRIPT_DIR}/../certs" + +if [ -z "$NAME" ] || [ -z "$OU" ]; then + echo "Usage: $0 [days-valid]" + echo "" + echo "Examples:" + echo " $0 payment-service automation # Service certificate (default 365 days)" + echo " $0 jane-doe platform-team # Admin certificate" + echo " $0 jira-plugin plugins # Plugin certificate" + echo " $0 contractor-bob monitoring 30 # Temporary certificate (30 days)" + echo "" + echo "Common OU values:" + echo " automation → service-writer role (create, read, update, list)" + echo " platform-team → admin role (full access)" + echo " monitoring → catalog-reader role (read, list only)" + echo " plugins → plugin role (read, update annotations)" + exit 1 +fi + +# Ensure cert directory exists +mkdir -p "$CERT_DIR" +cd "$CERT_DIR" + +# Check if CA exists +if [ ! -f "ca.pem" ] || [ ! -f "ca-key.pem" ]; then + echo "❌ Error: CA not found!" + echo " Run ./scripts/generate-dev-certs.sh first to create the CA" + exit 1 +fi + +echo "🔐 Generating certificate for: $NAME" +echo " OU: $OU" +echo " Organization: Company" +echo " Valid for: $DAYS_VALID days" +echo "" + +# Generate private key +openssl genrsa -out "${NAME}-key.pem" 2048 2>/dev/null + +# Generate CSR +openssl req -new \ + -key "${NAME}-key.pem" \ + -out "${NAME}.csr" \ + -subj "/CN=${NAME}/OU=${OU}/O=Company/C=US" \ + 2>/dev/null + +# Sign certificate +openssl x509 -req \ + -days ${DAYS_VALID} \ + -in "${NAME}.csr" \ + -CA ca.pem \ + -CAkey ca-key.pem \ + -CAcreateserial \ + -out "${NAME}-cert.pem" \ + -extfile <(echo "extendedKeyUsage=clientAuth") \ + 2>/dev/null + +# Clean up CSR +rm "${NAME}.csr" + +echo "✅ Certificate generated successfully!" +echo "" +echo "📋 Files created:" +echo " ${CERT_DIR}/${NAME}-cert.pem" +echo " ${CERT_DIR}/${NAME}-key.pem" +echo "" +echo "🔍 Certificate details:" +openssl x509 -in "${NAME}-cert.pem" -noout -subject -dates +echo "" +echo "🎯 Role mapping (based on OU):" +case $OU in + "platform-team"|"admin") + echo " Role: admin" + echo " Permissions: Full access (entity:*)" + ;; + "automation"|"ci-cd") + echo " Role: service-writer" + echo " Permissions: create, read, update, list entities" + ;; + "platform"|"monitoring"|"dashboard") + echo " Role: catalog-reader" + echo " Permissions: read, list entities only" + ;; + "plugins") + echo " Role: plugin" + echo " Permissions: read, update annotations only" + ;; + *) + echo " Role: Unknown - check security-config.yaml for mapping" + echo " You may need to add a rule for OU=${OU}" + ;; +esac +echo "" +echo "📦 Distribute these files securely to the user:" +echo " ${NAME}-cert.pem" +echo " ${NAME}-key.pem" +echo " ca.pem" +echo "" +echo "💡 Example distribution:" +echo " # Copy to user's machine:" +echo " scp ${NAME}-cert.pem ${NAME}-key.pem ca.pem user@host:~/.charybdis/" +echo "" +echo " # Or store in secrets manager:" +echo " vault kv put secret/charybdis/${NAME} \\" +echo " cert=@${NAME}-cert.pem \\" +echo " key=@${NAME}-key.pem \\" +echo " ca=@ca.pem" +echo "" +echo "🔒 Security reminder:" +echo " - Keep private key (${NAME}-key.pem) secret!" +echo " - Store securely (1Password, Vault, etc.)" +echo " - Never commit to git" +echo " - Valid for ${DAYS_VALID} days (expires $(date -v +${DAYS_VALID}d '+%Y-%m-%d' 2>/dev/null || date -d "+${DAYS_VALID} days" '+%Y-%m-%d' 2>/dev/null || echo 'N/A'))" +echo "" diff --git a/deploy/scripts/generate-dev-certs.sh b/deploy/scripts/generate-dev-certs.sh new file mode 100755 index 0000000..099b5a2 --- /dev/null +++ b/deploy/scripts/generate-dev-certs.sh @@ -0,0 +1,134 @@ +#!/bin/bash +set -e + +# Charybdis Development Certificate Generation Script +# This script generates self-signed certificates for local development and testing + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CERT_DIR="${SCRIPT_DIR}/../certs" +DAYS_VALID=365 + +echo "🔐 Charybdis Certificate Generation" +echo "====================================" +echo "" + +# Create certs directory +mkdir -p "${CERT_DIR}" +cd "${CERT_DIR}" + +# Function to generate certificate +generate_cert() { + local name=$1 + local cn=$2 + local ou=$3 + local cert_type=$4 # "server" or "client" + + echo "📝 Generating ${name}..." + + # Generate private key + openssl genrsa -out "${name}-key.pem" 2048 2>/dev/null + + # Generate CSR + openssl req -new \ + -key "${name}-key.pem" \ + -out "${name}.csr" \ + -subj "/CN=${cn}/OU=${ou}/O=Charybdis-Dev/C=US" \ + 2>/dev/null + + # Generate certificate + if [ "${cert_type}" = "server" ]; then + openssl x509 -req \ + -days ${DAYS_VALID} \ + -in "${name}.csr" \ + -CA ca.pem \ + -CAkey ca-key.pem \ + -CAcreateserial \ + -out "${name}-cert.pem" \ + -extfile <(echo "extendedKeyUsage=serverAuth +subjectAltName=DNS:localhost,DNS:charybdis,DNS:charybdis.local,IP:127.0.0.1") \ + 2>/dev/null + else + openssl x509 -req \ + -days ${DAYS_VALID} \ + -in "${name}.csr" \ + -CA ca.pem \ + -CAkey ca-key.pem \ + -CAcreateserial \ + -out "${name}-cert.pem" \ + -extfile <(echo "extendedKeyUsage=clientAuth") \ + 2>/dev/null + fi + + # Clean up CSR + rm "${name}.csr" + + echo " ✅ Generated ${name}-cert.pem and ${name}-key.pem" +} + +# 1. Generate Root CA +echo "🏛️ Generating Root CA..." +if [ ! -f ca.pem ]; then + openssl genrsa -out ca-key.pem 4096 2>/dev/null + openssl req -new -x509 \ + -days 3650 \ + -key ca-key.pem \ + -out ca.pem \ + -subj "/CN=Charybdis Dev Root CA/O=Charybdis-Dev/C=US" \ + 2>/dev/null + echo " ✅ Generated ca.pem (Root CA)" +else + echo " ℹ️ Root CA already exists, skipping" +fi +echo "" + +# 2. Generate Server Certificate +echo "🖥️ Generating Server Certificate..." +generate_cert "server" "localhost" "platform" "server" +echo "" + +# 3. Generate Client Certificates +echo "👥 Generating Client Certificates..." +echo "" + +# Admin client +generate_cert "admin" "admin-user" "platform-team" "client" + +# CI/CD Pipeline client +generate_cert "ci-pipeline" "ci-pipeline" "automation" "client" + +# Backstage client +generate_cert "backstage" "backstage" "platform" "client" + +# DefectDojo plugin client +generate_cert "defectdojo-plugin" "defectdojo-plugin" "plugins" "client" + +# DependencyTrack plugin client +generate_cert "dependencytrack-plugin" "dependencytrack-plugin" "plugins" "client" + +echo "" +echo "✅ Certificate Generation Complete!" +echo "" +echo "📂 Certificates generated in: ${CERT_DIR}" +echo "" +echo "📋 Summary:" +echo " - Root CA: ca.pem, ca-key.pem" +echo " - Server: server-cert.pem, server-key.pem" +echo " - Clients:" +echo " • admin-cert.pem, admin-key.pem (role: admin)" +echo " • ci-pipeline-cert.pem, ci-pipeline-key.pem (role: service-writer)" +echo " • backstage-cert.pem, backstage-key.pem (role: catalog-reader)" +echo " • defectdojo-plugin-cert.pem, defectdojo-plugin-key.pem (role: plugin)" +echo " • dependencytrack-plugin-cert.pem, dependencytrack-plugin-key.pem (role: plugin)" +echo "" +echo "🔍 Verify certificates:" +echo " openssl x509 -in ${CERT_DIR}/server-cert.pem -text -noout" +echo "" +echo "🚀 Use with Charybdis:" +echo " export SECURITY_MTLS_ENABLED=true" +echo " export SECURITY_MTLS_SERVER_CERT=${CERT_DIR}/server-cert.pem" +echo " export SECURITY_MTLS_SERVER_KEY=${CERT_DIR}/server-key.pem" +echo " export SECURITY_MTLS_CLIENT_CA=${CERT_DIR}/ca.pem" +echo "" +echo "⚠️ These are DEVELOPMENT certificates only!" +echo " Do NOT use in production. Generate proper certificates from a trusted CA." +echo "" diff --git a/deploy/scripts/keycloak/charybdis-realm.json b/deploy/scripts/keycloak/charybdis-realm.json new file mode 100644 index 0000000..a77079c --- /dev/null +++ b/deploy/scripts/keycloak/charybdis-realm.json @@ -0,0 +1,82 @@ +{ + "realm": "charybdis", + "enabled": true, + "clients": [ + { + "clientId": "charybdis-sync", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "charybdis-sync-secret", + "serviceAccountsEnabled": true, + "directAccessGrantsEnabled": false, + "publicClient": false, + "protocol": "openid-connect", + "fullScopeAllowed": true + } + ], + "groups": [ + { + "name": "engineering", + "path": "/engineering", + "subGroups": [ + { + "name": "backend", + "path": "/engineering/backend", + "subGroups": [] + }, + { + "name": "frontend", + "path": "/engineering/frontend", + "subGroups": [] + } + ] + }, + { + "name": "security", + "path": "/security", + "subGroups": [] + } + ], + "users": [ + { + "username": "alice", + "email": "alice@example.com", + "firstName": "Alice", + "lastName": "Martin", + "enabled": true, + "emailVerified": true, + "groups": ["/engineering/backend", "/security"] + }, + { + "username": "bob", + "email": "bob@example.com", + "firstName": "Bob", + "lastName": "Dupont", + "enabled": true, + "emailVerified": true, + "groups": ["/engineering/frontend"] + }, + { + "username": "charlie", + "email": "charlie@example.com", + "firstName": "Charlie", + "lastName": "Garcia", + "enabled": true, + "emailVerified": false, + "groups": ["/engineering"] + }, + { + "username": "service-account-charybdis-sync", + "enabled": true, + "serviceAccountClientId": "charybdis-sync", + "clientRoles": { + "realm-management": [ + "view-users", + "query-users", + "query-groups", + "view-realm" + ] + } + } + ] +} diff --git a/docs/PLUGIN_CONFIGURATION_GUIDE.md b/docs/PLUGIN_CONFIGURATION_GUIDE.md new file mode 100644 index 0000000..89cdf1f --- /dev/null +++ b/docs/PLUGIN_CONFIGURATION_GUIDE.md @@ -0,0 +1,1009 @@ +# Charybdis Plugin Configuration Guide + +Complete reference for configuring and using the Charybdis plugin system. + +## Table of Contents + +1. [Overview](#overview) +2. [Plugin System Architecture](#plugin-system-architecture) +3. [Configuration File Structure](#configuration-file-structure) +4. [Field Mapping System](#field-mapping-system) +5. [Entity Resolution](#entity-resolution) +6. [Plugin Configuration Reference](#plugin-configuration-reference) +7. [Environment Variables](#environment-variables) +8. [Best Practices](#best-practices) +9. [Troubleshooting](#troubleshooting) +10. [Examples](#examples) + +## Overview + +Charybdis plugins extend the core platform with integrations to external tools and services. Plugins react to entity lifecycle events (Create, Update, Delete) and automatically provision or sync resources in external systems. + +### Plugin Types + +**Event-Driven Plugins** (Current Implementation): +- React to entity lifecycle events +- Push data to external systems +- One-way sync: Charybdis → External Tool + +**Sync Plugins** (Future): +- Pull data from external systems on schedule +- Create/update entities in Charybdis +- One-way sync: External Tool → Charybdis + +## Plugin System Architecture + +``` +┌─────────────────────────────────────────┐ +│ Charybdis Core │ +│ ┌────────────────────────────────┐ │ +│ │ Entity Repository │ │ +│ │ (PostgreSQL) │ │ +│ └──────────┬─────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────┐ │ +│ │ Event Bus │ │ +│ │ (In-Memory / Future: Redis) │ │ +│ └──────────┬─────────────────────┘ │ +│ │ EntityEvent │ +│ │ (Created/Updated/Deleted) │ +└─────────────┼────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Event Dispatcher │ +│ (Routes events to plugins) │ +└──────────┬──────────────────────────────┘ + │ + ├─────────────────┐ + ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ +│ DefectDojo │ │ Future Plugins │ +│ Plugin │ │ (DependencyTrack)│ +│ │ │ │ +│ ┌──────────────┐ │ └──────────────────┘ +│ │ProductHandler│ │ +│ │UserHandler │ │ +│ │... │ │ +│ └──────────────┘ │ +└─────────┬────────┘ + │ HTTP/REST + ▼ +┌──────────────────┐ +│ External Tool │ +│ (DefectDojo) │ +└──────────────────┘ +``` + +### Key Components + +1. **Plugin**: Top-level plugin interface +2. **ResourceHandler**: Handles specific resource types (e.g., Products, Users) +3. **FieldMapper**: Maps Charybdis entity fields to external tool fields +4. **Event Dispatcher**: Routes entity events to appropriate handlers +5. **Plugin Manager**: Manages plugin lifecycle and registration + +## Configuration File Structure + +Plugin configuration lives in your main `config.toml` file under the `[plugins.]` section. + +### Basic Structure + +```toml +[plugins.] +enabled = true # Enable/disable plugin +base_url = "${API_URL}" # External tool API URL +api_token = "${API_TOKEN}" # Authentication token + # Plugin-specific settings + +[plugins..field_mappings.] + = # Field mappings per resource type +``` + +### Configuration Sections + +#### 1. Plugin Enable/Disable + +```toml +[plugins.defectdojo] +enabled = true # or false to disable +``` + +When disabled, the plugin: +- Will NOT be compiled into the binary +- Will NOT react to entity events +- Will NOT appear in plugin registry + +#### 2. Connection Settings + +```toml +[plugins.defectdojo] +base_url = "${DEFECTDOJO_URL}" +api_token = "${DEFECTDOJO_API_TOKEN}" +``` + +**Security Best Practice**: Always use environment variables for sensitive data (tokens, passwords, secrets). + +#### 3. Plugin-Specific Options + +Each plugin may have unique configuration options: + +```toml +[plugins.defectdojo] +default_product_type_id = 1 # Default product type for new products +auto_create_users = true # Auto-create missing users +auto_create_product_types = false # Require manual product type creation +``` + +#### 4. Field Mappings + +Field mappings define how Charybdis entity fields map to external tool API fields. + +```toml +[plugins.defectdojo.field_mappings.product] +name = "metadata.name" # Direct field mapping +description = "metadata.description" # Direct field mapping +business_criticality = { value = "high" } # Static value +tags = "metadata.tags" # Array mapping +``` + +## Field Mapping System + +### Mapping Types + +The field mapping system supports three types of mappings: + +#### 1. Direct Field Mapping (String) + +Maps a field from the Charybdis entity using dot notation: + +```toml +name = "metadata.name" +description = "metadata.description" +email = "spec.profile.email" +tags = "metadata.tags" +``` + +**Syntax**: `""` + +**Examples**: +- `"metadata.name"` → `entity.metadata.name` +- `"spec.owner"` → `entity.spec.owner` +- `"metadata.annotations.version"` → `entity.metadata.annotations["version"]` + +#### 2. Static Value Mapping (Object with `value` key) + +Provides a static value regardless of entity content: + +```toml +business_criticality = { value = "high" } +platform = { value = "web" } +is_active = { value = true } +priority = { value = 100 } +lifecycle = { value = "production" } +``` + +**Syntax**: `{ value = }` + +**Supported Types**: +- String: `{ value = "text" }` +- Boolean: `{ value = true }` +- Number: `{ value = 42 }` +- Object: `{ value = { key = "value" } }` +- Array: `{ value = ["item1", "item2"] }` + +#### 3. Complex Mapping with Entity Resolution (Object) + +Resolves entity references and extracts data from related entities: + +```toml +product_manager = { + from = "spec.owner", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} +``` + +**Syntax**: +```toml + = { + from = "", # Source field path + resolve_entity = "", # Entity kind to resolve to + extract = "", # Field to extract from resolved entity + resolve_array = , # Optional: resolve array of entities + lookup_entity = "" # Optional: lookup linked entity +} +``` + +**Parameters**: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `from` | string | ✓ | Source field path in dot notation | +| `resolve_entity` | string | ✓ | Entity kind to resolve (User, Component, etc.) | +| `extract` | string | ✓ | Field path to extract from resolved entity | +| `resolve_array` | boolean | ✗ | Set to true if `from` contains array of entity IDs | +| `lookup_entity` | string | ✗ | Find linked entity by source ID | + +## Entity Resolution + +Entity resolution is the most powerful feature of the field mapping system. It allows plugins to navigate entity relationships and extract data from related entities. + +### How Entity Resolution Works + +``` +1. Start with source entity (e.g., Component) + entity.spec.owner = "user:john.doe" + +2. Extract source value + from = "spec.owner" → "user:john.doe" + +3. Resolve entity reference + resolve_entity = "User" → Query for User entity with id "john.doe" + +4. Extract target field + extract = "annotations.defectdojo.com/user-id" → "123" + +5. Result + product_manager = 123 +``` + +### Single Entity Resolution + +Resolve one entity reference: + +```toml +[plugins.defectdojo.field_mappings.product] +product_manager = { + from = "spec.owner", # Component.spec.owner = "user:john.doe" + resolve_entity = "User", # Find User entity + extract = "annotations.defectdojo.com/user-id" # Get DD user ID → "123" +} +``` + +### Array Entity Resolution + +Resolve multiple entity references: + +```toml +[plugins.defectdojo.field_mappings.product_member] +user_ids = { + from = "spec.members", # Group.spec.members = ["user:john", "user:jane"] + resolve_entity = "User", # Find each User entity + extract = "annotations.defectdojo.com/user-id", # Get DD user ID for each + resolve_array = true # Process as array +} +``` + +**Result**: `user_ids = [123, 124]` + +### Linked Entity Lookup + +Find an entity linked to the source entity: + +```toml +engagement_product = { + from = "id", # Start with engagement entity ID + lookup_entity = "Component", # Find Component where... + extract = "annotations.defectdojo.com/product-id" # Component links to this engagement +} +``` + +This searches for a Component entity that references the source entity. + +### Nested Entity Resolution + +Chain multiple resolutions: + +```toml +team_lead_email = { + from = "spec.owner", # Component.spec.owner = "group:platform-team" + resolve_entity = "Group", # Find Group entity + extract = "spec.parent" # Get parent = "user:tech-lead" +} + +# Then in a second mapping: +lead_id = { + from = "spec.parent", # From previous resolution + resolve_entity = "User", # Find User entity + extract = "annotations.defectdojo.com/user-id" +} +``` + +### Entity Resolution Error Handling + +The field mapper handles errors gracefully: + +- **Entity not found**: Logs warning, field is omitted +- **Annotation missing**: Logs warning, field is omitted +- **Invalid entity reference**: Logs warning, field is omitted +- **Array with missing entities**: Logs warning, includes found entities only + +**In `map_all()` mode**: Failed field mappings log warnings but don't fail entire operation. + +## Plugin Configuration Reference + +### DefectDojo Plugin + +Complete configuration reference for the DefectDojo plugin. + +#### Connection Settings + +```toml +[plugins.defectdojo] +enabled = true +base_url = "${DEFECTDOJO_URL}" +api_token = "${DEFECTDOJO_API_TOKEN}" +``` + +#### Plugin Options + +```toml +[plugins.defectdojo] +# Default product type ID for new products (required if not mapped) +default_product_type_id = 1 + +# Automatically create users in DefectDojo when referenced +# If false, referenced users must exist in DefectDojo +auto_create_users = true + +# Automatically create product types in DefectDojo +# If false, product types must be created manually +auto_create_product_types = false +``` + +#### Resource Type: Product + +Maps **Component** entities to DefectDojo Products. + +```toml +[plugins.defectdojo.field_mappings.product] +# Required fields +name = "metadata.name" # Product name +description = "metadata.description" # Product description +product_type_id = { value = 1 } # Product type ID + +# Optional fields +tags = "metadata.tags" # Product tags (array) +business_criticality = { value = "high" } # very high, high, medium, low, very low, none +platform = { value = "web" } # web, mobile, desktop, iot, etc. +lifecycle = { value = "production" } # production, development, retirement +origin = { value = "internal" } # internal, external, third party +user_records = { value = 1000000 } # Number of user records +revenue = { value = "1000000" } # Revenue amount +external_audience = { value = true } # Accessible to external users +internet_accessible = { value = true } # Accessible via internet + +# User references (with entity resolution) +product_manager = { + from = "spec.owner", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} + +technical_contact = { + from = "metadata.annotations.technical-contact", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} + +team_manager = { + from = "metadata.annotations.team-manager", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} +``` + +#### Resource Type: User + +Maps **User** entities to DefectDojo Users. + +```toml +[plugins.defectdojo.field_mappings.user] +# Required fields +username = "metadata.name" # Username (unique) +email = "spec.profile.email" # Email address (unique) + +# Optional fields +first_name = "spec.profile.displayName" # First name +last_name = "spec.profile.displayName" # Last name (can use same field) +is_active = { value = true } # User active status +``` + +#### Resource Type: Product Type + +Maps **System** entities to DefectDojo Product Types. + +```toml +[plugins.defectdojo.field_mappings.product_type] +# Required fields +name = "metadata.name" # Product type name + +# Optional fields +description = "metadata.description" # Product type description +critical_product = { value = false } # Requires extra review +key_product = { value = true } # Important but not critical +``` + +#### Resource Type: Product Member + +Maps **Group** entities to DefectDojo Product Members (user-product-role assignments). + +**Note**: Only Group entities with specific structure are processed. + +```toml +[plugins.defectdojo.field_mappings.product_member] +# Product reference (resolve from Group parent) +product_id = { + from = "spec.parent", # Group.spec.parent = "component:my-service" + resolve_entity = "Component", # Find Component entity + extract = "annotations.defectdojo.com/product-id" # Get DD product ID +} + +# User reference (resolve from Group members) +user_id = { + from = "spec.members", # Group.spec.members = ["user:john", "user:jane"] + resolve_entity = "User", # Find User entities + extract = "annotations.defectdojo.com/user-id", # Get DD user IDs + resolve_array = true # Process as array +} + +# Role assignment +role_name = { value = "Reader" } # Owner, Maintainer, Reader, Writer, API_Importer +``` + +**DefectDojo Roles**: +- **Owner**: Full control over product +- **Maintainer**: Can modify product settings +- **Writer**: Can add findings and tests +- **Reader**: Read-only access +- **API_Importer**: Can import findings via API + +#### Resource Type: Engagement + +Maps **Resource** entities to DefectDojo Engagements (security assessments). + +```toml +[plugins.defectdojo.field_mappings.engagement] +# Required fields +name = "metadata.name" # Engagement name +description = "metadata.description" # Engagement description + +# Product reference +product_id = { + from = "spec.owner", # Resource.spec.owner = "component:my-service" + resolve_entity = "Component", # Find Component entity + extract = "annotations.defectdojo.com/product-id" +} + +# Date fields (ISO 8601 format: YYYY-MM-DD) +target_start = "spec.target_start" # Start date +target_end = "spec.target_end" # End date + +# Optional fields +status = { value = "In Progress" } # Not Started, In Progress, Completed, Cancelled +engagement_type = { value = "CI/CD" } # Interactive, CI/CD, etc. + +# Lead user reference +lead_id = { + from = "spec.dependsOn", # Resource.spec.dependsOn = ["user:lead"] + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} + +# Version control fields +version = "metadata.annotations.version" +commit_hash = "metadata.annotations.commit_hash" +branch_tag = "metadata.annotations.branch" +build_id = "metadata.annotations.build_id" +source_code_management_uri = "metadata.annotations.repo_url" + +# Test type flags +deduplication_on_engagement = { value = true } +threat_model = { value = false } +api_test = { value = true } +pen_test = { value = false } +check_list = { value = false } +``` + +## Environment Variables + +### Required Variables + +```bash +# DefectDojo Plugin +export DEFECTDOJO_URL="https://defectdojo.example.com" +export DEFECTDOJO_API_TOKEN="your-api-token-here" + +# Database +export DATABASE_URL="postgresql://user:pass@localhost/charybdis" + +# Optional: Telemetry +export OTLP_ENDPOINT="http://localhost:4317" +``` + +### Obtaining DefectDojo API Token + +1. Log into DefectDojo UI +2. Go to **User Profile** → **API Key** +3. Click **Generate** or copy existing key +4. Set environment variable: `export DEFECTDOJO_API_TOKEN=""` + +### Security Considerations + +**Never commit secrets to git**: +- ✓ Use environment variables for tokens/passwords +- ✓ Add `.env` to `.gitignore` +- ✓ Use secret management (HashiCorp Vault, AWS Secrets Manager) +- ✗ Don't hardcode secrets in `config.toml` + +## Best Practices + +### 1. Field Mapping Design + +**Start Simple**: +```toml +# Start with required fields only +name = "metadata.name" +description = "metadata.description" +``` + +**Add Optional Fields Gradually**: +```toml +# Add optional fields as needed +tags = "metadata.tags" +business_criticality = { value = "medium" } +``` + +**Use Entity Resolution Last**: +```toml +# Complex mappings last (requires other entities to exist) +product_manager = { + from = "spec.owner", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} +``` + +### 2. Entity Creation Order + +For entity resolution to work, create entities in this order: + +1. **Users** (no dependencies) +2. **Product Types** (no dependencies) +3. **Components** (may reference Users) +4. **Groups** (references Components and Users) +5. **Resources/Engagements** (references Components and Users) + +### 3. Testing Strategy + +**Test in Isolation**: +1. Create a test Component without references +2. Verify Product created in DefectDojo +3. Check annotation added: `defectdojo.com/product-id` + +**Test with References**: +1. Create User entity +2. Wait for DefectDojo sync +3. Create Component with `spec.owner = "user:john.doe"` +4. Verify product_manager set correctly in DefectDojo + +**Test Updates**: +1. Update Component name +2. Verify Product name updated in DefectDojo + +**Test Deletes**: +1. Delete Component +2. Verify Product deleted in DefectDojo + +### 4. Error Handling + +**Check Logs**: Plugin operations are logged with `tracing`: +``` +INFO DefectDojo plugin: Creating DefectDojo product for entity: component-123 +INFO DefectDojo plugin: Created DefectDojo product 456 for entity component-123 +WARN DefectDojo plugin: Entity component-789 has no DefectDojo product ID, skipping deletion +ERROR DefectDojo plugin: Failed to create product: API error 400: Invalid product type ID +``` + +**Log Levels**: +- `INFO`: Normal operations +- `WARN`: Non-critical issues (missing annotations, skipped operations) +- `ERROR`: Critical failures (API errors, invalid configuration) + +### 5. Performance Optimization + +**Minimize Entity Resolution**: +- Cache resolved IDs when possible +- Use static values when appropriate +- Avoid deep resolution chains + +**Batch Operations**: +- Create multiple entities in succession +- Plugin handles each independently +- No manual batching needed + +### 6. Configuration Management + +**Use Configuration Profiles**: + +```toml +# config.development.toml +[plugins.defectdojo] +base_url = "https://defectdojo-dev.example.com" +auto_create_users = true + +# config.production.toml +[plugins.defectdojo] +base_url = "https://defectdojo.example.com" +auto_create_users = false # Require manual user creation +``` + +**Environment-Specific Settings**: +```bash +# Development +export CONFIG_FILE="config.development.toml" + +# Production +export CONFIG_FILE="config.production.toml" +``` + +## Troubleshooting + +### Plugin Not Reacting to Events + +**Check**: +1. Plugin enabled: `plugins.defectdojo.enabled = true` +2. Plugin compiled: Run `cargo build` +3. Entity kind matches trigger: Component → product handler +4. Event bus running: Check logs for event publications + +### Field Mapping Not Working + +**Check**: +1. Field path correct: `"metadata.name"` not `"meta.name"` +2. Field exists in entity: Use YAML adapter to inspect entity +3. Static value syntax: `{ value = "text" }` not `"text"` +4. Entity resolution: Resolved entity exists and has required annotation + +### Entity Resolution Fails + +**Check**: +1. Referenced entity exists: `user:john.doe` exists in database +2. Referenced entity has annotation: `defectdojo.com/user-id` present +3. Entity kind correct: `resolve_entity = "User"` not `"user"` +4. Array syntax: `resolve_array = true` for array fields + +### API Errors + +**Common Errors**: + +``` +400 Bad Request: Invalid product type ID +→ Check default_product_type_id or product_type_id mapping + +401 Unauthorized: Invalid token +→ Check DEFECTDOJO_API_TOKEN is correct + +404 Not Found: Product not found +→ Entity may not have been created yet + +409 Conflict: User already exists +→ Normal for users (plugin detects and reuses) +``` + +### Missing Annotations + +If annotations not created: +1. Check entity update succeeded: `repository.update()` logs +2. Verify annotation key: `defectdojo.com/product-id` +3. Check database: `SELECT annotations FROM entities WHERE id = '...'` + +### Performance Issues + +If plugin causing slowness: +1. Check DefectDojo API response times +2. Reduce entity resolution depth +3. Use static values where possible +4. Check network connectivity to DefectDojo + +## Examples + +### Example 1: Basic Product Creation + +**Entity** (Component): +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: payment-service + description: Payment processing microservice + tags: + - payment + - critical + - pci-dss +spec: + type: service + lifecycle: production + owner: platform-team +``` + +**Configuration**: +```toml +[plugins.defectdojo.field_mappings.product] +name = "metadata.name" +description = "metadata.description" +tags = "metadata.tags" +product_type_id = { value = 1 } +business_criticality = { value = "very high" } +platform = { value = "web" } +lifecycle = "spec.lifecycle" +``` + +**Result in DefectDojo**: +```json +{ + "id": 456, + "name": "payment-service", + "description": "Payment processing microservice", + "tags": ["payment", "critical", "pci-dss"], + "prod_type": 1, + "business_criticality": "very high", + "platform": "web", + "lifecycle": "production" +} +``` + +**Updated Entity**: +```yaml +metadata: + annotations: + defectdojo.com/product-id: "456" +``` + +### Example 2: Product with User References + +**Entities**: + +User: +```yaml +apiVersion: backstage.io/v1alpha1 +kind: User +metadata: + name: john.doe +spec: + profile: + displayName: John Doe + email: john.doe@example.com + memberOf: + - platform-team +``` + +Component: +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: payment-service + annotations: + technical-contact: user:jane.smith +spec: + owner: user:john.doe +``` + +**Configuration**: +```toml +[plugins.defectdojo.field_mappings.product] +name = "metadata.name" +product_type_id = { value = 1 } + +product_manager = { + from = "spec.owner", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} + +technical_contact = { + from = "metadata.annotations.technical-contact", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} +``` + +**Resolution Process**: +1. Component created → Product handler triggered +2. Field mapper resolves `spec.owner = "user:john.doe"` +3. Queries for User entity with id "john.doe" +4. Extracts `annotations.defectdojo.com/user-id = "123"` +5. Sets `product_manager: 123` in API call + +### Example 3: Product Member Assignment + +**Entities**: + +Component (already has DD product ID): +```yaml +metadata: + annotations: + defectdojo.com/product-id: "456" +``` + +Users (already have DD user IDs): +```yaml +# john.doe +metadata: + annotations: + defectdojo.com/user-id: "123" + +# jane.smith +metadata: + annotations: + defectdojo.com/user-id: "124" +``` + +Group: +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Group +metadata: + name: payment-service-security-team +spec: + type: team + parent: component:payment-service + members: + - user:john.doe + - user:jane.smith +``` + +**Configuration**: +```toml +[plugins.defectdojo.field_mappings.product_member] +product_id = { + from = "spec.parent", + resolve_entity = "Component", + extract = "annotations.defectdojo.com/product-id" +} + +user_id = { + from = "spec.members", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id", + resolve_array = true +} + +role_name = { value = "Reader" } +``` + +**Result**: +- Creates Product Member: Product 456 + User 123 + Role "Reader" +- Creates Product Member: Product 456 + User 124 + Role "Reader" + +### Example 4: Engagement with Full Metadata + +**Entity**: +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Resource +metadata: + name: payment-service-q1-2025-assessment + description: Q1 2025 Security Assessment + annotations: + version: "2.3.0" + commit_hash: "abc123def456" + branch: "release/2.3" + build_id: "build-789" + repo_url: "https://github.com/example/payment-service" +spec: + type: security-assessment + owner: component:payment-service + dependsOn: + - user:security-lead + target_start: "2025-01-01" + target_end: "2025-03-31" +``` + +**Configuration**: +```toml +[plugins.defectdojo.field_mappings.engagement] +name = "metadata.name" +description = "metadata.description" + +product_id = { + from = "spec.owner", + resolve_entity = "Component", + extract = "annotations.defectdojo.com/product-id" +} + +lead_id = { + from = "spec.dependsOn", + resolve_entity = "User", + extract = "annotations.defectdojo.com/user-id" +} + +target_start = "spec.target_start" +target_end = "spec.target_end" +version = "metadata.annotations.version" +commit_hash = "metadata.annotations.commit_hash" +branch_tag = "metadata.annotations.branch" +build_id = "metadata.annotations.build_id" +source_code_management_uri = "metadata.annotations.repo_url" + +status = { value = "In Progress" } +engagement_type = { value = "CI/CD" } +deduplication_on_engagement = { value = true } +api_test = { value = true } +pen_test = { value = false } +``` + +**Result in DefectDojo**: +```json +{ + "id": 789, + "name": "payment-service-q1-2025-assessment", + "description": "Q1 2025 Security Assessment", + "product": 456, + "lead": 123, + "target_start": "2025-01-01", + "target_end": "2025-03-31", + "version": "2.3.0", + "commit_hash": "abc123def456", + "branch_tag": "release/2.3", + "build_id": "build-789", + "source_code_management_uri": "https://github.com/example/payment-service", + "status": "In Progress", + "engagement_type": "CI/CD", + "deduplication_on_engagement": true, + "api_test": true, + "pen_test": false +} +``` + +## Advanced Topics + +### Custom Field Mapping Logic + +For complex scenarios not covered by built-in mapping types, implement custom resource handlers. + +### Plugin Development + +See `docs/PLUGIN_DEVELOPMENT_GUIDE.md` (future) for creating new plugins. + +### Entity Resolution Performance + +Entity resolution requires database queries. For high-throughput scenarios: +- Use static values where possible +- Cache resolved IDs in memory +- Consider implementing a resolution cache layer + +### Multi-Environment Configuration + +```bash +# Development +cargo run -- --config config.development.toml + +# Staging +cargo run -- --config config.staging.toml + +# Production +cargo run -- --config config.production.toml +``` + +## Related Documentation + +- [DefectDojo Plugin README](../plugins/defectdojo/README.md) +- [Plugin Implementation Guide](./DEFECTDOJO_PLUGIN_IMPLEMENTATION.md) +- [Charybdis Architecture](architecture.md) +- [Field Mapper Tests](../src/plugins/field_mapper.rs#tests) + +## Support + +For issues or questions: +1. Check this documentation +2. Review plugin logs +3. Test with minimal configuration +4. Open issue on GitHub with: + - Configuration (sanitized) + - Entity YAML + - Error logs + - Expected vs actual behavior diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8ec4f87 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,985 @@ +# Charybdis - Architecture + +**Version**: 2.0 +**Last Updated**: 2026-05-06 +**Status**: Living Document + +> For the product vision and roadmap, see [VISION.md](../VISION.md). + +--- + +## Executive Summary + +**Charybdis** is a security-native platform engineering tool that unifies software catalog, vulnerability management, and compliance posture in a single event-driven platform. Built in Rust, deployed as a single binary. + +### Core Value Proposition + +**Problem**: +- Platform engineers run 5+ disconnected tools (Backstage, DefectDojo, Dependency-Track, license scanners, compliance spreadsheets) +- Software catalogs rely on static YAML files that drift from reality +- Security posture is invisible at the catalog level — vulns live in separate tools with no link to services +- Compliance evidence is assembled manually from fragmented data sources + +**Solution**: +- **Dynamic software catalog** — event-driven, gRPC-native, no static YAML +- **Native vulnerability management** — ingest scan results (SARIF, CycloneDX, SPDX), triage, assess, track +- **Security gates & compliance** — severity thresholds, license policies, compliance framework mappings +- **Plugin system for integrations** — Slack, Jira, GitHub, custom tools react to events +- **Single binary deployment** — Rust + PostgreSQL, ~50MB RAM, sub-millisecond latency + +### Use Case Examples + +**Service registration with auto-provisioning:** +``` +CI/CD Pipeline (Python) → gRPC CreateEntity(kind=Component) + ↓ + Charybdis persists entity + ↓ + Event: EntityCreated published + ↓ + ┌────────────────────┴────────────────────┐ + ↓ ↓ + Jira Plugin Slack Plugin + - Creates onboarding epic - Notifies #platform channel + ↓ ↓ + └────────────────────┬────────────────────┘ + ↓ + Entity visible via gRPC API / Backstage YAML adapter +``` + +**Vulnerability ingestion with security gates:** +``` +Scanner (Trivy) → CI/CD → gRPC IngestScan(sarif_report) + ↓ + Charybdis parses SARIF + Creates Vulnerability entities linked to Component + ↓ + Rules engine evaluates auto-assessment + Security gate checks thresholds + ↓ + ┌────────────────────┴────────────────────┐ + ↓ ↓ + Gate PASSED Gate FAILED + - Vulns tracked - Slack alert to #security + - Dashboard updated - Jira ticket created + - CI/CD pipeline blocked +``` + +--- + +## Architectural Principles + +### 1. **No Database Migrations Ever** + +The database schema is created once on first startup and **never changes**. + +**How it works**: +- Entities stored as protobuf bytes in `entity_data BYTEA` column +- Plugins add new entity types via protobuf `oneof` variants +- Build system regenerates code, database schema remains static + +**Benefits**: +- Deploy new plugins without downtime +- No migration scripts to manage +- Forward/backward compatibility built-in +- Easy rollback (protobuf versioning) + +### 2. **gRPC First, Everything Else is Adapter** + +Charybdis provides **only gRPC APIs**. All other interfaces (REST, YAML, GraphQL) are adapters on top. + +**Rationale**: +- **Strong typing**: Protobuf ensures type safety across all languages +- **Performance**: Binary protocol, efficient serialization +- **Multi-language**: Official gRPC clients for 10+ languages +- **Streaming**: Built-in support for real-time updates (future) +- **Code generation**: Automatic client/server code generation + +**What Charybdis provides**: +- ✅ gRPC API with protobuf definitions +- ✅ YAML adapter endpoints (for Backstage compatibility/migration) +- ❌ REST API (teams can add if needed via gateway) +- ❌ GraphQL (teams can add if needed) + +### 3. **Plugin-Based Extensibility** + +Plugins are **compile-time integrated** Rust crates that: + +1. **Define entity types** via protobuf schemas +2. **React to events** via EventHandler trait +3. **Call external APIs** to synchronize state +4. **Store references** in entity annotations +5. **(Future) Extend gRPC API** with custom endpoints + +**Plugin characteristics**: +- Not hot-swappable (require rebuild) +- Type-safe at compile time +- No runtime dependency resolution +- Configurable via YAML config file +- Can be developed by community + +### 4. **Event-Driven Orchestration** + +Events are published **after** entities are persisted: + +``` +CRUD Operation → Persist to DB → Publish Event → Plugins React +``` + +**Event flow**: +1. Entity created/updated/deleted in database +2. Event published to event bus with full entity data +3. All subscribed plugin handlers receive event +4. Plugins filter events they care about +5. Plugins perform asynchronous actions +6. Plugins update entity annotations (via repository) + +**Error handling**: +- Plugin errors **do not** fail the CRUD operation +- Entity is already persisted before event publishing +- Plugins log errors for observability +- Retries handled by event bus backend + +### 5. **Backstage Compatibility by Design** + +Charybdis entity model is **100% compatible** with Backstage's descriptor format. + +**How it works**: +- Core protobuf types mirror Backstage YAML structure +- YAML adapter endpoints serve entities in Backstage format +- Backstage reads Charybdis as a dynamic location provider +- No changes needed to Backstage frontend + +**Key compatibility points**: +- Entity `kind`, `apiVersion`, `metadata`, `spec` structure +- Annotation format (reverse-DNS style) +- Relationship types (`dependsOn`, `partOf`, etc.) +- All standard Backstage entity kinds supported + +--- + +## System Architecture + +### Component Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Client Layer │ +│ CI/CD Pipelines (Python, Go, Node.js, Bash, etc.) │ +│ Security Scanners (SARIF, CycloneDX, SPDX output) │ +│ IaC Tools (Terraform, Pulumi, Crossplane) │ +│ Backstage (via YAML adapter, optional) │ +└────────────────┬────────────────────────────────────────────────┘ + │ + │ gRPC / YAML HTTP + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Charybdis Core │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ gRPC API │ │ +│ │ EntityService: CRUD + List (with field masks) │ │ +│ │ ScanService: IngestScan (SARIF, CycloneDX, SPDX) │ │ +│ │ AssessmentService: Triage, Accept, Remediate │ │ +│ └────────────────────┬─────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────▼─────────────────────────────────────┐ │ +│ │ Security Engine (native) │ │ +│ │ - Vulnerability tracking per entity │ │ +│ │ - Rules engine (auto-assessment) │ │ +│ │ - Security gates (severity thresholds) │ │ +│ │ - License compliance │ │ +│ └────────────────────┬─────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────▼─────────────────────────────────────┐ │ +│ │ Adapters │ │ +│ │ - YAML Adapter (Backstage compat / migration) │ │ +│ └────────────────────┬─────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────▼─────────────────────────────────────┐ │ +│ │ Repository Layer │ │ +│ │ - PostgreSQL with protobuf storage │ │ +│ │ - JSONB annotations for fast queries │ │ +│ │ - Zero-migration schema │ │ +│ └────────────────────┬─────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────▼─────────────────────────────────────┐ │ +│ │ Event Bus System │ │ +│ │ - Memory backend (dev) │ │ +│ │ - Redis backend (prod, future) │ │ +│ └────────────────────┬─────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ Entity / Vulnerability / Gate events + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Plugin Layer (integrations) │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ +│ │ Slack/Teams │ │ Jira/GitHub │ │ Custom Plugins │ │ +│ │ Notifications │ │ Issue Track │ │ Your integrations │ │ +│ └──────┬────────┘ └──────┬───────┘ └──────────┬─────────────┘ │ +└─────────┼──────────────────┼─────────────────────┼──────────────┘ + ▼ ▼ ▼ + Slack/Teams API Jira/GitHub API External APIs +``` + +### Data Flow + +#### Entity Creation Flow + +``` +1. Client (CI/CD) + ↓ gRPC CreateEntityRequest +2. Charybdis gRPC API + ↓ Validate entity + ↓ Generate UUID, timestamps +3. Entity Repository + ↓ Serialize to protobuf bytes + ↓ INSERT INTO entities +4. PostgreSQL + ↓ Entity persisted +5. Event Bus + ↓ Publish EntityCreated event (with full entity data) +6. Plugin Handlers (parallel) + ├─ DefectDojo: Create product → Store product_id in annotations + ├─ DependencyTrack: Create project → Store project_uuid in annotations + └─ Custom: Custom actions +7. Repository.update_annotations() + ↓ Merge annotations from plugins + ↓ UPDATE entities SET annotations = ... +8. PostgreSQL + ↓ Entity updated with external tool IDs +9. Return CreateEntityResponse to client +``` + +#### Backstage Integration Flow + +``` +1. Backstage Catalog Backend + ↓ HTTP GET /yaml/locations +2. Charybdis YAML Adapter + ↓ Query repository.list_all() + ↓ Convert entities to Location YAML +3. Backstage receives location list + ↓ Fetches each entity: GET /yaml/entities/:id +4. Charybdis YAML Adapter + ↓ repository.get_by_id() + ↓ Convert entity protobuf → Backstage YAML +5. Backstage ingests entities + ↓ Displays in UI with all annotations + ↓ Shows linked DefectDojo products, etc. +``` + +--- + +## Core Entity Model + +### Protobuf Schema + +```protobuf +message Entity { + // System-managed fields + string id = 1; // UUID (auto-generated) + google.protobuf.Timestamp created_at = 21; + google.protobuf.Timestamp updated_at = 22; + + // Backstage-compatible fields + string api_version = 2; // "backstage.io/v1alpha1" + string kind = 3; // "Component", "API", "User", etc. + + // Polymorphic metadata (identifying information) + oneof metadata { + // Core types (built-in) + ComponentMetadata component_metadata = 10; + APIMetadata api_metadata = 11; + UserMetadata user_metadata = 12; + GroupMetadata group_metadata = 13; + SystemMetadata system_metadata = 14; + DomainMetadata domain_metadata = 15; + ResourceMetadata resource_metadata = 16; + LocationMetadata location_metadata = 17; + TemplateMetadata template_metadata = 18; + + // Plugin types (dynamically added) + // Example: DefectDojoProductMetadata defectdojo_product_metadata = 100; + } + + // Polymorphic spec (configuration and behavior) + oneof spec { + // Core types (built-in) + ComponentSpec component_spec = 10; + APISpec api_spec = 11; + UserSpec user_spec = 12; + GroupSpec group_spec = 13; + SystemSpec system_spec = 14; + DomainSpec domain_spec = 15; + ResourceSpec resource_spec = 16; + LocationSpec location_spec = 17; + TemplateSpec template_spec = 18; + + // Plugin types (dynamically added) + // Example: DefectDojoProductSpec defectdojo_product_spec = 100; + } + + // Annotations - external tool references + // Format: "tool.com/resource-type-attribute" + // Examples: + // "defectdojo.com/product-id": "42" + // "dependencytrack.com/project-uuid": "550e8400-..." + // "github.com/repo-slug": "myorg/myrepo" + map annotations = 20; +} +``` + +### Backstage Entity Kinds Mapping + +All Backstage entity kinds must be supported: + +| Backstage Kind | Charybdis Proto Type | Status | +|----------------|---------------------|--------| +| Component | ComponentMetadata/Spec | ✅ Done | +| Service | ServiceMetadata/Spec | ✅ Done | +| API | APIMetadata/Spec | ✅ Done | +| User | UserMetadata/Spec | ✅ Done | +| Group | GroupMetadata/Spec | ✅ Done | +| System | SystemMetadata/Spec | ✅ Done | +| Domain | DomainMetadata/Spec | ✅ Done | +| Resource | ResourceMetadata/Spec | ✅ Done | +| Location | LocationMetadata/Spec | Not planned (entities are event-driven, not file-based) | +| Template | TemplateMetadata/Spec | Phase 3 (Scaffolder) | + +--- + +## YAML Adapter Layer + +### Purpose + +Serve entities in Backstage-compatible YAML format over HTTP. + +### API Specification + +#### 1. List Locations + +**Endpoint**: `GET /yaml/locations` + +**Purpose**: Return a dynamic Location entity that lists all entities in Charybdis. + +**Response Format** (Backstage Location YAML): +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Location +metadata: + name: charybdis-all-entities + description: Dynamic location managed by Charybdis +spec: + type: charybdis + targets: + - http://charybdis.company.com/yaml/entities/service-payment + - http://charybdis.company.com/yaml/entities/service-auth + - http://charybdis.company.com/yaml/entities/api-payments-v1 + - http://charybdis.company.com/yaml/entities/user-john-doe + # ... all entities +``` + +**Implementation Notes**: +- Query `SELECT id, kind FROM entities` +- Generate URL for each entity: `/yaml/entities/{kind}-{id}` +- Return as Backstage Location YAML +- Cache response (invalidate on entity CRUD) + +#### 2. Get Entity by ID + +**Endpoint**: `GET /yaml/entities/:id` + +**Purpose**: Return a specific entity in Backstage YAML format. + +**Response Format** (Backstage Component YAML): +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: payment-service + namespace: production + description: Core payment processing service + annotations: + github.com/repo-slug: myorg/payment-service + defectdojo.com/product-id: "42" + dependencytrack.com/project-uuid: "550e8400-e29b-41d4-a716-446655440000" + tags: + - payments + - critical + links: + - url: https://dashboard.company.com/payment-service + title: Dashboard + icon: dashboard +spec: + type: service + lifecycle: production + owner: payments-team + system: payment-system + dependsOn: + - component:auth-service + providesApis: + - payments-api-v1 +``` + +**Implementation Notes**: +- Query `repository.get_by_id(id)` +- Convert Entity protobuf → Backstage YAML +- Map `oneof metadata/spec` to appropriate YAML structure +- Include all annotations +- Return as `text/yaml` content type + +#### 3. Configuration in Backstage + +**Backstage app-config.yaml**: +```yaml +catalog: + locations: + - type: url + target: http://charybdis.company.com/yaml/locations + rules: + - allow: [Component, API, User, Group, System, Domain, Resource] +``` + +**How it works**: +1. Backstage fetches `/yaml/locations` on catalog refresh +2. Parses Location YAML to get entity URLs +3. Fetches each entity URL (`/yaml/entities/:id`) +4. Ingests entities into Backstage catalog +5. Displays in UI with all metadata and annotations + +--- + +## Plugin System + +### Plugin Architecture + +Charybdis supports **two types of plugins**, both compile-time integrated: + +#### **1. Event-Driven Plugins** +React to entity lifecycle events (create, update, delete): +- **Example**: DefectDojo, DependencyTrack +- **Implement**: `EventDrivenPlugin` trait +- **Provide**: `ResourceHandler` implementations +- **Triggered by**: Entity CRUD operations + +#### **2. Sync Plugins** +Pull data from external sources on a schedule: +- **Example**: Okta, Keycloak, Active Directory +- **Implement**: `SyncPlugin` trait +- **Scheduled by**: Cron expressions +- **Triggered by**: Time-based schedule or manual API call + +### Generic Plugin Utilities (2025-01-15) + +All plugins have access to reusable utilities in `src/plugins/`: + +#### **PluginHttpClient** +Generic HTTP client with multiple auth methods: +- Token authentication (DefectDojo) +- Bearer authentication (modern APIs) +- API Key authentication (DependencyTrack) +- Basic authentication (JIRA, Bitbucket) +- Methods: `get()`, `post()`, `put()`, `patch()`, `delete()` + +#### **AnnotationHelper** +Consistent API for storing/retrieving plugin metadata: +- `set_id()` / `get_id()` - Store external resource IDs +- `set()` / `get()` - Store arbitrary metadata +- Enforces naming: `{plugin}.com/{resource}-id` +- Prevents annotation conflicts between plugins + +#### **FieldMapper** +Maps entity fields to external tool formats: +- Direct field paths: `"metadata.name"` +- Static values: `{"value": "Web Application"}` +- Complex mappings with entity resolution +- Repository integration for cross-entity queries + +#### **DateUtils** +Common date/time operations: +- `today()`, `today_plus_days(n)` - Date formatting +- `engagement_date_range(days)` - For time-bound resources +- `iso_timestamp()` - ISO 8601 timestamps + +### Plugin Responsibilities + +Plugins can: + +1. **Define custom entity types** (via protobuf) +2. **React to entity events** (via ResourceHandler trait) +3. **Call external APIs** (using PluginHttpClient) +4. **Store metadata** (using AnnotationHelper) +5. **Map fields** (using FieldMapper) +6. **Schedule syncs** (SyncPlugins with cron) +7. **(Future) Extend gRPC API** (custom services) + +### Plugin Lifecycle + +``` +1. Development + ├─ Define protobuf schema (metadata/spec messages) + ├─ Implement EventHandler trait + ├─ Implement external API client + └─ Add configuration schema + +2. Registration + ├─ Add to plugins.toml (enabled = true) + └─ Add to Cargo.toml dependencies + +3. Build Time + ├─ build.rs reads plugins.toml + ├─ Generates entities.proto with plugin types + ├─ Compiles all protobuf to Rust code + └─ Links plugin into binary + +4. Runtime + ├─ Load plugin configuration from config file + ├─ Initialize plugin handler + ├─ Subscribe to event bus + └─ React to entity events +``` + +### Plugin Configuration System + +**File**: `config.toml` (TOML format with `${VAR}` env var substitution) + +```toml +# Server configuration +[server] +grpc_host = "[::1]" +grpc_port = 50051 + +[server.yaml_adapter] +enabled = true +host = "0.0.0.0" +port = 8080 + +# Database +[database] +url = "${DATABASE_URL}" +max_connections = 10 + +# Plugin configurations +[plugins.defectdojo] +enabled = true +base_url = "${DEFECTDOJO_API_URL}" +api_token = "${DEFECTDOJO_API_TOKEN}" + +[plugins.defectdojo.default_engagement] +auto_create = true +name = "CI/CD Pipeline" +engagement_type = "CI/CD" + +[plugins.keycloak] +enabled = false +# base_url = "https://keycloak.company.com" +# realm = "master" + +[plugins.dependencytrack] +enabled = false +# base_url = "https://dependencytrack.company.com" +# api_key = "${DEPENDENCYTRACK_API_KEY}" +``` + +See `config.toml.example` for the full reference with all options documented. + +**Configuration loading** (`src/config.rs`): + +```rust +pub struct Config { + pub server: ServerConfig, + pub database: DatabaseConfig, + pub security: SecurityConfig, + pub telemetry: TelemetryConfig, + pub plugins: PluginsConfig, +} + +impl Config { + pub fn from_file(path: &str) -> Result { + let content = std::fs::read_to_string(path)?; + // Substitute ${VAR} with environment variable values + let content = substitute_env_vars(&content); + let config: Config = toml::from_str(&content)?; + Ok(config) + } +} +``` + +**Each plugin implements**: + +```rust +pub trait Plugin { + fn name(&self) -> &str; + fn plugin_type(&self) -> PluginType; + fn load_config(&mut self, config: PluginConfig) -> Result<()>; + fn validate_config(&self) -> Result<()>; +} +``` + +### Event Handler Behavior Configuration + +Plugins can configure different behaviors based on: +- Entity kind +- Event type (created/updated/deleted) +- Custom conditions + +**Example**: DefectDojo plugin config (`config.toml`) + +```toml +[plugins.defectdojo] +enabled = true +base_url = "${DEFECTDOJO_API_URL}" +api_token = "${DEFECTDOJO_API_TOKEN}" + +[plugins.defectdojo.default_engagement] +auto_create = true +name = "CI/CD Pipeline" +engagement_type = "CI/CD" +``` + +**Handler Implementation**: + +```rust +impl EventHandler for DefectDojoHandler { + async fn handle_event(&self, event: &EntityEvent) -> EventResult<()> { + // Check if plugin enabled + if !self.config.enabled { + return Ok(()); + } + + // Match event type to configured behavior + let behavior = match event.event_type { + EntityEventType::Created => &self.config.on_entity_created, + EntityEventType::Updated => &self.config.on_entity_updated, + EntityEventType::Deleted => &self.config.on_entity_deleted, + }; + + // Find matching rule for this entity kind + for rule in behavior { + if rule.matches(&event) { + return self.execute_action(&rule.action, event).await; + } + } + + Ok(()) + } +} +``` + +--- + +## Future: Plugin API Extensions + +**Future capability**: Plugins can expose custom gRPC endpoints. + +### Example: DefectDojo Query Plugin + +**Plugin extends API with custom service**: + +```protobuf +// plugins/defectdojo/proto/defectdojo_service.proto + +service DefectDojoService { + // Get findings for a specific entity + rpc GetEntityFindings(GetEntityFindingsRequest) returns (GetEntityFindingsResponse); + + // Get all active vulnerabilities + rpc GetActiveVulnerabilities(GetActiveVulnerabilitiesRequest) returns (GetActiveVulnerabilitiesResponse); + + // Sync entity with DefectDojo + rpc SyncEntity(SyncEntityRequest) returns (SyncEntityResponse); +} + +message GetEntityFindingsRequest { + string entity_id = 1; + string severity_filter = 2; // "Critical", "High", etc. + bool active_only = 3; +} + +message GetEntityFindingsResponse { + repeated Finding findings = 1; + int32 total_count = 2; +} + +message Finding { + int32 id = 1; + string title = 2; + string severity = 3; + string description = 4; + google.protobuf.Timestamp date = 5; + bool active = 6; +} +``` + +**Implementation**: + +```rust +pub struct DefectDojoServiceImpl { + client: DefectDojoClient, + repository: EntityRepository, +} + +#[async_trait] +impl DefectDojoService for DefectDojoServiceImpl { + async fn get_entity_findings( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + // Get entity to retrieve DefectDojo product ID + let entity = self.repository.get_by_id(&req.entity_id).await?; + let product_id = entity + .annotations + .get("defectdojo.com/product-id") + .ok_or_else(|| Status::not_found("No DefectDojo product linked"))?; + + // Fetch findings from DefectDojo API + let findings = self.client + .get_findings(product_id, &req.severity_filter, req.active_only) + .await?; + + Ok(Response::new(GetEntityFindingsResponse { + findings: findings.into_iter().map(|f| f.into()).collect(), + total_count: findings.len() as i32, + })) + } +} +``` + +**Registration in main.rs**: + +```rust +// Register plugin's custom gRPC service +if defectdojo_plugin.is_enabled() { + let defectdojo_service = DefectDojoServiceServer::new( + DefectDojoServiceImpl::new(defectdojo_client, repository.clone()) + ); + + server = server.add_service(defectdojo_service); +} +``` + +**Client usage**: + +```bash +grpcurl -d '{ + "entity_id": "service-payment", + "severity_filter": "Critical", + "active_only": true +}' localhost:50051 charybdis.plugins.defectdojo.DefectDojoService/GetEntityFindings +``` + +--- + +## Technology Decisions + +### Why gRPC Only? + +**Decision**: Charybdis core provides only gRPC. Teams add REST/GraphQL as needed. + +**Rationale**: +- **Strong typing**: Protobuf schemas ensure correctness across languages +- **Performance**: Binary protocol, efficient for CI/CD use cases +- **Multi-language**: Official clients for Python, Go, Node.js, Java, Rust, C++, C#, etc. +- **Streaming**: Built-in bidirectional streaming (future: real-time updates) +- **Code generation**: Automatic client generation eliminates boilerplate +- **Focus**: Core team maintains one high-quality API surface + +**Teams can add REST if needed**: +``` +grpc-gateway or Envoy → Charybdis gRPC +``` + +### Why PostgreSQL + JSONB? + +**Decision**: Use PostgreSQL with protobuf BYTEA storage and JSONB annotations. + +**Rationale**: +- **Scale**: Handles 1000s of entities easily (tested: 170k resources) +- **JSONB performance**: GIN indexes enable fast annotation queries +- **Familiarity**: Most teams already run PostgreSQL +- **Reliability**: ACID transactions, mature ecosystem +- **Future-proof**: Can add read replicas, partitioning if needed + +**Alternative considered**: MongoDB +- **Verdict**: PostgreSQL with JSONB provides same flexibility with better consistency guarantees + +### Why Compile-Time Plugins? + +**Decision**: Plugins are compile-time integrated, not runtime loaded. + +**Rationale**: +- **Type safety**: Rust compiler ensures correctness +- **Performance**: No dynamic loading overhead +- **Simplicity**: No plugin version compatibility matrix +- **Security**: No arbitrary code execution +- **Trade-off**: Requires rebuild to add plugins (acceptable for infrastructure tool) + +**Community plugins**: Published as crates, teams include in their build. + +### Why Event-Driven? + +**Decision**: Plugins react to events after entity persistence. + +**Rationale**: +- **Decoupling**: Plugins can't break core CRUD operations +- **Resilience**: Plugin failures are logged, not propagated +- **Async**: Long-running operations don't block API +- **Extensibility**: Add plugins without modifying core +- **Observability**: Events provide audit trail + +**Trade-off**: Plugins can't prevent entity creation (validation must be in core or client). + +--- + +## Scale Estimates + +### Reference Deployment + +**Company**: ~100 engineers +**Backstage Entities**: +- Components: ~1,000 +- APIs: ~1,000 +- Users: ~700 +- Groups: ~200 +- Systems: ~50 +- Domains: ~150 +- Resources: ~170,000 + +**Charybdis Requirements**: +- PostgreSQL: ~500MB storage (with JSONB annotations) +- Memory: ~256MB for Charybdis service +- CPU: Minimal (mostly I/O bound) + +**10x Scale** (1,000 engineers, 1.7M resources): +- PostgreSQL: ~5GB storage +- Memory: ~512MB +- CPU: Still minimal with proper indexing + +**Bottlenecks**: +- JSONB queries on annotations (solved with GIN indexes) +- Event bus throughput (solved with Redis/RabbitMQ backend) +- Plugin external API rate limits (solved with plugin-level queuing) + +--- + +## Development Roadmap + +> See [VISION.md](VISION.md) for the full product vision and strategic roadmap. + +### Phase 0: Catalog Foundation (Done) + +- ✅ Zero-migration database architecture +- ✅ gRPC API with protobuf (full entity CRUD) +- ✅ Event bus system (MemoryEventBus) +- ✅ Event dispatcher architecture (generic + plugin-specific) +- ✅ All Backstage entity kinds (Component, System, API, User, Group, Domain, Resource) +- ✅ YAML adapter endpoints (Backstage compatibility) +- ✅ gRPC field masks for partial updates +- ✅ OpenTelemetry observability (traces, metrics, logs) +- ✅ mTLS + RBAC security framework +- ✅ Config loading from TOML with env var substitution +- ✅ Plugin trait architecture (EventDriven + Sync) +- ✅ Generic plugin utilities (HttpClient, AnnotationHelper, FieldMapper, DateUtils) +- ✅ DefectDojo plugin (products, engagements, owner resolution) +- ✅ Keycloak plugin (user/group sync with annotations) + +### Phase 1: Security Core (Current Focus) + +**Goal**: Native vulnerability management and scan ingestion. + +- [ ] Vulnerability/Observation entity kind (protobuf + storage) +- [ ] ScanService gRPC endpoint for scan ingestion +- [ ] SARIF parser (covers majority of modern scanners) +- [ ] CycloneDX parser (SBOMs + vulnerability data) +- [ ] SPDX parser (license data) +- [ ] Assessment workflow (triage, accept risk, remediate) +- [ ] Rules engine for auto-assessment +- [ ] Security gates (severity thresholds per product) +- [ ] License tracking and policy engine + +### Phase 2: Compliance & Integrations + +**Goal**: Compliance frameworks and integration plugins. + +- [ ] Compliance framework mappings (NIS2, SOC2, DORA) +- [ ] VEX document support (CSAF, OpenVEX) +- [ ] Export/reporting (PDF, Excel) +- [ ] Notification plugins (Slack, Teams, email) +- [ ] Issue tracker plugins (Jira, GitHub, GitLab) +- [ ] Redis event bus backend + +### Phase 3: Scaffolder & Ecosystem + +**Goal**: Service scaffolding and community growth. + +- [ ] Service scaffolder (Git-native templates) +- [ ] Event-driven provisioning on scaffold +- [ ] Plugin SDK documentation +- [ ] Helm chart & 1-click deploy +- [ ] Community plugin registry + +--- + +## Success Criteria + +### Phase 1 Success (Security Core) + +- [ ] Ingest SARIF scan results via gRPC +- [ ] Vulnerabilities linked to catalog entities +- [ ] Rules engine auto-assesses based on severity/component +- [ ] Security gates block on threshold violations +- [ ] Assessment workflow (triage → accept/remediate) +- [ ] License data ingested from CycloneDX/SPDX + +### Production Success + +- [ ] 1,000+ entities managed with security posture +- [ ] <100ms p99 latency for CRUD operations +- [ ] Single `helm install` deployment +- [ ] Documentation complete +- [ ] 3+ integration plugins in production + +--- + +## Key Decisions Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **API Protocol** | gRPC only | Strong typing, multi-language, performance | +| **Frontend** | Backstage YAML adapter | Compatible with existing Backstage deployments | +| **Security features** | Native (core) | First-class, not plugins. Vulns, compliance, gates in the core | +| **External integrations** | Plugins | Slack, Jira, etc. as event-driven plugins | +| **Database** | PostgreSQL + JSONB | Scale, familiarity, ACID | +| **Plugin Loading** | Compile-time | Type safety, performance, security | +| **Event Model** | Post-persistence | Resilience, decoupling | +| **Schema Evolution** | Protobuf only | Zero migrations | +| **Configuration** | TOML + env vars | Flexibility, Rust-native | +| **Deployment** | Single binary + Docker + K8s | Cloud-native, minimal footprint | + +--- + +## References + +- [Backstage Descriptor Format](https://backstage.io/docs/features/software-catalog/descriptor-format) +- [gRPC Documentation](https://grpc.io/docs/) +- [Protocol Buffers Guide](https://protobuf.dev/) +- [PostgreSQL JSONB](https://www.postgresql.org/docs/current/datatype-json.html) + +--- + +## Document Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2025-01-03 | Initial comprehensive architecture document | +| 1.1 | 2025-01-15 | Added dual plugin architecture, generic utilities, updated roadmap | +| 2.0 | 2026-04-02 | Pivoted to security-native platform engineering tool. Extracted product vision to VISION.md. Added native vulnerability management, compliance frameworks to architecture. Updated roadmap and success criteria. | + +--- + +**This document covers architecture and technical decisions.** +For product vision and strategic direction, see [VISION.md](VISION.md). +All code and documentation must align with these documents. diff --git a/docs/core-concepts.md b/docs/core-concepts.md new file mode 100644 index 0000000..be0de79 --- /dev/null +++ b/docs/core-concepts.md @@ -0,0 +1,628 @@ +# Core Concepts + +Charybdis is a **security-native platform engineering tool** that unifies software catalog, vulnerability management, and compliance posture behind a gRPC API. When entities change or scan results are ingested, the event bus triggers rules evaluation, security gate checks, and plugin integrations automatically. + +This page explains the key concepts you need to understand. + +## Architecture Overview + +```mermaid +graph TB + subgraph "Sources" + A1[CI/CD Pipelines] + A2[Security Scanners] + A3[IaC Tools] + end + + subgraph "Charybdis Core" + B1[gRPC API] + B3[Authentication & Authorization] + C1[Entity Service] + C4[Vulnerability Engine] + C5[Security Gates & Rules] + C2[Event Bus] + end + + subgraph "Integrations - Plugins" + D1[Slack / Teams] + D2[Jira / GitHub] + D3[Custom Plugins] + end + + subgraph "Data Layer" + E1[(PostgreSQL)] + end + + A1 -.gRPC.-> B1 + A2 -.Scan Results.-> B1 + A3 -.gRPC.-> B1 + + B1 --> B3 + B3 --> C1 + B3 --> C4 + C4 --> C5 + C1 --> E1 + C4 --> E1 + C1 --> C2 + C4 --> C2 + + C2 -.Events.-> D1 + C2 -.Events.-> D2 + C2 -.Events.-> D3 + + style C4 fill:#E24A4A,stroke:#8A2E2E,color:#fff + style C5 fill:#E2884A,stroke:#8A5C2E,color:#fff + style C2 fill:#4A90E2,stroke:#2E5C8A,color:#fff +``` + +## Entities + +An **entity** is the core data model in Charybdis, representing any cataloged item in your software ecosystem. + +### Entity Structure + +Every entity has three main parts: + +```mermaid +classDiagram + class Entity { + +string id + +string kind + +metadata + +spec + +annotations + +timestamps + } + + class Metadata { + +string name + +string namespace + +string description + +labels + +links + +tags + } + + class Spec { + +string type + +string lifecycle + +string owner + +dependencies + } + + Entity --> Metadata + Entity --> Spec +``` + +### Entity Kinds + +Charybdis supports all standard Backstage entity kinds: + +| Kind | Description | Example | +|------|-------------|---------| +| **Service** | Individual microservices or applications | `payment-api` | +| **Component** | Reusable libraries, SDKs, modules | `auth-sdk` | +| **System** | Collections of services working together | `e-commerce-platform` | +| **API** | Interfaces exposed by components | `payments-rest-api` | +| **User** | Individual people | `john.doe` | +| **Group** | Teams and organizational units | `team-payments` | +| **Domain** | Business domains | `payments`, `shipping` | +| **Resource** | Infrastructure resources | `payments-db`, `cache-cluster` | + +#### Example: Service + +```json +{ + "kind": "Service", + "service_metadata": { + "name": "payment-api", + "namespace": "production", + "description": "Payment processing service", + "labels": { "team": "payments" } + }, + "service_spec": { + "type": "service", + "lifecycle": "production", + "owner": "team-payments", + "system": "payment-system" + } +} +``` + +#### Example: Component + +```json +{ + "kind": "Component", + "component_metadata": { + "name": "auth-sdk", + "namespace": "shared" + }, + "component_spec": { + "type": "library", + "lifecycle": "production", + "owner": "platform-team" + } +} +``` + +#### Example: System + +```json +{ + "kind": "System", + "system_metadata": { + "name": "e-commerce-platform", + "namespace": "production", + "description": "Complete e-commerce system" + }, + "system_spec": { + "owner": "platform-team", + "domain": "retail" + } +} +``` + +### Metadata Fields + +| Field | Type | Description | Required | +|-------|------|-------------|----------| +| `name` | string | Entity name (unique within namespace) | ✅ | +| `namespace` | string | Logical grouping (e.g., "production", "staging") | ✅ | +| `description` | string | Human-readable description | ❌ | +| `labels` | map | Key-value pairs for categorization | ❌ | +| `tags` | array | Search tags | ❌ | +| `links` | array | External URLs (dashboards, docs, etc.) | ❌ | + +### Annotations + +Annotations store integration-specific metadata: + +```json +{ + "annotations": { + "github.com/repo-slug": "myorg/payment-service", + "defectdojo.com/product-id": "123", + "dependencytrack.com/project-uuid": "550e8400...", + "pagerduty.com/service-id": "PXYZ123", + "grafana.com/dashboard-url": "https://..." + } +} +``` + +**Best Practices**: +- Use domain-style keys (`tool.com/key`) +- Store tool-specific IDs +- Keep values as strings +- Use for integration metadata only + +## Events + +Charybdis uses an **event-driven architecture** to trigger actions when entities change. + +### Event Flow + +```mermaid +sequenceDiagram + participant Client + participant API as Entity Service + participant Bus as Event Bus + participant Plugin1 as DefectDojo Plugin + participant Plugin2 as Dependency-Track + + Client->>API: CreateEntity(service) + API->>API: Store entity + API->>Bus: Emit EntityCreated event + Bus->>Plugin1: Handle event + Bus->>Plugin2: Handle event + Plugin1-->>Plugin1: Create DefectDojo product + Plugin2-->>Plugin2: Create DT project + API-->>Client: Return created entity + + Note over Bus,Plugin2: Asynchronous processing +``` + +### Event Types + +| Event | Trigger | Plugins Receive | +|-------|---------|----------------| +| `EntityCreated` | New entity created | Full entity data | +| `EntityUpdated` | Entity modified | Updated entity + changes | +| `EntityDeleted` | Entity removed | Entity ID + metadata | + +### Event Structure + +```rust +pub enum EntityEvent { + Created { + entity: Entity, + timestamp: DateTime, + }, + Updated { + entity: Entity, + previous: Entity, + timestamp: DateTime, + }, + Deleted { + id: String, + metadata: Metadata, + timestamp: DateTime, + }, +} +``` + +## Vulnerabilities & Security (Phase 1 — Planned) + +> **Note**: The features described in this section are part of Phase 1 (Security Core) and are not yet implemented. This documents the planned architecture. See [VISION.md](../VISION.md) for the roadmap. + +Security is a **first-class concept** in Charybdis, not a plugin. Once Phase 1 is complete, vulnerability management, scan ingestion, security gates, and assessment workflows will be native to the core. + +### Vulnerability Lifecycle + +```mermaid +sequenceDiagram + participant Scanner + participant API as Charybdis API + participant Rules as Rules Engine + participant Gate as Security Gate + participant Bus as Event Bus + participant Plugin as Slack / Jira + + Scanner->>API: IngestScan(SARIF report) + API->>API: Parse & create Vulnerability entities + API->>API: Link vulnerabilities to Component + API->>Rules: Evaluate auto-assessment rules + Rules-->>API: Auto-assess (e.g., accept known low-risk) + API->>Gate: Check security gate thresholds + alt Gate Passed + Gate-->>API: OK + else Gate Failed + Gate->>Bus: GateFailed event + Bus->>Plugin: Alert #security channel + end + API->>Bus: VulnerabilitiesIngested event + Bus->>Plugin: Notify / create tickets +``` + +### Scan Ingestion + +Charybdis ingests scan results natively. You don't need an external vulnerability management tool. + +| Format | Coverage | Use Case | +|--------|----------|----------| +| **SARIF** | 60%+ of modern scanners (Semgrep, CodeQL, Trivy, etc.) | SAST, DAST, secrets | +| **CycloneDX** | SBOMs + vulnerability data | SCA, license | +| **SPDX** | License and package data | License compliance | + +### Assessments + +Each vulnerability can be assessed: + +| Status | Meaning | +|--------|---------| +| **Open** | New, unreviewed vulnerability | +| **In Triage** | Under review by security team | +| **Accepted** | Risk accepted with justification | +| **Remediated** | Fixed, pending verification | +| **False Positive** | Not a real vulnerability | +| **Auto-Assessed** | Automatically assessed by rules engine | + +### Security Gates + +Security gates define thresholds per product: + +``` +payment-api: + critical: 0 # No critical vulns allowed + high: 5 # Up to 5 high + medium: 20 # Up to 20 medium +``` + +When a gate is violated, events fire and plugins react (block CI/CD, alert Slack, create Jira tickets). + +### Rules Engine + +Rules auto-assess vulnerabilities based on patterns: + +- Severity + component combination (e.g., "low severity in test dependencies → auto-accept") +- Scanner source (e.g., "all informational from ZAP → auto-accept") +- Known patterns (e.g., "CVE-XXXX already accepted org-wide") + +### License Compliance (Phase 2) + +Track licenses across your dependency tree: + +- Ingest license data from CycloneDX/SPDX +- Define license policies (allowed, restricted, banned) +- Flag violations per component +- Compliance reporting + +## Storage Model + +Charybdis uses PostgreSQL with JSONB for schema-less storage. + +### Database Schema + +```sql +CREATE TABLE entities ( + id UUID PRIMARY KEY, + kind TEXT NOT NULL, + entity_data BYTEA NOT NULL, -- Protobuf binary + annotations JSONB NOT NULL DEFAULT '{}', -- Plugin metadata (indexed) + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX idx_entities_kind ON entities(kind); +CREATE INDEX idx_entities_annotations ON entities USING GIN(annotations); +``` + +### Why Protobuf + JSONB? + +**Advantages**: +- ✅ No schema migrations — new entity kinds via protobuf `oneof`, schema never changes +- ✅ Protobuf binary storage for compact, versioned entity data +- ✅ JSONB annotations for fast querying with GIN indexes +- ✅ Forward/backward compatibility built-in + +**Example Query**: +```sql +-- Find entities with specific annotation +SELECT * FROM entities +WHERE annotations->>'defectdojo.com/product-id' = '42'; +``` + +## Security Model + +Charybdis implements defense-in-depth security. + +### Authentication: mTLS + +```mermaid +sequenceDiagram + participant Client + participant TLS as TLS Layer + participant Auth as Auth Interceptor + participant Service as Entity Service + + Client->>TLS: Connect with client certificate + TLS->>TLS: Validate certificate + TLS->>Auth: Extract certificate + Auth->>Auth: Parse identity (CN, OU, O) + Auth->>Auth: Map to role + Client->>Auth: Request (with identity) + Auth->>Auth: Check permissions + alt Authorized + Auth->>Service: Forward request + Service-->>Client: Response + else Denied + Auth-->>Client: PermissionDenied error + end +``` + +### Authorization: RBAC + +**Role Mapping**: + +``` +Certificate (CN, OU, O) → RBAC Role → Permissions +``` + +**Default Roles**: + +| Role | Certificate OU | Permissions | +|------|---------------|-------------| +| `platform` | `platform-team` | Full access (CRUD + list) | +| `automation` | `automation` | Create, read, update, list | +| `plugin` | `plugins` | Read, list only | + +### Permissions + +| Permission | Operations | Required For | +|------------|-----------|--------------| +| `entity:create` | Create new entities | CreateEntity | +| `entity:read` | Get entity by ID | GetEntity | +| `entity:update` | Modify entities | UpdateEntity | +| `entity:delete` | Remove entities | DeleteEntity | +| `entity:list` | List all entities | ListEntities | + +## Plugin System + +Core features (catalog, vulnerabilities, compliance) are **native**. Plugins handle **integrations** with external systems. + +### Plugin Architecture + +```mermaid +graph LR + A[Entity/Vuln Event] --> B[Event Bus] + B --> C{Plugin Manager} + C --> D[Slack Plugin] + C --> E[Jira Plugin] + C --> F[Custom Plugin] + + D --> G[Slack API] + E --> H[Jira API] + F --> I[Your Tool API] + + style C fill:#4A90E2,stroke:#2E5C8A +``` + +### What's Native vs. Plugin + +| Native (core) | Status | Plugin (integration) | Status | +|---|---|---|---| +| Software catalog | Done | DefectDojo sync | Done | +| Vulnerability management | Phase 1 | Keycloak user sync | Done | +| Security gates & rules | Phase 1 | Slack / Teams notifications | Planned | +| Assessment workflow | Phase 1 | Jira / GitHub issue creation | Planned | +| License compliance | Phase 2 | Custom integrations | Framework ready | +| TechDocs | Future | | | + +### Plugin Lifecycle + +1. **Configuration** - Load plugin settings from `plugins.toml` +2. **Initialization** - Plugin registers event handlers +3. **Event Processing** - Plugin receives events asynchronously (entity, vulnerability, gate events) +4. **External Integration** - Plugin calls external tool APIs +5. **Error Handling** - Failed plugins don't affect core service + +### Plugin Configuration + +Example `plugins.toml`: + +```toml +[plugins.slack] +enabled = true +webhook_url = "${SLACK_WEBHOOK_URL}" + +[[plugins.slack.on_gate_failed]] +channel = "#security-alerts" + +[[plugins.slack.on_entity_created]] +channel = "#platform" +``` + +See [Plugin Guide](plugins.md) for details. + +## Data Consistency + +### Eventual Consistency + +Charybdis uses **eventual consistency** for plugin integrations: + +- Entity CRUD operations are **immediately consistent** +- Plugin synchronization is **eventually consistent** +- Events are processed **asynchronously** + +```mermaid +graph LR + A[CreateEntity] -->|Immediate| B[Entity Stored] + B -->|Async| C[Event Emitted] + C -->|Async| D[Plugin Processing] + D -->|Eventual| E[External Tool Synced] + + style B fill:#00C851 + style E fill:#ffbb33 +``` + +### Guarantees + +| Operation | Consistency | Guarantee | +|-----------|-------------|-----------| +| Entity CRUD | Strong | Immediate | +| Entity queries | Strong | Read-your-writes | +| Event delivery | At-least-once | May retry | +| Plugin sync | Eventual | Best-effort | + +## Performance Characteristics + +### Scalability + +- **Entities**: Tested with 100,000+ entities +- **Throughput**: 1,000+ requests/second +- **Latency**: Sub-millisecond average +- **Concurrency**: Tokio async runtime + +### Resource Usage + +Typical resource consumption: + +| Component | CPU | Memory | Storage | +|-----------|-----|--------|---------| +| Charybdis | < 5% | ~50 MB | Minimal | +| PostgreSQL | ~10% | ~256 MB | Depends on entity count | + +### Optimization Tips + +1. **Index annotations** used for frequent queries +2. **Use connection pooling** (built-in) +3. **Enable query caching** in PostgreSQL +4. **Monitor event bus** queue depth + +## Backstage Migration + +Charybdis includes a YAML adapter for teams migrating from Backstage. This is a **migration path**, not the primary interface. + +### How It Works + +```mermaid +graph LR + A[Charybdis Entity] --> B[YAML Adapter] + B --> C[Backstage YAML Format] + C --> D[Backstage Catalog] + + style B fill:#4A90E2,stroke:#2E5C8A +``` + +Point Backstage at Charybdis and stop maintaining `catalog-info.yaml` files: + +```yaml +catalog: + locations: + - type: url + target: http://charybdis:8080/yaml/locations +``` + +### Recommended Migration Path + +1. **Start** with Charybdis + YAML adapter feeding your existing Backstage +2. **Adopt** Charybdis gRPC API for CI/CD integrations and security scanning +3. **Leverage** event-driven plugins for auto-provisioning (DefectDojo, Jira, etc.) +4. **Optionally retire** Backstage when Charybdis covers your catalog needs + +## Best Practices + +### Entity Design + +✅ **DO**: +- Use descriptive names +- Group by namespace +- Add relevant labels +- Include documentation links +- Set appropriate owners + +❌ **DON'T**: +- Store sensitive data in metadata +- Use very long descriptions +- Create deeply nested hierarchies +- Duplicate data across entities + +### Naming Conventions + +``` +-- + +Examples: +- payment-api-prod +- user-service-staging +- auth-library +- e-commerce-system +``` + +### Metadata Organization + +```json +{ + "labels": { + "team": "payments", // Ownership + "tier": "critical", // Importance + "environment": "production" // Deployment + }, + "tags": ["pci-compliant", "public-api"], + "links": [ + { "url": "...", "title": "Dashboard" }, + { "url": "...", "title": "Documentation" } + ] +} +``` + +## Next Steps + +- Read the [Vision & Roadmap](../VISION.md) to understand where Charybdis is going +- Configure [Security](security.md) for production (mTLS + RBAC) +- Explore [Plugins](plugins.md) for external integrations +- Review the [Architecture](architecture.md) for technical deep dive + +--- + +**Questions?** [Open an issue](../../issues). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..61116aa --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,508 @@ +# Getting Started with Charybdis + +This guide will get you from zero to a running Charybdis instance in minutes. By the end, you'll have a working software catalog that can register services via gRPC, ingest scan results, and auto-trigger integrations via event-driven plugins. + +## Prerequisites + +- **Rust** - 1.70 or later ([install](https://rustup.rs/)) +- **PostgreSQL** - 14 or later +- **grpcurl** - For testing (optional, [install](https://github.com/fullstorydev/grpcurl)) + +## Installation + +### Option 1: From Source + +```bash +# Clone the repository +git clone https://github.com/charybdis-catalog/charybdis.git +cd charybdis + +# Build +cargo build --release + +# The binary will be at target/release/charybdis +``` + +### Option 2: Docker (Coming Soon) + +```bash +docker pull charybdis/charybdis:latest +``` + +## Quick Start + +### Step 1: Start PostgreSQL + +Using Docker: + +```bash +docker run -d \ + --name charybdis-postgres \ + -e POSTGRES_PASSWORD=mysecretpassword \ + -p 5432:5432 \ + postgres:15 +``` + +Or use an existing PostgreSQL instance. + +### Step 2: Configure Charybdis + +**Recommended: Use config.toml** + +Copy the example configuration: + +```bash +cp config.toml.example config.toml +``` + +Edit `config.toml` and set your database URL: + +```toml +[database] +url = "${DATABASE_URL}" +``` + +Set the environment variable: + +```bash +export DATABASE_URL="postgresql://postgres:mysecretpassword@localhost:5432/postgres" +``` + +**Alternative: Environment Variables Only (Legacy)** + +If you prefer environment variables: + +```bash +# Database +export DATABASE_URL="postgresql://postgres:mysecretpassword@localhost:5432/postgres" + +# Disable security for quick start +export SECURITY_MTLS_ENABLED=false +export SECURITY_RBAC_ENABLED=false + +# Logging +export RUST_LOG=info,charybdis=debug +``` + +### Step 3: Run Charybdis + +```bash +cargo run +``` + +You should see: + +``` +INFO charybdis: Database ready +INFO charybdis: Event bus started successfully +INFO charybdis: EntityService server listening on [::1]:50051 +``` + +### Step 4: Verify It's Working + +Test with grpcurl: + +```bash +# List available services +grpcurl -plaintext localhost:50051 list + +# Output: +# charybdis.entities.EntityService +# grpc.reflection.v1.ServerReflection +``` + +Congratulations! Charybdis is running! 🎉 + +## Creating Your First Entity + +### Using grpcurl + +Create a service entity: + +```bash +grpcurl -plaintext \ + -d '{ + "entity": { + "kind": "Service", + "service_metadata": { + "name": "payment-service", + "namespace": "production", + "description": "Core payment processing service" + } + } + }' \ + localhost:50051 charybdis.entities.EntityService/CreateEntity +``` + +Response: + +```json +{ + "entity": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "kind": "Service", + "serviceMetadata": { + "name": "payment-service", + "namespace": "production", + "description": "Core payment processing service" + }, + "createdAt": "2025-11-04T10:00:00Z", + "updatedAt": "2025-11-04T10:00:00Z" + } +} +``` + +### List All Entities + +```bash +grpcurl -plaintext -d '{}' \ + localhost:50051 charybdis.entities.EntityService/ListEntities +``` + +### Get Entity by ID + +```bash +grpcurl -plaintext \ + -d '{"id": "550e8400-e29b-41d4-a716-446655440000"}' \ + localhost:50051 charybdis.entities.EntityService/GetEntity +``` + +## Entity Types + +Charybdis supports three main entity types: + +### Service + +Individual microservices or applications: + +```bash +grpcurl -plaintext -d '{ + "entity": { + "kind": "Service", + "service_metadata": { + "name": "user-api", + "namespace": "production", + "description": "User management API" + } + } +}' localhost:50051 charybdis.entities.EntityService/CreateEntity +``` + +### System + +Collections of related services: + +```bash +grpcurl -plaintext -d '{ + "entity": { + "kind": "System", + "system_metadata": { + "name": "payment-system", + "namespace": "production", + "description": "Complete payment processing system" + } + } +}' localhost:50051 charybdis.entities.EntityService/CreateEntity +``` + +### Component + +Reusable components or libraries: + +```bash +grpcurl -plaintext -d '{ + "entity": { + "kind": "Component", + "component_spec": { + "type": "library", + "lifecycle": "production", + "owner": "platform-team" + }, + "component_metadata": { + "name": "auth-library", + "namespace": "shared", + "description": "Shared authentication library" + } + } +}' localhost:50051 charybdis.entities.EntityService/CreateEntity +``` + +## Adding Metadata + +Entities support rich metadata: + +```bash +grpcurl -plaintext -d '{ + "entity": { + "kind": "Service", + "service_metadata": { + "name": "payment-service", + "namespace": "production", + "description": "Payment processing", + "labels": { + "team": "payments", + "tier": "critical" + }, + "links": [ + { + "url": "https://dashboard.company.com/payments", + "title": "Dashboard", + "icon": "dashboard" + } + ], + "tags": ["payments", "pci-compliant", "critical"] + }, + "annotations": { + "github.com/repo-slug": "myorg/payment-service", + "pagerduty.com/service-id": "PXYZ123" + } + } +}' localhost:50051 charybdis.entities.EntityService/CreateEntity +``` + +## Integrating with CI/CD + +### Example: GitHub Actions + +Create a workflow to register services automatically: + +```yaml +name: Register Service +on: + push: + branches: [main] + +jobs: + register: + runs-on: ubuntu-latest + steps: + - name: Register in Charybdis + run: | + grpcurl -plaintext \ + -d '{ + "entity": { + "kind": "Service", + "service_metadata": { + "name": "${{ github.event.repository.name }}", + "namespace": "production", + "description": "${{ github.event.repository.description }}" + }, + "annotations": { + "github.com/repo-slug": "${{ github.repository }}" + } + } + }' \ + your-charybdis-host:50051 \ + charybdis.entities.EntityService/CreateEntity +``` + +### Example: GitLab CI + +```yaml +register_service: + stage: deploy + script: + - | + grpcurl -plaintext \ + -d "{ + \"entity\": { + \"kind\": \"Service\", + \"service_metadata\": { + \"name\": \"${CI_PROJECT_NAME}\", + \"namespace\": \"${CI_ENVIRONMENT_NAME}\" + } + } + }" \ + your-charybdis-host:50051 \ + charybdis.entities.EntityService/CreateEntity +``` + +## Enabling Security + +For production use, enable mTLS and RBAC: + +### Step 1: Generate Certificates + +```bash +# Use the provided test script +./test-mtls-rbac.sh +``` + +This creates: +- `certs/ca.pem` - Certificate Authority +- `certs/server-cert.pem` / `server-key.pem` - Server certificate +- `certs/admin-cert.pem` / `admin-key.pem` - Admin client certificate + +### Step 2: Enable Security + +```bash +export SECURITY_MTLS_ENABLED=true +export SECURITY_MTLS_SERVER_CERT=./certs/server-cert.pem +export SECURITY_MTLS_SERVER_KEY=./certs/server-key.pem +export SECURITY_MTLS_CLIENT_CA=./certs/ca.pem +export SECURITY_RBAC_ENABLED=true +``` + +### Step 3: Test with mTLS + +```bash +grpcurl \ + -cacert certs/ca.pem \ + -cert certs/admin-cert.pem \ + -key certs/admin-key.pem \ + -d '{}' \ + localhost:50051 charybdis.entities.EntityService/ListEntities +``` + +See the [Security Guide](security.md) for detailed configuration. + +## Backstage Migration (Optional) + +If you currently use Backstage and want to migrate gradually, the built-in YAML adapter serves entities in Backstage format: + +```yaml +# backstage app-config.yaml +catalog: + locations: + - type: url + target: http://your-charybdis-host:8080/yaml/locations + rules: + - allow: [Component, System, Service] +``` + +Backstage will automatically discover and import entities from Charybdis. You can run both in parallel — entities managed via gRPC are immediately visible in Backstage. + +## Troubleshooting + +### Port Already in Use + +``` +Error: transport error +``` + +**Solution**: Check if another process is using port 50051: + +```bash +lsof -ti:50051 +``` + +Kill the process or change the port: + +```bash +export GRPC_PORT=50052 +``` + +### Database Connection Failed + +``` +Error: password authentication failed +``` + +**Solution**: Verify your DATABASE_URL: + +```bash +# Test connection +psql "$DATABASE_URL" -c "SELECT 1;" +``` + +### Permission Denied (with security enabled) + +``` +Code: PermissionDenied +Message: Role 'X' does not have permission 'Y' +``` + +**Solution**: Check your certificate and role mappings. See [Security Guide](security.md). + +## Next Steps + +Now that you have Charybdis running: + +1. Learn about [Core Concepts](core-concepts.md) — entities, vulnerabilities, events +2. Read the [Vision & Roadmap](../VISION.md) — where Charybdis is going +3. Configure [Security](security.md) for production (mTLS + RBAC) +4. Explore [Plugins](../plugins/README.md) for external integrations + +## Configuration + +Charybdis supports two configuration methods: + +### 1. Configuration File (Recommended) + +Use `config.toml` for structured configuration: + +```toml +# config.toml +[server] +grpc_host = "[::1]" +grpc_port = 50051 + +[database] +url = "${DATABASE_URL}" # Environment variable substitution + +[security.mtls] +enabled = false + +[security.rbac] +enabled = false + +[telemetry] +service_name = "charybdis" +environment = "development" +enable_console = true +``` + +**Benefits:** +- ✅ Organized by section (server, database, security, telemetry, plugins) +- ✅ Environment variable substitution with `${VAR_NAME}` +- ✅ Comments and documentation inline +- ✅ Easy to version control (excluding secrets) +- ✅ No need to export dozens of environment variables + +**Using Environment Variables in config.toml:** + +```toml +[database] +url = "${DATABASE_URL}" # Will be substituted at runtime + +[plugins.defectdojo] +api_key = "${DEFECTDOJO_API_KEY}" # Secrets stay in environment +``` + +Then set only the secrets: + +```bash +export DATABASE_URL="postgresql://..." +export DEFECTDOJO_API_KEY="secret-key" +``` + +### 2. Environment Variables (Legacy) + +If `config.toml` is not found, Charybdis falls back to environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | (required) | PostgreSQL connection string | +| `GRPC_HOST` | `[::1]` | gRPC server bind address | +| `GRPC_PORT` | `50051` | gRPC server port | +| `RUST_LOG` | `info` | Logging level | +| `SECURITY_MTLS_ENABLED` | `false` | Enable mTLS authentication | +| `SECURITY_RBAC_ENABLED` | `false` | Enable RBAC authorization | +| `OTEL_ENABLE_CONSOLE` | `true` | Enable console logging | +| `OTEL_SERVICE_NAME` | `charybdis` | Service name for telemetry | + +### Configuration File Locations + +Charybdis looks for configuration files in this order: + +1. `./config.toml` (current directory) +2. `./charybdis.toml` +3. `/etc/charybdis/config.toml` (Linux/Unix) + +If none are found, it uses environment variables. + +See `config.toml.example` for a complete configuration template with all options documented. + +--- + +**Need help?** Check the [troubleshooting guide](troubleshooting.md) or [open an issue](../../issues). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..2d40ad2 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,125 @@ +# Charybdis Documentation + +**The security-native platform engineering tool.** + +## What is Charybdis? + +Charybdis is a platform engineering tool that unifies **software catalog**, **vulnerability management**, and **compliance posture** in a single event-driven platform. Built in Rust, deployed as a single binary. + +Instead of running Backstage + DefectDojo + Dependency-Track + a license scanner + a compliance spreadsheet, you run Charybdis. + +### The Problems It Solves + +1. **Fragmented tooling** — Your software catalog, vulnerability data, license info, and compliance evidence live in 5 different tools that don't talk to each other. Charybdis unifies them. + +2. **Static catalog data** — Traditional catalogs rely on YAML files that go stale within weeks. Charybdis is event-driven — CI/CD pipelines and IaC tools register and update entities via gRPC, so the catalog is always accurate. + +3. **Security as an afterthought** — In Backstage, security is a plugin. In Charybdis, every entity carries its vulnerability posture, license status, and compliance state natively. + +4. **Manual provisioning** — New service? Manually create entries in every tool. With Charybdis, one gRPC call catalogs the service and event-driven plugins handle the rest. + +5. **Compliance evidence assembly** — Compliance reporting pulls from real vulnerability and license data, not spreadsheets. + +## How It Works + +``` +CI/CD or Scanner ──gRPC──> Charybdis + ├── Catalogs the service (event-driven) ← Done + ├── Fires events to plugins (DefectDojo, ...) ← Done + ├── Ingests scan results (SARIF, CycloneDX) ← Phase 1 + ├── Evaluates security gates & rules ← Phase 1 + └── Exposes catalog via YAML adapter (Backstage) ← Done +``` + +**One platform. Your services are cataloged. Your vulns are tracked. Your compliance is visible. In real-time.** + +## Key Concepts + +| Concept | Description | +|---------|-------------| +| **Entity** | Anything in your software ecosystem: services, systems, components, APIs, users, groups, domains, resources | +| **Vulnerability** | *(Phase 1)* A security finding linked to an entity, ingested from scanner output (SARIF, CycloneDX) | +| **Assessment** | *(Phase 1)* The triage decision on a vulnerability: accept risk, remediate, auto-assessed by rules | +| **Security Gate** | *(Phase 1)* Severity thresholds per product — blocks deployments when violated | +| **Event Bus** | Publishes lifecycle events when entities or vulnerabilities change | +| **Plugin** | Reacts to events to integrate with external systems (Slack, Jira, GitHub, custom) | +| **Annotations** | Key-value metadata on entities for external references (e.g. `github.com/repo-slug`) | + +## Architecture + +```mermaid +graph LR + A[CI/CD / Scanners] -->|gRPC| B[Charybdis] + B -->|Native| C[Software Catalog] + B -->|Native| D[Vuln Management] + B -->|Native| E[Compliance] + B -->|Events| F[Plugins: Slack / Jira / Custom] + B -->|YAML| G[Backstage - optional] + + style B fill:#4A90E2,stroke:#2E5C8A,color:#fff + style D fill:#E24A4A,stroke:#8A2E2E,color:#fff + style E fill:#4AE28A,stroke:#2E8A5C,color:#fff +``` + +## Documentation + +### Getting Started +- [Installation & Quick Start](getting-started.md) — Get Charybdis running and register your first entity +- [Demo Stack](../deploy/DEMO.md) — Full demo with DefectDojo + +### Understanding Charybdis +- [Core Concepts](core-concepts.md) — Entities, vulnerabilities, events, and data model +- [Vision & Roadmap](../VISION.md) — Where Charybdis is going and why +- [Architecture](architecture.md) — Technical design decisions + +### Configuration +- [Security](security.md) — mTLS authentication and RBAC authorization +- [Plugin Configuration](PLUGIN_CONFIGURATION_GUIDE.md) — Setting up and configuring plugins + +### Extending Charybdis +- [Plugin Development](../plugins/README.md) — Build your own integration plugins + +## Quick Example + +Register a service from your CI/CD pipeline: + +```bash +grpcurl -plaintext -d '{ + "entity": { + "kind": "Component", + "component_metadata": { + "name": "payment-api", + "description": "Payment processing service" + }, + "component_spec": { + "type": "service", + "lifecycle": "production", + "owner": "team-payments" + } + } +}' charybdis:50051 charybdis.entities.EntityService/CreateEntity +``` + +**What happens next:** +- Entity stored in PostgreSQL with a UUID +- `EntityCreated` event published to the event bus +- Plugins react (e.g., DefectDojo creates a product automatically) +- Entity available via gRPC and YAML adapter + +No YAML file to write. No PR to open. No manual provisioning. + +## Backstage Migration + +Already using Backstage? Charybdis provides a YAML adapter for gradual migration. Point Backstage at Charybdis as a catalog source — entities registered via gRPC are immediately available in Backstage. + +```yaml +# backstage app-config.yaml +catalog: + locations: + - type: url + target: http://charybdis:8080/yaml/locations +``` + +--- + +**Ready to get started?** Head to the [Getting Started Guide](getting-started.md). diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..e5312c4 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,503 @@ +# Security + +Charybdis provides enterprise-grade security with mTLS authentication and RBAC authorization. + +## Overview + +```mermaid +graph TB + A[Client Request] -->|mTLS Handshake| B[TLS Layer] + B -->|Certificate| C[Auth Interceptor] + C -->|Extract Identity| D{Parse Certificate} + D -->|CN, OU, O| E[Role Mapping] + E -->|Role| F{Permission Check} + F -->|Authorized| G[Entity Service] + F -->|Denied| H[PermissionDenied Error] + G -->|Audit Log| I[Security Events] + + style C fill:#f9d71c,stroke:#f9a825 + style F fill:#4A90E2,stroke:#2E5C8A +``` + +## mTLS Authentication + +Mutual TLS (mTLS) provides certificate-based client authentication. + +### How It Works + +1. **Client** presents X.509 certificate during TLS handshake +2. **Server** validates certificate against trusted CA +3. **Charybdis** extracts certificate identity (CN, OU, O) +4. **RBAC Engine** maps identity to role +5. **Permissions** are checked before allowing the request + +### Certificate Structure + +``` +Subject: CN=admin-user, OU=platform-team, O=Charybdis-Dev, C=US +Issuer: CN=Charybdis Root CA, O=Charybdis-Dev, C=US +Validity: Not Before: Nov 3 2025, Not After: Nov 3 2026 +``` + +**Fields Used for Authentication**: +- `CN` (Common Name) - User or service identifier +- `OU` (Organizational Unit) - Team or role identifier +- `O` (Organization) - Organization name + +## Configuration + +### Environment Variables + +```bash +# Enable mTLS +export SECURITY_MTLS_ENABLED=true + +# Server certificate and key +export SECURITY_MTLS_SERVER_CERT=./certs/server-cert.pem +export SECURITY_MTLS_SERVER_KEY=./certs/server-key.pem + +# Client CA for verification +export SECURITY_MTLS_CLIENT_CA=./certs/ca.pem + +# Enable RBAC +export SECURITY_RBAC_ENABLED=true +``` + +### Generating Certificates + +#### Development Certificates + +Use the provided test script: + +```bash +./test-mtls-rbac.sh +``` + +This generates: +- `ca.pem` / `ca-key.pem` - Certificate Authority +- `server-cert.pem` / `server-key.pem` - Server certificate +- `admin-cert.pem` / `admin-key.pem` - Admin client (OU=platform-team) +- Various plugin certificates (OU=plugins) + +#### Production Certificates + +For production, use a proper PKI: + +**Options**: +- **Internal PKI**: HashiCorp Vault, cert-manager, step-ca +- **Public CA**: Let's Encrypt (for internet-facing) +- **Enterprise CA**: Your organization's certificate authority + +**Requirements**: +- Valid for at least 90 days +- Issued by trusted CA +- Proper subject alternative names (SANs) +- Appropriate key usage extensions + +### Certificate Best Practices + +✅ **DO**: +- Use short-lived certificates (24-90 days) +- Implement automatic rotation +- Monitor expiration dates +- Use strong key lengths (2048+ bit RSA or 256+ bit ECC) +- Store private keys securely +- Use separate CAs for dev/prod + +❌ **DON'T**: +- Use self-signed certs in production +- Share private keys +- Use the same certificate across environments +- Ignore expiration warnings +- Store keys in version control + +## RBAC Authorization + +Role-Based Access Control (RBAC) enforces fine-grained permissions. + +### Role Mapping + +Charybdis maps certificate attributes to roles: + +``` +Certificate OU → RBAC Role → Permissions +``` + +### Default Roles + +| Role | Certificate OU | Description | Permissions | +|------|---------------|-------------|-------------| +| `platform` | `platform-team` | Platform administrators | Full access (CRUD + list) | +| `automation` | `automation` | CI/CD pipelines | Create, read, update, list | +| `plugin` | `plugins` | Integration plugins | Read, list only | + +### Permissions + +| Permission | Methods | Description | +|------------|---------|-------------| +| `entity:create` | CreateEntity | Create new entities | +| `entity:read` | GetEntity | Read entity by ID | +| `entity:update` | UpdateEntity, PartialUpdateEntity | Modify entities | +| `entity:delete` | DeleteEntity | Delete entities | +| `entity:list` | ListEntities | List all entities | + +### Permission Matrix + +| Role | Create | Read | Update | Delete | List | +|------|--------|------|--------|--------|------| +| `platform` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `automation` | ✅ | ✅ | ✅ | ❌ | ✅ | +| `plugin` | ❌ | ✅ | ❌ | ❌ | ✅ | + +## Usage Examples + +### Admin Access (Full Permissions) + +```bash +# Create entity +grpcurl \ + -cacert certs/ca.pem \ + -cert certs/admin-cert.pem \ + -key certs/admin-key.pem \ + -d '{"entity": {...}}' \ + localhost:50051 charybdis.entities.EntityService/CreateEntity + +# ✅ SUCCESS +``` + +### Automation Access (Limited) + +```bash +# CI/CD can create +grpcurl \ + -cacert certs/ca.pem \ + -cert certs/ci-pipeline-cert.pem \ + -key certs/ci-pipeline-key.pem \ + -d '{"entity": {...}}' \ + localhost:50051 charybdis.entities.EntityService/CreateEntity + +# ✅ SUCCESS + +# But cannot delete +grpcurl \ + -cacert certs/ca.pem \ + -cert certs/ci-pipeline-cert.pem \ + -key certs/ci-pipeline-key.pem \ + -d '{"id": "..."}' \ + localhost:50051 charybdis.entities.EntityService/DeleteEntity + +# ❌ Code: PermissionDenied +# Message: Role 'automation' does not have permission 'entity:delete' +``` + +### Plugin Access (Read-Only) + +```bash +# Plugin can list +grpcurl \ + -cacert certs/ca.pem \ + -cert certs/defectdojo-plugin-cert.pem \ + -key certs/defectdojo-plugin-key.pem \ + -d '{}' \ + localhost:50051 charybdis.entities.EntityService/ListEntities + +# ✅ SUCCESS + +# But cannot create +grpcurl \ + -cacert certs/ca.pem \ + -cert certs/defectdojo-plugin-cert.pem \ + -key certs/defectdojo-plugin-key.pem \ + -d '{"entity": {...}}' \ + localhost:50051 charybdis.entities.EntityService/CreateEntity + +# ❌ Code: PermissionDenied +# Message: Role 'plugin' does not have permission 'entity:create' +``` + +## Custom Role Configuration + +### Defining Custom Roles + +Edit `src/main.rs::setup_default_rbac_config()`: + +```rust +let role_mappings = vec![ + // Custom developer role + RoleMapping { + role: "developer".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: None, + ou: Some("engineering".to_string()), + o: None, + }, + }], + }, +]; + +let mut permissions = HashMap::new(); +permissions.insert( + "developer".to_string(), + vec![ + "entity:create".to_string(), + "entity:read".to_string(), + "entity:list".to_string(), + ], +); +``` + +### Certificate-Based Mapping + +Map specific certificates to roles: + +```rust +// Map by CN (specific user/service) +RoleRule { + subject: SubjectMatch { + cn: Some("jenkins-ci".to_string()), + ou: None, + o: None, + }, +} + +// Map by Organization +RoleRule { + subject: SubjectMatch { + cn: None, + ou: None, + o: Some("External-Partner".to_string()), + }, +} + +// Combined matching +RoleRule { + subject: SubjectMatch { + cn: Some("admin".to_string()), + ou: Some("platform-team".to_string()), + o: Some("MyCompany".to_string()), + }, +} +``` + +## Audit Logging + +All security events are logged for compliance and forensics. + +### Event Types + +| Event | Logged Information | +|-------|-------------------| +| Authentication Success | Identity (CN, OU, O), timestamp | +| Authentication Failure | Reason, timestamp | +| Authorization Success | Identity, role, method, duration | +| Authorization Denial | Identity, role, method, required permission | +| Certificate Error | Error details, certificate info | + +### Log Format + +``` +2025-11-04T10:16:22Z INFO [AUDIT] Authorization Success + identity: CN=admin-user, OU=platform-team + role: platform + method: /charybdis.entities.EntityService/CreateEntity + permission: entity:create + duration: 0.08ms + +2025-11-04T10:16:25Z WARN [AUDIT] Authorization Denied + identity: CN=defectdojo-plugin, OU=plugins + role: plugin + method: /charybdis.entities.EntityService/CreateEntity + required_permission: entity:create + reason: Role 'plugin' does not have permission 'entity:create' + duration: 0.05ms +``` + +### Audit Configuration + +```bash +# Enable audit logging (default: true) +export SECURITY_RBAC_AUDIT_ENABLED=true + +# Log all requests (default: true) +export SECURITY_RBAC_AUDIT_LOG_ALL_REQUESTS=true + +# Log denied requests (default: true) +export SECURITY_RBAC_AUDIT_LOG_DENIED=true +``` + +## Reverse Proxy Deployment + +For environments where mTLS termination happens at a reverse proxy: + +### Architecture + +```mermaid +graph LR + A[Client] -->|mTLS| B[Reverse Proxy] + B -->|HTTP + Cert Header| C[Charybdis] + + style B fill:#4A90E2,stroke:#2E5C8A +``` + +### Proxy Configuration + +#### Envoy + +```yaml +- name: envoy.filters.http.lua + typed_config: + inline_code: | + function envoy_on_request(request_handle) + local cert = request_handle:connection():ssl():peerCertificatePresented() + if cert then + request_handle:headers():add("x-client-cert", cert) + end + end +``` + +#### nginx + +```nginx +location / { + proxy_set_header X-Client-Cert $ssl_client_cert; + proxy_pass http://charybdis:50051; +} +``` + +### Header Format + +Charybdis accepts certificates in these headers: +- `x-forwarded-client-cert` (Envoy/Istio standard) +- `x-client-cert` (nginx) + +Format: Base64-encoded DER certificate + +## Troubleshooting + +### Common Issues + +#### 1. Certificate Not Found + +``` +ERROR: Client certificate required but not provided +``` + +**Causes**: +- Certificate not sent by client +- Wrong certificate path +- Certificate expired + +**Solutions**: +```bash +# Verify certificate is valid +openssl x509 -in cert.pem -noout -text + +# Check expiration +openssl x509 -in cert.pem -noout -dates + +# Test TLS handshake +openssl s_client -connect localhost:50051 \ + -CAfile ca.pem -cert cert.pem -key key.pem +``` + +#### 2. Permission Denied + +``` +Code: PermissionDenied +Message: Role 'X' does not have permission 'Y' +``` + +**Causes**: +- Certificate OU doesn't match any role +- Role lacks required permission + +**Solutions**: +```bash +# Check certificate attributes +openssl x509 -in cert.pem -noout -subject + +# Review role mappings in src/main.rs +# Verify permission requirements in docs +``` + +#### 3. No Role Assigned + +``` +ERROR: No role assigned to identity: CN=..., OU=... +``` + +**Causes**: +- Certificate OU not configured in role mappings +- Typo in OU value + +**Solutions**: +- Add role mapping for the OU +- Generate new certificate with correct OU +- Check role mapping configuration + +## Security Hardening + +### Production Checklist + +- [ ] Use certificates from trusted CA +- [ ] Enable mTLS (`SECURITY_MTLS_ENABLED=true`) +- [ ] Enable RBAC (`SECURITY_RBAC_ENABLED=true`) +- [ ] Implement certificate rotation (90 days max) +- [ ] Monitor certificate expiration +- [ ] Enable audit logging +- [ ] Use TLS 1.3 only +- [ ] Implement rate limiting +- [ ] Set up intrusion detection +- [ ] Regular security audits + +### Certificate Rotation + +Implement automatic rotation: + +```bash +#!/bin/bash +# rotate-certs.sh + +# Generate new certificate +./generate-cert.sh + +# Gracefully restart Charybdis +kill -HUP $(pidof charybdis) + +# Verify new cert is active +sleep 5 +openssl s_client -connect localhost:50051 < /dev/null | \ + openssl x509 -noout -dates +``` + +### Monitoring + +Monitor security metrics: + +- Authentication success/failure rate +- Authorization denial rate +- Certificate expiration warnings +- Audit log anomalies +- Failed login attempts + +## Compliance + +Charybdis security features support compliance requirements: + +| Standard | Supported Features | +|----------|-------------------| +| **SOC 2** | Audit logging, access control, encryption in transit | +| **ISO 27001** | Authentication, authorization, audit trails | +| **PCI DSS** | Encryption, access control, logging | +| **HIPAA** | Access control, audit logging, encryption | + +## Next Steps + +- 🔌 Configure [Plugins](plugins.md) with proper certificates +- 🚀 Review [Deployment Guide](deployment.md) for production +- 📊 Set up [Monitoring](monitoring.md) for security events + +--- + +**Security Questions?** Open a [security issue](../../security) (for vulnerabilities, use private disclosure). diff --git a/plugins.toml b/plugins.toml new file mode 100644 index 0000000..d9b9fe2 --- /dev/null +++ b/plugins.toml @@ -0,0 +1,56 @@ +# Charybdis Plugin Registry +# This file defines which plugins are enabled and their protobuf field number allocations. +# Field numbers must be unique to avoid conflicts in the generated protobuf. + +# Field number ranges: +# - Core types: 1-99 (reserved for Charybdis core entity types) +# - Plugin types: 100+ (allocated sequentially per plugin) + +[core] +# Core entity types are always enabled +proto_path = "proto/core" +metadata_field_range = "10-49" +spec_field_range = "10-49" + +# Plugin configurations +# Each plugin must declare: +# - enabled: whether to include this plugin in the build +# - proto_path: path to the plugin's .proto files +# - metadata_field_number: unique field number for the metadata oneof variant +# - spec_field_number: unique field number for the spec oneof variant + +[plugins.defectdojo] +enabled = true +proto_path = "plugins/defectdojo/proto" +metadata_field_number = 100 +spec_field_number = 200 +description = "DefectDojo security testing integration" + +[plugins.keycloak] +enabled = true +proto_path = "plugins/keycloak/proto" +metadata_field_number = 102 +spec_field_number = 202 +description = "Keycloak identity provider sync (users and groups)" + +[plugins.dependencytrack] +enabled = true +proto_path = "plugins/dependencytrack/proto" +metadata_field_number = 101 +spec_field_number = 201 +description = "Dependency-Track component analysis integration" + +# Example plugin configuration: +# [plugins.my_custom_plugin] +# enabled = true +# proto_path = "plugins/my_custom_plugin/proto" +# metadata_field_number = 102 +# spec_field_number = 102 +# description = "My custom plugin for X integration" + +# NOTE: When adding a new plugin: +# 1. Create the plugin directory structure: plugins//proto/.proto +# 2. Define Metadata and Spec messages in the proto file +# 3. Add configuration here with unique field numbers +# 4. Set enabled = true +# 5. Run `cargo build` to regenerate entities.proto diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..10b8088 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,340 @@ +# Charybdis Plugins + +This directory contains plugin implementations that extend Charybdis with integrations to external security and development tools. + +## What are Plugins? + +Charybdis supports **two types of plugins**, both compile-time integrated: + +### **1. Event-Driven Plugins** +React to entity lifecycle events (create, update, delete): +- **Example**: DefectDojo, DependencyTrack +- **Implement**: `EventDrivenPlugin` trait + `ResourceHandler` +- **Triggered by**: Entity CRUD operations +- **Use case**: Auto-create resources in external tools when entities are created + +### **2. Sync Plugins** +Pull data from external sources on a schedule: +- **Example**: Okta, Keycloak, Active Directory +- **Implement**: `SyncPlugin` trait +- **Triggered by**: Cron schedule or manual API call +- **Use case**: Sync users/groups from identity providers + +## Generic Utilities + +All plugins have access to reusable utilities in `src/plugins/`: + +- **`PluginHttpClient`** - Multi-auth HTTP client (Token, Bearer, API Key, Basic Auth) +- **`AnnotationHelper`** - Consistent API for storing/retrieving plugin metadata +- **`FieldMapper`** - Maps entity fields to external tool formats +- **`DateUtils`** - Common date/time operations + +## Plugin Structure + +Each plugin follows this structure: + +``` +plugins/ +└── my_plugin/ + ├── Cargo.toml # Plugin crate definition + ├── README.md # Plugin-specific documentation + ├── proto/ + │ └── my_plugin.proto # Protobuf entity definitions + └── src/ + ├── lib.rs # Plugin entry point + ├── handler.rs # Event handler implementation + ├── client.rs # External API client + └── config.rs # Plugin configuration +``` + +## Available Plugins + +### DefectDojo +- **Type**: Event-Driven +- **Purpose**: Security vulnerability management integration +- **External API**: DefectDojo REST API v2 +- **Handlers**: ProductHandler, EngagementHandler, ProductTypeHandler, ProductMemberHandler +- **Features**: + - Automatic product creation on Component/Service creation + - Product updates on entity changes + - Auto-create CI/CD engagements + - Owner resolution (Group → members → email annotation → DefectDojo user lookup) + - Configurable OIDC provider mapping (Keycloak, Okta, Azure AD) + - Annotation storage (`defectdojo.com/product-id`, `defectdojo.com/engagement-id`, `defectdojo.com/owner-member-ids`) + +### Keycloak +- **Type**: Sync +- **Purpose**: Identity provider sync (users and groups) +- **External API**: Keycloak Admin REST API +- **Features**: + - User sync with profile and annotations (display_name, email, picture) + - Group sync with hierarchy (parent, children, members) + - Configurable annotations (e.g., `keycloak.com/email` for OIDC mapping) + - On-startup sync, manual trigger via gRPC + +### DependencyTrack (Scaffolded) +- **Type**: Event-Driven +- **Purpose**: Software supply chain security +- **External API**: DependencyTrack REST API +- **Status**: Scaffolded (proto + crate structure), handler logic not implemented +- **Planned**: + - Auto-create projects for components + - SBOM ingestion + +## Creating a New Plugin + +### Quick Start + +1. **Create plugin directory structure:** + ```bash + mkdir -p plugins/my_plugin/{proto,src} + ``` + +2. **Add to plugins.toml:** + ```toml + [plugins.my_plugin] + enabled = true + proto_path = "plugins/my_plugin/proto" + metadata_field_number = 102 # Use next available number + spec_field_number = 102 + description = "My custom integration" + ``` + +3. **Define protobuf schema:** + Create `plugins/my_plugin/proto/my_plugin.proto`: + ```protobuf + syntax = "proto3"; + package charybdis.plugins.my_plugin; + + message MyPluginMetadata { + string name = 1; + string description = 2; + } + + message MyPluginSpec { + string integration_type = 1; + bool enabled = 2; + } + ``` + +4. **Create plugin crate:** + Create `plugins/my_plugin/Cargo.toml`: + ```toml + [package] + name = "charybdis-plugin-my-plugin" + version = "0.1.0" + edition = "2021" + + [dependencies] + charybdis = { path = "../.." } + async-trait = "0.1" + tokio = { version = "1.0", features = ["full"] } + tracing = "0.1" + ``` + +5. **Implement event handler:** + Create `plugins/my_plugin/src/lib.rs`: + ```rust + use async_trait::async_trait; + use charybdis::events::{EventHandler, EntityEvent, EventResult}; + + pub struct MyPluginHandler; + + #[async_trait] + impl EventHandler for MyPluginHandler { + async fn handle_event(&self, event: &EntityEvent) -> EventResult<()> { + // React to entity changes + Ok(()) + } + } + ``` + +6. **Build and test:** + ```bash + cargo build + cargo test + ``` + +## Plugin Field Number Allocation + +Field numbers must be unique across all plugins to avoid protobuf conflicts: + +| Range | Allocation | +|----------|-----------------------| +| 1-99 | Core entity types | +| 100 | DefectDojo | +| 101 | DependencyTrack | +| 102-199 | Available for plugins | + +When creating a new plugin, use the next available number in the 102+ range. + +## Plugin Configuration + +Plugins are configured in `config.toml` with `${VAR}` env var substitution for secrets: + +```toml +[plugins.defectdojo] +enabled = true +base_url = "${DEFECTDOJO_API_URL}" +api_token = "${DEFECTDOJO_API_TOKEN}" + +[plugins.defectdojo.default_engagement] +auto_create = true +name = "CI/CD Pipeline" + +[plugins.keycloak] +enabled = true +base_url = "${KEYCLOAK_URL}" +realm = "master" +client_id = "charybdis-sync" +client_secret = "${KEYCLOAK_CLIENT_SECRET}" +``` + +See `config.toml.example` for all options. + +## Plugin Lifecycle + +1. **Build Time:** + - `build.rs` reads `plugins.toml` + - Generates `proto/entities.proto` with plugin types + - Compiles all protobuf files + - Generates Rust code + +2. **Runtime:** + - Plugin handlers registered with event bus + - Events published on entity CRUD operations + - Handlers react asynchronously + - External APIs called as needed + +3. **Event Flow:** + ``` + Client creates entity + → Entity stored in database + → Event published to event bus + → Plugin handler receives event + → Plugin calls external API + → Plugin stores external ID in annotations + ``` + +## Using Annotations + +Plugins store external tool IDs in entity annotations: + +```rust +// Store external ID +entity.annotations.insert( + "my-plugin.com/resource-id".to_string(), + "ext-12345".to_string(), +); + +// Query by external ID in SQL +SELECT * FROM entities +WHERE annotations->>'my-plugin.com/resource-id' = 'ext-12345'; +``` + +### Annotation Naming Convention + +Use reverse-DNS style: +- `defectdojo.com/product-id` +- `dependencytrack.com/project-uuid` +- `github.com/repo-slug` +- `{tool}.com/{resource}-{attribute}` + +## Best Practices + +### 1. Error Handling +- Don't panic - return errors +- Log but continue on non-critical failures +- Implement retry logic for transient errors + +### 2. Idempotency +- Check if resource exists before creating +- Make operations safe to retry +- Handle duplicate creation gracefully + +### 3. Performance +- Don't block event handlers +- Use `tokio::spawn` for long operations +- Batch operations when possible + +### 4. Testing +- Unit test event handlers +- Mock external API clients +- Integration tests with real APIs (optional) + +### 5. Documentation +- Document required environment variables +- Provide example configurations +- Explain entity model and annotations + +## Contributing Plugins + +We welcome plugin contributions! To contribute: + +1. Fork the repository +2. Create your plugin following the structure above +3. Add comprehensive tests +4. Document configuration and usage +5. Submit a pull request + +### Plugin Requirements + +- [ ] Protobuf definitions with Metadata and Spec messages +- [ ] Event handler implementation +- [ ] External API client (if applicable) +- [ ] Configuration via environment variables +- [ ] README with setup instructions +- [ ] Unit tests for event handler +- [ ] Example usage in documentation + +## Plugin Distribution Models + +### In-Tree (Current) +Plugins live in the `plugins/` directory and are enabled via `plugins.toml`. + +**Pros:** +- Easy to discover +- Consistent quality +- Tested together + +**Cons:** +- Requires core repo access +- All plugins built together + +### External Crates (Future) +Plugins distributed as separate Rust crates. + +**Example:** +```toml +[dependencies] +charybdis-plugin-custom = "0.1" +``` + +**Pros:** +- Independent versioning +- Community contributions +- Optional dependencies + +**Cons:** +- Discovery harder +- Compatibility challenges + +### Plugin Marketplace (Future) +Central registry of available plugins (like Backstage). + +## Resources + +- [Plugin Configuration Guide](../docs/PLUGIN_CONFIGURATION_GUIDE.md) +- [Core Concepts](../docs/core-concepts.md) +- [Architecture](../docs/architecture.md) +- [Protobuf Style Guide](https://protobuf.dev/programming-guides/style/) + +## Support + +- Open an issue for bug reports +- Discussions for questions +- PRs for contributions + +## License + +Same as Charybdis core (see LICENSE file in repository root) \ No newline at end of file diff --git a/plugins/defectdojo/Cargo.toml b/plugins/defectdojo/Cargo.toml new file mode 100644 index 0000000..941330c --- /dev/null +++ b/plugins/defectdojo/Cargo.toml @@ -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"] } diff --git a/plugins/defectdojo/README.md b/plugins/defectdojo/README.md new file mode 100644 index 0000000..e41c74c --- /dev/null +++ b/plugins/defectdojo/README.md @@ -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. diff --git a/plugins/defectdojo/proto/defectdojo.proto b/plugins/defectdojo/proto/defectdojo.proto new file mode 100644 index 0000000..afecb41 --- /dev/null +++ b/plugins/defectdojo/proto/defectdojo.proto @@ -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; +} diff --git a/plugins/defectdojo/src/handlers/engagement.rs b/plugins/defectdojo/src/handlers/engagement.rs new file mode 100644 index 0000000..f0992d3 --- /dev/null +++ b/plugins/defectdojo/src/handlers/engagement.rs @@ -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, + field_mapper: FieldMapper, + trigger_kinds: Vec, +} + +impl EngagementHandler { + pub fn new( + client: DefectDojoClient, + repository: Arc, + field_mappings: HashMap, + ) -> 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 { + 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 { + 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 { + 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> { + // 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(()) + } +} diff --git a/plugins/defectdojo/src/handlers/mod.rs b/plugins/defectdojo/src/handlers/mod.rs new file mode 100644 index 0000000..e13591d --- /dev/null +++ b/plugins/defectdojo/src/handlers/mod.rs @@ -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; diff --git a/plugins/defectdojo/src/handlers/product.rs b/plugins/defectdojo/src/handlers/product.rs new file mode 100644 index 0000000..82cb02b --- /dev/null +++ b/plugins/defectdojo/src/handlers/product.rs @@ -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, + field_mapper: FieldMapper, + annotation_helper: AnnotationHelper, + config: DefectDojoConfig, + engagement_handler: Arc, + trigger_kinds: Vec, +} + +impl ProductHandler { + pub fn new( + client: DefectDojoClient, + repository: Arc, + field_mappings: HashMap, + config: DefectDojoConfig, + engagement_handler: Arc, + ) -> 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 { + 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 { + 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> { + 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> { + 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> { + 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> { + 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 { + 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> { + // 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 = 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(()) + } +} diff --git a/plugins/defectdojo/src/handlers/product_member.rs b/plugins/defectdojo/src/handlers/product_member.rs new file mode 100644 index 0000000..cc09ac3 --- /dev/null +++ b/plugins/defectdojo/src/handlers/product_member.rs @@ -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, + field_mapper: FieldMapper, + trigger_kinds: Vec, +} + +impl ProductMemberHandler { + pub fn new( + client: DefectDojoClient, + repository: Arc, + field_mappings: HashMap, + ) -> 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 { + 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 { + 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> { + 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 { + 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> { + // 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(()) + } +} diff --git a/plugins/defectdojo/src/handlers/product_type.rs b/plugins/defectdojo/src/handlers/product_type.rs new file mode 100644 index 0000000..9c4be37 --- /dev/null +++ b/plugins/defectdojo/src/handlers/product_type.rs @@ -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, + field_mapper: FieldMapper, + trigger_kinds: Vec, +} + +impl ProductTypeHandler { + pub fn new( + client: DefectDojoClient, + repository: Arc, + field_mappings: HashMap, + ) -> 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 { + 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> { + 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 { + 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> { + // 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(()) + } +} diff --git a/plugins/defectdojo/src/lib.rs b/plugins/defectdojo/src/lib.rs new file mode 100644 index 0000000..6e9900a --- /dev/null +++ b/plugins/defectdojo/src/lib.rs @@ -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, + + /// 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>, +} + +/// 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) -> Result { + // 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 { + 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 { + self.client.get(path).await + } + + /// Make a POST request to DefectDojo API + pub async fn post(&self, path: &str, body: &serde_json::Value) -> Result { + 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 { + 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, + handlers: Vec>, +} + +impl DefectDojoPlugin { + pub fn new(config: DefectDojoConfig, repository: Arc) -> Result { + // 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> = 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> { + self.handlers.clone() + } +} diff --git a/plugins/defectdojo/src/utils.rs b/plugins/defectdojo/src/utils.rs new file mode 100644 index 0000000..8ad6727 --- /dev/null +++ b/plugins/defectdojo/src/utils.rs @@ -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 { + 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, + repository: Arc, +) -> 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 + } +} diff --git a/plugins/defectdojo/tests/integration_tests.rs b/plugins/defectdojo/tests/integration_tests.rs new file mode 100644 index 0000000..4576ae8 --- /dev/null +++ b/plugins/defectdojo/tests/integration_tests.rs @@ -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"); +} diff --git a/plugins/dependencytrack/Cargo.toml b/plugins/dependencytrack/Cargo.toml new file mode 100644 index 0000000..16d2029 --- /dev/null +++ b/plugins/dependencytrack/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "charybdis-dependencytrack-plugin" +version = "0.1.0" +edition = "2021" + +[lib] +name = "charybdis_dependencytrack" +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" diff --git a/plugins/dependencytrack/proto/dependencytrack.proto b/plugins/dependencytrack/proto/dependencytrack.proto new file mode 100644 index 0000000..c02a722 --- /dev/null +++ b/plugins/dependencytrack/proto/dependencytrack.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package charybdis.plugins.dependencytrack; + +// Dependency-Track plugin metadata +message DependencytrackMetadata { + string name = 1; + string description = 2; + + // Resource type (project) + string resource_type = 3; +} + +// Dependency-Track plugin spec +message DependencytrackSpec { + // Sync status information + string sync_status = 1; + string last_sync = 2; + string error_message = 3; + + // Configuration stored as JSON + string config_json = 4; +} diff --git a/plugins/dependencytrack/src/handlers/mod.rs b/plugins/dependencytrack/src/handlers/mod.rs new file mode 100644 index 0000000..93fd005 --- /dev/null +++ b/plugins/dependencytrack/src/handlers/mod.rs @@ -0,0 +1,3 @@ +mod project; + +pub use project::ProjectHandler; diff --git a/plugins/dependencytrack/src/handlers/project.rs b/plugins/dependencytrack/src/handlers/project.rs new file mode 100644 index 0000000..fb84e98 --- /dev/null +++ b/plugins/dependencytrack/src/handlers/project.rs @@ -0,0 +1,172 @@ +use anyhow::Result; +use async_trait::async_trait; +use charybdis::charybdis::entities::{entity, Entity}; +use charybdis::database::EntityRepository; +use charybdis::plugins::ResourceHandler; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::{info, warn}; + +use crate::{DependencyTrackClient, DependencyTrackConfig}; + +/// Project handler — maps Component entities to Dependency-Track Projects +pub struct ProjectHandler { + client: DependencyTrackClient, + repository: Arc, + config: DependencyTrackConfig, + trigger_kinds: Vec, +} + +impl ProjectHandler { + pub fn new( + client: DependencyTrackClient, + repository: Arc, + config: DependencyTrackConfig, + ) -> Self { + Self { + client, + repository, + config, + trigger_kinds: vec!["Component".to_string()], + } + } + + /// Get project UUID from entity annotations + fn get_project_uuid(&self, entity: &Entity) -> Option { + entity + .annotations + .get("dependencytrack.com/project-uuid") + .cloned() + } + + /// Extract metadata fields from a Component entity + fn extract_component_fields(&self, entity: &Entity) -> (String, String, Vec) { + let mut name = entity.id.clone(); + let mut description = String::new(); + let mut tags = Vec::new(); + + if let Some(entity::Metadata::ComponentMetadata(m)) = &entity.metadata { + name = m.name.clone(); + description = m.description.clone(); + tags.extend(m.tags.clone()); + } + + if let Some(entity::Spec::ComponentSpec(s)) = &entity.spec { + if !s.lifecycle.is_empty() { + tags.push(format!("lifecycle:{}", s.lifecycle)); + } + if !s.r#type.is_empty() { + tags.push(format!("type:{}", s.r#type)); + } + if !s.owner.is_empty() { + tags.push(format!("owner:{}", s.owner)); + } + } + + (name, description, tags) + } + + /// Extract version from entity (defaults to "latest") + fn extract_version(&self, entity: &Entity) -> String { + // Check annotations first + if let Some(version) = entity.annotations.get("app.kubernetes.io/version") { + return version.clone(); + } + if let Some(version) = entity.annotations.get("version") { + return version.clone(); + } + "latest".to_string() + } +} + +#[async_trait] +impl ResourceHandler for ProjectHandler { + fn resource_type(&self) -> &str { + "dependencytrack_project" + } + + fn trigger_kinds(&self) -> &[String] { + &self.trigger_kinds + } + + fn creates_entity_kind(&self) -> &str { + "" + } + + async fn handle_create(&self, entity: &Entity) -> Result> { + let (name, description, tags) = self.extract_component_fields(entity); + let version = self.extract_version(entity); + + let project_uuid = self + .client + .create_project(&name, &version, &description, &tags) + .await?; + + // Store project UUID in annotations (annotation-only update, no double-write) + let mut annotations = HashMap::new(); + annotations.insert( + "dependencytrack.com/project-uuid".to_string(), + project_uuid.clone(), + ); + + self.repository + .update_annotations(&entity.id, annotations) + .await?; + + info!( + "Linked entity {} to Dependency-Track project {}", + entity.id, project_uuid + ); + + Ok(None) + } + + async fn handle_update(&self, entity: &Entity) -> Result<()> { + if let Some(project_uuid) = self.get_project_uuid(entity) { + let (name, description, tags) = self.extract_component_fields(entity); + let version = self.extract_version(entity); + + self.client + .update_project(&project_uuid, &name, &version, &description, &tags) + .await?; + } else { + warn!( + "Entity {} has no Dependency-Track project UUID — creating one", + entity.id + ); + + let (name, description, tags) = self.extract_component_fields(entity); + let version = self.extract_version(entity); + + let project_uuid = self + .client + .create_project(&name, &version, &description, &tags) + .await?; + + let mut annotations = HashMap::new(); + annotations.insert( + "dependencytrack.com/project-uuid".to_string(), + project_uuid, + ); + + self.repository + .update_annotations(&entity.id, annotations) + .await?; + } + + Ok(()) + } + + async fn handle_delete(&self, entity: &Entity) -> Result<()> { + if let Some(project_uuid) = self.get_project_uuid(entity) { + self.client.delete_project(&project_uuid).await?; + } else { + warn!( + "Entity {} has no Dependency-Track project UUID — skipping deletion", + entity.id + ); + } + + Ok(()) + } +} diff --git a/plugins/dependencytrack/src/lib.rs b/plugins/dependencytrack/src/lib.rs new file mode 100644 index 0000000..ec0fb0d --- /dev/null +++ b/plugins/dependencytrack/src/lib.rs @@ -0,0 +1,162 @@ +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; +use tracing::info; + +/// Dependency-Track plugin configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DependencyTrackConfig { + /// Dependency-Track API base URL + pub base_url: String, + + /// API key for authentication + pub api_key: String, + + /// Request timeout in seconds + #[serde(default = "default_timeout")] + pub timeout_secs: u64, + + /// Auto-create projects for new Component entities + #[serde(default = "default_auto_create_project")] + pub auto_create_project: bool, + + /// Default team UUID to assign to new projects (optional) + #[serde(default)] + pub default_team_uuid: Option, + + /// Field mappings for each resource type + #[serde(default)] + pub field_mappings: HashMap>, +} + +fn default_timeout() -> u64 { + 30 +} + +fn default_auto_create_project() -> bool { + true +} + +impl DependencyTrackConfig { + /// Load Dependency-Track configuration from TOML config map + pub fn from_toml(config: &HashMap) -> Result { + // Convert HashMap to TOML Value + let value = toml::Value::Table(config.clone().into_iter().collect()); + + // Deserialize to DependencyTrackConfig + let config: DependencyTrackConfig = value + .try_into() + .map_err(|e| anyhow!("Failed to parse Dependency-Track configuration: {}", e))?; + + Ok(config) + } +} + +/// Dependency-Track API client (wrapper around PluginHttpClient) +#[derive(Clone)] +pub struct DependencyTrackClient { + client: PluginHttpClient, +} + +impl DependencyTrackClient { + pub fn new(base_url: String, api_key: String) -> Result { + let client = PluginHttpClient::new(base_url, AuthConfig::api_key("X-Api-Key", api_key))?; + Ok(Self { client }) + } + + /// Make a GET request to Dependency-Track API + pub async fn get(&self, path: &str) -> Result { + self.client.get(path).await + } + + /// Make a POST request to Dependency-Track API + pub async fn post(&self, path: &str, body: &serde_json::Value) -> Result { + self.client.post(path, body).await + } + + /// Make a PUT request to Dependency-Track API + pub async fn put(&self, path: &str, body: &serde_json::Value) -> Result { + self.client.put(path, body).await + } + + /// Make a DELETE request to Dependency-Track API + pub async fn delete(&self, path: &str) -> Result<()> { + self.client.delete(path).await + } +} + +/// Dependency-Track event-driven plugin +#[allow(dead_code)] +pub struct DependencyTrackPlugin { + config: DependencyTrackConfig, + client: DependencyTrackClient, + repository: Arc, + handlers: Vec>, +} + +impl DependencyTrackPlugin { + pub fn new(config: DependencyTrackConfig, repository: Arc) -> Result { + // Validate configuration + if config.base_url.is_empty() { + return Err(anyhow!("Dependency-Track base_url cannot be empty")); + } + if config.api_key.is_empty() { + return Err(anyhow!("Dependency-Track api_key cannot be empty")); + } + + let client = DependencyTrackClient::new(config.base_url.clone(), config.api_key.clone())?; + + // Resource handlers will be registered here as they are implemented + let handlers: Vec> = vec![]; + + info!( + "Dependency-Track plugin initialized (base_url={})", + config.base_url + ); + + Ok(Self { + config, + client, + repository, + handlers, + }) + } +} + +#[async_trait] +impl Plugin for DependencyTrackPlugin { + fn name(&self) -> &str { + "dependencytrack" + } + + 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<()> { + if self.config.base_url.is_empty() { + return Err(anyhow!("Dependency-Track base_url cannot be empty")); + } + if self.config.api_key.is_empty() { + return Err(anyhow!("Dependency-Track api_key cannot be empty")); + } + Ok(()) + } +} + +#[async_trait] +impl EventDrivenPlugin for DependencyTrackPlugin { + fn resource_handlers(&self) -> Vec> { + self.handlers.clone() + } +} diff --git a/plugins/keycloak/Cargo.toml b/plugins/keycloak/Cargo.toml new file mode 100644 index 0000000..e0ba6cc --- /dev/null +++ b/plugins/keycloak/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "charybdis-keycloak-plugin" +version = "0.1.0" +edition = "2021" + +[lib] +name = "charybdis_keycloak" +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" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.18", features = ["v4"] } + +[dev-dependencies] +tokio-test = "0.4" diff --git a/plugins/keycloak/proto/keycloak.proto b/plugins/keycloak/proto/keycloak.proto new file mode 100644 index 0000000..355be97 --- /dev/null +++ b/plugins/keycloak/proto/keycloak.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package charybdis.plugins.keycloak; + +// Keycloak plugin metadata +// Used for plugin-specific sync status entities +message KeycloakMetadata { + string name = 1; + string description = 2; + + // Resource type (user, group) + string resource_type = 3; + + // Keycloak realm this resource belongs to + string realm = 4; +} + +// Keycloak plugin spec +message KeycloakSpec { + // Sync status information + string sync_status = 1; + string last_sync = 2; + string error_message = 3; + + // Configuration stored as JSON + string config_json = 4; +} diff --git a/plugins/keycloak/src/lib.rs b/plugins/keycloak/src/lib.rs new file mode 100644 index 0000000..54e33ae --- /dev/null +++ b/plugins/keycloak/src/lib.rs @@ -0,0 +1,355 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use charybdis::database::EntityRepository; +use charybdis::plugins::http_client::{AuthConfig, PluginHttpClient}; +use charybdis::plugins::{Plugin, PluginConfig, PluginType, SyncConfig, SyncPlugin, SyncResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::info; + +mod sync; + +/// Keycloak plugin configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeycloakConfig { + /// Keycloak server base URL (e.g., "https://keycloak.company.com") + pub base_url: String, + + /// Keycloak realm to sync from + pub realm: String, + + /// Client ID for service account authentication + pub client_id: String, + + /// Client secret for service account authentication + pub client_secret: String, + + /// Sync configuration + #[serde(default)] + pub sync: SyncOptions, +} + +/// Sync options +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncOptions { + /// Cron schedule (e.g., "0 */5 * * * *" for every 5 minutes) + #[serde(default)] + pub schedule: Option, + + /// Sync on startup + #[serde(default = "default_true")] + pub on_startup: bool, + + /// Allow manual trigger via API + #[serde(default = "default_true")] + pub manual_trigger: bool, + + /// Sync users + #[serde(default = "default_true")] + pub sync_users: bool, + + /// Sync groups + #[serde(default = "default_true")] + pub sync_groups: bool, + + /// Namespace to assign to synced entities + #[serde(default = "default_namespace")] + pub namespace: String, + + /// Max results per API page + #[serde(default = "default_page_size")] + pub page_size: i32, +} + +fn default_true() -> bool { + true +} + +fn default_namespace() -> String { + "keycloak".to_string() +} + +fn default_page_size() -> i32 { + 100 +} + +impl Default for SyncOptions { + fn default() -> Self { + Self { + schedule: None, + on_startup: true, + manual_trigger: true, + sync_users: true, + sync_groups: true, + namespace: default_namespace(), + page_size: default_page_size(), + } + } +} + +impl KeycloakConfig { + /// Load Keycloak configuration from TOML config map + pub fn from_toml(config: &HashMap) -> Result { + let value = toml::Value::Table(config.clone().into_iter().collect()); + let config: KeycloakConfig = value + .try_into() + .map_err(|e| anyhow!("Failed to parse Keycloak configuration: {}", e))?; + Ok(config) + } +} + +/// Keycloak API client +#[derive(Clone)] +pub struct KeycloakClient { + http: PluginHttpClient, + raw_client: reqwest::Client, + realm: String, + token_url: String, + client_id: String, + client_secret: String, + /// Cached access token + access_token: Arc>>, +} + +/// Keycloak user representation from the Admin REST API +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeycloakUser { + pub id: String, + pub username: String, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub first_name: Option, + #[serde(default)] + pub last_name: Option, + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub email_verified: bool, + #[serde(default)] + pub attributes: Option>>, +} + +/// Keycloak group representation from the Admin REST API +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeycloakGroup { + pub id: String, + pub name: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub sub_groups: Vec, + #[serde(default)] + pub attributes: Option>>, +} + +impl KeycloakClient { + pub fn new(config: &KeycloakConfig) -> Result { + let base_url = config.base_url.trim_end_matches('/'); + let admin_url = format!("{}/admin/realms/{}", base_url, config.realm); + + // Create HTTP client without auth — we'll add the token manually after obtaining it + let http = PluginHttpClient::new(admin_url, AuthConfig::None)?; + + let raw_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| anyhow!("Failed to create HTTP client for Keycloak: {}", e))?; + + let token_url = format!( + "{}/realms/{}/protocol/openid-connect/token", + base_url, config.realm + ); + + Ok(Self { + http, + raw_client, + realm: config.realm.clone(), + token_url, + client_id: config.client_id.clone(), + client_secret: config.client_secret.clone(), + access_token: Arc::new(tokio::sync::RwLock::new(None)), + }) + } + + /// Obtain an access token using client credentials grant + pub async fn authenticate(&self) -> Result<()> { + info!("Authenticating with Keycloak (client credentials grant)"); + + let response = self.raw_client + .post(&self.token_url) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", &self.client_id), + ("client_secret", &self.client_secret), + ]) + .send() + .await + .map_err(|e| anyhow!("Keycloak token request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "Keycloak authentication failed ({}): {}", + status, + body + )); + } + + let token_response: serde_json::Value = response.json().await?; + let access_token = token_response["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token in Keycloak response"))? + .to_string(); + + let mut token = self.access_token.write().await; + *token = Some(access_token); + + info!("Keycloak authentication successful"); + Ok(()) + } + + /// Make an authenticated GET request to Keycloak Admin API + async fn get(&self, path: &str) -> Result { + let token = self.access_token.read().await; + let token = token + .as_ref() + .ok_or_else(|| anyhow!("Not authenticated — call authenticate() first"))?; + + let url = format!("{}/{}", self.http.base_url(), path.trim_start_matches('/')); + + let response = self.raw_client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/json") + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!("Keycloak API error ({}): {}", status, body)); + } + + Ok(response.json().await?) + } + + /// Fetch all users from the realm (paginated) + pub async fn fetch_users(&self, page_size: i32) -> Result> { + let mut all_users = Vec::new(); + let mut first = 0; + + loop { + let response = self + .get(&format!("users?first={}&max={}", first, page_size)) + .await?; + + let users: Vec = serde_json::from_value(response)?; + let count = users.len(); + all_users.extend(users); + + if (count as i32) < page_size { + break; + } + first += page_size; + } + + info!("Fetched {} users from Keycloak realm '{}'", all_users.len(), self.realm); + Ok(all_users) + } + + /// Fetch all groups from the realm + pub async fn fetch_groups(&self) -> Result> { + let response = self.get("groups?briefRepresentation=false").await?; + let groups: Vec = serde_json::from_value(response)?; + info!("Fetched {} top-level groups from Keycloak realm '{}'", groups.len(), self.realm); + Ok(groups) + } + + /// Fetch members of a group + pub async fn fetch_group_members(&self, group_id: &str) -> Result> { + let response = self.get(&format!("groups/{}/members", group_id)).await?; + let members: Vec = serde_json::from_value(response)?; + Ok(members) + } +} + +/// Keycloak sync plugin +pub struct KeycloakPlugin { + config: KeycloakConfig, + client: KeycloakClient, + repository: Arc, + sync_config: SyncConfig, +} + +impl KeycloakPlugin { + pub fn new(config: KeycloakConfig, repository: Arc) -> Result { + if config.base_url.is_empty() { + return Err(anyhow!("Keycloak base_url cannot be empty")); + } + if config.realm.is_empty() { + return Err(anyhow!("Keycloak realm cannot be empty")); + } + if config.client_id.is_empty() { + return Err(anyhow!("Keycloak client_id cannot be empty")); + } + if config.client_secret.is_empty() { + return Err(anyhow!("Keycloak client_secret cannot be empty")); + } + + let client = KeycloakClient::new(&config)?; + + let sync_config = SyncConfig { + schedule: config.sync.schedule.clone(), + on_startup: config.sync.on_startup, + manual_trigger: config.sync.manual_trigger, + }; + + Ok(Self { + config, + client, + repository, + sync_config, + }) + } +} + +#[async_trait] +impl Plugin for KeycloakPlugin { + fn name(&self) -> &str { + "keycloak" + } + + fn plugin_type(&self) -> PluginType { + PluginType::Sync + } + + fn load_config(&mut self, _config: PluginConfig) -> Result<()> { + Ok(()) + } + + fn validate_config(&self) -> Result<()> { + if self.config.base_url.is_empty() { + return Err(anyhow!("Keycloak base_url cannot be empty")); + } + if self.config.realm.is_empty() { + return Err(anyhow!("Keycloak realm cannot be empty")); + } + Ok(()) + } +} + +#[async_trait] +impl SyncPlugin for KeycloakPlugin { + fn sync_config(&self) -> &SyncConfig { + &self.sync_config + } + + async fn sync(&self) -> Result { + sync::run_sync(&self.client, &self.repository, &self.config).await + } +} diff --git a/plugins/keycloak/src/sync.rs b/plugins/keycloak/src/sync.rs new file mode 100644 index 0000000..710c2cd --- /dev/null +++ b/plugins/keycloak/src/sync.rs @@ -0,0 +1,545 @@ +use anyhow::Result; +use charybdis::charybdis::core::{ + GroupMetadata, GroupProfile, GroupSpec, UserMetadata, UserProfile, UserSpec, +}; +use charybdis::charybdis::entities::{entity, Entity}; +use charybdis::database::EntityRepository; +use std::collections::HashMap; +use tracing::{debug, error, info, warn}; + +use crate::{KeycloakClient, KeycloakConfig, KeycloakGroup, KeycloakUser}; +use charybdis::plugins::SyncResult; + +/// Run a full sync cycle: fetch users and groups from Keycloak, reconcile with Charybdis. +/// +/// This is a pure pull operation — it only reads from Keycloak and writes to Charybdis. +/// Nothing is ever pushed back to Keycloak. +pub async fn run_sync( + client: &KeycloakClient, + repository: &EntityRepository, + config: &KeycloakConfig, +) -> Result { + info!("Starting Keycloak sync for realm '{}'", config.realm); + + // Authenticate with Keycloak + client.authenticate().await?; + + let mut result = SyncResult { + entities_created: 0, + entities_updated: 0, + entities_deleted: 0, + errors: Vec::new(), + }; + + // Load existing Charybdis entities that were previously synced from Keycloak + let existing_entities = repository.list_all().await?; + let existing_users: HashMap = existing_entities + .iter() + .filter(|e| e.kind == "User") + .filter_map(|e| { + e.annotations + .get("keycloak.com/user-id") + .map(|kc_id| (kc_id.clone(), e.clone())) + }) + .collect(); + + let existing_groups: HashMap = existing_entities + .iter() + .filter(|e| e.kind == "Group") + .filter_map(|e| { + e.annotations + .get("keycloak.com/group-id") + .map(|kc_id| (kc_id.clone(), e.clone())) + }) + .collect(); + + // Sync users + if config.sync.sync_users { + match client.fetch_users(config.sync.page_size).await { + Ok(users) => { + info!("Syncing {} users from Keycloak", users.len()); + for user in &users { + match sync_user(user, &existing_users, repository, config).await { + Ok(SyncAction::Created) => result.entities_created += 1, + Ok(SyncAction::Updated) => result.entities_updated += 1, + Err(e) => { + let msg = format!("Failed to sync user '{}': {}", user.username, e); + warn!("{}", msg); + result.errors.push(msg); + } + } + } + } + Err(e) => { + let msg = format!("Failed to fetch users from Keycloak: {}", e); + error!("{}", msg); + result.errors.push(msg); + } + } + } + + // Sync groups + if config.sync.sync_groups { + match client.fetch_groups().await { + Ok(groups) => { + let flat_groups = flatten_groups(&groups); + info!( + "Syncing {} groups from Keycloak (flattened)", + flat_groups.len() + ); + for group in &flat_groups { + match sync_group(group, &existing_groups, client, repository, config).await { + Ok(SyncAction::Created) => result.entities_created += 1, + Ok(SyncAction::Updated) => result.entities_updated += 1, + Err(e) => { + let msg = format!("Failed to sync group '{}': {}", group.name, e); + warn!("{}", msg); + result.errors.push(msg); + } + } + } + } + Err(e) => { + let msg = format!("Failed to fetch groups from Keycloak: {}", e); + error!("{}", msg); + result.errors.push(msg); + } + } + } + + info!( + "Keycloak sync complete: created={}, updated={}, errors={}", + result.entities_created, + result.entities_updated, + result.errors.len() + ); + + Ok(result) +} + +/// Result of syncing a single entity +enum SyncAction { + Created, + Updated, +} + +/// Build a Charybdis User entity from a Keycloak user +fn build_user_entity(kc_user: &KeycloakUser, config: &KeycloakConfig) -> Entity { + let display_name = match (&kc_user.first_name, &kc_user.last_name) { + (Some(first), Some(last)) => format!("{} {}", first, last), + (Some(first), None) => first.clone(), + (None, Some(last)) => last.clone(), + (None, None) => kc_user.username.clone(), + }; + + let description = format!("User synced from Keycloak realm '{}'", config.realm); + + let mut labels = HashMap::new(); + labels.insert("keycloak.realm".to_string(), config.realm.clone()); + labels.insert("enabled".to_string(), kc_user.enabled.to_string()); + if kc_user.email_verified { + labels.insert("email-verified".to_string(), "true".to_string()); + } + + let mut annotations = HashMap::new(); + annotations.insert("keycloak.com/user-id".to_string(), kc_user.id.clone()); + annotations.insert( + "keycloak.com/username".to_string(), + kc_user.username.clone(), + ); + annotations.insert("keycloak.com/realm".to_string(), config.realm.clone()); + if let Some(email) = &kc_user.email { + annotations.insert("keycloak.com/email".to_string(), email.clone()); + } + + Entity { + id: String::new(), // Will be set by server on create, or overwritten for updates + kind: "User".to_string(), + metadata: Some(entity::Metadata::UserMetadata(UserMetadata { + name: kc_user.username.clone(), + namespace: config.sync.namespace.clone(), + description, + labels, + tags: vec!["keycloak".to_string(), "synced".to_string()], + links: vec![], + })), + spec: Some(entity::Spec::UserSpec(UserSpec { + profile: Some(UserProfile { + display_name, + email: kc_user.email.clone().unwrap_or_default(), + picture: String::new(), + }), + member_of: vec![], // Populated during group sync via annotations + })), + annotations, + created_at: None, + updated_at: None, + } +} + +/// Build a Charybdis Group entity from a Keycloak group +fn build_group_entity( + kc_group: &KeycloakGroup, + member_usernames: Vec, + config: &KeycloakConfig, +) -> Entity { + let description = format!( + "Group synced from Keycloak realm '{}' (path: {})", + config.realm, kc_group.path + ); + + let mut labels = HashMap::new(); + labels.insert("keycloak.realm".to_string(), config.realm.clone()); + labels.insert("keycloak.path".to_string(), kc_group.path.clone()); + + let mut annotations = HashMap::new(); + annotations.insert("keycloak.com/group-id".to_string(), kc_group.id.clone()); + annotations.insert( + "keycloak.com/group-path".to_string(), + kc_group.path.clone(), + ); + annotations.insert("keycloak.com/realm".to_string(), config.realm.clone()); + annotations.insert( + "keycloak.com/member-count".to_string(), + member_usernames.len().to_string(), + ); + + let parent = extract_parent_group(&kc_group.path); + let children: Vec = kc_group.sub_groups.iter().map(|g| g.name.clone()).collect(); + + Entity { + id: String::new(), + kind: "Group".to_string(), + metadata: Some(entity::Metadata::GroupMetadata(GroupMetadata { + name: kc_group.name.clone(), + namespace: config.sync.namespace.clone(), + description, + labels, + tags: vec!["keycloak".to_string(), "synced".to_string()], + links: vec![], + })), + spec: Some(entity::Spec::GroupSpec(GroupSpec { + r#type: "team".to_string(), + profile: Some(GroupProfile { + display_name: kc_group.name.clone(), + email: String::new(), + picture: String::new(), + }), + parent: parent.unwrap_or_default(), + children, + members: member_usernames, + })), + annotations, + created_at: None, + updated_at: None, + } +} + +/// Sync a single Keycloak user to a Charybdis User entity. +/// +/// If the entity already exists (matched by `keycloak.com/user-id` annotation), +/// it is always updated to reflect the current Keycloak state. +/// Non-Keycloak annotations on the existing entity are preserved. +async fn sync_user( + kc_user: &KeycloakUser, + existing: &HashMap, + repository: &EntityRepository, + config: &KeycloakConfig, +) -> Result { + let entity = build_user_entity(kc_user, config); + + if let Some(existing_entity) = existing.get(&kc_user.id) { + debug!( + "Updating user '{}' (Keycloak ID: {})", + kc_user.username, kc_user.id + ); + let mut updated = entity; + updated.id = existing_entity.id.clone(); + + // Preserve annotations that aren't managed by this plugin + for (k, v) in &existing_entity.annotations { + if !k.starts_with("keycloak.com/") { + updated.annotations.entry(k.clone()).or_insert(v.clone()); + } + } + + repository.update(&existing_entity.id, &updated).await?; + return Ok(SyncAction::Updated); + } + + debug!( + "Creating user '{}' (Keycloak ID: {})", + kc_user.username, kc_user.id + ); + repository.create(&entity).await?; + Ok(SyncAction::Created) +} + +/// Sync a single Keycloak group to a Charybdis Group entity. +/// +/// If the entity already exists (matched by `keycloak.com/group-id` annotation), +/// it is always updated to reflect the current Keycloak state. +/// Non-Keycloak annotations on the existing entity are preserved. +async fn sync_group( + kc_group: &KeycloakGroup, + existing: &HashMap, + client: &KeycloakClient, + repository: &EntityRepository, + config: &KeycloakConfig, +) -> Result { + // Fetch current group members from Keycloak + let members = client.fetch_group_members(&kc_group.id).await?; + let member_usernames: Vec = members.iter().map(|m| m.username.clone()).collect(); + + let entity = build_group_entity(kc_group, member_usernames, config); + + if let Some(existing_entity) = existing.get(&kc_group.id) { + debug!( + "Updating group '{}' (Keycloak ID: {})", + kc_group.name, kc_group.id + ); + let mut updated = entity; + updated.id = existing_entity.id.clone(); + + // Preserve annotations that aren't managed by this plugin + for (k, v) in &existing_entity.annotations { + if !k.starts_with("keycloak.com/") { + updated.annotations.entry(k.clone()).or_insert(v.clone()); + } + } + + repository.update(&existing_entity.id, &updated).await?; + return Ok(SyncAction::Updated); + } + + debug!( + "Creating group '{}' (Keycloak ID: {})", + kc_group.name, kc_group.id + ); + repository.create(&entity).await?; + Ok(SyncAction::Created) +} + +/// Flatten a nested group tree into a flat list +fn flatten_groups(groups: &[KeycloakGroup]) -> Vec { + let mut flat = Vec::new(); + for group in groups { + flat.push(group.clone()); + if !group.sub_groups.is_empty() { + flat.extend(flatten_groups(&group.sub_groups)); + } + } + flat +} + +/// Extract parent group name from a Keycloak group path. +/// e.g., "/engineering/backend" -> Some("engineering") +fn extract_parent_group(path: &str) -> Option { + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + if parts.len() > 1 { + Some(parts[parts.len() - 2].to_string()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_flatten_groups() { + let groups = vec![KeycloakGroup { + id: "1".to_string(), + name: "engineering".to_string(), + path: "/engineering".to_string(), + sub_groups: vec![ + KeycloakGroup { + id: "2".to_string(), + name: "backend".to_string(), + path: "/engineering/backend".to_string(), + sub_groups: vec![], + attributes: None, + }, + KeycloakGroup { + id: "3".to_string(), + name: "frontend".to_string(), + path: "/engineering/frontend".to_string(), + sub_groups: vec![], + attributes: None, + }, + ], + attributes: None, + }]; + + let flat = flatten_groups(&groups); + assert_eq!(flat.len(), 3); + assert_eq!(flat[0].name, "engineering"); + assert_eq!(flat[1].name, "backend"); + assert_eq!(flat[2].name, "frontend"); + } + + #[test] + fn test_extract_parent_group() { + assert_eq!(extract_parent_group("/engineering"), None); + assert_eq!( + extract_parent_group("/engineering/backend"), + Some("engineering".to_string()) + ); + assert_eq!( + extract_parent_group("/org/engineering/backend"), + Some("engineering".to_string()) + ); + } + + #[test] + fn test_build_user_entity() { + let kc_user = KeycloakUser { + id: "kc-user-123".to_string(), + username: "jdoe".to_string(), + email: Some("john.doe@example.com".to_string()), + first_name: Some("John".to_string()), + last_name: Some("Doe".to_string()), + enabled: true, + email_verified: true, + attributes: None, + }; + + let config = KeycloakConfig { + base_url: "https://keycloak.example.com".to_string(), + realm: "test-realm".to_string(), + client_id: "test-client".to_string(), + client_secret: "secret".to_string(), + sync: crate::SyncOptions::default(), + }; + + let entity = build_user_entity(&kc_user, &config); + + assert_eq!(entity.kind, "User"); + assert_eq!( + entity.annotations.get("keycloak.com/user-id"), + Some(&"kc-user-123".to_string()) + ); + assert_eq!( + entity.annotations.get("keycloak.com/email"), + Some(&"john.doe@example.com".to_string()) + ); + + // Check metadata + match &entity.metadata { + Some(entity::Metadata::UserMetadata(m)) => { + assert_eq!(m.name, "jdoe"); + assert_eq!(m.namespace, "keycloak"); + assert!(m.tags.contains(&"keycloak".to_string())); + } + _ => panic!("Expected UserMetadata"), + } + + // Check spec + match &entity.spec { + Some(entity::Spec::UserSpec(s)) => { + let profile = s.profile.as_ref().unwrap(); + assert_eq!(profile.display_name, "John Doe"); + assert_eq!(profile.email, "john.doe@example.com"); + } + _ => panic!("Expected UserSpec"), + } + } + + #[test] + fn test_build_group_entity() { + let kc_group = KeycloakGroup { + id: "kc-group-456".to_string(), + name: "backend".to_string(), + path: "/engineering/backend".to_string(), + sub_groups: vec![], + attributes: None, + }; + + let config = KeycloakConfig { + base_url: "https://keycloak.example.com".to_string(), + realm: "test-realm".to_string(), + client_id: "test-client".to_string(), + client_secret: "secret".to_string(), + sync: crate::SyncOptions::default(), + }; + + let members = vec!["jdoe".to_string(), "asmith".to_string()]; + let entity = build_group_entity(&kc_group, members, &config); + + assert_eq!(entity.kind, "Group"); + assert_eq!( + entity.annotations.get("keycloak.com/group-id"), + Some(&"kc-group-456".to_string()) + ); + assert_eq!( + entity.annotations.get("keycloak.com/member-count"), + Some(&"2".to_string()) + ); + + // Check metadata + match &entity.metadata { + Some(entity::Metadata::GroupMetadata(m)) => { + assert_eq!(m.name, "backend"); + assert_eq!(m.namespace, "keycloak"); + } + _ => panic!("Expected GroupMetadata"), + } + + // Check spec + match &entity.spec { + Some(entity::Spec::GroupSpec(s)) => { + assert_eq!(s.parent, "engineering"); + assert_eq!(s.members, vec!["jdoe", "asmith"]); + assert_eq!(s.r#type, "team"); + } + _ => panic!("Expected GroupSpec"), + } + } + + #[test] + fn test_build_user_entity_minimal() { + let kc_user = KeycloakUser { + id: "kc-user-minimal".to_string(), + username: "ghost".to_string(), + email: None, + first_name: None, + last_name: None, + enabled: false, + email_verified: false, + attributes: None, + }; + + let config = KeycloakConfig { + base_url: "https://keycloak.example.com".to_string(), + realm: "minimal".to_string(), + client_id: "c".to_string(), + client_secret: "s".to_string(), + sync: crate::SyncOptions::default(), + }; + + let entity = build_user_entity(&kc_user, &config); + + // No email annotation when email is None + assert!(!entity.annotations.contains_key("keycloak.com/email")); + + // Display name falls back to username + match &entity.spec { + Some(entity::Spec::UserSpec(s)) => { + let profile = s.profile.as_ref().unwrap(); + assert_eq!(profile.display_name, "ghost"); + assert_eq!(profile.email, ""); + } + _ => panic!("Expected UserSpec"), + } + + // Labels should reflect disabled state + match &entity.metadata { + Some(entity::Metadata::UserMetadata(m)) => { + assert_eq!(m.labels.get("enabled"), Some(&"false".to_string())); + assert!(!m.labels.contains_key("email-verified")); + } + _ => panic!("Expected UserMetadata"), + } + } +} diff --git a/proto/core/api.proto b/proto/core/api.proto new file mode 100644 index 0000000..9a241dc --- /dev/null +++ b/proto/core/api.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; + +package charybdis.core; + +import "core/common.proto"; + +// APIMetadata represents identifying information for an API entity +// An API is a contract for data exchange between software components +message APIMetadata { + // Required: The name of the API + string name = 1; + + // Optional: Namespace for organizational grouping (default: "default") + string namespace = 2; + + // Optional: Human-readable description of the API + string description = 3; + + // Optional: Labels for categorization and filtering + // Key-value pairs (e.g., "protocol": "rest", "version": "v1") + map labels = 4; + + // Optional: Tags for additional classification + repeated string tags = 5; + + // Optional: Links to external resources + repeated Link links = 6; +} + +// APISpec represents configuration for an API entity +message APISpec { + // Required: Type of API definition + // Standard types: "openapi", "grpc", "graphql", "asyncapi", "rest" + string type = 1; + + // Required: Lifecycle stage (e.g., "production", "experimental", "deprecated") + string lifecycle = 2; + + // Required: Owner team or individual (references a Group or User entity) + string owner = 3; + + // Optional: System this API belongs to (references a System entity) + string system = 4; + + // Required: The API definition itself + // For OpenAPI: the full OpenAPI spec in YAML or JSON + // For gRPC: the .proto file content or reference + // For GraphQL: the schema definition + string definition = 5; +} diff --git a/proto/core/common.proto b/proto/core/common.proto new file mode 100644 index 0000000..e526ff4 --- /dev/null +++ b/proto/core/common.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package charybdis.core; + +// Link represents an external URL reference +// Used across all entity types for documentation, dashboards, etc. +message Link { + // URL to the external resource + string url = 1; + + // Human-readable title for the link + string title = 2; + + // Optional: Icon identifier (e.g., "github", "dashboard", "docs") + string icon = 3; + + // Optional: Type of link (e.g., "documentation", "monitoring", "repository") + string type = 4; +} diff --git a/proto/core/component.proto b/proto/core/component.proto new file mode 100644 index 0000000..53af5c1 --- /dev/null +++ b/proto/core/component.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package charybdis.core; + +import "core/common.proto"; + +// ComponentMetadata represents identifying information for a component entity +// A component is a software component (service, website, library, etc.) +message ComponentMetadata { + // Required: The name of the component + string name = 1; + + // Optional: Namespace for organizational grouping (default: "default") + string namespace = 2; + + // Optional: Human-readable description of the component + string description = 3; + + // Optional: Labels for categorization and filtering + // Key-value pairs (e.g., "environment": "production", "team": "payments") + map labels = 4; + + // Optional: Tags for additional classification + repeated string tags = 5; + + // Optional: Links to external resources + repeated Link links = 6; +} + +// ComponentSpec represents configuration for a component entity +message ComponentSpec { + // Required: Type of component (e.g., "service", "website", "library", "documentation") + string type = 1; + + // Required: Lifecycle stage (e.g., "production", "experimental", "deprecated") + string lifecycle = 2; + + // Required: Owner team or individual (references a Group or User entity) + string owner = 3; + + // Optional: Parent system this component belongs to (references a System entity) + string system = 4; + + // Optional: Sub-component of another component (references a Component entity) + string subcomponent_of = 5; + + // Optional: Components this component depends on (references Component entities) + repeated string depends_on = 6; + + // Optional: APIs this component provides (references API entities) + repeated string provides_apis = 7; + + // Optional: APIs this component consumes (references API entities) + repeated string consumes_apis = 8; +} diff --git a/proto/core/domain.proto b/proto/core/domain.proto new file mode 100644 index 0000000..bf7385c --- /dev/null +++ b/proto/core/domain.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package charybdis.core; + +import "core/common.proto"; + +// DomainMetadata represents identifying information for a domain entity +// A domain is a high-level organizational boundary (e.g., "payments", "identity") +message DomainMetadata { + // Required: The domain name + string name = 1; + + // Optional: Namespace for organizational grouping (default: "default") + string namespace = 2; + + // Optional: Human-readable description of the domain + string description = 3; + + // Optional: Labels for categorization and filtering + // Key-value pairs (e.g., "business-unit": "platform", "priority": "high") + map labels = 4; + + // Optional: Tags for additional classification + repeated string tags = 5; + + // Optional: Links to external resources + repeated Link links = 6; +} + +// DomainSpec represents configuration for a domain entity +message DomainSpec { + // Required: Owner team or individual (references a Group or User entity) + string owner = 1; +} diff --git a/proto/core/finding.proto b/proto/core/finding.proto new file mode 100644 index 0000000..0dd94db --- /dev/null +++ b/proto/core/finding.proto @@ -0,0 +1,114 @@ +syntax = "proto3"; + +package charybdis.core; + +import "google/protobuf/timestamp.proto"; + +// FindingMetadata represents identifying information for a security finding +message FindingMetadata { + // Required: Title of the finding (e.g., "SQL Injection in login handler") + string title = 1; + + // Optional: Namespace (default: "default") + string namespace = 2; + + // Optional: Detailed description of the vulnerability + string description = 3; + + // Optional: Labels for categorization and filtering + map labels = 4; + + // Optional: Tags (e.g., "owasp-top-10", "cwe-89") + repeated string tags = 5; +} + +// FindingSpec contains the security-relevant data for a finding +message FindingSpec { + // Required: Reference to the component this finding belongs to (entity name or UUID) + string component_ref = 1; + + // Required: Lifecycle/environment scope (e.g., "production", "integration", "development") + string lifecycle = 2; + + // Required: Severity level + Severity severity = 3; + + // Required: Current state of the finding + FindingState state = 4; + + // Required: Scanner that produced this finding + string scanner = 5; + + // Required: Rule/check identifier from the scanner (e.g., "CWE-89", "RUSTSEC-2024-001") + string rule_id = 6; + + // Computed: Fingerprint for deduplication (set by reconciliation engine) + string fingerprint = 7; + + // Optional: File path where the vulnerability was found + string file_path = 8; + + // Optional: Line number in the file + uint32 line_start = 9; + + // Optional: End line number (for multi-line findings) + uint32 line_end = 10; + + // Optional: CWE identifier (e.g., "CWE-89") + string cwe = 11; + + // Optional: CVE identifier (e.g., "CVE-2024-1234") + string cve = 12; + + // Optional: CVSS score (0.0 - 10.0) + float cvss_score = 13; + + // Optional: Affected package/dependency name + string package_name = 14; + + // Optional: Affected package version + string package_version = 15; + + // Optional: Fixed version (if known) + string fixed_version = 16; + + // Optional: URL to more details (advisory, documentation) + string details_url = 17; + + // Timestamp of first detection + google.protobuf.Timestamp first_seen = 18; + + // Timestamp of most recent detection + google.protobuf.Timestamp last_seen = 19; + + // Optional: Timestamp when the finding was resolved + google.protobuf.Timestamp resolved_at = 20; + + // Optional: Scan identifier that produced this finding (for tracing back to CI run) + string scan_id = 21; +} + +// Severity levels aligned with CVSS qualitative ratings +enum Severity { + SEVERITY_UNSPECIFIED = 0; + SEVERITY_INFO = 1; + SEVERITY_LOW = 2; + SEVERITY_MEDIUM = 3; + SEVERITY_HIGH = 4; + SEVERITY_CRITICAL = 5; +} + +// Finding lifecycle states +enum FindingState { + FINDING_STATE_UNSPECIFIED = 0; + // Active: currently detected by scanner + FINDING_STATE_ACTIVE = 1; + // Resolved: no longer detected by scanner (auto-closed on reimport) + FINDING_STATE_RESOLVED = 2; + // Accepted: risk accepted by human decision + FINDING_STATE_ACCEPTED = 3; + // False positive: marked as not a real issue + FINDING_STATE_FALSE_POSITIVE = 4; + // Reopened: was resolved but detected again + FINDING_STATE_REOPENED = 5; +} diff --git a/proto/core/group.proto b/proto/core/group.proto new file mode 100644 index 0000000..c11042e --- /dev/null +++ b/proto/core/group.proto @@ -0,0 +1,60 @@ +syntax = "proto3"; + +package charybdis.core; + +import "core/common.proto"; + +// GroupMetadata represents identifying information for a group entity +// A group represents a team or organizational unit +message GroupMetadata { + // Required: The group name (e.g., "team-a", "engineering") + string name = 1; + + // Optional: Namespace for organizational grouping (default: "default") + string namespace = 2; + + // Optional: Human-readable description of the group + string description = 3; + + // Optional: Labels for categorization and filtering + // Key-value pairs (e.g., "department": "engineering", "cost-center": "1234") + map labels = 4; + + // Optional: Tags for additional classification + repeated string tags = 5; + + // Optional: Links to external resources (e.g., team page, Slack channel) + repeated Link links = 6; +} + +// GroupSpec represents configuration for a group entity +message GroupSpec { + // Required: Type of group + // Standard types: "team", "business-unit", "product-area", "root", "department" + string type = 1; + + // Optional: Group profile information + GroupProfile profile = 2; + + // Optional: Parent group for hierarchy (references a Group entity) + // Leave empty for root-level groups + string parent = 3; + + // Optional: Child groups in the hierarchy (references Group entities) + repeated string children = 4; + + // Optional: Direct members of this group (references User entities) + repeated string members = 5; +} + +// GroupProfile contains detailed profile information +message GroupProfile { + // Display name (e.g., "Engineering Team") + string display_name = 1; + + // Group email address + string email = 2; + + // Group picture/logo URL + string picture = 3; +} diff --git a/proto/core/resource.proto b/proto/core/resource.proto new file mode 100644 index 0000000..05c1c76 --- /dev/null +++ b/proto/core/resource.proto @@ -0,0 +1,44 @@ +syntax = "proto3"; + +package charybdis.core; + +import "core/common.proto"; + +// ResourceMetadata represents identifying information for a resource entity +// A resource is infrastructure or operational component (database, S3 bucket, cluster, etc.) +message ResourceMetadata { + // Required: The resource name + string name = 1; + + // Optional: Namespace for organizational grouping (default: "default") + string namespace = 2; + + // Optional: Human-readable description of the resource + string description = 3; + + // Optional: Labels for categorization and filtering + // Key-value pairs (e.g., "cloud": "aws", "region": "us-west-2") + map labels = 4; + + // Optional: Tags for additional classification + repeated string tags = 5; + + // Optional: Links to external resources (e.g., AWS console, monitoring dashboard) + repeated Link links = 6; +} + +// ResourceSpec represents configuration for a resource entity +message ResourceSpec { + // Required: Type of resource + // Standard types: "database", "s3-bucket", "cluster", "pipeline", "queue", "cache" + string type = 1; + + // Required: Owner team or individual (references a Group or User entity) + string owner = 2; + + // Optional: System this resource belongs to (references a System entity) + string system = 3; + + // Optional: Resources this resource depends on (references other Resource entities) + repeated string depends_on = 4; +} diff --git a/proto/core/service.proto b/proto/core/service.proto new file mode 100644 index 0000000..3e92e3e --- /dev/null +++ b/proto/core/service.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package charybdis.core; + +import "core/common.proto"; + +// ServiceMetadata represents identifying information for a service entity +// Compatible with Backstage Component metadata structure +message ServiceMetadata { + // Required: The name of the service + string name = 1; + + // Optional: Namespace for organizational grouping (e.g., "production", "staging") + string namespace = 2; + + // Optional: Human-readable description of what this service does + string description = 3; + + // Optional: Labels for categorization and filtering + repeated string labels = 4; + + // Optional: Tags for additional classification + repeated string tags = 5; + + // Optional: Links to external resources + repeated Link links = 6; +} + +// ServiceSpec represents configuration and behavior for a service entity +// Compatible with Backstage Component spec structure +message ServiceSpec { + // Type of service (e.g., "backend-service", "frontend", "api", "website") + string type = 1; + + // Lifecycle stage (e.g., "production", "experimental", "deprecated") + string lifecycle = 2; + + // Owner team or individual (e.g., "team-platform", "john.doe@company.com") + string owner = 3; + + // Optional: Parent system this service belongs to + string system = 4; + + // Optional: Sub-component of another service + string subcomponent_of = 5; + + // Optional: Services this service depends on + repeated string depends_on = 6; + + // Optional: Services that provide APIs this service consumes + repeated string consumes_apis = 7; + + // Optional: APIs that this service provides + repeated string provides_apis = 8; +} diff --git a/proto/core/system.proto b/proto/core/system.proto new file mode 100644 index 0000000..7b9cb2e --- /dev/null +++ b/proto/core/system.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package charybdis.core; + +import "core/common.proto"; + +// SystemMetadata represents identifying information for a system entity +// A system is a collection of services and resources that work together +message SystemMetadata { + // Required: The name of the system + string name = 1; + + // Optional: Namespace for organizational grouping (default: "default") + string namespace = 2; + + // Optional: Human-readable description of the system + string description = 3; + + // Optional: Labels for categorization and filtering + // Key-value pairs (e.g., "environment": "production", "criticality": "high") + map labels = 4; + + // Optional: Tags for additional classification + repeated string tags = 5; + + // Optional: Links to external resources + repeated Link links = 6; +} + +// SystemSpec represents configuration for a system entity +message SystemSpec { + // Required: Owner team or individual responsible for this system (references a Group or User entity) + string owner = 1; + + // Optional: Domain this system belongs to (references a Domain entity) + string domain = 2; +} diff --git a/proto/core/user.proto b/proto/core/user.proto new file mode 100644 index 0000000..ebbf6a9 --- /dev/null +++ b/proto/core/user.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package charybdis.core; + +import "core/common.proto"; + +// UserMetadata represents identifying information for a user entity +// A user represents a person in the organization +message UserMetadata { + // Required: The username (e.g., "jdoe", "john.doe") + string name = 1; + + // Optional: Namespace for organizational grouping (default: "default") + string namespace = 2; + + // Optional: Full name or description + string description = 3; + + // Optional: Labels for categorization and filtering + // Key-value pairs (e.g., "department": "engineering", "location": "SF") + map labels = 4; + + // Optional: Tags for additional classification + repeated string tags = 5; + + // Optional: Links to external resources (e.g., GitHub profile, LinkedIn) + repeated Link links = 6; +} + +// UserSpec represents configuration for a user entity +message UserSpec { + // Optional: User profile information + UserProfile profile = 1; + + // Optional: Groups this user is a member of (references Group entities) + repeated string member_of = 2; +} + +// UserProfile contains detailed profile information +message UserProfile { + // Display name (e.g., "John Doe") + string display_name = 1; + + // Email address + string email = 2; + + // Avatar/picture URL + string picture = 3; +} diff --git a/proto/entities.proto b/proto/entities.proto new file mode 100644 index 0000000..991f2f2 --- /dev/null +++ b/proto/entities.proto @@ -0,0 +1,200 @@ +syntax = "proto3"; + +package charybdis.entities; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/field_mask.proto"; +import "core/service.proto"; +import "core/system.proto"; +import "core/component.proto"; +import "core/api.proto"; +import "core/user.proto"; +import "core/group.proto"; +import "core/domain.proto"; +import "core/resource.proto"; +import "core/finding.proto"; + +// Plugin imports will be inserted here by build.rs +import "plugins/defectdojo/proto/defectdojo.proto"; +import "plugins/keycloak/proto/keycloak.proto"; +import "plugins/dependencytrack/proto/dependencytrack.proto"; + + +// GENERATED FILE - DO NOT EDIT MANUALLY +// This file is generated by build.rs based on plugins.toml configuration +// To add new entity types, configure plugins in plugins.toml and rebuild + +// Main entity structure +// This represents any cataloged entity in Charybdis +message Entity { + // Unique identifier (UUID) + string id = 1; + + // Entity kind (e.g., "Service", "System", "Component") + // This determines which metadata/spec variant is populated + string kind = 2; + + // Polymorphic metadata - identifying information + // Each kind has its own metadata structure + oneof metadata { + charybdis.core.ServiceMetadata service_metadata = 10; + charybdis.core.SystemMetadata system_metadata = 11; + charybdis.core.ComponentMetadata component_metadata = 12; + charybdis.core.APIMetadata api_metadata = 13; + charybdis.core.UserMetadata user_metadata = 14; + charybdis.core.GroupMetadata group_metadata = 15; + charybdis.core.DomainMetadata domain_metadata = 16; + charybdis.core.ResourceMetadata resource_metadata = 17; + charybdis.core.FindingMetadata finding_metadata = 24; + + // Plugin metadata types will be inserted here by build.rs + // Example: + // charybdis.plugins.defectdojo.DefectDojoProductMetadata defectdojo_product_metadata = 100; + // charybdis.plugins.dependencytrack.DependencyTrackProjectMetadata dependencytrack_project_metadata = 101; + charybdis.plugins.defectdojo.DefectdojoMetadata defectdojo_metadata = 100; + charybdis.plugins.keycloak.KeycloakMetadata keycloak_metadata = 102; + charybdis.plugins.dependencytrack.DependencytrackMetadata dependencytrack_metadata = 101; + + } + + // Polymorphic spec - configuration and behavior + // Each kind has its own spec structure + oneof spec { + charybdis.core.ServiceSpec service_spec = 4; + charybdis.core.SystemSpec system_spec = 5; + charybdis.core.ComponentSpec component_spec = 6; + charybdis.core.APISpec api_spec = 7; + charybdis.core.UserSpec user_spec = 8; + charybdis.core.GroupSpec group_spec = 9; + charybdis.core.DomainSpec domain_spec = 18; + charybdis.core.ResourceSpec resource_spec = 19; + charybdis.core.FindingSpec finding_spec = 25; + + // Plugin spec types will be inserted here by build.rs + // Example: + // charybdis.plugins.defectdojo.DefectDojoProductSpec defectdojo_product_spec = 100; + // charybdis.plugins.dependencytrack.DependencyTrackProjectSpec dependencytrack_project_spec = 101; + charybdis.plugins.defectdojo.DefectdojoSpec defectdojo_spec = 200; + charybdis.plugins.keycloak.KeycloakSpec keycloak_spec = 202; + charybdis.plugins.dependencytrack.DependencytrackSpec dependencytrack_spec = 201; + + } + + // Annotations - arbitrary string key-value pairs + // This is where plugins store external tool IDs and references + // Examples: + // "defectdojo.com/product-id": "12345" + // "dependencytrack.com/project-uuid": "550e8400-e29b-41d4-a716-446655440000" + // "github.com/repo-slug": "myorg/myrepo" + map annotations = 20; + + // Timestamps (managed by the system) + google.protobuf.Timestamp created_at = 21; + google.protobuf.Timestamp updated_at = 22; +} + +// Request to create a new entity +message CreateEntityRequest { + // Entity data (id, created_at, updated_at will be set by server) + Entity entity = 1; +} + +// Response after creating an entity +message CreateEntityResponse { + // The created entity with server-assigned fields + Entity entity = 1; +} + +// Request to get an entity by ID +message GetEntityRequest { + // The UUID of the entity to retrieve + string id = 1; +} + +// Response containing a single entity +message GetEntityResponse { + Entity entity = 1; +} + +// Request to update an existing entity +message UpdateEntityRequest { + // The UUID of the entity to update + string id = 1; + + // Updated entity data (id, created_at will be ignored) + Entity entity = 2; + + // Optional: Field mask for partial updates + // If not provided, performs a full update + // If provided, only updates the fields specified in the mask + // Example paths: "kind", "annotations", "metadata", "spec", + // "annotations.github.com/repo-slug", + // "component_metadata.name", "component_spec.lifecycle" + google.protobuf.FieldMask update_mask = 3; +} + +// Response after updating an entity +message UpdateEntityResponse { + // The updated entity + Entity entity = 1; +} + +// Request to delete an entity by ID +message DeleteEntityRequest { + // The UUID of the entity to delete + string id = 1; +} + +// Response after deleting an entity +message DeleteEntityResponse { + // Whether the deletion was successful + bool success = 1; +} + +// Request to list entities +message ListEntitiesRequest { + // Optional: Filter by kind + string kind = 1; + + // Optional: Filter by annotation key-value pairs + map annotations = 2; + + // Page size (default: 100, max: 1000) + int32 page_size = 3; + + // Cursor for next page (opaque token from previous response) + string page_token = 4; + + // Optional: Filter by name + string name = 5; +} + +// Response containing a list of entities +message ListEntitiesResponse { + // The list of entities matching the filter + repeated Entity entities = 1; + + // Cursor for next page (empty if no more results) + string next_page_token = 2; + + // Total count of matching entities + int32 total_count = 3; +} + +// Service definition for managing entities +service EntityService { + // Creates a new entity + rpc CreateEntity (CreateEntityRequest) returns (CreateEntityResponse); + + // Gets an entity by its ID + rpc GetEntity (GetEntityRequest) returns (GetEntityResponse); + + // Updates an existing entity + rpc UpdateEntity (UpdateEntityRequest) returns (UpdateEntityResponse); + + // Deletes an entity by its ID + rpc DeleteEntity (DeleteEntityRequest) returns (DeleteEntityResponse); + + // Lists entities with optional filtering + rpc ListEntities (ListEntitiesRequest) returns (ListEntitiesResponse); +} diff --git a/proto/entities.proto.template b/proto/entities.proto.template new file mode 100644 index 0000000..b16fe9d --- /dev/null +++ b/proto/entities.proto.template @@ -0,0 +1,191 @@ +syntax = "proto3"; + +package charybdis.entities; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/field_mask.proto"; +import "core/service.proto"; +import "core/system.proto"; +import "core/component.proto"; +import "core/api.proto"; +import "core/user.proto"; +import "core/group.proto"; +import "core/domain.proto"; +import "core/resource.proto"; +import "core/finding.proto"; + +// Plugin imports will be inserted here by build.rs +{{PLUGIN_IMPORTS}} + +// GENERATED FILE - DO NOT EDIT MANUALLY +// This file is generated by build.rs based on plugins.toml configuration +// To add new entity types, configure plugins in plugins.toml and rebuild + +// Main entity structure +// This represents any cataloged entity in Charybdis +message Entity { + // Unique identifier (UUID) + string id = 1; + + // Entity kind (e.g., "Service", "System", "Component") + // This determines which metadata/spec variant is populated + string kind = 2; + + // Polymorphic metadata - identifying information + // Each kind has its own metadata structure + oneof metadata { + charybdis.core.ServiceMetadata service_metadata = 10; + charybdis.core.SystemMetadata system_metadata = 11; + charybdis.core.ComponentMetadata component_metadata = 12; + charybdis.core.APIMetadata api_metadata = 13; + charybdis.core.UserMetadata user_metadata = 14; + charybdis.core.GroupMetadata group_metadata = 15; + charybdis.core.DomainMetadata domain_metadata = 16; + charybdis.core.ResourceMetadata resource_metadata = 17; + charybdis.core.FindingMetadata finding_metadata = 24; + + // Plugin metadata types will be inserted here by build.rs + // Example: + // charybdis.plugins.defectdojo.DefectDojoProductMetadata defectdojo_product_metadata = 100; + // charybdis.plugins.dependencytrack.DependencyTrackProjectMetadata dependencytrack_project_metadata = 101; + {{PLUGIN_METADATA_FIELDS}} + } + + // Polymorphic spec - configuration and behavior + // Each kind has its own spec structure + oneof spec { + charybdis.core.ServiceSpec service_spec = 4; + charybdis.core.SystemSpec system_spec = 5; + charybdis.core.ComponentSpec component_spec = 6; + charybdis.core.APISpec api_spec = 7; + charybdis.core.UserSpec user_spec = 8; + charybdis.core.GroupSpec group_spec = 9; + charybdis.core.DomainSpec domain_spec = 18; + charybdis.core.ResourceSpec resource_spec = 19; + charybdis.core.FindingSpec finding_spec = 25; + + // Plugin spec types will be inserted here by build.rs + // Example: + // charybdis.plugins.defectdojo.DefectDojoProductSpec defectdojo_product_spec = 100; + // charybdis.plugins.dependencytrack.DependencyTrackProjectSpec dependencytrack_project_spec = 101; + {{PLUGIN_SPEC_FIELDS}} + } + + // Annotations - arbitrary string key-value pairs + // This is where plugins store external tool IDs and references + // Examples: + // "defectdojo.com/product-id": "12345" + // "dependencytrack.com/project-uuid": "550e8400-e29b-41d4-a716-446655440000" + // "github.com/repo-slug": "myorg/myrepo" + map annotations = 20; + + // Timestamps (managed by the system) + google.protobuf.Timestamp created_at = 21; + google.protobuf.Timestamp updated_at = 22; +} + +// Request to create a new entity +message CreateEntityRequest { + // Entity data (id, created_at, updated_at will be set by server) + Entity entity = 1; +} + +// Response after creating an entity +message CreateEntityResponse { + // The created entity with server-assigned fields + Entity entity = 1; +} + +// Request to get an entity by ID +message GetEntityRequest { + // The UUID of the entity to retrieve + string id = 1; +} + +// Response containing a single entity +message GetEntityResponse { + Entity entity = 1; +} + +// Request to update an existing entity +message UpdateEntityRequest { + // The UUID of the entity to update + string id = 1; + + // Updated entity data (id, created_at will be ignored) + Entity entity = 2; + + // Optional: Field mask for partial updates + // If not provided, performs a full update + // If provided, only updates the fields specified in the mask + // Example paths: "kind", "annotations", "metadata", "spec", + // "annotations.github.com/repo-slug", + // "component_metadata.name", "component_spec.lifecycle" + google.protobuf.FieldMask update_mask = 3; +} + +// Response after updating an entity +message UpdateEntityResponse { + // The updated entity + Entity entity = 1; +} + +// Request to delete an entity by ID +message DeleteEntityRequest { + // The UUID of the entity to delete + string id = 1; +} + +// Response after deleting an entity +message DeleteEntityResponse { + // Whether the deletion was successful + bool success = 1; +} + +// Request to list entities +message ListEntitiesRequest { + // Optional: Filter by kind + string kind = 1; + + // Optional: Filter by annotation key-value pairs + map annotations = 2; + + // Page size (default: 100, max: 1000) + int32 page_size = 3; + + // Cursor for next page (opaque token from previous response) + string page_token = 4; + + // Optional: Filter by name + string name = 5; +} + +// Response containing a list of entities +message ListEntitiesResponse { + // The list of entities matching the filter + repeated Entity entities = 1; + + // Cursor for next page (empty if no more results) + string next_page_token = 2; + + // Total count of matching entities + int32 total_count = 3; +} + +// Service definition for managing entities +service EntityService { + // Creates a new entity + rpc CreateEntity (CreateEntityRequest) returns (CreateEntityResponse); + + // Gets an entity by its ID + rpc GetEntity (GetEntityRequest) returns (GetEntityResponse); + + // Updates an existing entity + rpc UpdateEntity (UpdateEntityRequest) returns (UpdateEntityResponse); + + // Deletes an entity by its ID + rpc DeleteEntity (DeleteEntityRequest) returns (DeleteEntityResponse); + + // Lists entities with optional filtering + rpc ListEntities (ListEntitiesRequest) returns (ListEntitiesResponse); +} diff --git a/proto/ingestion.proto b/proto/ingestion.proto new file mode 100644 index 0000000..b5eeab2 --- /dev/null +++ b/proto/ingestion.proto @@ -0,0 +1,127 @@ +syntax = "proto3"; + +package charybdis.ingestion; + +import "google/protobuf/timestamp.proto"; +import "core/finding.proto"; + +// Service for ingesting security scan results +service IngestionService { + // Import a scan report: parse, reconcile with existing findings, and persist + rpc ImportScan (ImportScanRequest) returns (ImportScanResponse); + + // Dry-run a scan report: parse, reconcile, return diff without persisting + // Useful for MR/PR comments: "this change introduces X new vulnerabilities" + rpc DryRunScan (DryRunScanRequest) returns (DryRunScanResponse); +} + +// Request to import a scan report +message ImportScanRequest { + // Required: Reference to the component (entity name or UUID) + string component_ref = 1; + + // Required: Lifecycle/environment scope (e.g., "production", "integration") + string lifecycle = 2; + + // Required: Format of the scan data (e.g., "sarif", "cyclonedx-vex") + string format = 3; + + // Required: Raw scan report data (JSON/XML bytes) + bytes data = 4; + + // Optional: Scanner name override (if not derivable from the report) + string scanner_name = 5; + + // Optional: Identifier for this scan run (e.g., CI job ID) + string scan_id = 6; +} + +// Response after importing a scan +message ImportScanResponse { + // Summary of what happened during reconciliation + ReconciliationSummary summary = 1; + + // New findings created during this import + repeated FindingResult new_findings = 2; + + // Findings that were resolved (no longer detected) + repeated FindingResult resolved_findings = 3; + + // Findings that were reopened (detected again after being resolved) + repeated FindingResult reopened_findings = 4; +} + +// Request for dry-run scan (same as import but no persistence) +message DryRunScanRequest { + // Required: Reference to the component (entity name or UUID) + string component_ref = 1; + + // Required: Lifecycle/environment scope + string lifecycle = 2; + + // Required: Format of the scan data + string format = 3; + + // Required: Raw scan report data + bytes data = 4; + + // Optional: Scanner name override + string scanner_name = 5; +} + +// Response for dry-run scan +message DryRunScanResponse { + // Summary of what would happen + ReconciliationSummary summary = 1; + + // New findings that would be created + repeated FindingResult new_findings = 2; + + // Findings that would be resolved + repeated FindingResult resolved_findings = 3; + + // Findings that would be reopened + repeated FindingResult reopened_findings = 4; +} + +// Summary statistics of a reconciliation operation +message ReconciliationSummary { + // Total findings parsed from the scan report + uint32 total_parsed = 1; + + // New findings (not previously seen) + uint32 new_count = 2; + + // Existing findings still detected (unchanged) + uint32 unchanged_count = 3; + + // Previously active findings no longer detected (resolved) + uint32 resolved_count = 4; + + // Previously resolved findings detected again (reopened) + uint32 reopened_count = 5; +} + +// A finding result returned in import/dry-run responses +message FindingResult { + // Finding title + string title = 1; + + // Severity + charybdis.core.Severity severity = 2; + + // Scanner rule ID + string rule_id = 3; + + // File path (if applicable) + string file_path = 4; + + // Line number (if applicable) + uint32 line_start = 5; + + // Fingerprint used for deduplication + string fingerprint = 6; + + // Entity ID (set for existing findings, empty for dry-run new findings) + string entity_id = 7; +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..1284627 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1 @@ +pub mod yaml; diff --git a/src/adapters/yaml/backstage.rs b/src/adapters/yaml/backstage.rs new file mode 100644 index 0000000..1d27a11 --- /dev/null +++ b/src/adapters/yaml/backstage.rs @@ -0,0 +1,540 @@ +use crate::charybdis::entities::{Entity, entity::Metadata, entity::Spec}; +use serde_json::{Value, json}; +use std::collections::HashMap; + +/// Create a Backstage Location YAML that lists all entities +pub fn create_location_yaml(entities: &[Entity], base_url: &str) -> String { + let mut targets = Vec::new(); + + for entity in entities { + // Generate URL for each entity + let entity_url = format!("{}/yaml/entities/{}", base_url, entity.id); + targets.push(entity_url); + } + + let location = json!({ + "apiVersion": "backstage.io/v1alpha1", + "kind": "Location", + "metadata": { + "name": "charybdis-all-entities", + "description": "Dynamic location managed by Charybdis" + }, + "spec": { + "type": "charybdis", + "targets": targets + } + }); + + serde_yaml::to_string(&location).unwrap_or_else(|_| String::from("# Error generating YAML")) +} + +/// Convert a Charybdis Entity to Backstage YAML format +pub fn entity_to_yaml(entity: &Entity) -> Result> { + let mut backstage_entity: HashMap = HashMap::new(); + + // API version + backstage_entity.insert("apiVersion".to_string(), json!("backstage.io/v1alpha1")); + + // Kind + backstage_entity.insert("kind".to_string(), json!(entity.kind)); + + // Metadata + let metadata = extract_metadata(entity)?; + backstage_entity.insert("metadata".to_string(), metadata); + + // Spec + if let Some(spec) = extract_spec(entity) { + backstage_entity.insert("spec".to_string(), spec); + } + + // Convert to YAML + Ok(serde_yaml::to_string(&backstage_entity)?) +} + +/// Extract metadata from Entity based on its type +fn extract_metadata(entity: &Entity) -> Result> { + let mut metadata = json!({}); + + match &entity.metadata { + Some(Metadata::ServiceMetadata(m)) => { + metadata["name"] = json!(m.name); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.labels.is_empty() { + metadata["labels"] = json!(m.labels); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + if !m.links.is_empty() { + let links: Vec = m + .links + .iter() + .map(|link| { + json!({ + "url": link.url, + "title": link.title, + "icon": link.icon + }) + }) + .collect(); + metadata["links"] = json!(links); + } + } + Some(Metadata::SystemMetadata(m)) => { + metadata["name"] = json!(m.name); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.labels.is_empty() { + metadata["labels"] = json!(m.labels); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + if !m.links.is_empty() { + let links: Vec = m + .links + .iter() + .map(|link| { + json!({ + "url": link.url, + "title": link.title, + "icon": link.icon + }) + }) + .collect(); + metadata["links"] = json!(links); + } + } + Some(Metadata::ComponentMetadata(m)) => { + metadata["name"] = json!(m.name); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.labels.is_empty() { + metadata["labels"] = json!(m.labels); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + if !m.links.is_empty() { + let links: Vec = m + .links + .iter() + .map(|link| { + json!({ + "url": link.url, + "title": link.title, + "icon": link.icon + }) + }) + .collect(); + metadata["links"] = json!(links); + } + } + Some(Metadata::ApiMetadata(m)) => { + metadata["name"] = json!(m.name); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.labels.is_empty() { + metadata["labels"] = json!(m.labels); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + if !m.links.is_empty() { + let links: Vec = m + .links + .iter() + .map(|link| { + json!({ + "url": link.url, + "title": link.title, + "icon": link.icon + }) + }) + .collect(); + metadata["links"] = json!(links); + } + } + Some(Metadata::UserMetadata(m)) => { + metadata["name"] = json!(m.name); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.labels.is_empty() { + metadata["labels"] = json!(m.labels); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + if !m.links.is_empty() { + let links: Vec = m + .links + .iter() + .map(|link| { + json!({ + "url": link.url, + "title": link.title, + "icon": link.icon + }) + }) + .collect(); + metadata["links"] = json!(links); + } + } + Some(Metadata::GroupMetadata(m)) => { + metadata["name"] = json!(m.name); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.labels.is_empty() { + metadata["labels"] = json!(m.labels); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + if !m.links.is_empty() { + let links: Vec = m + .links + .iter() + .map(|link| { + json!({ + "url": link.url, + "title": link.title, + "icon": link.icon + }) + }) + .collect(); + metadata["links"] = json!(links); + } + } + Some(Metadata::DomainMetadata(m)) => { + metadata["name"] = json!(m.name); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.labels.is_empty() { + metadata["labels"] = json!(m.labels); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + if !m.links.is_empty() { + let links: Vec = m + .links + .iter() + .map(|link| { + json!({ + "url": link.url, + "title": link.title, + "icon": link.icon + }) + }) + .collect(); + metadata["links"] = json!(links); + } + } + Some(Metadata::ResourceMetadata(m)) => { + metadata["name"] = json!(m.name); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.labels.is_empty() { + metadata["labels"] = json!(m.labels); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + if !m.links.is_empty() { + let links: Vec = m + .links + .iter() + .map(|link| { + json!({ + "url": link.url, + "title": link.title, + "icon": link.icon + }) + }) + .collect(); + metadata["links"] = json!(links); + } + } + Some(Metadata::DefectdojoMetadata(m)) => { + // DefectDojo plugin metadata + metadata["name"] = json!(m.name); + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.resource_type.is_empty() { + metadata["annotations"] = json!({ + "defectdojo.com/resource-type": m.resource_type + }); + } + } + Some(Metadata::KeycloakMetadata(m)) => { + // Keycloak plugin metadata + metadata["name"] = json!(m.name); + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + let mut annotations = serde_json::Map::new(); + if !m.resource_type.is_empty() { + annotations.insert("keycloak.org/resource-type".to_string(), json!(m.resource_type)); + } + if !m.realm.is_empty() { + annotations.insert("keycloak.org/realm".to_string(), json!(m.realm)); + } + if !annotations.is_empty() { + metadata["annotations"] = Value::Object(annotations); + } + } + Some(Metadata::DependencytrackMetadata(m)) => { + // Dependency-Track plugin metadata + metadata["name"] = json!(m.name); + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.resource_type.is_empty() { + metadata["annotations"] = json!({ + "dependencytrack.org/resource-type": m.resource_type + }); + } + } + Some(Metadata::FindingMetadata(m)) => { + metadata["name"] = json!(m.title); + if !m.namespace.is_empty() { + metadata["namespace"] = json!(m.namespace); + } + if !m.description.is_empty() { + metadata["description"] = json!(m.description); + } + if !m.tags.is_empty() { + metadata["tags"] = json!(m.tags); + } + } + None => { + return Err("Entity has no metadata".into()); + } + } + + // Add annotations + if !entity.annotations.is_empty() { + metadata["annotations"] = json!(entity.annotations); + } + + Ok(metadata) +} + +/// Extract spec from Entity based on its type +fn extract_spec(entity: &Entity) -> Option { + match &entity.spec { + Some(Spec::ServiceSpec(s)) => { + let mut spec = serde_json::Map::new(); + spec.insert("type".to_string(), json!(s.r#type)); + spec.insert("lifecycle".to_string(), json!(s.lifecycle)); + spec.insert("owner".to_string(), json!(s.owner)); + if !s.system.is_empty() { + spec.insert("system".to_string(), json!(s.system)); + } + if !s.depends_on.is_empty() { + spec.insert("dependsOn".to_string(), json!(s.depends_on)); + } + if !s.provides_apis.is_empty() { + spec.insert("providesApis".to_string(), json!(s.provides_apis)); + } + if !s.consumes_apis.is_empty() { + spec.insert("consumesApis".to_string(), json!(s.consumes_apis)); + } + Some(Value::Object(spec)) + } + Some(Spec::SystemSpec(s)) => { + let mut spec = serde_json::Map::new(); + spec.insert("owner".to_string(), json!(s.owner)); + if !s.domain.is_empty() { + spec.insert("domain".to_string(), json!(s.domain)); + } + Some(Value::Object(spec)) + } + Some(Spec::ComponentSpec(s)) => { + let mut spec = serde_json::Map::new(); + spec.insert("type".to_string(), json!(s.r#type)); + spec.insert("lifecycle".to_string(), json!(s.lifecycle)); + spec.insert("owner".to_string(), json!(s.owner)); + if !s.system.is_empty() { + spec.insert("system".to_string(), json!(s.system)); + } + if !s.subcomponent_of.is_empty() { + spec.insert("subcomponentOf".to_string(), json!(s.subcomponent_of)); + } + if !s.depends_on.is_empty() { + spec.insert("dependsOn".to_string(), json!(s.depends_on)); + } + if !s.provides_apis.is_empty() { + spec.insert("providesApis".to_string(), json!(s.provides_apis)); + } + if !s.consumes_apis.is_empty() { + spec.insert("consumesApis".to_string(), json!(s.consumes_apis)); + } + Some(Value::Object(spec)) + } + Some(Spec::ApiSpec(s)) => { + let mut spec = serde_json::Map::new(); + spec.insert("type".to_string(), json!(s.r#type)); + spec.insert("lifecycle".to_string(), json!(s.lifecycle)); + spec.insert("owner".to_string(), json!(s.owner)); + if !s.system.is_empty() { + spec.insert("system".to_string(), json!(s.system)); + } + spec.insert("definition".to_string(), json!(s.definition)); + Some(Value::Object(spec)) + } + Some(Spec::UserSpec(s)) => { + let mut spec = json!({}); + if let Some(profile) = &s.profile { + spec["profile"] = json!({ + "displayName": profile.display_name, + "email": profile.email, + "picture": profile.picture, + }); + } + if !s.member_of.is_empty() { + spec["memberOf"] = json!(s.member_of); + } + Some(spec) + } + Some(Spec::GroupSpec(s)) => { + let mut spec = json!({ + "type": s.r#type, + }); + if let Some(profile) = &s.profile { + spec["profile"] = json!({ + "displayName": profile.display_name, + "email": profile.email, + "picture": profile.picture, + }); + } + if !s.parent.is_empty() { + spec["parent"] = json!(s.parent); + } + if !s.children.is_empty() { + spec["children"] = json!(s.children); + } + if !s.members.is_empty() { + spec["members"] = json!(s.members); + } + Some(spec) + } + Some(Spec::DomainSpec(s)) => Some(json!({ + "owner": s.owner, + })), + Some(Spec::ResourceSpec(s)) => Some(json!({ + "type": s.r#type, + "owner": s.owner, + "system": if s.system.is_empty() { Value::Null } else { json!(s.system) }, + "dependsOn": if s.depends_on.is_empty() { Value::Null } else { json!(s.depends_on) }, + })), + Some(Spec::DefectdojoSpec(s)) => { + // DefectDojo plugin spec - parse config_json if present + let mut spec = json!({}); + if !s.config_json.is_empty() { + if let Ok(config) = serde_json::from_str::(&s.config_json) { + spec = config; + } + } + if !s.sync_status.is_empty() { + spec["sync_status"] = json!(s.sync_status); + } + Some(spec) + } + Some(Spec::KeycloakSpec(s)) => { + // Keycloak plugin spec - parse config_json if present + let mut spec = json!({}); + if !s.config_json.is_empty() { + if let Ok(config) = serde_json::from_str::(&s.config_json) { + spec = config; + } + } + if !s.sync_status.is_empty() { + spec["sync_status"] = json!(s.sync_status); + } + if !s.last_sync.is_empty() { + spec["last_sync"] = json!(s.last_sync); + } + if !s.error_message.is_empty() { + spec["error_message"] = json!(s.error_message); + } + Some(spec) + } + Some(Spec::DependencytrackSpec(s)) => { + // Dependency-Track plugin spec - parse config_json if present + let mut spec = json!({}); + if !s.config_json.is_empty() { + if let Ok(config) = serde_json::from_str::(&s.config_json) { + spec = config; + } + } + if !s.sync_status.is_empty() { + spec["sync_status"] = json!(s.sync_status); + } + if !s.last_sync.is_empty() { + spec["last_sync"] = json!(s.last_sync); + } + if !s.error_message.is_empty() { + spec["error_message"] = json!(s.error_message); + } + Some(spec) + } + Some(Spec::FindingSpec(s)) => { + let mut spec = json!({}); + spec["component_ref"] = json!(s.component_ref); + spec["lifecycle"] = json!(s.lifecycle); + spec["severity"] = json!(s.severity); + spec["state"] = json!(s.state); + spec["scanner"] = json!(s.scanner); + spec["rule_id"] = json!(s.rule_id); + if !s.file_path.is_empty() { + spec["file_path"] = json!(s.file_path); + } + if s.line_start > 0 { + spec["line_start"] = json!(s.line_start); + } + if !s.cwe.is_empty() { + spec["cwe"] = json!(s.cwe); + } + if !s.cve.is_empty() { + spec["cve"] = json!(s.cve); + } + Some(spec) + } + None => None, + } +} diff --git a/src/adapters/yaml/mod.rs b/src/adapters/yaml/mod.rs new file mode 100644 index 0000000..d020b8d --- /dev/null +++ b/src/adapters/yaml/mod.rs @@ -0,0 +1,154 @@ +use crate::database::EntityRepository; +use axum::{ + Router, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::{debug, error, info}; + +mod backstage; + +struct CachedResponse { + data: String, + cached_at: Instant, +} + +/// State shared across YAML adapter handlers +#[derive(Clone)] +pub struct YamlAdapterState { + pub repository: Arc, + pub base_url: String, + cache: Arc>>, + cache_ttl: Duration, +} + +impl YamlAdapterState { + /// Invalidate the locations cache (for event-based invalidation on entity changes) + pub async fn invalidate_cache(&self) { + let mut cache = self.cache.write().await; + *cache = None; + } +} + +/// Start the YAML adapter HTTP server +pub async fn start_yaml_adapter( + host: String, + port: u16, + repository: Arc, + base_url: String, +) -> Result<(), Box> { + let state = YamlAdapterState { + repository, + base_url, + cache: Arc::new(RwLock::new(None)), + cache_ttl: Duration::from_secs(30), + }; + + let app = Router::new() + .route("/yaml/locations", get(get_locations)) + .route("/yaml/entities/:id", get(get_entity)) + .with_state(state); + + let addr = format!("{}:{}", host, port); + info!("Starting YAML adapter HTTP server on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +/// GET /yaml/locations +/// Returns a Backstage Location entity listing all entities in Charybdis +async fn get_locations(State(state): State) -> Response { + info!("Received GET /yaml/locations request"); + + // Check cache first + { + let cache = state.cache.read().await; + if let Some(ref cached) = *cache { + if cached.cached_at.elapsed() < state.cache_ttl { + debug!("Serving /yaml/locations from cache"); + return ( + StatusCode::OK, + [("content-type", "text/yaml; charset=utf-8")], + cached.data.clone(), + ) + .into_response(); + } + } + } + + // Cache miss or expired — fetch from database + match state.repository.list_all().await { + Ok(entities) => { + let location_yaml = backstage::create_location_yaml(&entities, &state.base_url); + + // Update cache + { + let mut cache = state.cache.write().await; + *cache = Some(CachedResponse { + data: location_yaml.clone(), + cached_at: Instant::now(), + }); + } + + ( + StatusCode::OK, + [("content-type", "text/yaml; charset=utf-8")], + location_yaml, + ) + .into_response() + } + Err(e) => { + error!(error = %e, "Failed to list entities for location"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to retrieve entities", + ) + .into_response() + } + } +} + +/// GET /yaml/entities/:id +/// Returns a specific entity in Backstage YAML format +async fn get_entity(Path(id): Path, State(state): State) -> Response { + info!(entity_id = %id, "Received GET /yaml/entities/:id request"); + + match state.repository.get_by_id(&id).await { + Ok(Some(entity)) => match backstage::entity_to_yaml(&entity) { + Ok(yaml) => ( + StatusCode::OK, + [("content-type", "text/yaml; charset=utf-8")], + yaml, + ) + .into_response(), + Err(e) => { + error!(entity_id = %id, error = %e, "Failed to convert entity to YAML"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to convert entity to YAML", + ) + .into_response() + } + }, + Ok(None) => { + info!(entity_id = %id, "Entity not found"); + (StatusCode::NOT_FOUND, "Entity not found").into_response() + } + Err(e) => { + error!(entity_id = %id, error = %e, "Failed to retrieve entity"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to retrieve entity", + ) + .into_response() + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..112a99d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,325 @@ +/// Configuration management for Charybdis +/// +/// This module handles loading configuration from a TOML file with support for +/// environment variable substitution using ${VAR_NAME} syntax. +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use tracing::{debug, info}; + +/// Main Charybdis configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Server configuration + pub server: ServerConfig, + + /// Database configuration + pub database: DatabaseConfig, + + /// Security configuration (mTLS and RBAC) + #[serde(default)] + pub security: crate::security::config::SecurityConfig, + + /// OpenTelemetry configuration + #[serde(default)] + pub telemetry: crate::telemetry::config::TelemetryConfig, + + /// Plugin configuration + #[serde(default)] + pub plugins: PluginsConfig, +} + +/// Server configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + /// gRPC server host + #[serde(default = "default_grpc_host")] + pub grpc_host: String, + + /// gRPC server port + #[serde(default = "default_grpc_port")] + pub grpc_port: u16, + + /// YAML adapter configuration + #[serde(default)] + pub yaml_adapter: YamlAdapterConfig, +} + +/// YAML adapter configuration for Backstage integration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YamlAdapterConfig { + /// Enable YAML adapter + #[serde(default = "default_yaml_enabled")] + pub enabled: bool, + + /// YAML adapter host + #[serde(default = "default_yaml_host")] + pub host: String, + + /// YAML adapter port + #[serde(default = "default_yaml_port")] + pub port: u16, +} + +/// Database configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseConfig { + /// PostgreSQL connection URL + /// Supports environment variable substitution: ${DATABASE_URL} + pub url: String, + + /// Maximum number of connections in the pool + #[serde(default = "default_max_connections")] + pub max_connections: u32, + + /// Connection timeout in seconds + #[serde(default = "default_connection_timeout")] + pub connection_timeout_secs: u64, +} + +/// Plugin system configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PluginsConfig { + /// DefectDojo plugin configuration + #[serde(default)] + pub defectdojo: Option>, + + /// Dependency-Track plugin configuration + #[serde(default)] + pub dependencytrack: Option>, + + /// Keycloak plugin configuration + #[serde(default)] + pub keycloak: Option>, + + /// Custom plugin configurations + #[serde(flatten)] + pub custom: HashMap>, +} + +// Default values +fn default_grpc_host() -> String { + "[::1]".to_string() +} + +fn default_grpc_port() -> u16 { + 50051 +} + +fn default_yaml_enabled() -> bool { + true +} + +fn default_yaml_host() -> String { + "0.0.0.0".to_string() +} + +fn default_yaml_port() -> u16 { + 8080 +} + +fn default_max_connections() -> u32 { + 10 +} + +fn default_connection_timeout() -> u64 { + 30 +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + grpc_host: default_grpc_host(), + grpc_port: default_grpc_port(), + yaml_adapter: YamlAdapterConfig::default(), + } + } +} + +impl Default for YamlAdapterConfig { + fn default() -> Self { + Self { + enabled: default_yaml_enabled(), + host: default_yaml_host(), + port: default_yaml_port(), + } + } +} + +impl Config { + /// Load configuration from a TOML file + /// + /// Environment variables in the format ${VAR_NAME} will be substituted + /// with their values from the environment. + pub fn from_file>(path: P) -> Result { + let path = path.as_ref(); + info!("Loading configuration from: {}", path.display()); + + // Read the file + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + + // Substitute environment variables + let content = Self::substitute_env_vars(&content)?; + + debug!("Parsing configuration"); + + // Parse TOML + let config: Config = toml::from_str(&content) + .with_context(|| format!("Failed to parse config file: {}", path.display()))?; + + info!("Configuration loaded successfully"); + Ok(config) + } + + /// Load configuration from default location + /// + /// Looks for config in the following order: + /// 1. ./config.toml (current directory) + /// 2. ./charybdis.toml + /// 3. /etc/charybdis/config.toml (Linux/Unix) + /// + /// Falls back to environment variables if no config file is found. + pub fn load() -> Result { + let candidates = vec![ + "./config.toml", + "./charybdis.toml", + "/etc/charybdis/config.toml", + ]; + + for path in candidates { + if Path::new(path).exists() { + return Self::from_file(path); + } + } + + info!("No config file found, using environment variables"); + Self::from_env() + } + + /// Create configuration from environment variables (legacy support) + pub fn from_env() -> Result { + use crate::security::config::SecurityConfig; + use crate::telemetry::config::TelemetryConfig; + + let database_url = std::env::var("DATABASE_URL") + .context("DATABASE_URL must be set (or provide config.toml)")?; + + Ok(Config { + server: ServerConfig { + grpc_host: std::env::var("GRPC_HOST").unwrap_or_else(|_| default_grpc_host()), + grpc_port: std::env::var("GRPC_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(default_grpc_port), + yaml_adapter: YamlAdapterConfig::default(), + }, + database: DatabaseConfig { + url: database_url, + max_connections: default_max_connections(), + connection_timeout_secs: default_connection_timeout(), + }, + security: SecurityConfig::from_env(), + telemetry: TelemetryConfig::from_env(), + plugins: PluginsConfig::default(), + }) + } + + /// Substitute environment variables in the format ${VAR_NAME} or ${VAR_NAME:-default} + fn substitute_env_vars(content: &str) -> Result { + let mut result = content.to_string(); + // Match ${VAR_NAME} or ${VAR_NAME:-default_value} + let var_pattern = + regex::Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)(?::-((?:[^}])*))?\}").unwrap(); + + for capture in var_pattern.captures_iter(content) { + let full_match = &capture[0]; + let var_name = &capture[1]; + let default_value = capture.get(2).map(|m| m.as_str()); + + match std::env::var(var_name) { + Ok(value) => { + debug!("Substituting ${{{}}}", var_name); + result = result.replace(full_match, &value); + } + Err(_) => { + if let Some(default) = default_value { + debug!( + "Environment variable not set: ${{{}}}, using default: {}", + var_name, default + ); + result = result.replace(full_match, default); + } else { + debug!("Environment variable not set: ${{{}}}", var_name); + } + } + } + } + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_env_var_substitution() { + // Use PATH which always exists in test environment + let path_value = std::env::var("PATH").unwrap(); + + let input = "some_path = \"${PATH}\""; + let result = Config::substitute_env_vars(input).unwrap(); + + assert!(result.contains(&path_value)); + assert!(!result.contains("${PATH}")); + } + + #[test] + fn test_env_var_substitution_missing() { + // Test that missing env vars are left as-is (allowing optional substitution) + let input = "url = \"postgresql://${NONEXISTENT_VAR_12345}:password@localhost\""; + let result = Config::substitute_env_vars(input).unwrap(); + + // Variable should remain unchanged when not found + assert!(result.contains("${NONEXISTENT_VAR_12345}")); + } + + #[test] + fn test_env_var_substitution_multiple() { + // Test multiple variable substitutions + let path_value = std::env::var("PATH").unwrap(); + + let input = "path = \"${PATH}\" and user = \"${USER}\""; + let result = Config::substitute_env_vars(input).unwrap(); + + assert!(result.contains(&path_value)); + assert!(!result.contains("${PATH}")); + } + + #[test] + fn test_default_values() { + let server = ServerConfig::default(); + assert_eq!(server.grpc_port, 50051); + assert_eq!(server.grpc_host, "[::1]"); + } + + #[test] + fn test_env_var_with_default_value() { + let input = "url = \"${NONEXISTENT_VAR_99999:-http://localhost:8080}\""; + let result = Config::substitute_env_vars(input).unwrap(); + assert_eq!(result, "url = \"http://localhost:8080\""); + } + + #[test] + fn test_env_var_with_default_value_overridden() { + // PATH always exists — use it with a default that should be ignored + let path_value = std::env::var("PATH").unwrap(); + let input = "url = \"${PATH:-http://fallback:8080}\""; + let result = Config::substitute_env_vars(input).unwrap(); + assert_eq!(result, format!("url = \"{}\"", path_value)); + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..7e79770 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,1112 @@ +use chrono::{DateTime, Utc}; +use prost::Message; +use sqlx::PgPool; +use std::time::Instant; +use tracing::{debug, error, info, instrument}; +use uuid::Uuid; + +use crate::charybdis::entities::Entity; +use crate::charybdis::entities::entity::{Metadata, Spec}; +use crate::error::Result; + +/// Result of a paginated list query +#[derive(Debug)] +pub struct PaginatedEntities { + pub entities: Vec, + pub next_page_token: Option, + pub total_count: i32, +} + +fn extract_entity_name(entity: &Entity) -> Option { + match &entity.metadata { + Some(Metadata::ComponentMetadata(m)) => Some(m.name.clone()), + Some(Metadata::ServiceMetadata(m)) => Some(m.name.clone()), + Some(Metadata::SystemMetadata(m)) => Some(m.name.clone()), + Some(Metadata::ApiMetadata(m)) => Some(m.name.clone()), + Some(Metadata::ResourceMetadata(m)) => Some(m.name.clone()), + Some(Metadata::DomainMetadata(m)) => Some(m.name.clone()), + Some(Metadata::UserMetadata(m)) => Some(m.name.clone()), + Some(Metadata::GroupMetadata(m)) => Some(m.name.clone()), + Some(Metadata::FindingMetadata(m)) => Some(m.title.clone()), + _ => None, + } +} + +#[derive(Clone, Debug)] +pub struct EntityRepository { + pool: PgPool, +} + +impl EntityRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + #[instrument(skip(self, entity), fields(entity.id, entity.kind, db.operation = "create"))] + pub async fn create(&self, entity: &Entity) -> Result { + let start = Instant::now(); + + // Generate a new UUID for the entity + let id = Uuid::new_v4(); + let now = Utc::now(); + + // Create a new entity with the generated ID and timestamps + let mut entity_with_metadata = entity.clone(); + entity_with_metadata.id = id.to_string(); + entity_with_metadata.created_at = Some(prost_types::Timestamp { + seconds: now.timestamp(), + nanos: now.timestamp_subsec_nanos() as i32, + }); + entity_with_metadata.updated_at = Some(prost_types::Timestamp { + seconds: now.timestamp(), + nanos: now.timestamp_subsec_nanos() as i32, + }); + + // Record span attributes + tracing::Span::current() + .record("entity.id", id.to_string()) + .record("entity.kind", &entity_with_metadata.kind); + + // Serialize the entity to protobuf bytes + let entity_data = entity_with_metadata.encode_to_vec(); + + // Extract kind, name, and annotations for database columns + let kind = entity_with_metadata.kind.clone(); + let name = extract_entity_name(&entity_with_metadata); + let annotations_json = serde_json::to_value(&entity_with_metadata.annotations)?; + + debug!( + entity.id = %id, + entity.kind = %kind, + "Creating entity in database" + ); + + // Insert into database + let result = sqlx::query( + "INSERT INTO entities (id, kind, name, entity_data, annotations, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)" + ) + .bind(id) + .bind(kind.clone()) + .bind(name.as_deref()) + .bind(entity_data) + .bind(annotations_json) + .bind(now) + .bind(now) + .execute(&self.pool) + .await; + + match result { + Ok(_) => { + let duration = start.elapsed(); + info!( + entity.id = %id, + entity.kind = %kind, + duration_ms = duration.as_millis(), + "Entity created successfully" + ); + Ok(entity_with_metadata) + } + Err(e) => { + let duration = start.elapsed(); + error!( + entity.id = %id, + entity.kind = %kind, + duration_ms = duration.as_millis(), + error = %e, + "Failed to create entity" + ); + Err(e.into()) + } + } + } + + #[instrument(skip(self), fields(entity.id = %id, db.operation = "get"))] + pub async fn get_by_id(&self, id: &str) -> Result> { + let start = Instant::now(); + let entity_uuid = Uuid::parse_str(id)?; + + debug!(entity.id = %id, "Fetching entity from database"); + + let result = + sqlx::query_as::<_, (Vec,)>("SELECT entity_data FROM entities WHERE id = $1") + .bind(entity_uuid) + .fetch_optional(&self.pool) + .await; + + match result { + Ok(row) => { + let duration = start.elapsed(); + match row { + Some((entity_data,)) => { + let entity = Entity::decode(entity_data.as_slice())?; + info!( + entity.id = %id, + entity.kind = %entity.kind, + duration_ms = duration.as_millis(), + "Entity fetched successfully" + ); + Ok(Some(entity)) + } + None => { + debug!( + entity.id = %id, + duration_ms = duration.as_millis(), + "Entity not found" + ); + Ok(None) + } + } + } + Err(e) => { + let duration = start.elapsed(); + error!( + entity.id = %id, + duration_ms = duration.as_millis(), + error = %e, + "Failed to fetch entity" + ); + Err(e.into()) + } + } + } + + #[instrument(skip(self, updated_entity), fields(entity.id = %id, entity.kind, db.operation = "update"))] + pub async fn update(&self, id: &str, updated_entity: &Entity) -> Result> { + let start = Instant::now(); + let entity_uuid = Uuid::parse_str(id)?; + let now = Utc::now(); + + debug!(entity.id = %id, "Updating entity in database"); + + // First, check if the entity exists + let existing_row = + sqlx::query_as::<_, (Vec,)>("SELECT entity_data FROM entities WHERE id = $1") + .bind(entity_uuid) + .fetch_optional(&self.pool) + .await?; + + match existing_row { + Some((entity_data_bytes,)) => { + // Decode the existing entity to preserve created_at + let existing_entity = Entity::decode(entity_data_bytes.as_slice())?; + + // Create updated entity with preserved created_at and new updated_at + let mut entity_with_metadata = updated_entity.clone(); + entity_with_metadata.id = id.to_string(); + entity_with_metadata.created_at = existing_entity.created_at; // Preserve original created_at + entity_with_metadata.updated_at = Some(prost_types::Timestamp { + seconds: now.timestamp(), + nanos: now.timestamp_subsec_nanos() as i32, + }); + + // Record span attributes + let kind = entity_with_metadata.kind.clone(); + let name = extract_entity_name(&entity_with_metadata); + tracing::Span::current().record("entity.kind", &kind); + + // Serialize the updated entity to protobuf bytes + let entity_data = entity_with_metadata.encode_to_vec(); + + // Extract annotations for database columns + let annotations_json = serde_json::to_value(&entity_with_metadata.annotations)?; + + // Update in database + let result = sqlx::query( + "UPDATE entities SET kind = $1, name = $2, entity_data = $3, annotations = $4, updated_at = $5 WHERE id = $6" + ) + .bind(kind.clone()) + .bind(name.as_deref()) + .bind(entity_data) + .bind(annotations_json) + .bind(now) + .bind(entity_uuid) + .execute(&self.pool) + .await; + + match result { + Ok(_) => { + let duration = start.elapsed(); + info!( + entity.id = %id, + entity.kind = %kind, + duration_ms = duration.as_millis(), + "Entity updated successfully" + ); + Ok(Some(entity_with_metadata)) + } + Err(e) => { + let duration = start.elapsed(); + error!( + entity.id = %id, + entity.kind = %kind, + duration_ms = duration.as_millis(), + error = %e, + "Failed to update entity" + ); + Err(e.into()) + } + } + } + None => { + let duration = start.elapsed(); + debug!( + entity.id = %id, + duration_ms = duration.as_millis(), + "Entity not found for update" + ); + Ok(None) + } + } + } + + #[instrument(skip(self), fields(entity.id = %id, db.operation = "delete"))] + pub async fn delete(&self, id: &str) -> Result { + let start = Instant::now(); + let entity_uuid = Uuid::parse_str(id)?; + + debug!(entity.id = %id, "Deleting entity from database"); + + let result = sqlx::query("DELETE FROM entities WHERE id = $1") + .bind(entity_uuid) + .execute(&self.pool) + .await; + + match result { + Ok(query_result) => { + let duration = start.elapsed(); + let deleted = query_result.rows_affected() > 0; + if deleted { + info!( + entity.id = %id, + duration_ms = duration.as_millis(), + "Entity deleted successfully" + ); + } else { + debug!( + entity.id = %id, + duration_ms = duration.as_millis(), + "Entity not found for deletion" + ); + } + Ok(deleted) + } + Err(e) => { + let duration = start.elapsed(); + error!( + entity.id = %id, + duration_ms = duration.as_millis(), + error = %e, + "Failed to delete entity" + ); + Err(e.into()) + } + } + } + + #[instrument(skip(self), fields(db.operation = "list_all"))] + pub async fn list_all(&self) -> Result> { + let start = Instant::now(); + + debug!("Listing all entities from database"); + + let result = sqlx::query_as::<_, (Vec,)>( + "SELECT entity_data FROM entities ORDER BY created_at DESC", + ) + .fetch_all(&self.pool) + .await; + + match result { + Ok(rows) => { + let mut entities = Vec::new(); + for (entity_data,) in rows { + let entity = Entity::decode(entity_data.as_slice())?; + entities.push(entity); + } + + let duration = start.elapsed(); + info!( + count = entities.len(), + duration_ms = duration.as_millis(), + "Entities listed successfully" + ); + + Ok(entities) + } + Err(e) => { + let duration = start.elapsed(); + error!( + duration_ms = duration.as_millis(), + error = %e, + "Failed to list entities" + ); + Err(e.into()) + } + } + } + + /// List entities filtered by kind (e.g., "User", "Group", "Component") + #[instrument(skip(self), fields(db.operation = "list_by_kind"))] + pub async fn list_by_kind(&self, kind: &str) -> Result> { + let start = Instant::now(); + + let result = sqlx::query_as::<_, (Vec,)>( + "SELECT entity_data FROM entities WHERE kind = $1 ORDER BY created_at DESC", + ) + .bind(kind) + .fetch_all(&self.pool) + .await; + + match result { + Ok(rows) => { + let mut entities = Vec::new(); + for (entity_data,) in rows { + let entity = Entity::decode(entity_data.as_slice())?; + entities.push(entity); + } + + let duration = start.elapsed(); + debug!( + kind = kind, + count = entities.len(), + duration_ms = duration.as_millis(), + "Entities listed by kind" + ); + + Ok(entities) + } + Err(e) => { + let duration = start.elapsed(); + error!( + kind = kind, + duration_ms = duration.as_millis(), + error = %e, + "Failed to list entities by kind" + ); + Err(e.into()) + } + } + } + + /// List entities with cursor-based pagination, kind filter, and name filter. + /// Cursor is encoded as "created_at:id" for stable ordering. + #[instrument(skip(self), fields(db.operation = "list_paginated"))] + pub async fn list_paginated( + &self, + kind: Option<&str>, + name: Option<&str>, + page_size: i32, + page_token: Option<&str>, + ) -> Result { + let start = Instant::now(); + let limit = page_size.clamp(1, 1000) as i64; + + // Decode cursor: "timestamp|uuid" (pipe separator since RFC3339 contains colons) + let cursor = page_token.and_then(|token| { + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD.decode(token).ok()?; + let s = String::from_utf8(decoded).ok()?; + let (ts_str, id_str) = s.rsplit_once('|')?; + let ts = DateTime::parse_from_rfc3339(ts_str).ok()?.with_timezone(&Utc); + let id = Uuid::parse_str(id_str).ok()?; + Some((ts, id)) + }); + + // Build count query + let total_count: i64 = match (kind, name) { + (Some(k), Some(n)) => { + sqlx::query_scalar("SELECT COUNT(*) FROM entities WHERE kind = $1 AND name = $2") + .bind(k) + .bind(n) + .fetch_one(&self.pool) + .await? + } + (Some(k), None) => { + sqlx::query_scalar("SELECT COUNT(*) FROM entities WHERE kind = $1") + .bind(k) + .fetch_one(&self.pool) + .await? + } + (None, Some(n)) => { + sqlx::query_scalar("SELECT COUNT(*) FROM entities WHERE name = $1") + .bind(n) + .fetch_one(&self.pool) + .await? + } + (None, None) => { + sqlx::query_scalar("SELECT COUNT(*) FROM entities") + .fetch_one(&self.pool) + .await? + } + }; + + // Build data query with cursor-based pagination + // Order: created_at DESC, id DESC (newest first, deterministic) + let rows: Vec<(Vec, DateTime, Uuid)> = match (kind, name, &cursor) { + (Some(k), Some(n), Some((cursor_ts, cursor_id))) => { + sqlx::query_as( + "SELECT entity_data, created_at, id FROM entities \ + WHERE kind = $1 AND name = $2 AND (created_at, id) < ($3, $4) \ + ORDER BY created_at DESC, id DESC LIMIT $5", + ) + .bind(k) + .bind(n) + .bind(cursor_ts) + .bind(cursor_id) + .bind(limit + 1) + .fetch_all(&self.pool) + .await? + } + (Some(k), Some(n), None) => { + sqlx::query_as( + "SELECT entity_data, created_at, id FROM entities \ + WHERE kind = $1 AND name = $2 \ + ORDER BY created_at DESC, id DESC LIMIT $3", + ) + .bind(k) + .bind(n) + .bind(limit + 1) + .fetch_all(&self.pool) + .await? + } + (Some(k), None, Some((cursor_ts, cursor_id))) => { + sqlx::query_as( + "SELECT entity_data, created_at, id FROM entities \ + WHERE kind = $1 AND (created_at, id) < ($2, $3) \ + ORDER BY created_at DESC, id DESC LIMIT $4", + ) + .bind(k) + .bind(cursor_ts) + .bind(cursor_id) + .bind(limit + 1) + .fetch_all(&self.pool) + .await? + } + (Some(k), None, None) => { + sqlx::query_as( + "SELECT entity_data, created_at, id FROM entities \ + WHERE kind = $1 \ + ORDER BY created_at DESC, id DESC LIMIT $2", + ) + .bind(k) + .bind(limit + 1) + .fetch_all(&self.pool) + .await? + } + (None, Some(n), Some((cursor_ts, cursor_id))) => { + sqlx::query_as( + "SELECT entity_data, created_at, id FROM entities \ + WHERE name = $1 AND (created_at, id) < ($2, $3) \ + ORDER BY created_at DESC, id DESC LIMIT $4", + ) + .bind(n) + .bind(cursor_ts) + .bind(cursor_id) + .bind(limit + 1) + .fetch_all(&self.pool) + .await? + } + (None, Some(n), None) => { + sqlx::query_as( + "SELECT entity_data, created_at, id FROM entities \ + WHERE name = $1 \ + ORDER BY created_at DESC, id DESC LIMIT $2", + ) + .bind(n) + .bind(limit + 1) + .fetch_all(&self.pool) + .await? + } + (None, None, Some((cursor_ts, cursor_id))) => { + sqlx::query_as( + "SELECT entity_data, created_at, id FROM entities \ + WHERE (created_at, id) < ($1, $2) \ + ORDER BY created_at DESC, id DESC LIMIT $3", + ) + .bind(cursor_ts) + .bind(cursor_id) + .bind(limit + 1) + .fetch_all(&self.pool) + .await? + } + (None, None, None) => { + sqlx::query_as( + "SELECT entity_data, created_at, id FROM entities \ + ORDER BY created_at DESC, id DESC LIMIT $1", + ) + .bind(limit + 1) + .fetch_all(&self.pool) + .await? + } + }; + + // If we got limit+1 rows, there's a next page + let has_next = rows.len() as i64 > limit; + let rows_to_use = if has_next { + &rows[..limit as usize] + } else { + &rows[..] + }; + + let mut entities = Vec::with_capacity(rows_to_use.len()); + let mut last_cursor = None; + for (entity_data, created_at, id) in rows_to_use { + let entity = Entity::decode(entity_data.as_slice())?; + entities.push(entity); + last_cursor = Some((created_at, id)); + } + + let next_page_token = if has_next { + last_cursor.map(|(ts, id)| { + use base64::Engine; + let cursor_str = format!("{}|{}", ts.to_rfc3339(), id); + base64::engine::general_purpose::STANDARD.encode(cursor_str) + }) + } else { + None + }; + + let duration = start.elapsed(); + debug!( + count = entities.len(), + total_count = total_count, + has_next = has_next, + duration_ms = duration.as_millis(), + "Paginated list complete" + ); + + Ok(PaginatedEntities { + entities, + next_page_token, + total_count: total_count as i32, + }) + } + + /// Get an entity by kind and name (O(1) lookup via composite index) + #[instrument(skip(self), fields(db.operation = "get_by_kind_and_name"))] + pub async fn get_by_kind_and_name(&self, kind: &str, name: &str) -> Result> { + let start = Instant::now(); + + debug!(kind = kind, name = name, "Fetching entity by kind and name"); + + let result = sqlx::query_as::<_, (Vec,)>( + "SELECT entity_data FROM entities WHERE kind = $1 AND name = $2 LIMIT 1", + ) + .bind(kind) + .bind(name) + .fetch_optional(&self.pool) + .await; + + match result { + Ok(row) => { + let duration = start.elapsed(); + match row { + Some((entity_data,)) => { + let entity = Entity::decode(entity_data.as_slice())?; + debug!( + kind = kind, + name = name, + duration_ms = duration.as_millis(), + "Entity found by kind+name" + ); + Ok(Some(entity)) + } + None => { + debug!( + kind = kind, + name = name, + duration_ms = duration.as_millis(), + "Entity not found by kind+name" + ); + Ok(None) + } + } + } + Err(e) => { + let duration = start.elapsed(); + error!( + kind = kind, + name = name, + duration_ms = duration.as_millis(), + error = %e, + "Failed to fetch entity by kind+name" + ); + Err(e.into()) + } + } + } + + /// Update entity annotations atomically using JSONB merge. + /// Race-safe: merges at the database level without read-modify-write. + #[instrument(skip(self, new_annotations), fields(entity.id = %id, db.operation = "update_annotations"))] + pub async fn update_annotations( + &self, + id: &str, + new_annotations: std::collections::HashMap, + ) -> Result { + let start = Instant::now(); + let entity_uuid = Uuid::parse_str(id)?; + let now = Utc::now(); + + debug!( + entity.id = %id, + annotation_count = new_annotations.len(), + "Updating entity annotations atomically" + ); + + let new_annotations_json = serde_json::to_value(&new_annotations)?; + + // Atomic merge: annotations || new_json merges at SQL level (no read-modify-write race) + // Also fetches merged result + entity_data so we can rebuild the protobuf blob + let result = sqlx::query_as::<_, (Vec, serde_json::Value)>( + "UPDATE entities \ + SET annotations = annotations || $1::jsonb, updated_at = $2 \ + WHERE id = $3 \ + RETURNING entity_data, annotations", + ) + .bind(&new_annotations_json) + .bind(now) + .bind(entity_uuid) + .fetch_optional(&self.pool) + .await; + + match result { + Ok(Some((entity_data_bytes, merged_annotations_json))) => { + // Rebuild protobuf blob with merged annotations + let mut entity = Entity::decode(entity_data_bytes.as_slice())?; + if let Ok(merged) = serde_json::from_value::>(merged_annotations_json) { + entity.annotations = merged; + } + entity.updated_at = Some(prost_types::Timestamp { + seconds: now.timestamp(), + nanos: now.timestamp_subsec_nanos() as i32, + }); + + let updated_data = entity.encode_to_vec(); + sqlx::query("UPDATE entities SET entity_data = $1 WHERE id = $2") + .bind(updated_data) + .bind(entity_uuid) + .execute(&self.pool) + .await?; + + let duration = start.elapsed(); + info!( + entity.id = %id, + duration_ms = duration.as_millis(), + "Annotations updated atomically" + ); + Ok(true) + } + Ok(None) => { + debug!(entity.id = %id, "Entity not found for annotation update"); + Ok(false) + } + Err(e) => { + let duration = start.elapsed(); + error!( + entity.id = %id, + duration_ms = duration.as_millis(), + error = %e, + "Failed to update annotations" + ); + Err(e.into()) + } + } + } + + /// Partially update an entity using a field mask + /// Only the fields specified in the field mask will be updated + #[instrument(skip(self, partial_entity, field_mask), fields(entity.id = %id, entity.kind, db.operation = "partial_update"))] + pub async fn partial_update( + &self, + id: &str, + partial_entity: &Entity, + field_mask: &prost_types::FieldMask, + ) -> Result> { + let start = Instant::now(); + let entity_uuid = Uuid::parse_str(id)?; + let now = Utc::now(); + + debug!( + entity.id = %id, + field_count = field_mask.paths.len(), + "Performing partial entity update" + ); + + // First, check if the entity exists + let existing_row = + sqlx::query_as::<_, (Vec,)>("SELECT entity_data FROM entities WHERE id = $1") + .bind(entity_uuid) + .fetch_optional(&self.pool) + .await?; + + match existing_row { + Some((entity_data_bytes,)) => { + // Decode the existing entity + let mut existing_entity = Entity::decode(entity_data_bytes.as_slice())?; + + // Apply field mask to merge partial_entity into existing_entity + apply_field_mask(&mut existing_entity, partial_entity, field_mask)?; + + // Update timestamps + existing_entity.id = id.to_string(); + existing_entity.updated_at = Some(prost_types::Timestamp { + seconds: now.timestamp(), + nanos: now.timestamp_subsec_nanos() as i32, + }); + + // Record span attributes + let kind = existing_entity.kind.clone(); + let name = extract_entity_name(&existing_entity); + tracing::Span::current().record("entity.kind", &kind); + + // Serialize the updated entity to protobuf bytes + let entity_data = existing_entity.encode_to_vec(); + + // Extract annotations for database columns + let annotations_json = serde_json::to_value(&existing_entity.annotations)?; + + // Update in database + let result = sqlx::query( + "UPDATE entities SET kind = $1, name = $2, entity_data = $3, annotations = $4, updated_at = $5 WHERE id = $6" + ) + .bind(kind.clone()) + .bind(name.as_deref()) + .bind(entity_data) + .bind(annotations_json) + .bind(now) + .bind(entity_uuid) + .execute(&self.pool) + .await; + + match result { + Ok(_) => { + let duration = start.elapsed(); + info!( + entity.id = %id, + entity.kind = %kind, + duration_ms = duration.as_millis(), + "Entity partially updated successfully" + ); + Ok(Some(existing_entity)) + } + Err(e) => { + let duration = start.elapsed(); + error!( + entity.id = %id, + entity.kind = %kind, + duration_ms = duration.as_millis(), + error = %e, + "Failed to partially update entity" + ); + Err(e.into()) + } + } + } + None => { + let duration = start.elapsed(); + debug!( + entity.id = %id, + duration_ms = duration.as_millis(), + "Entity not found for partial update" + ); + Ok(None) + } + } + } +} + +/// Apply a field mask to merge partial entity into existing entity +/// This function updates only the fields specified in the field mask +fn apply_field_mask( + existing: &mut Entity, + partial: &Entity, + field_mask: &prost_types::FieldMask, +) -> Result<()> { + macro_rules! apply_metadata_field { + ($partial:expr, $existing:expr, $variant:ident, $field:ident) => { + if let Some(Metadata::$variant(ref partial_meta)) = $partial.metadata { + if let Some(Metadata::$variant(ref mut existing_meta)) = $existing.metadata { + existing_meta.$field = partial_meta.$field.clone(); + } + } + }; + } + + macro_rules! apply_spec_field { + ($partial:expr, $existing:expr, $variant:ident, $field:ident) => { + if let Some(Spec::$variant(ref partial_spec)) = $partial.spec { + if let Some(Spec::$variant(ref mut existing_spec)) = $existing.spec { + existing_spec.$field = partial_spec.$field.clone(); + } + } + }; + } + + // If field mask is empty, update all fields (same as full update) + if field_mask.paths.is_empty() { + tracing::warn!("Empty field mask: performing full update of all fields"); + // Copy all non-system fields from partial to existing + existing.kind = partial.kind.clone(); + existing.annotations = partial.annotations.clone(); + existing.metadata = partial.metadata.clone(); + existing.spec = partial.spec.clone(); + return Ok(()); + } + + // Process each path in the field mask + for path in &field_mask.paths { + match path.as_str() { + // Top-level fields + "kind" => { + existing.kind = partial.kind.clone(); + } + "annotations" => { + existing.annotations = partial.annotations.clone(); + } + "metadata" => { + existing.metadata = partial.metadata.clone(); + } + "spec" => { + existing.spec = partial.spec.clone(); + } + + // Nested fields - annotations + path if path.starts_with("annotations.") => { + if let Some(key) = path.strip_prefix("annotations.") { + if let Some(value) = partial.annotations.get(key) { + existing.annotations.insert(key.to_string(), value.clone()); + } + } + } + + // Component metadata fields + "component_metadata.name" => apply_metadata_field!(partial, existing, ComponentMetadata, name), + "component_metadata.namespace" => apply_metadata_field!(partial, existing, ComponentMetadata, namespace), + "component_metadata.description" => apply_metadata_field!(partial, existing, ComponentMetadata, description), + "component_metadata.labels" => apply_metadata_field!(partial, existing, ComponentMetadata, labels), + "component_metadata.tags" => apply_metadata_field!(partial, existing, ComponentMetadata, tags), + "component_metadata.links" => apply_metadata_field!(partial, existing, ComponentMetadata, links), + + // Component spec fields + "component_spec.type" => apply_spec_field!(partial, existing, ComponentSpec, r#type), + "component_spec.lifecycle" => apply_spec_field!(partial, existing, ComponentSpec, lifecycle), + "component_spec.owner" => apply_spec_field!(partial, existing, ComponentSpec, owner), + "component_spec.system" => apply_spec_field!(partial, existing, ComponentSpec, system), + "component_spec.subcomponent_of" => apply_spec_field!(partial, existing, ComponentSpec, subcomponent_of), + "component_spec.depends_on" => apply_spec_field!(partial, existing, ComponentSpec, depends_on), + "component_spec.provides_apis" => apply_spec_field!(partial, existing, ComponentSpec, provides_apis), + "component_spec.consumes_apis" => apply_spec_field!(partial, existing, ComponentSpec, consumes_apis), + + // Service metadata fields + "service_metadata.name" => apply_metadata_field!(partial, existing, ServiceMetadata, name), + "service_metadata.namespace" => apply_metadata_field!(partial, existing, ServiceMetadata, namespace), + "service_metadata.description" => apply_metadata_field!(partial, existing, ServiceMetadata, description), + "service_metadata.labels" => apply_metadata_field!(partial, existing, ServiceMetadata, labels), + "service_metadata.tags" => apply_metadata_field!(partial, existing, ServiceMetadata, tags), + "service_metadata.links" => apply_metadata_field!(partial, existing, ServiceMetadata, links), + + // Service spec fields + "service_spec.type" => apply_spec_field!(partial, existing, ServiceSpec, r#type), + "service_spec.lifecycle" => apply_spec_field!(partial, existing, ServiceSpec, lifecycle), + "service_spec.owner" => apply_spec_field!(partial, existing, ServiceSpec, owner), + "service_spec.system" => apply_spec_field!(partial, existing, ServiceSpec, system), + "service_spec.subcomponent_of" => apply_spec_field!(partial, existing, ServiceSpec, subcomponent_of), + "service_spec.depends_on" => apply_spec_field!(partial, existing, ServiceSpec, depends_on), + "service_spec.consumes_apis" => apply_spec_field!(partial, existing, ServiceSpec, consumes_apis), + "service_spec.provides_apis" => apply_spec_field!(partial, existing, ServiceSpec, provides_apis), + + // System metadata fields + "system_metadata.name" => apply_metadata_field!(partial, existing, SystemMetadata, name), + "system_metadata.namespace" => apply_metadata_field!(partial, existing, SystemMetadata, namespace), + "system_metadata.description" => apply_metadata_field!(partial, existing, SystemMetadata, description), + "system_metadata.labels" => apply_metadata_field!(partial, existing, SystemMetadata, labels), + "system_metadata.tags" => apply_metadata_field!(partial, existing, SystemMetadata, tags), + "system_metadata.links" => apply_metadata_field!(partial, existing, SystemMetadata, links), + + // System spec fields + "system_spec.owner" => apply_spec_field!(partial, existing, SystemSpec, owner), + "system_spec.domain" => apply_spec_field!(partial, existing, SystemSpec, domain), + + // API metadata fields + "api_metadata.name" => apply_metadata_field!(partial, existing, ApiMetadata, name), + "api_metadata.namespace" => apply_metadata_field!(partial, existing, ApiMetadata, namespace), + "api_metadata.description" => apply_metadata_field!(partial, existing, ApiMetadata, description), + "api_metadata.labels" => apply_metadata_field!(partial, existing, ApiMetadata, labels), + "api_metadata.tags" => apply_metadata_field!(partial, existing, ApiMetadata, tags), + "api_metadata.links" => apply_metadata_field!(partial, existing, ApiMetadata, links), + + // API spec fields + "api_spec.type" => apply_spec_field!(partial, existing, ApiSpec, r#type), + "api_spec.lifecycle" => apply_spec_field!(partial, existing, ApiSpec, lifecycle), + "api_spec.owner" => apply_spec_field!(partial, existing, ApiSpec, owner), + "api_spec.system" => apply_spec_field!(partial, existing, ApiSpec, system), + "api_spec.definition" => apply_spec_field!(partial, existing, ApiSpec, definition), + + // User metadata fields + "user_metadata.name" => apply_metadata_field!(partial, existing, UserMetadata, name), + "user_metadata.namespace" => apply_metadata_field!(partial, existing, UserMetadata, namespace), + "user_metadata.description" => apply_metadata_field!(partial, existing, UserMetadata, description), + "user_metadata.labels" => apply_metadata_field!(partial, existing, UserMetadata, labels), + "user_metadata.tags" => apply_metadata_field!(partial, existing, UserMetadata, tags), + "user_metadata.links" => apply_metadata_field!(partial, existing, UserMetadata, links), + + // User spec fields + "user_spec.profile" => apply_spec_field!(partial, existing, UserSpec, profile), + "user_spec.member_of" => apply_spec_field!(partial, existing, UserSpec, member_of), + + // User spec nested profile fields + "user_spec.profile.display_name" => { + if let Some(Spec::UserSpec(ref partial_spec)) = partial.spec { + if let Some(Spec::UserSpec(ref mut existing_spec)) = existing.spec { + if let Some(ref partial_profile) = partial_spec.profile { + let profile = existing_spec.profile.get_or_insert_default(); + profile.display_name = partial_profile.display_name.clone(); + } + } + } + } + "user_spec.profile.email" => { + if let Some(Spec::UserSpec(ref partial_spec)) = partial.spec { + if let Some(Spec::UserSpec(ref mut existing_spec)) = existing.spec { + if let Some(ref partial_profile) = partial_spec.profile { + let profile = existing_spec.profile.get_or_insert_default(); + profile.email = partial_profile.email.clone(); + } + } + } + } + "user_spec.profile.picture" => { + if let Some(Spec::UserSpec(ref partial_spec)) = partial.spec { + if let Some(Spec::UserSpec(ref mut existing_spec)) = existing.spec { + if let Some(ref partial_profile) = partial_spec.profile { + let profile = existing_spec.profile.get_or_insert_default(); + profile.picture = partial_profile.picture.clone(); + } + } + } + } + + // Group metadata fields + "group_metadata.name" => apply_metadata_field!(partial, existing, GroupMetadata, name), + "group_metadata.namespace" => apply_metadata_field!(partial, existing, GroupMetadata, namespace), + "group_metadata.description" => apply_metadata_field!(partial, existing, GroupMetadata, description), + "group_metadata.labels" => apply_metadata_field!(partial, existing, GroupMetadata, labels), + "group_metadata.tags" => apply_metadata_field!(partial, existing, GroupMetadata, tags), + "group_metadata.links" => apply_metadata_field!(partial, existing, GroupMetadata, links), + + // Group spec fields + "group_spec.type" => apply_spec_field!(partial, existing, GroupSpec, r#type), + "group_spec.profile" => apply_spec_field!(partial, existing, GroupSpec, profile), + "group_spec.parent" => apply_spec_field!(partial, existing, GroupSpec, parent), + "group_spec.children" => apply_spec_field!(partial, existing, GroupSpec, children), + "group_spec.members" => apply_spec_field!(partial, existing, GroupSpec, members), + + // Group spec nested profile fields + "group_spec.profile.display_name" => { + if let Some(Spec::GroupSpec(ref partial_spec)) = partial.spec { + if let Some(Spec::GroupSpec(ref mut existing_spec)) = existing.spec { + if let Some(ref partial_profile) = partial_spec.profile { + let profile = existing_spec.profile.get_or_insert_default(); + profile.display_name = partial_profile.display_name.clone(); + } + } + } + } + "group_spec.profile.email" => { + if let Some(Spec::GroupSpec(ref partial_spec)) = partial.spec { + if let Some(Spec::GroupSpec(ref mut existing_spec)) = existing.spec { + if let Some(ref partial_profile) = partial_spec.profile { + let profile = existing_spec.profile.get_or_insert_default(); + profile.email = partial_profile.email.clone(); + } + } + } + } + "group_spec.profile.picture" => { + if let Some(Spec::GroupSpec(ref partial_spec)) = partial.spec { + if let Some(Spec::GroupSpec(ref mut existing_spec)) = existing.spec { + if let Some(ref partial_profile) = partial_spec.profile { + let profile = existing_spec.profile.get_or_insert_default(); + profile.picture = partial_profile.picture.clone(); + } + } + } + } + + // Domain metadata fields + "domain_metadata.name" => apply_metadata_field!(partial, existing, DomainMetadata, name), + "domain_metadata.namespace" => apply_metadata_field!(partial, existing, DomainMetadata, namespace), + "domain_metadata.description" => apply_metadata_field!(partial, existing, DomainMetadata, description), + "domain_metadata.labels" => apply_metadata_field!(partial, existing, DomainMetadata, labels), + "domain_metadata.tags" => apply_metadata_field!(partial, existing, DomainMetadata, tags), + "domain_metadata.links" => apply_metadata_field!(partial, existing, DomainMetadata, links), + + // Domain spec fields + "domain_spec.owner" => apply_spec_field!(partial, existing, DomainSpec, owner), + + // Resource metadata fields + "resource_metadata.name" => apply_metadata_field!(partial, existing, ResourceMetadata, name), + "resource_metadata.namespace" => apply_metadata_field!(partial, existing, ResourceMetadata, namespace), + "resource_metadata.description" => apply_metadata_field!(partial, existing, ResourceMetadata, description), + "resource_metadata.labels" => apply_metadata_field!(partial, existing, ResourceMetadata, labels), + "resource_metadata.tags" => apply_metadata_field!(partial, existing, ResourceMetadata, tags), + "resource_metadata.links" => apply_metadata_field!(partial, existing, ResourceMetadata, links), + + // Resource spec fields + "resource_spec.type" => apply_spec_field!(partial, existing, ResourceSpec, r#type), + "resource_spec.owner" => apply_spec_field!(partial, existing, ResourceSpec, owner), + "resource_spec.system" => apply_spec_field!(partial, existing, ResourceSpec, system), + "resource_spec.depends_on" => apply_spec_field!(partial, existing, ResourceSpec, depends_on), + + _ => { + tracing::warn!("Unknown field mask path: {}", path); + } + } + } + + Ok(()) +} + +pub async fn create_connection_pool(database_url: &str) -> Result { + let pool = PgPool::connect(database_url).await?; + Ok(pool) +} + +/// Ensures the database schema exists (idempotent - safe to run multiple times) +/// This is called on startup and creates the schema if it doesn't exist. +/// The schema NEVER changes - plugins only extend protobuf definitions. +pub async fn ensure_schema(pool: &PgPool) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS entities ( + id UUID PRIMARY KEY, + kind VARCHAR NOT NULL, + name VARCHAR, + entity_data BYTEA NOT NULL, + annotations JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + "#, + ) + .execute(pool) + .await?; + + sqlx::query(r#"CREATE INDEX IF NOT EXISTS idx_entities_kind ON entities(kind)"#) + .execute(pool) + .await?; + + sqlx::query(r#"CREATE INDEX IF NOT EXISTS idx_entities_kind_name ON entities(kind, name)"#) + .execute(pool) + .await?; + + sqlx::query(r#"CREATE INDEX IF NOT EXISTS idx_entities_annotations ON entities USING GIN (annotations)"#) + .execute(pool) + .await?; + + sqlx::query(r#"CREATE INDEX IF NOT EXISTS idx_entities_created_at_id ON entities(created_at DESC, id DESC)"#) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..122d653 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,60 @@ +use thiserror::Error; +use tonic::Status; + +#[derive(Debug, Error)] +pub enum CharybdisError { + #[error("Entity not found: {0}")] + NotFound(String), + + #[error("Validation failed: {0}")] + Validation(String), + + #[error("Conflict: {0}")] + Conflict(String), + + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("Serialization error: {0}")] + Serialization(String), + + #[error("Invalid UUID: {0}")] + InvalidUuid(#[from] uuid::Error), + + #[error("Plugin error: {plugin}: {message}")] + Plugin { plugin: String, message: String }, + + #[error("Internal error: {0}")] + Internal(String), +} + +impl From for CharybdisError { + fn from(e: prost::DecodeError) -> Self { + Self::Serialization(e.to_string()) + } +} + +impl From for CharybdisError { + fn from(e: serde_json::Error) -> Self { + Self::Serialization(e.to_string()) + } +} + +impl From for Status { + fn from(err: CharybdisError) -> Self { + match &err { + CharybdisError::NotFound(msg) => Status::not_found(msg.clone()), + CharybdisError::Validation(msg) => Status::invalid_argument(msg.clone()), + CharybdisError::Conflict(msg) => Status::already_exists(msg.clone()), + CharybdisError::InvalidUuid(_) => { + Status::invalid_argument(format!("Invalid UUID: {err}")) + } + CharybdisError::Plugin { .. } => Status::internal(err.to_string()), + CharybdisError::Database(_) => Status::internal("Database operation failed"), + CharybdisError::Serialization(_) => Status::internal("Data serialization failed"), + CharybdisError::Internal(msg) => Status::internal(msg.clone()), + } + } +} + +pub type Result = std::result::Result; diff --git a/src/events/backends/memory.rs b/src/events/backends/memory.rs new file mode 100644 index 0000000..91ab2b1 --- /dev/null +++ b/src/events/backends/memory.rs @@ -0,0 +1,240 @@ +use async_trait::async_trait; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::{RwLock, broadcast}; +use tracing::{debug, error, info, instrument}; + +use crate::events::{EntityEvent, EventBus, EventDispatcher, EventHandler, EventResult}; + +/// Simple in-memory event bus for development and testing +pub struct MemoryEventBus { + dispatcher: Arc, + sender: Option>, + is_running: Arc>, +} + +impl MemoryEventBus { + /// Create a new in-memory event bus + pub fn new() -> Self { + Self { + dispatcher: Arc::new(EventDispatcher::new()), + sender: None, + is_running: Arc::new(RwLock::new(false)), + } + } + + /// Create with custom dispatcher + pub fn with_dispatcher(dispatcher: Arc) -> Self { + Self { + dispatcher, + sender: None, + is_running: Arc::new(RwLock::new(false)), + } + } +} + +#[async_trait] +impl EventBus for MemoryEventBus { + #[instrument(skip(self, event), fields( + event.id = %event.event_id, + event.type = ?event.event_type, + event.entity_id = %event.entity_id + ))] + async fn publish(&self, event: EntityEvent) -> EventResult<()> { + let start = Instant::now(); + + debug!( + event_id = %event.event_id, + event_type = ?event.event_type, + entity_id = %event.entity_id, + "Publishing event to memory bus" + ); + + // Send through broadcast channel if we have one + if let Some(sender) = &self.sender { + if let Err(e) = sender.send(event.clone()) { + error!("Failed to send event through broadcast channel: {}", e); + } + } + + // Always dispatch directly to handlers + self.dispatcher.dispatch(&event).await?; + + let duration = start.elapsed(); + info!( + event.id = %event.event_id, + event.type = ?event.event_type, + duration_ms = duration.as_millis(), + "Event published successfully" + ); + + Ok(()) + } + + async fn subscribe(&self, handler: Arc) -> EventResult<()> { + debug!("Subscribing handler to memory event bus"); + self.dispatcher.add_handler(handler).await; + Ok(()) + } + + async fn start(&self) -> EventResult<()> { + let mut is_running = self.is_running.write().await; + + if *is_running { + return Ok(()); + } + + debug!("Starting memory event bus"); + + // Create broadcast channel for potential future use + let (_sender, _receiver) = broadcast::channel::(1000); + + // Store sender (we need to modify self, but we can't because of the immutable reference) + // For now, we'll keep it simple and not use the broadcast channel + + *is_running = true; + Ok(()) + } + + async fn stop(&self) -> EventResult<()> { + let mut is_running = self.is_running.write().await; + + if !*is_running { + return Ok(()); + } + + debug!("Stopping memory event bus"); + *is_running = false; + Ok(()) + } +} + +impl Default for MemoryEventBus { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::events::{EntityEvent, EntityEventType}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use uuid::Uuid; + + struct TestHandler { + counter: Arc, + event_type_filter: Option, + } + + impl TestHandler { + fn new(counter: Arc) -> Self { + Self { + counter, + event_type_filter: None, + } + } + + fn with_filter(counter: Arc, event_type: EntityEventType) -> Self { + Self { + counter, + event_type_filter: Some(event_type), + } + } + } + + #[async_trait] + impl EventHandler for TestHandler { + async fn handle_event(&self, event: &EntityEvent) -> EventResult<()> { + // Filter by event type if specified + if let Some(filter_type) = &self.event_type_filter { + if event.event_type != *filter_type { + return Ok(()); + } + } + + self.counter.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[tokio::test] + async fn test_memory_event_bus_creation() { + let bus = MemoryEventBus::new(); + assert!(!*bus.is_running.read().await); + } + + #[tokio::test] + async fn test_start_stop() { + let bus = MemoryEventBus::new(); + + assert!(bus.start().await.is_ok()); + assert!(*bus.is_running.read().await); + + assert!(bus.stop().await.is_ok()); + assert!(!*bus.is_running.read().await); + } + + #[tokio::test] + async fn test_subscribe_and_publish() { + let bus = MemoryEventBus::new(); + let counter = Arc::new(AtomicUsize::new(0)); + + let handler = Arc::new(TestHandler::new(counter.clone())); + bus.subscribe(handler).await.unwrap(); + + let event = EntityEvent::created(Uuid::new_v4()); + bus.publish(event).await.unwrap(); + + // Give a moment for async processing + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + assert_eq!(counter.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_multiple_handlers() { + let bus = MemoryEventBus::new(); + let counter1 = Arc::new(AtomicUsize::new(0)); + let counter2 = Arc::new(AtomicUsize::new(0)); + + let handler1 = Arc::new(TestHandler::new(counter1.clone())); + let handler2 = Arc::new(TestHandler::new(counter2.clone())); + + bus.subscribe(handler1).await.unwrap(); + bus.subscribe(handler2).await.unwrap(); + + let event = EntityEvent::created(Uuid::new_v4()); + bus.publish(event).await.unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + assert_eq!(counter1.load(Ordering::SeqCst), 1); + assert_eq!(counter2.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn test_event_filtering() { + let bus = MemoryEventBus::new(); + let counter = Arc::new(AtomicUsize::new(0)); + + // Handler only cares about Created events + let handler = Arc::new(TestHandler::with_filter( + counter.clone(), + EntityEventType::Created, + )); + bus.subscribe(handler).await.unwrap(); + + // Send a Created event - should be handled + let created_event = EntityEvent::created(Uuid::new_v4()); + bus.publish(created_event).await.unwrap(); + + // Send an Updated event - should be ignored + let updated_event = EntityEvent::updated(Uuid::new_v4()); + bus.publish(updated_event).await.unwrap(); + + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + assert_eq!(counter.load(Ordering::SeqCst), 1); // Only the Created event + } +} diff --git a/src/events/backends/mod.rs b/src/events/backends/mod.rs new file mode 100644 index 0000000..7be8822 --- /dev/null +++ b/src/events/backends/mod.rs @@ -0,0 +1,13 @@ +//! Event bus backend implementations +//! +//! This module contains different implementations of the EventBus trait +//! for various message brokers and storage systems. + +pub mod memory; + +// Future backends (not yet implemented): +// pub mod redis; +// pub mod rabbitmq; + +// Re-export the backends +pub use memory::MemoryEventBus; diff --git a/src/events/bus.rs b/src/events/bus.rs new file mode 100644 index 0000000..31d8f5b --- /dev/null +++ b/src/events/bus.rs @@ -0,0 +1,138 @@ +use async_trait::async_trait; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tracing::instrument; + +use super::handler::{EventHandler, EventResult}; +use super::types::EntityEvent; + +const HANDLER_TIMEOUT: Duration = Duration::from_secs(30); + +/// Simple trait for event bus implementations +#[async_trait] +pub trait EventBus: Send + Sync { + /// Publish an event + async fn publish(&self, event: EntityEvent) -> EventResult<()>; + + /// Subscribe a handler to receive events + async fn subscribe(&self, handler: Arc) -> EventResult<()>; + + /// Start the event bus + async fn start(&self) -> EventResult<()>; + + /// Stop the event bus + async fn stop(&self) -> EventResult<()>; +} + +/// Simple event dispatcher that manages handlers +pub struct EventDispatcher { + handlers: tokio::sync::RwLock>>, +} + +impl EventDispatcher { + /// Create a new dispatcher + pub fn new() -> Self { + Self { + handlers: tokio::sync::RwLock::new(Vec::new()), + } + } + + /// Add a handler + pub async fn add_handler(&self, handler: Arc) { + let mut handlers = self.handlers.write().await; + handlers.push(handler); + } + + /// Dispatch an event to all handlers with timeout and panic isolation + #[instrument(skip(self, event), fields( + event.id = %event.event_id, + event.type = ?event.event_type, + event.entity_id = %event.entity_id, + handler.count + ))] + pub async fn dispatch(&self, event: &EntityEvent) -> EventResult<()> { + let start = Instant::now(); + let handlers = self.handlers.read().await; + let handler_count = handlers.len(); + + tracing::Span::current().record("handler.count", handler_count); + + tracing::debug!( + event.id = %event.event_id, + event.type = ?event.event_type, + entity_id = %event.entity_id, + handler_count = handler_count, + "Dispatching event to handlers" + ); + + let mut success_count = 0; + let mut error_count = 0; + + for handler in handlers.iter() { + let handler_start = Instant::now(); + let handler_clone = handler.clone(); + let event_clone = event.clone(); + + let result = tokio::time::timeout( + HANDLER_TIMEOUT, + tokio::spawn(async move { handler_clone.handle_event(&event_clone).await }), + ) + .await; + + match result { + Ok(Ok(Ok(_))) => { + success_count += 1; + tracing::debug!( + event.id = %event.event_id, + handler_duration_ms = handler_start.elapsed().as_millis(), + "Handler succeeded" + ); + } + Ok(Ok(Err(e))) => { + error_count += 1; + tracing::error!( + error = %e, + event_id = %event.event_id, + handler_duration_ms = handler_start.elapsed().as_millis(), + "Event handler failed" + ); + } + Ok(Err(join_err)) => { + error_count += 1; + tracing::error!( + error = %join_err, + event_id = %event.event_id, + "Event handler panicked" + ); + } + Err(_) => { + error_count += 1; + tracing::error!( + event_id = %event.event_id, + timeout_secs = HANDLER_TIMEOUT.as_secs(), + "Event handler timed out" + ); + } + } + } + + let total_duration = start.elapsed(); + tracing::info!( + event.id = %event.event_id, + event.type = ?event.event_type, + handler_count = handler_count, + success_count = success_count, + error_count = error_count, + duration_ms = total_duration.as_millis(), + "Event dispatched" + ); + + Ok(()) + } +} + +impl Default for EventDispatcher { + fn default() -> Self { + Self::new() + } +} diff --git a/src/events/handler.rs b/src/events/handler.rs new file mode 100644 index 0000000..468bf1e --- /dev/null +++ b/src/events/handler.rs @@ -0,0 +1,25 @@ +use async_trait::async_trait; +use thiserror::Error; + +use super::types::EntityEvent; + +/// Result type for event handling operations +pub type EventResult = Result; + +/// Errors that can occur during event handling +#[derive(Debug, Error)] +pub enum EventError { + #[error("Handler error: {0}")] + HandlerError(String), + + #[error("Handler execution failed: {message}")] + ExecutionError { message: String }, +} + +/// Simple trait that plugins implement to react to events +#[async_trait] +pub trait EventHandler: Send + Sync { + /// Handle an event + /// The handler can inspect the event and decide whether to act on it + async fn handle_event(&self, event: &EntityEvent) -> EventResult<()>; +} diff --git a/src/events/mod.rs b/src/events/mod.rs new file mode 100644 index 0000000..396607e --- /dev/null +++ b/src/events/mod.rs @@ -0,0 +1,14 @@ +//! Simple event system for Charybdis +//! +//! This module provides a generic event publishing and subscription system. +//! Events are published when entities are created, updated, or deleted. +//! Plugins and other components can subscribe to react to these events. + +pub mod types; +pub mod handler; +pub mod bus; +pub mod backends; + +pub use types::*; +pub use handler::*; +pub use bus::*; diff --git a/src/events/types.rs b/src/events/types.rs new file mode 100644 index 0000000..3662c92 --- /dev/null +++ b/src/events/types.rs @@ -0,0 +1,85 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use uuid::Uuid; + +use crate::charybdis::entities::Entity; + +/// Simple event representing something that happened to an entity +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityEvent { + /// Unique identifier for this event + pub event_id: Uuid, + + /// The entity this event is about + pub entity_id: Uuid, + + /// What happened to the entity + pub event_type: EntityEventType, + + /// When this event occurred + pub timestamp: DateTime, + + /// Optional metadata (plugins can add whatever they need) + pub metadata: HashMap, + + /// Full entity data (for handlers to access without fetching) + /// Not serialized to avoid duplication in persistent event stores + #[serde(skip)] + pub entity_data: Option>, +} + +/// Types of things that can happen to entities +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EntityEventType { + /// Entity was created + Created, + + /// Entity was updated + Updated, + + /// Entity was deleted + Deleted, +} + +impl EntityEvent { + /// Create a new event + pub fn new(entity_id: Uuid, event_type: EntityEventType) -> Self { + Self { + event_id: Uuid::new_v4(), + entity_id, + event_type, + timestamp: Utc::now(), + metadata: HashMap::new(), + entity_data: None, + } + } + + /// Add metadata to this event + pub fn with_metadata(mut self, key: String, value: String) -> Self { + self.metadata.insert(key, value); + self + } + + /// Attach full entity data to this event + pub fn with_entity_data(mut self, entity: Arc) -> Self { + self.entity_data = Some(entity); + self + } + + /// Convenience method to create a Created event + pub fn created(entity_id: Uuid) -> Self { + Self::new(entity_id, EntityEventType::Created) + } + + /// Convenience method to create an Updated event + pub fn updated(entity_id: Uuid) -> Self { + Self::new(entity_id, EntityEventType::Updated) + } + + /// Convenience method to create a Deleted event + pub fn deleted(entity_id: Uuid) -> Self { + Self::new(entity_id, EntityEventType::Deleted) + } +} diff --git a/src/findings/fingerprint.rs b/src/findings/fingerprint.rs new file mode 100644 index 0000000..5967866 --- /dev/null +++ b/src/findings/fingerprint.rs @@ -0,0 +1,132 @@ +use sha2::{Digest, Sha256}; + +use crate::scanners::NormalizedFinding; + +/// Compute a stable fingerprint for deduplication. +/// +/// Priority: +/// 1. Scanner-provided fingerprint (e.g., SARIF partialFingerprints) — most stable, +/// survives line shifts because scanners compute it from code content, not position. +/// 2. Fallback: hash of (scanner + rule_id + file_path) — deliberately excludes line number +/// to avoid ghost resolve/create when lines shift. Trade-off: if the same rule fires +/// twice in the same file, they collapse into one finding. +pub fn compute_fingerprint( + scanner: &str, + finding: &NormalizedFinding, +) -> String { + if let Some(ref fp) = finding.scanner_fingerprint { + return fp.clone(); + } + + let mut hasher = Sha256::new(); + + hasher.update(scanner.as_bytes()); + hasher.update(b"|"); + hasher.update(finding.rule_id.as_bytes()); + hasher.update(b"|"); + + if let Some(ref path) = finding.file_path { + hasher.update(path.as_bytes()); + } + + let hash = hasher.finalize(); + hex::encode(hash) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scanners::{NormalizedFinding, Severity}; + + fn make_finding(rule_id: &str, file: Option<&str>, line: Option) -> NormalizedFinding { + NormalizedFinding { + title: "test".to_string(), + description: String::new(), + severity: Severity::Medium, + scanner: "test-scanner".to_string(), + rule_id: rule_id.to_string(), + file_path: file.map(|s| s.to_string()), + line_start: line, + line_end: None, + cwe: None, + cve: None, + cvss_score: None, + package_name: None, + package_version: None, + fixed_version: None, + details_url: None, + tags: vec![], + scanner_fingerprint: None, + } + } + + #[test] + fn test_same_input_same_fingerprint() { + let f1 = make_finding("RULE-1", Some("src/main.rs"), Some(42)); + let f2 = make_finding("RULE-1", Some("src/main.rs"), Some(42)); + + let fp1 = compute_fingerprint("scanner", &f1); + let fp2 = compute_fingerprint("scanner", &f2); + + assert_eq!(fp1, fp2); + } + + #[test] + fn test_line_shift_same_fingerprint() { + let f1 = make_finding("RULE-1", Some("src/main.rs"), Some(42)); + let f2 = make_finding("RULE-1", Some("src/main.rs"), Some(99)); + + let fp1 = compute_fingerprint("scanner", &f1); + let fp2 = compute_fingerprint("scanner", &f2); + + assert_eq!(fp1, fp2, "line number should NOT affect fallback fingerprint"); + } + + #[test] + fn test_different_rule_different_fingerprint() { + let f1 = make_finding("RULE-1", Some("src/main.rs"), Some(42)); + let f2 = make_finding("RULE-2", Some("src/main.rs"), Some(42)); + + let fp1 = compute_fingerprint("scanner", &f1); + let fp2 = compute_fingerprint("scanner", &f2); + + assert_ne!(fp1, fp2); + } + + #[test] + fn test_different_file_different_fingerprint() { + let f1 = make_finding("RULE-1", Some("src/main.rs"), None); + let f2 = make_finding("RULE-1", Some("src/lib.rs"), None); + + let fp1 = compute_fingerprint("scanner", &f1); + let fp2 = compute_fingerprint("scanner", &f2); + + assert_ne!(fp1, fp2); + } + + #[test] + fn test_scanner_fingerprint_takes_priority() { + let mut f1 = make_finding("RULE-1", Some("src/main.rs"), Some(42)); + f1.scanner_fingerprint = Some("scanner-provided-hash-abc123".to_string()); + + let mut f2 = make_finding("RULE-1", Some("src/main.rs"), Some(99)); + f2.scanner_fingerprint = Some("scanner-provided-hash-abc123".to_string()); + + let fp1 = compute_fingerprint("scanner", &f1); + let fp2 = compute_fingerprint("scanner", &f2); + + assert_eq!(fp1, "scanner-provided-hash-abc123"); + assert_eq!(fp1, fp2); + } + + #[test] + fn test_no_file_path_stable() { + let f1 = make_finding("RULE-1", None, None); + let f2 = make_finding("RULE-1", None, None); + + let fp1 = compute_fingerprint("scanner", &f1); + let fp2 = compute_fingerprint("scanner", &f2); + + assert_eq!(fp1, fp2); + } +} diff --git a/src/findings/ingestion.rs b/src/findings/ingestion.rs new file mode 100644 index 0000000..b4fa133 --- /dev/null +++ b/src/findings/ingestion.rs @@ -0,0 +1,246 @@ +use std::sync::Arc; + +use tonic::{Request, Response, Status}; +use tracing::{error, info, instrument}; +use uuid::Uuid; + +use crate::charybdis::ingestion::ingestion_service_server::IngestionService; +use crate::charybdis::ingestion::{ + DryRunScanRequest, DryRunScanResponse, FindingResult, ImportScanRequest, ImportScanResponse, + ReconciliationSummary, +}; +use crate::database::EntityRepository; +use crate::findings::reconciler::{ReconciliationEngine, ReconciledFinding}; +use crate::scanners::{ParserRegistry, Severity}; + +pub struct MyIngestionService { + repository: Arc, + reconciler: ReconciliationEngine, + parser_registry: Arc, +} + +impl MyIngestionService { + pub fn new(repository: Arc, parser_registry: Arc) -> Self { + Self { + repository: repository.clone(), + reconciler: ReconciliationEngine::new(repository), + parser_registry, + } + } + + /// Resolve a component_ref (UUID or entity name) to a stable UUID. + /// If it's already a valid UUID, use it directly. + /// Otherwise, search for an entity with that name. + async fn resolve_component_id(&self, component_ref: &str) -> Result { + if uuid::Uuid::parse_str(component_ref).is_ok() { + // Verify entity exists + self.repository + .get_by_id(component_ref) + .await + .map_err(|e| Status::internal(format!("Database error: {}", e)))? + .ok_or_else(|| { + Status::not_found(format!("Component not found: {}", component_ref)) + })?; + return Ok(component_ref.to_string()); + } + + // O(1) lookup by kind+name + if let Some(entity) = self + .repository + .get_by_kind_and_name("Component", component_ref) + .await + .map_err(|e| Status::internal(format!("Database error: {}", e)))? + { + return Ok(entity.id.clone()); + } + + Err(Status::not_found(format!( + "Component not found: {}", + component_ref + ))) + } +} + + +#[tonic::async_trait] +impl IngestionService for MyIngestionService { + #[instrument(skip(self, request), fields(component_ref, lifecycle, format))] + async fn import_scan( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + tracing::Span::current() + .record("component_ref", &req.component_ref) + .record("lifecycle", &req.lifecycle) + .record("format", &req.format); + + info!( + component_ref = %req.component_ref, + lifecycle = %req.lifecycle, + format = %req.format, + "Received ImportScan request" + ); + + let component_id = self.resolve_component_id(&req.component_ref).await?; + + let parser = self + .parser_registry + .get(&req.format) + .ok_or_else(|| Status::invalid_argument(format!("Unsupported format: {}", req.format)))?; + + let report = parser + .parse(&req.data) + .map_err(|e| Status::invalid_argument(format!("Failed to parse scan data: {}", e)))?; + + let scanner_name = if req.scanner_name.is_empty() { + report.scanner_name.clone() + } else { + req.scanner_name.clone() + }; + + let scan_id = if req.scan_id.is_empty() { + Uuid::new_v4().to_string() + } else { + req.scan_id.clone() + }; + + let result = self + .reconciler + .reconcile(&component_id, &req.lifecycle, &scanner_name, &report.findings) + .await + .map_err(|e| { + error!(error = %e, "Reconciliation failed"); + Status::internal("Reconciliation failed") + })?; + + self.reconciler + .apply( + &component_id, + &req.lifecycle, + &scanner_name, + &scan_id, + &result, + &report.findings, + ) + .await + .map_err(|e| { + error!(error = %e, "Failed to apply reconciliation"); + Status::internal("Failed to apply reconciliation result") + })?; + + let summary = ReconciliationSummary { + total_parsed: report.findings.len() as u32, + new_count: result.new_findings.len() as u32, + unchanged_count: result.unchanged.len() as u32, + resolved_count: result.resolved.len() as u32, + reopened_count: result.reopened.len() as u32, + }; + + info!( + total = summary.total_parsed, + new = summary.new_count, + resolved = summary.resolved_count, + reopened = summary.reopened_count, + "ImportScan completed" + ); + + Ok(Response::new(ImportScanResponse { + summary: Some(summary), + new_findings: result.new_findings.iter().map(to_finding_result).collect(), + resolved_findings: result.resolved.iter().map(to_finding_result).collect(), + reopened_findings: result.reopened.iter().map(to_finding_result).collect(), + })) + } + + #[instrument(skip(self, request), fields(component_ref, lifecycle, format))] + async fn dry_run_scan( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + tracing::Span::current() + .record("component_ref", &req.component_ref) + .record("lifecycle", &req.lifecycle) + .record("format", &req.format); + + info!( + component_ref = %req.component_ref, + lifecycle = %req.lifecycle, + format = %req.format, + "Received DryRunScan request" + ); + + let component_id = self.resolve_component_id(&req.component_ref).await?; + + let parser = self + .parser_registry + .get(&req.format) + .ok_or_else(|| Status::invalid_argument(format!("Unsupported format: {}", req.format)))?; + + let report = parser + .parse(&req.data) + .map_err(|e| Status::invalid_argument(format!("Failed to parse scan data: {}", e)))?; + + let scanner_name = if req.scanner_name.is_empty() { + report.scanner_name.clone() + } else { + req.scanner_name.clone() + }; + + let result = self + .reconciler + .reconcile(&component_id, &req.lifecycle, &scanner_name, &report.findings) + .await + .map_err(|e| { + error!(error = %e, "Reconciliation failed"); + Status::internal("Reconciliation failed") + })?; + + let summary = ReconciliationSummary { + total_parsed: report.findings.len() as u32, + new_count: result.new_findings.len() as u32, + unchanged_count: result.unchanged.len() as u32, + resolved_count: result.resolved.len() as u32, + reopened_count: result.reopened.len() as u32, + }; + + info!( + total = summary.total_parsed, + new = summary.new_count, + resolved = summary.resolved_count, + "DryRunScan completed (no changes persisted)" + ); + + Ok(Response::new(DryRunScanResponse { + summary: Some(summary), + new_findings: result.new_findings.iter().map(to_finding_result).collect(), + resolved_findings: result.resolved.iter().map(to_finding_result).collect(), + reopened_findings: result.reopened.iter().map(to_finding_result).collect(), + })) + } +} + +fn to_finding_result(reconciled: &ReconciledFinding) -> FindingResult { + FindingResult { + title: reconciled.title.clone(), + severity: domain_severity_to_proto_i32(reconciled.severity), + rule_id: reconciled.rule_id.clone(), + file_path: reconciled.file_path.clone().unwrap_or_default(), + line_start: reconciled.line_start.unwrap_or(0), + fingerprint: reconciled.fingerprint.clone(), + entity_id: reconciled.entity_id.clone().unwrap_or_default(), + } +} + +fn domain_severity_to_proto_i32(severity: Severity) -> i32 { + match severity { + Severity::Info => 1, + Severity::Low => 2, + Severity::Medium => 3, + Severity::High => 4, + Severity::Critical => 5, + } +} diff --git a/src/findings/mod.rs b/src/findings/mod.rs new file mode 100644 index 0000000..ba4c84e --- /dev/null +++ b/src/findings/mod.rs @@ -0,0 +1,3 @@ +pub mod fingerprint; +pub mod ingestion; +pub mod reconciler; diff --git a/src/findings/reconciler.rs b/src/findings/reconciler.rs new file mode 100644 index 0000000..3f62a26 --- /dev/null +++ b/src/findings/reconciler.rs @@ -0,0 +1,362 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use anyhow::Result; +use chrono::Utc; +use tracing::{info, instrument}; + +use crate::charybdis::core::{FindingMetadata, FindingSpec, FindingState, Severity as ProtoSeverity}; +use crate::charybdis::entities::entity::{Metadata, Spec}; +use crate::charybdis::entities::Entity; +use crate::database::EntityRepository; +use crate::findings::fingerprint::compute_fingerprint; +use crate::scanners::{NormalizedFinding, Severity}; + +/// Result of reconciliation — what changed between existing state and incoming scan +#[derive(Debug)] +pub struct ReconciliationResult { + pub new_findings: Vec, + pub unchanged: Vec, + pub resolved: Vec, + pub reopened: Vec, +} + +#[derive(Debug, Clone)] +pub struct ReconciledFinding { + pub entity_id: Option, + pub title: String, + pub severity: Severity, + pub rule_id: String, + pub file_path: Option, + pub line_start: Option, + pub fingerprint: String, +} + +pub struct ReconciliationEngine { + repository: Arc, +} + +impl ReconciliationEngine { + pub fn new(repository: Arc) -> Self { + Self { repository } + } + + /// Reconcile incoming findings against existing findings for a (component, lifecycle) scope. + /// Does NOT persist anything — caller decides whether to apply or return as dry-run. + #[instrument(skip(self, incoming), fields(component_ref, lifecycle, incoming_count = incoming.len()))] + pub async fn reconcile( + &self, + component_ref: &str, + lifecycle: &str, + scanner: &str, + incoming: &[NormalizedFinding], + ) -> Result { + let existing = self.load_existing_findings(component_ref, lifecycle).await?; + + let mut existing_by_fingerprint: HashMap = HashMap::new(); + for entity in existing { + if let Some(Spec::FindingSpec(ref spec)) = entity.spec { + existing_by_fingerprint.insert(spec.fingerprint.clone(), entity.clone()); + } + } + + let mut new_findings = Vec::new(); + let mut unchanged = Vec::new(); + let mut reopened = Vec::new(); + let mut seen_fingerprints = HashSet::new(); + + for finding in incoming { + let fp = compute_fingerprint(scanner, finding); + seen_fingerprints.insert(fp.clone()); + + if let Some(existing_entity) = existing_by_fingerprint.get(&fp) { + let state = existing_entity + .spec + .as_ref() + .and_then(|s| match s { + Spec::FindingSpec(spec) => Some(spec.state), + _ => None, + }) + .unwrap_or(0); + + let reconciled = ReconciledFinding { + entity_id: Some(existing_entity.id.clone()), + title: finding.title.clone(), + severity: finding.severity, + rule_id: finding.rule_id.clone(), + file_path: finding.file_path.clone(), + line_start: finding.line_start, + fingerprint: fp, + }; + + if state == FindingState::Resolved as i32 + || state == FindingState::FalsePositive as i32 + { + reopened.push(reconciled); + } else { + unchanged.push(reconciled); + } + } else { + new_findings.push(ReconciledFinding { + entity_id: None, + title: finding.title.clone(), + severity: finding.severity, + rule_id: finding.rule_id.clone(), + file_path: finding.file_path.clone(), + line_start: finding.line_start, + fingerprint: fp, + }); + } + } + + // Findings in DB that weren't in the incoming scan → resolved + let resolved: Vec = existing_by_fingerprint + .iter() + .filter(|(fp, _)| !seen_fingerprints.contains(*fp)) + .filter(|(_, entity)| { + let state = entity + .spec + .as_ref() + .and_then(|s| match s { + Spec::FindingSpec(spec) => Some(spec.state), + _ => None, + }) + .unwrap_or(0); + state == FindingState::Active as i32 || state == FindingState::Reopened as i32 + }) + .map(|(fp, entity)| { + let (title, severity, rule_id, file_path, line_start) = entity + .spec + .as_ref() + .map(|s| match s { + Spec::FindingSpec(spec) => ( + entity + .metadata + .as_ref() + .and_then(|m| match m { + Metadata::FindingMetadata(meta) => Some(meta.title.clone()), + _ => None, + }) + .unwrap_or_default(), + proto_severity_to_domain(spec.severity), + spec.rule_id.clone(), + if spec.file_path.is_empty() { None } else { Some(spec.file_path.clone()) }, + if spec.line_start == 0 { None } else { Some(spec.line_start) }, + ), + _ => (String::new(), Severity::Medium, String::new(), None, None), + }) + .unwrap_or((String::new(), Severity::Medium, String::new(), None, None)); + + ReconciledFinding { + entity_id: Some(entity.id.clone()), + title, + severity, + rule_id, + file_path, + line_start, + fingerprint: fp.clone(), + } + }) + .collect(); + + info!( + new = new_findings.len(), + unchanged = unchanged.len(), + resolved = resolved.len(), + reopened = reopened.len(), + "Reconciliation complete" + ); + + Ok(ReconciliationResult { + new_findings, + unchanged, + resolved, + reopened, + }) + } + + /// Apply reconciliation result: create, update, and resolve findings in the database + #[instrument(skip(self, result, incoming), fields(component_ref, lifecycle))] + pub async fn apply( + &self, + component_ref: &str, + lifecycle: &str, + scanner: &str, + scan_id: &str, + result: &ReconciliationResult, + incoming: &[NormalizedFinding], + ) -> Result<()> { + let now = Utc::now(); + let timestamp = prost_types::Timestamp { + seconds: now.timestamp(), + nanos: now.timestamp_subsec_nanos() as i32, + }; + + // Create new findings + for reconciled in &result.new_findings { + let normalized = incoming + .iter() + .find(|f| compute_fingerprint(scanner, f) == reconciled.fingerprint); + + if let Some(finding) = normalized { + let entity = build_finding_entity( + finding, + component_ref, + lifecycle, + scanner, + scan_id, + &reconciled.fingerprint, + ×tamp, + ); + self.repository.create(&entity).await?; + } + } + + // Update last_seen on unchanged findings + for reconciled in &result.unchanged { + if let Some(ref entity_id) = reconciled.entity_id { + let mut annotations = std::collections::HashMap::new(); + annotations.insert( + "charybdis.io/last-seen".to_string(), + now.to_rfc3339(), + ); + annotations.insert( + "charybdis.io/scan-id".to_string(), + scan_id.to_string(), + ); + self.repository.update_annotations(entity_id, annotations).await?; + } + } + + // Resolve findings no longer detected + for reconciled in &result.resolved { + if let Some(ref entity_id) = reconciled.entity_id { + self.mark_finding_state(entity_id, FindingState::Resolved).await?; + } + } + + // Reopen findings detected again + for reconciled in &result.reopened { + if let Some(ref entity_id) = reconciled.entity_id { + self.mark_finding_state(entity_id, FindingState::Reopened).await?; + } + } + + Ok(()) + } + + async fn load_existing_findings( + &self, + component_ref: &str, + lifecycle: &str, + ) -> Result> { + let finding_entities = self.repository.list_by_kind("Finding").await?; + + let findings: Vec = finding_entities + .into_iter() + .filter(|e| { + e.spec + .as_ref() + .map(|s| match s { + Spec::FindingSpec(spec) => { + spec.component_ref == component_ref && spec.lifecycle == lifecycle + } + _ => false, + }) + .unwrap_or(false) + }) + .collect(); + + Ok(findings) + } + + async fn mark_finding_state(&self, entity_id: &str, state: FindingState) -> Result<()> { + let entity = self.repository.get_by_id(entity_id).await?; + if let Some(mut entity) = entity { + if let Some(Spec::FindingSpec(ref mut spec)) = entity.spec { + spec.state = state as i32; + if state == FindingState::Resolved { + spec.resolved_at = Some(prost_types::Timestamp { + seconds: Utc::now().timestamp(), + nanos: Utc::now().timestamp_subsec_nanos() as i32, + }); + } + } + self.repository.update(entity_id, &entity).await?; + } + Ok(()) + } +} + +fn build_finding_entity( + finding: &NormalizedFinding, + component_ref: &str, + lifecycle: &str, + scanner: &str, + scan_id: &str, + fingerprint: &str, + timestamp: &prost_types::Timestamp, +) -> Entity { + let metadata = FindingMetadata { + title: finding.title.clone(), + namespace: "default".to_string(), + description: finding.description.clone(), + labels: std::collections::HashMap::new(), + tags: finding.tags.clone(), + }; + + let spec = FindingSpec { + component_ref: component_ref.to_string(), + lifecycle: lifecycle.to_string(), + severity: domain_severity_to_proto(finding.severity) as i32, + state: FindingState::Active as i32, + scanner: scanner.to_string(), + rule_id: finding.rule_id.clone(), + fingerprint: fingerprint.to_string(), + file_path: finding.file_path.clone().unwrap_or_default(), + line_start: finding.line_start.unwrap_or(0), + line_end: finding.line_end.unwrap_or(0), + cwe: finding.cwe.clone().unwrap_or_default(), + cve: finding.cve.clone().unwrap_or_default(), + cvss_score: finding.cvss_score.unwrap_or(0.0), + package_name: finding.package_name.clone().unwrap_or_default(), + package_version: finding.package_version.clone().unwrap_or_default(), + fixed_version: finding.fixed_version.clone().unwrap_or_default(), + details_url: finding.details_url.clone().unwrap_or_default(), + first_seen: Some(timestamp.clone()), + last_seen: Some(timestamp.clone()), + resolved_at: None, + scan_id: scan_id.to_string(), + }; + + Entity { + id: String::new(), + kind: "Finding".to_string(), + metadata: Some(Metadata::FindingMetadata(metadata)), + spec: Some(Spec::FindingSpec(spec)), + annotations: std::collections::HashMap::new(), + created_at: None, + updated_at: None, + } +} + +fn domain_severity_to_proto(severity: Severity) -> ProtoSeverity { + match severity { + Severity::Info => ProtoSeverity::Info, + Severity::Low => ProtoSeverity::Low, + Severity::Medium => ProtoSeverity::Medium, + Severity::High => ProtoSeverity::High, + Severity::Critical => ProtoSeverity::Critical, + } +} + +fn proto_severity_to_domain(proto: i32) -> Severity { + match proto { + 1 => Severity::Info, + 2 => Severity::Low, + 3 => Severity::Medium, + 4 => Severity::High, + 5 => Severity::Critical, + _ => Severity::Medium, + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..269bfc2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,396 @@ +use std::sync::Arc; +use std::time::Instant; +use tonic::{Request, Response, Status}; +use tracing::{error, info, instrument}; +use uuid::Uuid; + +pub mod adapters; +pub mod config; +pub mod database; +pub mod error; +pub mod events; +pub mod findings; +pub mod plugins; +pub mod scanners; +pub mod security; +pub mod telemetry; + +// Include the generated protobuf code with serde support +pub mod charybdis { + pub mod core { + tonic::include_proto!("charybdis.core"); + } + pub mod entities { + tonic::include_proto!("charybdis.entities"); + } + pub mod ingestion { + tonic::include_proto!("charybdis.ingestion"); + } + pub mod plugins { + pub mod defectdojo { + tonic::include_proto!("charybdis.plugins.defectdojo"); + } + pub mod dependencytrack { + tonic::include_proto!("charybdis.plugins.dependencytrack"); + } + pub mod keycloak { + tonic::include_proto!("charybdis.plugins.keycloak"); + } + } +} + +use charybdis::entities::entity_service_server::EntityService; + +// Export the compiled protobuf descriptor set for reflection +// This is used by binaries that need to set up gRPC reflection +pub static ENTITY_DESCRIPTOR_SET: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/entity_descriptor.bin")); +use charybdis::entities::{ + CreateEntityRequest, CreateEntityResponse, DeleteEntityRequest, DeleteEntityResponse, + GetEntityRequest, GetEntityResponse, ListEntitiesRequest, ListEntitiesResponse, + UpdateEntityRequest, UpdateEntityResponse, +}; + +use database::EntityRepository; +use events::{EntityEvent, EventBus}; +use security::interceptor::AuthInterceptor; +use telemetry::Metrics; + +#[derive(Clone)] +pub struct MyEntityService { + repository: EntityRepository, + event_bus: Arc, + metrics: Metrics, + auth_interceptor: Option, +} + +impl MyEntityService { + pub fn new( + repository: EntityRepository, + event_bus: Arc, + metrics: Metrics, + auth_interceptor: Option, + ) -> Self { + Self { + repository, + event_bus, + metrics, + auth_interceptor, + } + } +} + +#[tonic::async_trait] +impl EntityService for MyEntityService { + #[instrument(skip(self, request), fields(entity.kind))] + async fn create_entity( + &self, + request: Request, + ) -> Result, Status> { + let start = Instant::now(); + + let request = if let Some(ref interceptor) = self.auth_interceptor { + interceptor + .authorize_request(request, "/charybdis.entities.EntityService/CreateEntity")? + } else { + request + }; + + let entity_data = request + .into_inner() + .entity + .ok_or_else(|| Status::invalid_argument("Entity data is missing"))?; + + validate_entity(&entity_data)?; + + let entity_kind = entity_data.kind.clone(); + tracing::Span::current().record("entity.kind", &entity_kind); + + let created_entity = self + .repository + .create(&entity_data) + .await + .map_err(|e| { + error!(error = %e, "Failed to create entity"); + Status::from(e) + })?; + + let entity_id = created_entity.id.clone(); + info!(entity_id = %entity_id, entity_kind = %entity_kind, "Entity created"); + + if let Ok(uuid) = Uuid::parse_str(&entity_id) { + let event = EntityEvent::created(uuid) + .with_metadata("entity_kind".to_string(), created_entity.kind.clone()) + .with_entity_data(Arc::new(created_entity.clone())); + + if let Err(e) = self.event_bus.publish(event).await { + error!(entity_id = %entity_id, error = %e, "Failed to publish entity created event"); + } + } + + let duration = start.elapsed().as_secs_f64(); + self.metrics + .record_entity_operation("create", &entity_kind, duration); + + Ok(Response::new(CreateEntityResponse { + entity: Some(created_entity), + })) + } + + #[instrument(skip(self, request), fields(entity.id))] + async fn get_entity( + &self, + request: Request, + ) -> Result, Status> { + let start = Instant::now(); + + let request = if let Some(ref interceptor) = self.auth_interceptor { + interceptor.authorize_request(request, "/charybdis.entities.EntityService/GetEntity")? + } else { + request + }; + + let entity_id = request.into_inner().id; + tracing::Span::current().record("entity.id", &entity_id); + + let entity = self + .repository + .get_by_id(&entity_id) + .await + .map_err(|e| { + error!(entity_id = %entity_id, error = %e, "Failed to get entity"); + Status::from(e) + })? + .ok_or_else(|| Status::not_found(format!("Entity not found: {}", entity_id)))?; + + let duration = start.elapsed().as_secs_f64(); + self.metrics + .record_entity_operation("get", &entity.kind, duration); + + Ok(Response::new(GetEntityResponse { + entity: Some(entity), + })) + } + + #[instrument(skip(self, request), fields(entity.id, entity.kind))] + async fn update_entity( + &self, + request: Request, + ) -> Result, Status> { + let start = Instant::now(); + + let request = if let Some(ref interceptor) = self.auth_interceptor { + interceptor + .authorize_request(request, "/charybdis.entities.EntityService/UpdateEntity")? + } else { + request + }; + + let req = request.into_inner(); + let entity_id = req.id.clone(); + + let updated_entity_data = req + .entity + .ok_or_else(|| Status::invalid_argument("Entity data is missing"))?; + + validate_entity(&updated_entity_data)?; + + let entity_kind = updated_entity_data.kind.clone(); + tracing::Span::current().record("entity.id", &entity_id); + tracing::Span::current().record("entity.kind", &entity_kind); + + let update_result = if let Some(field_mask) = req.update_mask { + self.repository + .partial_update(&entity_id, &updated_entity_data, &field_mask) + .await + } else { + self.repository + .update(&entity_id, &updated_entity_data) + .await + }; + + let updated_entity = update_result + .map_err(|e| { + error!(entity_id = %entity_id, error = %e, "Failed to update entity"); + Status::from(e) + })? + .ok_or_else(|| { + Status::not_found(format!("Entity not found: {}", entity_id)) + })?; + + info!(entity_id = %entity_id, entity_kind = %entity_kind, "Entity updated"); + + if let Ok(uuid) = Uuid::parse_str(&entity_id) { + let event = EntityEvent::updated(uuid) + .with_metadata("entity_kind".to_string(), updated_entity.kind.clone()) + .with_entity_data(Arc::new(updated_entity.clone())); + + if let Err(e) = self.event_bus.publish(event).await { + error!(entity_id = %entity_id, error = %e, "Failed to publish entity updated event"); + } + } + + let duration = start.elapsed().as_secs_f64(); + self.metrics + .record_entity_operation("update", &entity_kind, duration); + + Ok(Response::new(UpdateEntityResponse { + entity: Some(updated_entity), + })) + } + + #[instrument(skip(self, request), fields(entity.id, entity.kind))] + async fn delete_entity( + &self, + request: Request, + ) -> Result, Status> { + let start = Instant::now(); + + let request = if let Some(ref interceptor) = self.auth_interceptor { + interceptor + .authorize_request(request, "/charybdis.entities.EntityService/DeleteEntity")? + } else { + request + }; + + let entity_id = request.into_inner().id; + tracing::Span::current().record("entity.id", &entity_id); + + // Fetch entity before deletion for event metadata + let entity = self + .repository + .get_by_id(&entity_id) + .await + .map_err(|e| { + error!(entity_id = %entity_id, error = %e, "Failed to get entity for deletion"); + Status::from(e) + })? + .ok_or_else(|| { + Status::not_found(format!("Entity not found: {}", entity_id)) + })?; + + let entity_kind = entity.kind.clone(); + tracing::Span::current().record("entity.kind", &entity_kind); + + let deleted = self.repository.delete(&entity_id).await.map_err(|e| { + error!(entity_id = %entity_id, error = %e, "Failed to delete entity"); + Status::from(e) + })?; + + if !deleted { + return Err(Status::not_found(format!("Entity not found: {}", entity_id))); + } + + info!(entity_id = %entity_id, entity_kind = %entity_kind, "Entity deleted"); + + if let Ok(uuid) = Uuid::parse_str(&entity_id) { + let event = EntityEvent::deleted(uuid) + .with_metadata("entity_kind".to_string(), entity_kind.clone()); + + if let Err(e) = self.event_bus.publish(event).await { + error!(entity_id = %entity_id, error = %e, "Failed to publish entity deleted event"); + } + } + + let duration = start.elapsed().as_secs_f64(); + self.metrics + .record_entity_operation("delete", &entity_kind, duration); + + Ok(Response::new(DeleteEntityResponse { success: true })) + } + + #[instrument(skip(self, request))] + async fn list_entities( + &self, + request: Request, + ) -> Result, Status> { + let start = Instant::now(); + + let request = if let Some(ref interceptor) = self.auth_interceptor { + interceptor + .authorize_request(request, "/charybdis.entities.EntityService/ListEntities")? + } else { + request + }; + + let req = request.into_inner(); + let kind = if req.kind.is_empty() { None } else { Some(req.kind.as_str()) }; + let name = if req.name.is_empty() { None } else { Some(req.name.as_str()) }; + let page_size = if req.page_size == 0 { 100 } else { req.page_size }; + let page_token = if req.page_token.is_empty() { None } else { Some(req.page_token.as_str()) }; + + let paginated = self + .repository + .list_paginated(kind, name, page_size, page_token) + .await + .map_err(|e| { + error!(error = %e, "Failed to list entities"); + Status::from(e) + })?; + + let duration = start.elapsed().as_secs_f64(); + self.metrics + .record_entity_operation("list", kind.unwrap_or("all"), duration); + + Ok(Response::new(ListEntitiesResponse { + entities: paginated.entities, + next_page_token: paginated.next_page_token.unwrap_or_default(), + total_count: paginated.total_count, + })) + } +} + +const VALID_KINDS: &[&str] = &[ + "Service", + "System", + "Component", + "API", + "User", + "Group", + "Domain", + "Resource", + "Finding", +]; + +fn validate_entity(entity: &charybdis::entities::Entity) -> Result<(), Status> { + use charybdis::entities::entity::Metadata; + + if entity.kind.is_empty() { + return Err(Status::invalid_argument("Entity kind is required")); + } + if !VALID_KINDS.contains(&entity.kind.as_str()) { + return Err(Status::invalid_argument(format!( + "Invalid entity kind '{}'. Valid kinds: {}", + entity.kind, + VALID_KINDS.join(", ") + ))); + } + + let metadata = entity + .metadata + .as_ref() + .ok_or_else(|| Status::invalid_argument("Entity metadata is required"))?; + + // Verify metadata variant matches declared kind + let metadata_kind = match metadata { + Metadata::ServiceMetadata(_) => "Service", + Metadata::SystemMetadata(_) => "System", + Metadata::ComponentMetadata(_) => "Component", + Metadata::ApiMetadata(_) => "API", + Metadata::UserMetadata(_) => "User", + Metadata::GroupMetadata(_) => "Group", + Metadata::DomainMetadata(_) => "Domain", + Metadata::ResourceMetadata(_) => "Resource", + Metadata::FindingMetadata(_) => "Finding", + _ => return Ok(()), // Plugin metadata — skip kind check + }; + + if metadata_kind != entity.kind { + return Err(Status::invalid_argument(format!( + "Metadata type '{}' does not match entity kind '{}'", + metadata_kind, entity.kind + ))); + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fdcec84 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,337 @@ +use std::sync::Arc; +use tonic::transport::{Certificate, Identity, Server, ServerTlsConfig}; +use tonic_reflection::server::Builder as ReflectionBuilder; +use tracing::{info, warn}; + +// Plugin imports +// Plugins are loaded dynamically through the workspace +// The binary doesn't directly depend on plugins to avoid cyclic dependencies + +// Import MyEntityService and EntityServiceServer from the library crate +use charybdis::{ + MyEntityService, + charybdis::entities::entity_service_server::EntityServiceServer, + charybdis::ingestion::ingestion_service_server::IngestionServiceServer, + config::Config, + database, + events::{EventBus, backends::MemoryEventBus}, + findings::ingestion::MyIngestionService, + scanners::ParserRegistry, + security::{ + audit::AuditLogger, + config::{RoleMapping, RoleRule, SecurityConfig, SubjectMatch}, + interceptor::AuthInterceptor, + rbac::RbacEngine, + tls, + }, + telemetry::{init_telemetry, shutdown_telemetry}, +}; +use std::collections::HashMap; + +// Include the compiled protobuf descriptor set for reflection. +static ENTITY_DESCRIPTOR_SET: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/entity_descriptor.bin")); + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Install default crypto provider for rustls (required in rustls 0.23+) + // Using ring as the crypto backend since we have tls-ring feature enabled + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Load configuration from file or environment + info!("Loading configuration..."); + let config = Config::load()?; + info!("Configuration loaded successfully"); + + // Initialize OpenTelemetry (traces, metrics, logs) + let metrics = init_telemetry(config.telemetry.clone())?; + + let addr = format!("{}:{}", config.server.grpc_host, config.server.grpc_port).parse()?; + + info!("Connecting to database..."); + let pool = database::create_connection_pool(&config.database.url).await?; + + info!("Ensuring database schema exists..."); + database::ensure_schema(&pool).await?; + info!("Database ready"); + + // Initialize event bus + info!("Initializing event bus..."); + let event_bus: Arc = Arc::new(MemoryEventBus::new()); + event_bus.start().await?; + info!("Event bus started successfully"); + + // Initialize plugin system + info!("Initializing plugin system..."); + let entity_repository = Arc::new(database::EntityRepository::new(pool.clone())); + let plugin_manager = charybdis::plugins::manager::PluginManager::new(); + + // TODO: Plugin loading + // Plugins need to be loaded dynamically to avoid cyclic dependencies + // Options: + // 1. Use dynamic linking (dlopen) - requires building plugins as .so/.dll/.dylib + // 2. Use a plugin loader binary that links both charybdis and plugins + // 3. Implement a plugin registry pattern where plugins register themselves + // + // For now, plugins are built in the workspace but not automatically loaded + // Users can create custom binaries that link specific plugins they need + if config.plugins.defectdojo.is_some() { + warn!("DefectDojo plugin configured but plugin loading not yet implemented"); + warn!("Plugins are compile-time integrated - build fails due to cyclic dependency"); + warn!("This will be resolved in a future update with dynamic plugin loading"); + } + + // Create event dispatcher and subscribe to event bus + let event_plugins = plugin_manager.event_driven_plugins().to_vec(); + if !event_plugins.is_empty() { + let dispatcher = charybdis::plugins::dispatcher::EventDispatcher::new( + event_plugins, + entity_repository.clone(), + ); + let dispatcher = Arc::new(dispatcher); + + // Subscribe dispatcher to all entity events + // EventDispatcher implements EventHandler, so it can be passed directly + event_bus.subscribe(dispatcher).await?; + + info!("Event dispatcher subscribed to event bus"); + } + + info!( + "Plugin system initialized with {} event-driven plugins", + plugin_manager.event_driven_plugins().len() + ); + + // Load security configuration + let mut security_config = config.security.clone(); + + // Initialize security components if RBAC is enabled + let auth_interceptor = if security_config.rbac.enabled { + info!("Security (RBAC) is enabled"); + + // Configure default roles if none are defined + if security_config.rbac.role_mappings.is_empty() { + info!("No role mappings configured, setting up defaults"); + security_config = setup_default_rbac_config(); + } + + let rbac_engine = Arc::new(RbacEngine::new(security_config.rbac.clone())); + let audit_logger = Arc::new(AuditLogger::new(security_config.rbac.audit.enabled)); + Some(AuthInterceptor::new(rbac_engine, audit_logger)) + } else { + warn!("Security (RBAC) is disabled - running in insecure mode"); + None + }; + + // Clone repository for YAML adapter (EntityRepository is Clone) + let yaml_repository = entity_repository.clone(); + + let my_entity_service = MyEntityService::new( + (*entity_repository).clone(), + event_bus, + metrics, + auth_interceptor, + ); + + // Initialize parser registry and ingestion service + let parser_registry = Arc::new(ParserRegistry::with_builtins()); + let my_ingestion_service = + MyIngestionService::new(entity_repository.clone(), parser_registry); + + // Configure and build the reflection service using the embedded descriptor set. + // In tonic-reflection 0.14+, use build_v1() instead of build() + let reflection_service = ReflectionBuilder::configure() + .register_encoded_file_descriptor_set(ENTITY_DESCRIPTOR_SET) + .build_v1()?; + + // Configure server with optional mTLS + let mut server_builder = Server::builder(); + + if security_config.mtls.enabled { + info!("mTLS is enabled - configuring TLS"); + + // Validate certificate files exist + tls::validate_cert_files(&security_config.mtls)?; + + // Load certificate files + let (server_cert, server_key, ca_cert) = tls::load_tls_files(&security_config.mtls)?; + + // Create TLS identity and CA certificate + let identity = Identity::from_pem(&server_cert, &server_key); + let client_ca = Certificate::from_pem(&ca_cert); + + // Configure TLS with client certificate verification + let tls_config = ServerTlsConfig::new() + .identity(identity) + .client_ca_root(client_ca); + + server_builder = server_builder.tls_config(tls_config)?; + info!("mTLS configuration complete"); + } else { + warn!("mTLS is disabled - running without transport security"); + } + + info!("EntityService server listening on {}", addr); + + // Start YAML adapter if enabled + let yaml_adapter_handle = if config.server.yaml_adapter.enabled { + let yaml_host = config.server.yaml_adapter.host.clone(); + let yaml_port = config.server.yaml_adapter.port; + let yaml_repo = yaml_repository.clone(); + let base_url = format!("http://{}:{}", yaml_host, yaml_port); + + info!("Starting YAML adapter on {}:{}", yaml_host, yaml_port); + + Some(tokio::spawn(async move { + if let Err(e) = charybdis::adapters::yaml::start_yaml_adapter( + yaml_host, yaml_port, yaml_repo, base_url, + ) + .await + { + tracing::error!("YAML adapter error: {}", e); + } + })) + } else { + info!("YAML adapter is disabled"); + None + }; + + // Run the server with graceful shutdown + let server = server_builder + .add_service(EntityServiceServer::new(my_entity_service)) + .add_service(IngestionServiceServer::new(my_ingestion_service)) + .add_service(reflection_service) + .serve(addr); + + // Handle shutdown + tokio::select! { + result = server => { + if let Err(e) = result { + tracing::error!("Server error: {}", e); + } + } + _ = tokio::signal::ctrl_c() => { + info!("Received shutdown signal"); + } + } + + // Abort YAML adapter if it's running + if let Some(handle) = yaml_adapter_handle { + handle.abort(); + } + + // Gracefully shutdown telemetry + shutdown_telemetry().await; + info!("Shutdown complete"); + + Ok(()) +} + +/// Setup default RBAC configuration for testing/development +/// Maps certificate attributes to roles based on OU (Organizational Unit) +fn setup_default_rbac_config() -> SecurityConfig { + use charybdis::security::config::{AuditConfig, RbacConfig}; + + let mut permissions = HashMap::new(); + + // Admin role: full access + permissions.insert( + "admin".to_string(), + vec![ + "entity:create".to_string(), + "entity:read".to_string(), + "entity:update".to_string(), + "entity:delete".to_string(), + "entity:list".to_string(), + ], + ); + + // Platform team: full access (mapped from platform-team OU) + permissions.insert( + "platform".to_string(), + vec![ + "entity:create".to_string(), + "entity:read".to_string(), + "entity:update".to_string(), + "entity:delete".to_string(), + "entity:list".to_string(), + ], + ); + + // Automation/CI: create, read, update, list + permissions.insert( + "automation".to_string(), + vec![ + "entity:create".to_string(), + "entity:read".to_string(), + "entity:update".to_string(), + "entity:list".to_string(), + ], + ); + + // Plugins: read and list only + permissions.insert( + "plugin".to_string(), + vec!["entity:read".to_string(), "entity:list".to_string()], + ); + + let role_mappings = vec![ + // Map platform-team OU to platform role + RoleMapping { + role: "platform".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: None, + ou: Some("platform-team".to_string()), + o: None, + }, + }], + }, + // Map automation OU to automation role + RoleMapping { + role: "automation".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: None, + ou: Some("automation".to_string()), + o: None, + }, + }], + }, + // Map plugins OU to plugin role + RoleMapping { + role: "plugin".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: None, + ou: Some("plugins".to_string()), + o: None, + }, + }], + }, + ]; + + SecurityConfig { + mtls: charybdis::security::config::MtlsConfig { + enabled: std::env::var("SECURITY_MTLS_ENABLED") + .unwrap_or_else(|_| "false".to_string()) + .parse() + .unwrap_or(false), + server_cert: std::env::var("SECURITY_MTLS_SERVER_CERT").unwrap_or_default(), + server_key: std::env::var("SECURITY_MTLS_SERVER_KEY").unwrap_or_default(), + client_ca_cert: std::env::var("SECURITY_MTLS_CLIENT_CA").unwrap_or_default(), + require_client_cert: true, + crl_file: None, + }, + rbac: RbacConfig { + enabled: true, + role_mappings, + permissions, + audit: AuditConfig { + enabled: true, + log_all_requests: true, + log_denied_requests: true, + }, + }, + } +} diff --git a/src/plugins/annotations.rs b/src/plugins/annotations.rs new file mode 100644 index 0000000..6dfcb76 --- /dev/null +++ b/src/plugins/annotations.rs @@ -0,0 +1,377 @@ +//! Annotation helper utilities for plugins +//! +//! Provides a consistent API for plugins to store and retrieve metadata +//! in entity annotations using standardized naming conventions. +//! +//! # Annotation Format +//! +//! Annotations follow the reverse-DNS format: `{plugin}.com/{resource}-{attribute}` +//! +//! Examples: +//! - `defectdojo.com/product-id` - DefectDojo product ID +//! - `defectdojo.com/cicd-engagement-id` - CI/CD engagement ID +//! - `dependencytrack.com/project-uuid` - DependencyTrack project UUID +//! - `github.com/repo-slug` - GitHub repository slug + +use crate::charybdis::entities::Entity; +use std::fmt::Display; +use std::str::FromStr; + +/// Helper for managing plugin annotations on entities +/// +/// Provides a consistent API for plugins to store and retrieve IDs and +/// metadata in entity annotations. +/// +/// # Example +/// ```rust,ignore +/// use charybdis::plugins::annotations::AnnotationHelper; +/// +/// let helper = AnnotationHelper::new("defectdojo"); +/// +/// // Store a product ID +/// helper.set_id(&mut entity, "product", 123); +/// +/// // Retrieve the product ID +/// let product_id: Option = helper.get_id(&entity, "product"); +/// +/// // Store custom metadata +/// helper.set(&mut entity, "last-scan-date", "2025-01-15"); +/// ``` +pub struct AnnotationHelper { + plugin_name: String, +} + +impl AnnotationHelper { + /// Create a new annotation helper for a plugin + /// + /// # Arguments + /// * `plugin_name` - Name of the plugin (e.g., "defectdojo", "dependencytrack") + pub fn new(plugin_name: impl Into) -> Self { + Self { + plugin_name: plugin_name.into(), + } + } + + /// Store an ID annotation for a resource + /// + /// Creates annotation in format: `{plugin}.com/{resource}-id` + /// + /// # Arguments + /// * `entity` - Entity to annotate + /// * `resource_type` - Type of resource (e.g., "product", "engagement", "project") + /// * `id` - ID value to store + /// + /// # Example + /// ```rust,ignore + /// helper.set_id(&mut entity, "product", 123); + /// // Creates annotation: defectdojo.com/product-id = "123" + /// + /// helper.set_id(&mut entity, "cicd-engagement", 456); + /// // Creates annotation: defectdojo.com/cicd-engagement-id = "456" + /// ``` + pub fn set_id(&self, entity: &mut Entity, resource_type: &str, id: impl Display) { + let key = self.id_key(resource_type); + entity.annotations.insert(key, id.to_string()); + } + + /// Retrieve an ID annotation for a resource + /// + /// Parses annotation in format: `{plugin}.com/{resource}-id` + /// + /// # Arguments + /// * `entity` - Entity to read from + /// * `resource_type` - Type of resource (e.g., "product", "engagement") + /// + /// # Returns + /// `Some(id)` if annotation exists and can be parsed, `None` otherwise + /// + /// # Example + /// ```rust,ignore + /// let product_id: Option = helper.get_id(&entity, "product"); + /// let project_uuid: Option = helper.get_id(&entity, "project"); + /// ``` + pub fn get_id(&self, entity: &Entity, resource_type: &str) -> Option { + let key = self.id_key(resource_type); + entity.annotations.get(&key)?.parse().ok() + } + + /// Check if an ID annotation exists + /// + /// # Example + /// ```rust,ignore + /// if helper.has_id(&entity, "product") { + /// println!("Entity has a DefectDojo product"); + /// } + /// ``` + pub fn has_id(&self, entity: &Entity, resource_type: &str) -> bool { + let key = self.id_key(resource_type); + entity.annotations.contains_key(&key) + } + + /// Remove an ID annotation + /// + /// # Example + /// ```rust,ignore + /// helper.remove_id(&mut entity, "product"); + /// ``` + pub fn remove_id(&self, entity: &mut Entity, resource_type: &str) -> Option { + let key = self.id_key(resource_type); + entity.annotations.remove(&key) + } + + /// Set a generic annotation + /// + /// Creates annotation in format: `{plugin}.com/{key}` + /// + /// # Example + /// ```rust,ignore + /// helper.set(&mut entity, "last-scan-date", "2025-01-15"); + /// helper.set(&mut entity, "scan-status", "completed"); + /// helper.set(&mut entity, "vulnerabilities-count", 5); + /// ``` + pub fn set(&self, entity: &mut Entity, key: &str, value: impl Display) { + let full_key = self.annotation_key(key); + entity.annotations.insert(full_key, value.to_string()); + } + + /// Get a generic annotation + /// + /// # Example + /// ```rust,ignore + /// let scan_date: Option<&String> = helper.get(&entity, "last-scan-date"); + /// ``` + pub fn get<'a>(&self, entity: &'a Entity, key: &str) -> Option<&'a String> { + let full_key = self.annotation_key(key); + entity.annotations.get(&full_key) + } + + /// Get a generic annotation and parse it + /// + /// # Example + /// ```rust,ignore + /// let vuln_count: Option = helper.get_parsed(&entity, "vulnerabilities-count"); + /// ``` + pub fn get_parsed(&self, entity: &Entity, key: &str) -> Option { + self.get(entity, key)?.parse().ok() + } + + /// Check if a generic annotation exists + pub fn has(&self, entity: &Entity, key: &str) -> bool { + let full_key = self.annotation_key(key); + entity.annotations.contains_key(&full_key) + } + + /// Remove a generic annotation + pub fn remove(&self, entity: &mut Entity, key: &str) -> Option { + let full_key = self.annotation_key(key); + entity.annotations.remove(&full_key) + } + + /// Get all annotations for this plugin + /// + /// Returns a vector of (key, value) pairs for all annotations belonging to this plugin + /// + /// # Example + /// ```rust,ignore + /// for (key, value) in helper.get_all(&entity) { + /// println!("{}: {}", key, value); + /// } + /// ``` + pub fn get_all<'a>(&self, entity: &'a Entity) -> Vec<(&'a str, &'a String)> { + let prefix = format!("{}.com/", self.plugin_name); + entity + .annotations + .iter() + .filter(|(k, _)| k.starts_with(&prefix)) + .map(|(k, v)| (k.as_str(), v)) + .collect() + } + + /// Build the full annotation key for an ID + fn id_key(&self, resource_type: &str) -> String { + format!("{}.com/{}-id", self.plugin_name, resource_type) + } + + /// Build the full annotation key for a generic annotation + fn annotation_key(&self, key: &str) -> String { + format!("{}.com/{}", self.plugin_name, key) + } + + /// Get the plugin name + pub fn plugin_name(&self) -> &str { + &self.plugin_name + } +} + +/// Create standard tool reference annotation +/// +/// Helper function for creating tool reference annotations that link to external UIs +/// +/// # Example +/// ```rust,ignore +/// use charybdis::plugins::annotations::tool_reference; +/// +/// let url = tool_reference("defectdojo", "product", 123, "https://dd.example.com/product/123"); +/// // Returns: "defectdojo.com/product-url" +/// entity.annotations.insert(url, "https://dd.example.com/product/123".to_string()); +/// ``` +pub fn tool_reference_key(plugin: &str, resource_type: &str) -> String { + format!("{}.com/{}-url", plugin, resource_type) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn create_test_entity() -> Entity { + Entity { + id: "test-123".to_string(), + kind: "Component".to_string(), + metadata: None, + spec: None, + annotations: HashMap::new(), + created_at: None, + updated_at: None, + } + } + + #[test] + fn test_set_and_get_id() { + let helper = AnnotationHelper::new("defectdojo"); + let mut entity = create_test_entity(); + + // Set ID + helper.set_id(&mut entity, "product", 123); + + // Verify annotation key format + assert!(entity.annotations.contains_key("defectdojo.com/product-id")); + assert_eq!( + entity.annotations.get("defectdojo.com/product-id"), + Some(&"123".to_string()) + ); + + // Get ID back as i32 + let product_id: Option = helper.get_id(&entity, "product"); + assert_eq!(product_id, Some(123)); + + // Get ID as String + let product_id_str: Option = helper.get_id(&entity, "product"); + assert_eq!(product_id_str, Some("123".to_string())); + } + + #[test] + fn test_has_and_remove_id() { + let helper = AnnotationHelper::new("defectdojo"); + let mut entity = create_test_entity(); + + // Initially no ID + assert!(!helper.has_id(&entity, "product")); + + // Set ID + helper.set_id(&mut entity, "product", 123); + assert!(helper.has_id(&entity, "product")); + + // Remove ID + let removed = helper.remove_id(&mut entity, "product"); + assert_eq!(removed, Some("123".to_string())); + assert!(!helper.has_id(&entity, "product")); + } + + #[test] + fn test_multiple_ids() { + let helper = AnnotationHelper::new("defectdojo"); + let mut entity = create_test_entity(); + + // Set multiple IDs + helper.set_id(&mut entity, "product", 123); + helper.set_id(&mut entity, "engagement", 456); + helper.set_id(&mut entity, "cicd-engagement", 789); + + // Verify all IDs + assert_eq!(helper.get_id::(&entity, "product"), Some(123)); + assert_eq!(helper.get_id::(&entity, "engagement"), Some(456)); + assert_eq!(helper.get_id::(&entity, "cicd-engagement"), Some(789)); + } + + #[test] + fn test_generic_annotations() { + let helper = AnnotationHelper::new("defectdojo"); + let mut entity = create_test_entity(); + + // Set generic annotations + helper.set(&mut entity, "last-scan-date", "2025-01-15"); + helper.set(&mut entity, "scan-status", "completed"); + helper.set(&mut entity, "vulnerabilities-count", 5); + + // Get annotations + assert_eq!( + helper.get(&entity, "last-scan-date"), + Some(&"2025-01-15".to_string()) + ); + assert_eq!( + helper.get(&entity, "scan-status"), + Some(&"completed".to_string()) + ); + + // Get parsed annotation + let count: Option = helper.get_parsed(&entity, "vulnerabilities-count"); + assert_eq!(count, Some(5)); + } + + #[test] + fn test_get_all_annotations() { + let helper = AnnotationHelper::new("defectdojo"); + let mut entity = create_test_entity(); + + // Set multiple annotations + helper.set_id(&mut entity, "product", 123); + helper.set(&mut entity, "scan-status", "completed"); + + // Add annotation from different plugin + entity.annotations.insert( + "github.com/repo-slug".to_string(), + "myorg/myrepo".to_string(), + ); + + // Get all defectdojo annotations + let annotations = helper.get_all(&entity); + + // Should have 2 defectdojo annotations (not the github one) + assert_eq!(annotations.len(), 2); + + // Verify content + let keys: Vec<&str> = annotations.iter().map(|(k, _)| *k).collect(); + assert!(keys.contains(&"defectdojo.com/product-id")); + assert!(keys.contains(&"defectdojo.com/scan-status")); + assert!(!keys.contains(&"github.com/repo-slug")); + } + + #[test] + fn test_different_plugins() { + let dd_helper = AnnotationHelper::new("defectdojo"); + let dt_helper = AnnotationHelper::new("dependencytrack"); + let mut entity = create_test_entity(); + + // Set IDs from different plugins + dd_helper.set_id(&mut entity, "product", 123); + dt_helper.set_id(&mut entity, "project", "uuid-abc-123"); + + // Verify isolation + assert_eq!(dd_helper.get_id::(&entity, "product"), Some(123)); + assert_eq!( + dt_helper.get_id::(&entity, "project"), + Some("uuid-abc-123".to_string()) + ); + + // Cross-plugin queries should return None + assert_eq!(dd_helper.get_id::(&entity, "project"), None); + assert_eq!(dt_helper.get_id::(&entity, "product"), None); + } + + #[test] + fn test_tool_reference_key() { + let key = tool_reference_key("defectdojo", "product"); + assert_eq!(key, "defectdojo.com/product-url"); + } +} diff --git a/src/plugins/date_utils.rs b/src/plugins/date_utils.rs new file mode 100644 index 0000000..2ffde95 --- /dev/null +++ b/src/plugins/date_utils.rs @@ -0,0 +1,236 @@ +//! Date and time utilities for plugins +//! +//! Provides consistent date formatting and manipulation functions +//! commonly needed by plugins integrating with external tools. + +use chrono::{DateTime, Duration, Utc}; + +/// Get today's date in YYYY-MM-DD format +/// +/// # Example +/// ```rust +/// use charybdis::plugins::date_utils::today; +/// +/// let date = today(); // "2025-01-15" +/// ``` +pub fn today() -> String { + Utc::now().format("%Y-%m-%d").to_string() +} + +/// Get date N days from now in YYYY-MM-DD format +/// +/// # Arguments +/// * `days` - Number of days to add (positive) or subtract (negative) +/// +/// # Example +/// ```rust +/// use charybdis::plugins::date_utils::today_plus_days; +/// +/// let next_year = today_plus_days(365); // 1 year from now +/// let last_week = today_plus_days(-7); // 7 days ago +/// ``` +pub fn today_plus_days(days: i64) -> String { + (Utc::now() + Duration::days(days)) + .format("%Y-%m-%d") + .to_string() +} + +/// Get current timestamp in ISO 8601 format (RFC 3339) +/// +/// # Example +/// ```rust +/// use charybdis::plugins::date_utils::iso_timestamp; +/// +/// let timestamp = iso_timestamp(); // "2025-01-15T10:30:45.123Z" +/// ``` +pub fn iso_timestamp() -> String { + Utc::now().to_rfc3339() +} + +/// Get current Unix timestamp (seconds since epoch) +/// +/// # Example +/// ```rust +/// use charybdis::plugins::date_utils::unix_timestamp; +/// +/// let ts = unix_timestamp(); // 1736938245 +/// ``` +pub fn unix_timestamp() -> i64 { + Utc::now().timestamp() +} + +/// Format a DateTime as YYYY-MM-DD +/// +/// # Example +/// ```rust +/// use chrono::Utc; +/// use charybdis::plugins::date_utils::format_date; +/// +/// let dt = Utc::now(); +/// let formatted = format_date(&dt); // "2025-01-15" +/// ``` +pub fn format_date(dt: &DateTime) -> String { + dt.format("%Y-%m-%d").to_string() +} + +/// Format a DateTime as ISO 8601 / RFC 3339 +/// +/// # Example +/// ```rust +/// use chrono::Utc; +/// use charybdis::plugins::date_utils::format_iso; +/// +/// let dt = Utc::now(); +/// let formatted = format_iso(&dt); // "2025-01-15T10:30:45.123Z" +/// ``` +pub fn format_iso(dt: &DateTime) -> String { + dt.to_rfc3339() +} + +/// Create a date range for engagements/assessments +/// +/// Returns (start_date, end_date) as YYYY-MM-DD strings +/// +/// # Arguments +/// * `duration_days` - Duration of the engagement in days +/// +/// # Example +/// ```rust +/// use charybdis::plugins::date_utils::engagement_date_range; +/// +/// // Create 1-year engagement starting today +/// let (start, end) = engagement_date_range(365); +/// ``` +pub fn engagement_date_range(duration_days: i64) -> (String, String) { + let start = today(); + let end = today_plus_days(duration_days); + (start, end) +} + +/// Parse ISO 8601 date string to DateTime +/// +/// # Example +/// ```rust +/// use charybdis::plugins::date_utils::parse_iso; +/// +/// let dt = parse_iso("2025-01-15T10:30:45Z").unwrap(); +/// ``` +pub fn parse_iso(s: &str) -> Result, chrono::ParseError> { + DateTime::parse_from_rfc3339(s).map(|dt| dt.with_timezone(&Utc)) +} + +/// Parse YYYY-MM-DD date string to DateTime (at midnight UTC) +/// +/// # Example +/// ```rust +/// use charybdis::plugins::date_utils::parse_date; +/// +/// let dt = parse_date("2025-01-15").unwrap(); +/// ``` +pub fn parse_date(s: &str) -> Result, chrono::ParseError> { + use chrono::NaiveDate; + + let naive = NaiveDate::parse_from_str(s, "%Y-%m-%d")?; + Ok(naive.and_hms_opt(0, 0, 0).unwrap().and_utc()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Datelike, Timelike}; + + #[test] + fn test_today() { + let date = today(); + assert_eq!(date.len(), 10); // YYYY-MM-DD format + assert!(date.contains('-')); + } + + #[test] + fn test_today_plus_days() { + let future = today_plus_days(365); + let past = today_plus_days(-7); + + assert_eq!(future.len(), 10); + assert_eq!(past.len(), 10); + + // Future date should be greater than past date + assert!(future > past); + } + + #[test] + fn test_iso_timestamp() { + let ts = iso_timestamp(); + assert!(ts.contains('T')); + assert!(ts.ends_with('Z') || ts.contains('+')); + } + + #[test] + fn test_unix_timestamp() { + let ts = unix_timestamp(); + // Should be a reasonable Unix timestamp (after 2020, before 2100) + assert!(ts > 1577836800); // 2020-01-01 + assert!(ts < 4102444800); // 2100-01-01 + } + + #[test] + fn test_engagement_date_range() { + let (start, end) = engagement_date_range(365); + + assert_eq!(start.len(), 10); + assert_eq!(end.len(), 10); + assert!(end > start); + + // Verify it's roughly 365 days apart + let start_dt = parse_date(&start).unwrap(); + let end_dt = parse_date(&end).unwrap(); + let diff = end_dt.signed_duration_since(start_dt); + assert_eq!(diff.num_days(), 365); + } + + #[test] + fn test_format_date() { + let dt = Utc::now(); + let formatted = format_date(&dt); + assert_eq!(formatted.len(), 10); + assert_eq!(formatted.matches('-').count(), 2); + } + + #[test] + fn test_format_iso() { + let dt = Utc::now(); + let formatted = format_iso(&dt); + assert!(formatted.contains('T')); + } + + #[test] + fn test_parse_iso() { + let parsed = parse_iso("2025-01-15T10:30:45Z").unwrap(); + assert_eq!(parsed.year(), 2025); + assert_eq!(parsed.month(), 1); + assert_eq!(parsed.day(), 15); + } + + #[test] + fn test_parse_date() { + let parsed = parse_date("2025-01-15").unwrap(); + assert_eq!(parsed.year(), 2025); + assert_eq!(parsed.month(), 1); + assert_eq!(parsed.day(), 15); + assert_eq!(parsed.hour(), 0); + assert_eq!(parsed.minute(), 0); + } + + #[test] + fn test_roundtrip() { + // Format and parse should roundtrip + let original = Utc::now(); + let formatted = format_date(&original); + let parsed = parse_date(&formatted).unwrap(); + + // Should match year, month, day (time is lost in date format) + assert_eq!(original.year(), parsed.year()); + assert_eq!(original.month(), parsed.month()); + assert_eq!(original.day(), parsed.day()); + } +} diff --git a/src/plugins/dispatcher.rs b/src/plugins/dispatcher.rs new file mode 100644 index 0000000..272de5a --- /dev/null +++ b/src/plugins/dispatcher.rs @@ -0,0 +1,252 @@ +use crate::database::EntityRepository; +use crate::events::{EntityEvent, EntityEventType, EventHandler, EventResult}; +use crate::plugins::EventDrivenPlugin; +use anyhow::Result; +use async_trait::async_trait; +use std::sync::Arc; +use tracing::{error, info, instrument}; + +/// Event dispatcher for event-driven plugins +/// Receives entity lifecycle events and dispatches to registered plugins +pub struct EventDispatcher { + plugins: Vec>, + repository: Arc, +} + +impl EventDispatcher { + /// Create a new event dispatcher + pub fn new( + plugins: Vec>, + repository: Arc, + ) -> Self { + info!( + "Initializing event dispatcher with {} plugins", + plugins.len() + ); + Self { + plugins, + repository, + } + } + + /// Dispatch an entity event to all registered plugins + #[instrument(skip(self, event), fields(entity.id = %event.entity_id))] + pub async fn dispatch(&self, event: &EntityEvent) -> Result<()> { + let entity = event + .entity_data + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Event missing entity data"))?; + + let entity_kind = &entity.kind; + + info!( + "Dispatching {:?} event for {} entity (id: {})", + event.event_type, entity_kind, event.entity_id + ); + + for plugin in &self.plugins { + if let Err(e) = self + .dispatch_to_plugin(plugin, event, entity, entity_kind) + .await + { + error!("Plugin {} failed to handle event: {}", plugin.name(), e); + // Continue with other plugins even if one fails + } + } + + Ok(()) + } + + /// Dispatch event to a specific plugin + async fn dispatch_to_plugin( + &self, + plugin: &Arc, + event: &EntityEvent, + entity: &Arc, + entity_kind: &str, + ) -> Result<()> { + for handler in plugin.resource_handlers() { + match event.event_type { + EntityEventType::Created => { + // Check if this handler should trigger for this entity kind + if handler.trigger_kinds().contains(&entity_kind.to_string()) { + info!( + "Plugin '{}' resource handler '{}' triggered by {} creation", + plugin.name(), + handler.resource_type(), + entity_kind + ); + + // Handler may create a NEW plugin-managed entity (different kind/ID). + // Handlers that only update annotations should return None. + if let Some(plugin_entity) = handler.handle_create(entity).await? { + // Guard: prevent accidental duplication of the triggering entity + if plugin_entity.id == entity.id { + error!( + "Plugin '{}' handler '{}' returned entity with same ID as trigger ({}). \ + Use repository.update_annotations() and return None instead.", + plugin.name(), + handler.resource_type(), + entity.id + ); + continue; + } + + info!( + "Plugin '{}' created {} entity (id: {})", + plugin.name(), + plugin_entity.kind, + plugin_entity.id + ); + + self.repository.create(&plugin_entity).await?; + } + } + } + + EntityEventType::Updated => { + // Check if this handler should trigger for this entity kind + if handler.trigger_kinds().contains(&entity_kind.to_string()) { + info!( + "Plugin '{}' handling update of {} entity", + plugin.name(), + entity_kind + ); + + handler.handle_update(entity).await?; + } + } + + EntityEventType::Deleted => { + // Check if this handler should trigger for this entity kind + if handler.trigger_kinds().contains(&entity_kind.to_string()) { + info!( + "Plugin '{}' handling deletion of {} entity", + plugin.name(), + entity_kind + ); + + handler.handle_delete(entity).await?; + } + } + } + } + + Ok(()) + } +} + +/// Implement EventHandler so EventDispatcher can be subscribed to the event bus +#[async_trait] +impl EventHandler for EventDispatcher { + async fn handle_event(&self, event: &EntityEvent) -> EventResult<()> { + self.dispatch(event) + .await + .map_err(|e| crate::events::EventError::HandlerError(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::charybdis::entities::Entity; + use crate::plugins::{Plugin, PluginConfig, PluginType, ResourceHandler}; + use async_trait::async_trait; + use uuid::Uuid; + + // Mock plugin for testing + struct MockPlugin { + name: String, + } + + #[async_trait] + impl Plugin for MockPlugin { + fn name(&self) -> &str { + &self.name + } + + fn plugin_type(&self) -> PluginType { + PluginType::EventDriven + } + + fn load_config(&mut self, _config: PluginConfig) -> Result<()> { + Ok(()) + } + + fn validate_config(&self) -> Result<()> { + Ok(()) + } + } + + #[async_trait] + impl EventDrivenPlugin for MockPlugin { + fn resource_handlers(&self) -> Vec> { + vec![Arc::new(MockResourceHandler)] + } + } + + // Mock resource handler + struct MockResourceHandler; + + #[async_trait] + impl ResourceHandler for MockResourceHandler { + fn resource_type(&self) -> &str { + "test_resource" + } + + fn trigger_kinds(&self) -> &[String] { + &[] + } + + fn creates_entity_kind(&self) -> &str { + "TestEntity" + } + + async fn handle_create(&self, _entity: &Entity) -> Result> { + Ok(None) + } + + async fn handle_update(&self, _entity: &Entity) -> Result<()> { + Ok(()) + } + + async fn handle_delete(&self, _entity: &Entity) -> Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn test_dispatcher_creation() { + // Skip if no database + if std::env::var("DATABASE_URL").is_err() { + return; + } + + let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL").unwrap()) + .await + .unwrap(); + + let repository = Arc::new(crate::database::EntityRepository::new(pool)); + + let plugins: Vec> = vec![Arc::new(MockPlugin { + name: "test-plugin".to_string(), + })]; + + let dispatcher = EventDispatcher::new(plugins, repository); + + // Create a test event + let test_entity = Arc::new(Entity { + id: Uuid::new_v4().to_string(), + kind: "Component".to_string(), + ..Default::default() + }); + + let event = EntityEvent::created(Uuid::new_v4()) + .with_metadata("entity_kind".to_string(), "Component".to_string()) + .with_entity_data(test_entity); + + // Should not panic + let result = dispatcher.dispatch(&event).await; + assert!(result.is_ok()); + } +} diff --git a/src/plugins/field_mapper.rs b/src/plugins/field_mapper.rs new file mode 100644 index 0000000..46c3663 --- /dev/null +++ b/src/plugins/field_mapper.rs @@ -0,0 +1,727 @@ +use crate::charybdis::entities::Entity; +use crate::database::EntityRepository; +use anyhow::{Result, anyhow}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::warn; + +/// Field mapper handles mapping between entity fields and external tool fields +pub struct FieldMapper { + mappings: HashMap, + repository: Option>, +} + +/// Field mapping types +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FieldMapping { + /// Complex mapping with entity resolution (must come first to match objects with "from" key) + Complex(ComplexMapping), + + /// Static value wrapper (object with "value" key) + /// Example: {"value": "Web Application"}, {"value": true}, {"value": 123} + StaticWrapper(StaticValue), + + /// Direct field access using dot notation (plain string) + /// Example: "metadata.name" accesses entity.metadata.name + Direct(String), +} + +/// Wrapper for static values to distinguish from field paths +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StaticValue { + pub value: Value, +} + +/// Complex field mapping configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplexMapping { + /// Source field path + pub from: String, + + /// Optional: Resolve entity reference + #[serde(skip_serializing_if = "Option::is_none")] + pub resolve_entity: Option, + + /// Optional: Find linked entity of this kind + #[serde(skip_serializing_if = "Option::is_none")] + pub lookup_entity: Option, + + /// Optional: Extract field from resolved entity + #[serde(skip_serializing_if = "Option::is_none")] + pub extract: Option, + + /// Optional: Resolve array of entity IDs + #[serde(default)] + pub resolve_array: bool, + + /// Optional: Transform function + #[serde(skip_serializing_if = "Option::is_none")] + pub transform: Option, +} + +impl FieldMapper { + /// Create a new field mapper from configuration + /// Config format: { "target_field": "source.field" or complex mapping } + pub fn new(config: HashMap) -> Result { + let mut mappings = HashMap::new(); + + for (field_name, mapping_value) in config { + let mapping = Self::parse_mapping(mapping_value)?; + mappings.insert(field_name, mapping); + } + + Ok(Self { + mappings, + repository: None, + }) + } + + /// Set the entity repository (required for entity resolution) + pub fn with_repository(mut self, repository: Arc) -> Self { + self.repository = Some(repository); + self + } + + /// Parse a mapping value from config + fn parse_mapping(value: Value) -> Result { + // Use serde untagged deserialization + // Order matters: Complex (with "from"), StaticWrapper (with "value"), Direct (string) + serde_json::from_value(value).map_err(|e| anyhow!("Failed to parse field mapping: {}", e)) + } + + /// Map a single field from source entity (async for entity resolution) + pub async fn map(&self, field_name: &str, entity: &Entity) -> Result { + let mapping = self + .mappings + .get(field_name) + .ok_or_else(|| anyhow!("No mapping configured for field: {}", field_name))?; + + match mapping { + FieldMapping::Direct(path) => self.get_field_by_path(entity, path), + + FieldMapping::StaticWrapper(static_val) => Ok(static_val.value.clone()), + + FieldMapping::Complex(complex) => self.map_complex(complex, entity).await, + } + } + + /// Handle complex field mapping with entity resolution + async fn map_complex(&self, mapping: &ComplexMapping, entity: &Entity) -> Result { + // Get source field value + let source_value = self.get_field_by_path(entity, &mapping.from)?; + + // If no entity resolution needed, just return (possibly transformed) value + if mapping.resolve_entity.is_none() && !mapping.resolve_array { + return Ok(source_value); + } + + // Entity resolution required - need repository + let repository = self + .repository + .as_ref() + .ok_or_else(|| anyhow!("Entity resolution requires repository"))?; + + // Handle array resolution + if mapping.resolve_array { + return self.resolve_array(source_value, mapping, repository).await; + } + + // Handle single entity resolution + if let Some(resolve_kind) = &mapping.resolve_entity { + return self + .resolve_entity(source_value, resolve_kind, mapping, repository) + .await; + } + + Ok(source_value) + } + + /// Resolve a single entity reference + async fn resolve_entity( + &self, + entity_id: Value, + expected_kind: &str, + mapping: &ComplexMapping, + repository: &EntityRepository, + ) -> Result { + let id_str = entity_id + .as_str() + .ok_or_else(|| anyhow!("Entity ID must be a string"))?; + + // Resolve the entity + let resolved = repository + .get_by_id(id_str) + .await? + .ok_or_else(|| anyhow!("Entity not found: {}", id_str))?; + + // Validate kind if specified + if resolved.kind != expected_kind { + return Err(anyhow!( + "Expected entity kind {}, got {}", + expected_kind, + resolved.kind + )); + } + + // If we need to lookup a linked entity + if let Some(lookup_kind) = &mapping.lookup_entity { + let linked = self + .find_linked_entity(&resolved.id, lookup_kind, repository) + .await?; + + // Extract field from linked entity + if let Some(extract_path) = &mapping.extract { + return self.get_field_by_path(&linked, extract_path); + } + + return entity_to_json(&linked); + } + + // Extract field from resolved entity + if let Some(extract_path) = &mapping.extract { + return self.get_field_by_path(&resolved, extract_path); + } + + // Return entire resolved entity + entity_to_json(&resolved) + } + + /// Resolve an array of entity references + async fn resolve_array( + &self, + entity_ids: Value, + mapping: &ComplexMapping, + repository: &EntityRepository, + ) -> Result { + let ids_array = entity_ids + .as_array() + .ok_or_else(|| anyhow!("Expected array of entity IDs"))?; + + let mut results = Vec::new(); + + for id_value in ids_array { + let resolve_kind = mapping + .resolve_entity + .as_ref() + .ok_or_else(|| anyhow!("resolve_entity required for array resolution"))?; + + match self + .resolve_entity(id_value.clone(), resolve_kind, mapping, repository) + .await + { + Ok(value) => results.push(value), + Err(e) => { + warn!("Failed to resolve entity in array: {}", e); + // Continue with other entities + } + } + } + + Ok(Value::Array(results)) + } + + /// Find an entity linked to a source entity + async fn find_linked_entity( + &self, + source_id: &str, + target_kind: &str, + repository: &EntityRepository, + ) -> Result { + let entities = repository.list_by_kind(target_kind).await?; + + // Find entity with linked_entity_id matching source_id + for entity in entities { + let linked_id = self + .get_field_by_path(&entity, "metadata.linked_entity_id") + .ok() + .and_then(|v| v.as_str().map(String::from)); + + if linked_id.as_deref() == Some(source_id) { + return Ok(entity); + } + } + + Err(anyhow!( + "No {} entity found linked to {}", + target_kind, + source_id + )) + } + + /// Get field value from entity using dot notation path + /// Example: "metadata.name" → entity.metadata.name + fn get_field_by_path(&self, entity: &Entity, path: &str) -> Result { + // Convert entity to JSON for easy path traversal + let entity_json = entity_to_json(entity)?; + + // Split path and traverse JSON + let parts: Vec<&str> = path.split('.').collect(); + let mut current = &entity_json; + + for part in parts { + current = current + .get(part) + .ok_or_else(|| anyhow!("Field not found in entity: {}", path))?; + } + + Ok(current.clone()) + } + + /// Map all configured fields from entity to target format + /// Returns HashMap of field_name → mapped_value + pub async fn map_all(&self, entity: &Entity) -> Result> { + let mut result = HashMap::new(); + + for (field_name, _) in &self.mappings { + match self.map(field_name, entity).await { + Ok(value) => { + result.insert(field_name.clone(), value); + } + Err(e) => { + // Log warning but don't fail entire mapping + warn!("Failed to map field {}: {}", field_name, e); + } + } + } + + Ok(result) + } + + /// Check if a field is mapped + pub fn has_mapping(&self, field_name: &str) -> bool { + self.mappings.contains_key(field_name) + } +} + +/// Helper function to convert Entity to JSON Value +/// Works around prost_types::Timestamp not implementing serde traits +fn entity_to_json(entity: &Entity) -> Result { + use crate::charybdis::entities::entity; + use serde_json::json; + + // Manual conversion to avoid prost_types::Timestamp serde issues + let mut entity_obj = serde_json::Map::new(); + + entity_obj.insert("id".to_string(), json!(&entity.id)); + entity_obj.insert("kind".to_string(), json!(&entity.kind)); + + // Add metadata if present - extract fields based on metadata type + if let Some(ref metadata) = entity.metadata { + let mut metadata_obj = serde_json::Map::new(); + + match metadata { + entity::Metadata::ServiceMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.name)); + if !m.namespace.is_empty() { + metadata_obj.insert("namespace".to_string(), json!(&m.namespace)); + } + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + // ServiceMetadata labels is repeated string (Vec) + if !m.labels.is_empty() { + metadata_obj.insert("labels".to_string(), json!(&m.labels)); + } + if !m.tags.is_empty() { + metadata_obj.insert("tags".to_string(), json!(&m.tags)); + } + } + entity::Metadata::SystemMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.name)); + if !m.namespace.is_empty() { + metadata_obj.insert("namespace".to_string(), json!(&m.namespace)); + } + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + if !m.labels.is_empty() { + metadata_obj.insert("labels".to_string(), json!(&m.labels)); + } + if !m.tags.is_empty() { + metadata_obj.insert("tags".to_string(), json!(&m.tags)); + } + } + entity::Metadata::ComponentMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.name)); + if !m.namespace.is_empty() { + metadata_obj.insert("namespace".to_string(), json!(&m.namespace)); + } + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + if !m.labels.is_empty() { + metadata_obj.insert("labels".to_string(), json!(&m.labels)); + } + if !m.tags.is_empty() { + metadata_obj.insert("tags".to_string(), json!(&m.tags)); + } + } + entity::Metadata::ApiMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.name)); + if !m.namespace.is_empty() { + metadata_obj.insert("namespace".to_string(), json!(&m.namespace)); + } + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + if !m.labels.is_empty() { + metadata_obj.insert("labels".to_string(), json!(&m.labels)); + } + if !m.tags.is_empty() { + metadata_obj.insert("tags".to_string(), json!(&m.tags)); + } + } + entity::Metadata::UserMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.name)); + if !m.namespace.is_empty() { + metadata_obj.insert("namespace".to_string(), json!(&m.namespace)); + } + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + if !m.labels.is_empty() { + metadata_obj.insert("labels".to_string(), json!(&m.labels)); + } + if !m.tags.is_empty() { + metadata_obj.insert("tags".to_string(), json!(&m.tags)); + } + } + entity::Metadata::GroupMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.name)); + if !m.namespace.is_empty() { + metadata_obj.insert("namespace".to_string(), json!(&m.namespace)); + } + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + if !m.labels.is_empty() { + metadata_obj.insert("labels".to_string(), json!(&m.labels)); + } + if !m.tags.is_empty() { + metadata_obj.insert("tags".to_string(), json!(&m.tags)); + } + } + entity::Metadata::DomainMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.name)); + if !m.namespace.is_empty() { + metadata_obj.insert("namespace".to_string(), json!(&m.namespace)); + } + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + if !m.labels.is_empty() { + metadata_obj.insert("labels".to_string(), json!(&m.labels)); + } + if !m.tags.is_empty() { + metadata_obj.insert("tags".to_string(), json!(&m.tags)); + } + } + entity::Metadata::ResourceMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.name)); + if !m.namespace.is_empty() { + metadata_obj.insert("namespace".to_string(), json!(&m.namespace)); + } + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + if !m.labels.is_empty() { + metadata_obj.insert("labels".to_string(), json!(&m.labels)); + } + if !m.tags.is_empty() { + metadata_obj.insert("tags".to_string(), json!(&m.tags)); + } + } + entity::Metadata::DefectdojoMetadata(_) => { + // Plugin type — skip for now + } + entity::Metadata::KeycloakMetadata(_) => { + // Plugin metadata type — skip + } + entity::Metadata::DependencytrackMetadata(_) => { + // Plugin metadata type — skip + } + entity::Metadata::FindingMetadata(m) => { + metadata_obj.insert("name".to_string(), json!(&m.title)); + if !m.description.is_empty() { + metadata_obj.insert("description".to_string(), json!(&m.description)); + } + } + } + + entity_obj.insert("metadata".to_string(), Value::Object(metadata_obj)); + } + + // Add spec if present - extract fields based on spec type + if let Some(ref spec) = entity.spec { + let mut spec_obj = serde_json::Map::new(); + + match spec { + entity::Spec::ServiceSpec(s) => { + if !s.r#type.is_empty() { + spec_obj.insert("type".to_string(), json!(&s.r#type)); + } + if !s.lifecycle.is_empty() { + spec_obj.insert("lifecycle".to_string(), json!(&s.lifecycle)); + } + if !s.owner.is_empty() { + spec_obj.insert("owner".to_string(), json!(&s.owner)); + } + if !s.system.is_empty() { + spec_obj.insert("system".to_string(), json!(&s.system)); + } + if !s.subcomponent_of.is_empty() { + spec_obj.insert("subcomponent_of".to_string(), json!(&s.subcomponent_of)); + } + if !s.depends_on.is_empty() { + spec_obj.insert("depends_on".to_string(), json!(&s.depends_on)); + } + if !s.consumes_apis.is_empty() { + spec_obj.insert("consumes_apis".to_string(), json!(&s.consumes_apis)); + } + if !s.provides_apis.is_empty() { + spec_obj.insert("provides_apis".to_string(), json!(&s.provides_apis)); + } + } + entity::Spec::SystemSpec(s) => { + if !s.owner.is_empty() { + spec_obj.insert("owner".to_string(), json!(&s.owner)); + } + if !s.domain.is_empty() { + spec_obj.insert("domain".to_string(), json!(&s.domain)); + } + } + entity::Spec::ComponentSpec(s) => { + if !s.r#type.is_empty() { + spec_obj.insert("type".to_string(), json!(&s.r#type)); + } + if !s.lifecycle.is_empty() { + spec_obj.insert("lifecycle".to_string(), json!(&s.lifecycle)); + } + if !s.owner.is_empty() { + spec_obj.insert("owner".to_string(), json!(&s.owner)); + } + if !s.system.is_empty() { + spec_obj.insert("system".to_string(), json!(&s.system)); + } + if !s.subcomponent_of.is_empty() { + spec_obj.insert("subcomponent_of".to_string(), json!(&s.subcomponent_of)); + } + if !s.depends_on.is_empty() { + spec_obj.insert("depends_on".to_string(), json!(&s.depends_on)); + } + if !s.provides_apis.is_empty() { + spec_obj.insert("provides_apis".to_string(), json!(&s.provides_apis)); + } + if !s.consumes_apis.is_empty() { + spec_obj.insert("consumes_apis".to_string(), json!(&s.consumes_apis)); + } + } + entity::Spec::ApiSpec(s) => { + if !s.r#type.is_empty() { + spec_obj.insert("type".to_string(), json!(&s.r#type)); + } + if !s.lifecycle.is_empty() { + spec_obj.insert("lifecycle".to_string(), json!(&s.lifecycle)); + } + if !s.owner.is_empty() { + spec_obj.insert("owner".to_string(), json!(&s.owner)); + } + if !s.system.is_empty() { + spec_obj.insert("system".to_string(), json!(&s.system)); + } + if !s.definition.is_empty() { + spec_obj.insert("definition".to_string(), json!(&s.definition)); + } + } + entity::Spec::UserSpec(s) => { + if let Some(profile) = &s.profile { + let mut profile_obj = serde_json::Map::new(); + if !profile.display_name.is_empty() { + profile_obj.insert("display_name".to_string(), json!(&profile.display_name)); + } + if !profile.email.is_empty() { + profile_obj.insert("email".to_string(), json!(&profile.email)); + } + if !profile.picture.is_empty() { + profile_obj.insert("picture".to_string(), json!(&profile.picture)); + } + if !profile_obj.is_empty() { + spec_obj.insert("profile".to_string(), Value::Object(profile_obj)); + } + } + if !s.member_of.is_empty() { + spec_obj.insert("member_of".to_string(), json!(&s.member_of)); + } + } + entity::Spec::GroupSpec(s) => { + if !s.r#type.is_empty() { + spec_obj.insert("type".to_string(), json!(&s.r#type)); + } + if let Some(profile) = &s.profile { + let mut profile_obj = serde_json::Map::new(); + if !profile.display_name.is_empty() { + profile_obj.insert("display_name".to_string(), json!(&profile.display_name)); + } + if !profile.email.is_empty() { + profile_obj.insert("email".to_string(), json!(&profile.email)); + } + if !profile.picture.is_empty() { + profile_obj.insert("picture".to_string(), json!(&profile.picture)); + } + if !profile_obj.is_empty() { + spec_obj.insert("profile".to_string(), Value::Object(profile_obj)); + } + } + if !s.parent.is_empty() { + spec_obj.insert("parent".to_string(), json!(&s.parent)); + } + if !s.children.is_empty() { + spec_obj.insert("children".to_string(), json!(&s.children)); + } + if !s.members.is_empty() { + spec_obj.insert("members".to_string(), json!(&s.members)); + } + } + entity::Spec::DomainSpec(s) => { + if !s.owner.is_empty() { + spec_obj.insert("owner".to_string(), json!(&s.owner)); + } + } + entity::Spec::ResourceSpec(s) => { + if !s.r#type.is_empty() { + spec_obj.insert("type".to_string(), json!(&s.r#type)); + } + if !s.owner.is_empty() { + spec_obj.insert("owner".to_string(), json!(&s.owner)); + } + if !s.system.is_empty() { + spec_obj.insert("system".to_string(), json!(&s.system)); + } + if !s.depends_on.is_empty() { + spec_obj.insert("depends_on".to_string(), json!(&s.depends_on)); + } + } + entity::Spec::DefectdojoSpec(_) => { + // Plugin type — skip for now + } + entity::Spec::KeycloakSpec(_) => { + // Plugin spec type — skip + } + entity::Spec::DependencytrackSpec(_) => { + // Plugin spec type — skip + } + entity::Spec::FindingSpec(s) => { + spec_obj.insert("component_ref".to_string(), json!(&s.component_ref)); + spec_obj.insert("lifecycle".to_string(), json!(&s.lifecycle)); + spec_obj.insert("scanner".to_string(), json!(&s.scanner)); + spec_obj.insert("rule_id".to_string(), json!(&s.rule_id)); + } + } + + entity_obj.insert("spec".to_string(), Value::Object(spec_obj)); + } + + // Add annotations if present + if !entity.annotations.is_empty() { + entity_obj.insert("annotations".to_string(), json!(&entity.annotations)); + } + + Ok(Value::Object(entity_obj)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::charybdis::core::ComponentMetadata; + use crate::charybdis::entities::entity; + use serde_json::json; + + fn create_test_entity(id: &str, kind: &str) -> Entity { + Entity { + id: id.to_string(), + kind: kind.to_string(), + metadata: Some(entity::Metadata::ComponentMetadata(ComponentMetadata { + name: "test-component".to_string(), + description: "Test description".to_string(), + ..Default::default() + })), + spec: None, + annotations: Default::default(), + created_at: None, + updated_at: None, + } + } + + #[tokio::test] + async fn test_direct_field_mapping() { + let config = HashMap::from([ + ("name".to_string(), json!("metadata.name")), + ("description".to_string(), json!("metadata.description")), + ]); + + let mapper = FieldMapper::new(config).unwrap(); + let entity = create_test_entity("test-123", "Component"); + + let name = mapper.map("name", &entity).await.unwrap(); + assert_eq!(name, json!("test-component")); + + let desc = mapper.map("description", &entity).await.unwrap(); + assert_eq!(desc, json!("Test description")); + } + + #[tokio::test] + async fn test_static_field_mapping() { + let config = HashMap::from([ + ( + "product_type".to_string(), + json!({"value": "Web Application"}), + ), + ("is_active".to_string(), json!({"value": true})), + ("priority".to_string(), json!({"value": 100})), + ]); + + let mapper = FieldMapper::new(config).unwrap(); + let entity = create_test_entity("test-123", "Component"); + + assert_eq!( + mapper.map("product_type", &entity).await.unwrap(), + json!("Web Application") + ); + assert_eq!(mapper.map("is_active", &entity).await.unwrap(), json!(true)); + assert_eq!(mapper.map("priority", &entity).await.unwrap(), json!(100)); + } + + #[tokio::test] + async fn test_map_all() { + let config = HashMap::from([ + ("name".to_string(), json!("metadata.name")), + ("type".to_string(), json!({"value": "Web Application"})), + ]); + + let mapper = FieldMapper::new(config).unwrap(); + let entity = create_test_entity("test-123", "Component"); + + let mapped = mapper.map_all(&entity).await.unwrap(); + + assert_eq!(mapped.get("name").unwrap(), &json!("test-component")); + assert_eq!(mapped.get("type").unwrap(), &json!("Web Application")); + } + + #[tokio::test] + async fn test_complex_mapping_parse() { + let config = HashMap::from([( + "members".to_string(), + json!({ + "from": "spec.owner", + "resolve_entity": "Group", + "extract": "spec.members" + }), + )]); + + let mapper = FieldMapper::new(config).unwrap(); + assert!(mapper.has_mapping("members")); + } +} diff --git a/src/plugins/http_client.rs b/src/plugins/http_client.rs new file mode 100644 index 0000000..8ed1957 --- /dev/null +++ b/src/plugins/http_client.rs @@ -0,0 +1,424 @@ +//! Generic HTTP client for plugin integrations +//! +//! Provides a reusable HTTP client with authentication support for external APIs. +//! All plugins integrating with REST APIs should use this instead of creating +//! their own HTTP clients. + +use anyhow::{Context, Result, anyhow}; +use reqwest::{Client, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::time::Duration; +use tracing::{debug, error}; + +/// Maximum number of retry attempts for transient failures +const MAX_RETRIES: u32 = 3; + +/// Initial backoff duration in milliseconds (doubles on each retry) +const INITIAL_BACKOFF_MS: u64 = 500; + +/// Authentication configuration for external APIs +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AuthConfig { + /// Token-based authentication (Authorization: Token xxx) + /// Used by: DefectDojo + Token { token: String }, + + /// Bearer token authentication (Authorization: Bearer xxx) + /// Used by: Many modern APIs + Bearer { token: String }, + + /// API Key in header (X-API-Key: xxx or custom header) + /// Used by: DependencyTrack, many others + ApiKey { header_name: String, key: String }, + + /// Basic authentication (Authorization: Basic base64(user:pass)) + /// Used by: JIRA, Bitbucket, etc. + BasicAuth { username: String, password: String }, + + /// No authentication + None, +} + +impl AuthConfig { + /// Create Token authentication + pub fn token(token: impl Into) -> Self { + Self::Token { + token: token.into(), + } + } + + /// Create Bearer authentication + pub fn bearer(token: impl Into) -> Self { + Self::Bearer { + token: token.into(), + } + } + + /// Create API Key authentication + pub fn api_key(header_name: impl Into, key: impl Into) -> Self { + Self::ApiKey { + header_name: header_name.into(), + key: key.into(), + } + } + + /// Create Basic authentication + pub fn basic(username: impl Into, password: impl Into) -> Self { + Self::BasicAuth { + username: username.into(), + password: password.into(), + } + } +} + +/// Generic HTTP client for plugin integrations +#[derive(Clone)] +pub struct PluginHttpClient { + client: Client, + base_url: String, + auth: AuthConfig, +} + +impl PluginHttpClient { + /// Create a new HTTP client + /// + /// # Arguments + /// * `base_url` - Base URL of the API (e.g., "https://api.example.com") + /// * `auth` - Authentication configuration + /// + /// # Example + /// ```rust,ignore + /// let client = PluginHttpClient::new( + /// "https://defectdojo.example.com/api/v2", + /// AuthConfig::token("your-api-token") + /// )?; + /// ``` + pub fn new(base_url: impl Into, auth: AuthConfig) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .pool_idle_timeout(Duration::from_secs(90)) + .build() + .context("Failed to create HTTP client")?; + + Ok(Self { + client, + base_url: base_url.into().trim_end_matches('/').to_string(), + auth, + }) + } + + /// Make a GET request + /// + /// # Arguments + /// * `path` - API endpoint path (e.g., "products/123") + /// + /// # Returns + /// JSON response as `serde_json::Value` + pub async fn get(&self, path: &str) -> Result { + let url = self.build_url(path); + debug!("GET {}", url); + + let response = self + .execute_with_retry(|| { + let mut request = self.client.get(&url); + request = self.add_auth_headers(request); + request.header("Accept", "application/json") + }) + .await?; + + self.handle_response(response).await + } + + /// Make a GET request with query parameters + /// + /// # Arguments + /// * `path` - API endpoint path + /// * `params` - Query parameters as HashMap + pub async fn get_with_params( + &self, + path: &str, + params: &HashMap, + ) -> Result { + let url = self.build_url(path); + debug!("GET {} with params: {:?}", url, params); + + let response = self + .execute_with_retry(|| { + let mut request = self.client.get(&url); + request = self.add_auth_headers(request); + request.header("Accept", "application/json").query(params) + }) + .await?; + + self.handle_response(response).await + } + + /// Make a POST request + /// + /// # Arguments + /// * `path` - API endpoint path + /// * `body` - Request body as JSON Value + pub async fn post(&self, path: &str, body: &Value) -> Result { + let url = self.build_url(path); + debug!("POST {} with body: {}", url, body); + + let response = self + .execute_with_retry(|| { + let mut request = self.client.post(&url); + request = self.add_auth_headers(request); + request + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(body) + }) + .await?; + + self.handle_response(response).await + } + + /// Make a PUT request + /// + /// # Arguments + /// * `path` - API endpoint path + /// * `body` - Request body as JSON Value + pub async fn put(&self, path: &str, body: &Value) -> Result { + let url = self.build_url(path); + debug!("PUT {} with body: {}", url, body); + + let response = self + .execute_with_retry(|| { + let mut request = self.client.put(&url); + request = self.add_auth_headers(request); + request + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(body) + }) + .await?; + + self.handle_response(response).await + } + + /// Make a PATCH request + /// + /// # Arguments + /// * `path` - API endpoint path + /// * `body` - Request body as JSON Value + pub async fn patch(&self, path: &str, body: &Value) -> Result { + let url = self.build_url(path); + debug!("PATCH {} with body: {}", url, body); + + let response = self + .execute_with_retry(|| { + let mut request = self.client.patch(&url); + request = self.add_auth_headers(request); + request + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(body) + }) + .await?; + + self.handle_response(response).await + } + + /// Make a DELETE request + /// + /// # Arguments + /// * `path` - API endpoint path + pub async fn delete(&self, path: &str) -> Result<()> { + let url = self.build_url(path); + debug!("DELETE {}", url); + + let response = self + .execute_with_retry(|| { + let mut request = self.client.delete(&url); + request = self.add_auth_headers(request); + request + }) + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + error!("HTTP DELETE failed ({}): {}", status, body); + return Err(anyhow!("HTTP DELETE failed ({}): {}", status, body)); + } + + debug!("DELETE {} succeeded", url); + Ok(()) + } + + /// Execute a request with exponential backoff retry logic. + /// + /// Retries on transient errors: + /// - HTTP 429 (Too Many Requests) + /// - HTTP 502 (Bad Gateway) + /// - HTTP 503 (Service Unavailable) + /// - HTTP 504 (Gateway Timeout) + /// - Connection errors + /// - Timeout errors + /// + /// Does NOT retry on client errors (4xx except 429) as those are permanent failures. + async fn execute_with_retry( + &self, + build_request: impl Fn() -> reqwest::RequestBuilder, + ) -> Result { + let mut last_error = None; + + for attempt in 0..=MAX_RETRIES { + if attempt > 0 { + let backoff = Duration::from_millis(INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1)); + debug!( + "Retrying request (attempt {}/{}) after {:?}", + attempt + 1, + MAX_RETRIES + 1, + backoff + ); + tokio::time::sleep(backoff).await; + } + + let request = build_request(); + match request.send().await { + Ok(response) => { + if response.status() == StatusCode::TOO_MANY_REQUESTS + || response.status() == StatusCode::BAD_GATEWAY + || response.status() == StatusCode::SERVICE_UNAVAILABLE + || response.status() == StatusCode::GATEWAY_TIMEOUT + { + if attempt < MAX_RETRIES { + debug!("Retryable status {}, will retry", response.status()); + last_error = Some(anyhow!( + "HTTP {} (attempt {})", + response.status(), + attempt + 1 + )); + continue; + } + } + return Ok(response); + } + Err(e) => { + if attempt < MAX_RETRIES && (e.is_timeout() || e.is_connect()) { + debug!("Retryable error: {}, will retry", e); + last_error = Some(anyhow!("Request failed: {}", e)); + continue; + } + return Err(anyhow!("HTTP request failed: {}", e)); + } + } + } + + Err(last_error.unwrap_or_else(|| anyhow!("Request failed after retries"))) + } + + /// Build full URL from path + fn build_url(&self, path: &str) -> String { + let path = path.trim_start_matches('/'); + format!("{}/{}", self.base_url, path) + } + + /// Add authentication headers to request + fn add_auth_headers(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match &self.auth { + AuthConfig::Token { token } => { + request.header("Authorization", format!("Token {}", token)) + } + AuthConfig::Bearer { token } => { + request.header("Authorization", format!("Bearer {}", token)) + } + AuthConfig::ApiKey { header_name, key } => request.header(header_name, key), + AuthConfig::BasicAuth { username, password } => { + request.basic_auth(username, Some(password)) + } + AuthConfig::None => request, + } + } + + /// Handle HTTP response and extract JSON + async fn handle_response(&self, response: Response) -> Result { + let status = response.status(); + let url = response.url().to_string(); + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + error!("HTTP request failed ({}): {}", status, body); + + // Provide helpful error messages based on status code + let error_msg = match status { + StatusCode::UNAUTHORIZED => { + "Authentication failed - check API credentials".to_string() + } + StatusCode::FORBIDDEN => "Access forbidden - check API permissions".to_string(), + StatusCode::NOT_FOUND => format!("Resource not found: {}", url), + StatusCode::BAD_REQUEST => format!("Bad request: {}", body), + StatusCode::INTERNAL_SERVER_ERROR => { + format!("Server error (500): {}", body) + } + _ => format!("HTTP {} error: {}", status, body), + }; + + return Err(anyhow!(error_msg)); + } + + // Parse JSON response + let json = response + .json::() + .await + .context("Failed to parse JSON response")?; + + debug!("HTTP request succeeded: {}", url); + Ok(json) + } + + /// Get the base URL + pub fn base_url(&self) -> &str { + &self.base_url + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_auth_config_constructors() { + let token = AuthConfig::token("test-token"); + assert!(matches!(token, AuthConfig::Token { .. })); + + let bearer = AuthConfig::bearer("test-bearer"); + assert!(matches!(bearer, AuthConfig::Bearer { .. })); + + let api_key = AuthConfig::api_key("X-API-Key", "test-key"); + assert!(matches!(api_key, AuthConfig::ApiKey { .. })); + + let basic = AuthConfig::basic("user", "pass"); + assert!(matches!(basic, AuthConfig::BasicAuth { .. })); + } + + #[test] + fn test_url_building() { + let client = PluginHttpClient::new("https://api.example.com/v2", AuthConfig::None).unwrap(); + + assert_eq!( + client.build_url("products"), + "https://api.example.com/v2/products" + ); + assert_eq!( + client.build_url("/products/123"), + "https://api.example.com/v2/products/123" + ); + } + + #[test] + fn test_base_url_trimming() { + let client = + PluginHttpClient::new("https://api.example.com/v2/", AuthConfig::None).unwrap(); + + assert_eq!(client.base_url(), "https://api.example.com/v2"); + } +} diff --git a/src/plugins/manager.rs b/src/plugins/manager.rs new file mode 100644 index 0000000..a19359e --- /dev/null +++ b/src/plugins/manager.rs @@ -0,0 +1,265 @@ +use crate::plugins::sync_scheduler::SyncScheduler; +use crate::plugins::{EventDrivenPlugin, SyncPlugin, SyncResult}; +use anyhow::{Result, anyhow}; +use std::sync::Arc; +use tracing::{error, info}; + +/// Plugin manager handles registration and lifecycle of all plugins +pub struct PluginManager { + event_driven_plugins: Vec>, + sync_plugins: Vec>, + sync_scheduler: SyncScheduler, +} + +impl PluginManager { + /// Create a new plugin manager + pub fn new() -> Self { + info!("Initializing plugin manager"); + Self { + event_driven_plugins: Vec::new(), + sync_plugins: Vec::new(), + sync_scheduler: SyncScheduler::new(), + } + } + + /// Register an event-driven plugin + pub fn register_event_driven(&mut self, plugin: Arc) { + info!("Registering event-driven plugin: {}", plugin.name()); + + // Validate configuration + if let Err(e) = plugin.validate_config() { + error!("Plugin {} configuration invalid: {}", plugin.name(), e); + return; + } + + self.event_driven_plugins.push(plugin); + } + + /// Register a sync plugin + pub fn register_sync(&mut self, plugin: Arc) { + info!("Registering sync plugin: {}", plugin.name()); + + // Validate configuration + if let Err(e) = plugin.validate_config() { + error!("Plugin {} configuration invalid: {}", plugin.name(), e); + return; + } + + let sync_config = plugin.sync_config(); + + // Schedule sync if cron expression is configured + if let Some(schedule) = &sync_config.schedule { + info!( + "Scheduling plugin {} with cron: {}", + plugin.name(), + schedule + ); + self.sync_scheduler + .schedule(plugin.clone(), schedule.clone()); + } + + // Run on startup if configured + if sync_config.on_startup { + let plugin_clone = plugin.clone(); + tokio::spawn(async move { + info!("Running startup sync for plugin: {}", plugin_clone.name()); + match plugin_clone.sync().await { + Ok(result) => { + info!( + "Startup sync completed for {}: created={}, updated={}, deleted={}, errors={}", + plugin_clone.name(), + result.entities_created, + result.entities_updated, + result.entities_deleted, + result.errors.len() + ); + } + Err(e) => { + error!("Startup sync failed for {}: {}", plugin_clone.name(), e); + } + } + }); + } + + self.sync_plugins.push(plugin); + } + + /// Get all registered event-driven plugins + pub fn event_driven_plugins(&self) -> &[Arc] { + &self.event_driven_plugins + } + + /// Get all registered sync plugins + pub fn sync_plugins(&self) -> &[Arc] { + &self.sync_plugins + } + + /// Manually trigger sync for a specific plugin by name + pub async fn trigger_sync(&self, plugin_name: &str) -> Result { + for plugin in &self.sync_plugins { + if plugin.name() == plugin_name { + let sync_config = plugin.sync_config(); + + if !sync_config.manual_trigger { + return Err(anyhow!( + "Manual trigger not enabled for plugin: {}", + plugin_name + )); + } + + info!("Manually triggering sync for plugin: {}", plugin_name); + return plugin.sync().await; + } + } + + Err(anyhow!("Sync plugin not found: {}", plugin_name)) + } + + /// Start the sync scheduler (starts all cron jobs) + pub async fn start_scheduler(&mut self) { + info!("Starting plugin sync scheduler"); + self.sync_scheduler.start().await; + } + + /// Get statistics about registered plugins + pub fn stats(&self) -> PluginStats { + PluginStats { + event_driven_count: self.event_driven_plugins.len(), + sync_count: self.sync_plugins.len(), + total_count: self.event_driven_plugins.len() + self.sync_plugins.len(), + } + } +} + +impl Default for PluginManager { + fn default() -> Self { + Self::new() + } +} + +/// Plugin manager statistics +#[derive(Debug, Clone)] +pub struct PluginStats { + pub event_driven_count: usize, + pub sync_count: usize, + pub total_count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::{Plugin, PluginConfig, PluginType, ResourceHandler, SyncConfig}; + use async_trait::async_trait; + + // Mock event-driven plugin + struct MockEventPlugin; + + #[async_trait] + impl Plugin for MockEventPlugin { + fn name(&self) -> &str { + "mock-event" + } + + fn plugin_type(&self) -> PluginType { + PluginType::EventDriven + } + + fn load_config(&mut self, _config: PluginConfig) -> Result<()> { + Ok(()) + } + + fn validate_config(&self) -> Result<()> { + Ok(()) + } + } + + #[async_trait] + impl EventDrivenPlugin for MockEventPlugin { + fn resource_handlers(&self) -> Vec> { + vec![] + } + } + + // Mock sync plugin + struct MockSyncPlugin { + sync_config: SyncConfig, + } + + #[async_trait] + impl Plugin for MockSyncPlugin { + fn name(&self) -> &str { + "mock-sync" + } + + fn plugin_type(&self) -> PluginType { + PluginType::Sync + } + + fn load_config(&mut self, _config: PluginConfig) -> Result<()> { + Ok(()) + } + + fn validate_config(&self) -> Result<()> { + Ok(()) + } + } + + #[async_trait] + impl SyncPlugin for MockSyncPlugin { + fn sync_config(&self) -> &SyncConfig { + &self.sync_config + } + + async fn sync(&self) -> Result { + Ok(SyncResult { + entities_created: 0, + entities_updated: 0, + entities_deleted: 0, + errors: vec![], + }) + } + } + + #[tokio::test] + async fn test_plugin_manager() { + let mut manager = PluginManager::new(); + + // Register event-driven plugin + manager.register_event_driven(Arc::new(MockEventPlugin)); + + // Register sync plugin + manager.register_sync(Arc::new(MockSyncPlugin { + sync_config: SyncConfig { + schedule: None, + on_startup: false, + manual_trigger: true, + }, + })); + + let stats = manager.stats(); + assert_eq!(stats.event_driven_count, 1); + assert_eq!(stats.sync_count, 1); + assert_eq!(stats.total_count, 2); + } + + #[tokio::test] + async fn test_manual_trigger() { + let mut manager = PluginManager::new(); + + manager.register_sync(Arc::new(MockSyncPlugin { + sync_config: SyncConfig { + schedule: None, + on_startup: false, + manual_trigger: true, + }, + })); + + // Should succeed + let result = manager.trigger_sync("mock-sync").await; + assert!(result.is_ok()); + + // Should fail for non-existent plugin + let result = manager.trigger_sync("non-existent").await; + assert!(result.is_err()); + } +} diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs new file mode 100644 index 0000000..aa0b141 --- /dev/null +++ b/src/plugins/mod.rs @@ -0,0 +1,138 @@ +// Core plugin system modules +pub mod dispatcher; +pub mod field_mapper; +pub mod manager; +pub mod sync_scheduler; + +// Generic utilities for all plugins +pub mod annotations; +pub mod date_utils; +pub mod http_client; + +use crate::charybdis::entities::Entity; +use anyhow::Result; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::Arc; + +/// Plugin type determines how the plugin is invoked +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PluginType { + /// Reacts to entity lifecycle events + EventDriven, + /// Pulls data from external source on schedule + Sync, +} + +/// Plugin health status +#[derive(Debug, Clone, PartialEq)] +pub enum HealthStatus { + Healthy, + Degraded(String), + Unhealthy(String), +} + +/// Base plugin trait - all plugins implement this +#[async_trait] +pub trait Plugin: Send + Sync { + /// Plugin name (e.g., "defectdojo", "keycloak") + fn name(&self) -> &str; + + /// Plugin type + fn plugin_type(&self) -> PluginType; + + /// Load configuration from TOML + fn load_config(&mut self, config: PluginConfig) -> Result<()>; + + /// Validate configuration (called before registration) + fn validate_config(&self) -> Result<()>; + + /// Health check — plugins can override to verify external connectivity + async fn health_check(&self) -> HealthStatus { + HealthStatus::Healthy + } +} + +/// Event-driven plugin trait +/// Plugins that react to entity lifecycle events +#[async_trait] +pub trait EventDrivenPlugin: Plugin { + /// Get resource handlers for entity events + fn resource_handlers(&self) -> Vec>; +} + +/// Sync plugin trait +/// Plugins that pull data from external sources on a schedule +#[async_trait] +pub trait SyncPlugin: Plugin { + /// Get sync configuration + fn sync_config(&self) -> &SyncConfig; + + /// Perform sync operation + /// Returns entities created/updated during sync + async fn sync(&self) -> Result; +} + +/// Configuration for sync plugins +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncConfig { + /// Cron schedule expression (e.g., "0 0 * * *" for daily at midnight) + pub schedule: Option, + + /// Run sync on Charybdis startup + #[serde(default)] + pub on_startup: bool, + + /// Allow manual trigger via API + #[serde(default)] + pub manual_trigger: bool, +} + +/// Result of a sync operation +#[derive(Debug)] +pub struct SyncResult { + pub entities_created: usize, + pub entities_updated: usize, + pub entities_deleted: usize, + pub errors: Vec, +} + +/// Resource handler for event-driven plugins +/// Each handler manages one type of resource (e.g., users, products) +#[async_trait] +pub trait ResourceHandler: Send + Sync { + /// Resource type name (e.g., "user", "product") + fn resource_type(&self) -> &str; + + /// Which Charybdis entity kinds trigger this resource creation + /// e.g., ["User"] for user provisioning, ["Component", "Service"] for products + fn trigger_kinds(&self) -> &[String]; + + /// What entity kind does this resource handler create + /// e.g., "DefectDojoUser", "DefectDojoProduct" + fn creates_entity_kind(&self) -> &str; + + /// Handle entity creation event + /// Returns optional plugin entity to be created in Charybdis + async fn handle_create(&self, entity: &Entity) -> Result>; + + /// Handle entity update event + async fn handle_update(&self, entity: &Entity) -> Result<()>; + + /// Handle entity deletion event + async fn handle_delete(&self, entity: &Entity) -> Result<()>; +} + +/// Plugin configuration from TOML +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginConfig { + pub enabled: bool, + + #[serde(rename = "type")] + pub plugin_type: PluginType, + + #[serde(flatten)] + pub settings: Value, // Plugin-specific settings +} diff --git a/src/plugins/sync_scheduler.rs b/src/plugins/sync_scheduler.rs new file mode 100644 index 0000000..21e708f --- /dev/null +++ b/src/plugins/sync_scheduler.rs @@ -0,0 +1,220 @@ +use crate::plugins::SyncPlugin; +use std::sync::Arc; +use tokio::task::JoinHandle; +use tokio_cron_scheduler::JobScheduler; +use tracing::{error, info}; + +/// Sync scheduler manages cron-based execution of sync plugins +pub struct SyncScheduler { + scheduler: Option, + jobs: Vec>, +} + +impl SyncScheduler { + /// Create a new sync scheduler + pub fn new() -> Self { + Self { + scheduler: None, + jobs: Vec::new(), + } + } + + /// Schedule a sync plugin to run on a cron schedule + pub fn schedule(&mut self, plugin: Arc, cron_expr: String) { + let plugin_name = plugin.name().to_string(); + + info!( + "Scheduling sync plugin '{}' with cron expression: {}", + plugin_name, cron_expr + ); + + // Store the job handle for cleanup + let job = tokio::spawn(async move { + // Parse cron expression + let schedule = match cron::Schedule::try_from(cron_expr.as_str()) { + Ok(s) => s, + Err(e) => { + error!( + "Invalid cron expression '{}' for plugin {}: {}", + cron_expr, plugin_name, e + ); + return; + } + }; + + loop { + // Get next execution time + let now = chrono::Utc::now(); + let next = match schedule.upcoming(chrono::Utc).next() { + Some(dt) => dt, + None => { + error!( + "Failed to calculate next execution time for plugin {}", + plugin_name + ); + break; + } + }; + + let duration = (next - now) + .to_std() + .unwrap_or(std::time::Duration::from_secs(0)); + + info!( + "Plugin '{}' scheduled to run in {:?} (at {})", + plugin_name, duration, next + ); + + // Wait until next execution time + tokio::time::sleep(duration).await; + + // Execute sync + info!("Running scheduled sync for plugin '{}'", plugin_name); + match plugin.sync().await { + Ok(result) => { + info!( + "Sync completed for '{}': created={}, updated={}, deleted={}, errors={}", + plugin_name, + result.entities_created, + result.entities_updated, + result.entities_deleted, + result.errors.len() + ); + + if !result.errors.is_empty() { + error!( + "Sync for '{}' completed with {} errors: {:?}", + plugin_name, + result.errors.len(), + result.errors + ); + } + } + Err(e) => { + error!("Sync failed for '{}': {}", plugin_name, e); + } + } + } + }); + + self.jobs.push(job); + } + + /// Start the scheduler (enables all scheduled jobs) + pub async fn start(&mut self) { + // Use tokio_cron_scheduler if available + if let Ok(scheduler) = JobScheduler::new().await { + info!("Sync scheduler started with tokio_cron_scheduler"); + self.scheduler = Some(scheduler); + + if let Some(s) = &self.scheduler { + if let Err(e) = s.start().await { + error!("Failed to start scheduler: {}", e); + } + } + } else { + info!("Sync scheduler started with manual cron implementation"); + } + } + + /// Stop all scheduled jobs + pub fn stop(&mut self) { + info!("Stopping sync scheduler"); + + for job in self.jobs.drain(..) { + job.abort(); + } + + self.scheduler = None; + } +} + +impl Default for SyncScheduler { + fn default() -> Self { + Self::new() + } +} + +impl Drop for SyncScheduler { + fn drop(&mut self) { + self.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::{Plugin, PluginConfig, PluginType, SyncConfig, SyncResult}; + use async_trait::async_trait; + + // Mock sync plugin for testing + struct MockSyncPlugin { + name: String, + sync_config: SyncConfig, + } + + #[async_trait] + impl Plugin for MockSyncPlugin { + fn name(&self) -> &str { + &self.name + } + + fn plugin_type(&self) -> PluginType { + PluginType::Sync + } + + fn load_config(&mut self, _config: PluginConfig) -> anyhow::Result<()> { + Ok(()) + } + + fn validate_config(&self) -> anyhow::Result<()> { + Ok(()) + } + } + + #[async_trait] + impl SyncPlugin for MockSyncPlugin { + fn sync_config(&self) -> &SyncConfig { + &self.sync_config + } + + async fn sync(&self) -> anyhow::Result { + Ok(SyncResult { + entities_created: 1, + entities_updated: 0, + entities_deleted: 0, + errors: vec![], + }) + } + } + + #[tokio::test] + async fn test_scheduler_creation() { + let mut scheduler = SyncScheduler::new(); + scheduler.start().await; + // Should not panic + } + + #[tokio::test] + async fn test_schedule_plugin() { + let mut scheduler = SyncScheduler::new(); + + let plugin = Arc::new(MockSyncPlugin { + name: "test-plugin".to_string(), + sync_config: SyncConfig { + schedule: Some("0 0 * * *".to_string()), + on_startup: false, + manual_trigger: true, + }, + }); + + // Schedule the plugin (daily at midnight) + scheduler.schedule(plugin, "0 0 * * *".to_string()); + + // Should have one scheduled job + assert_eq!(scheduler.jobs.len(), 1); + + // Clean up + scheduler.stop(); + } +} diff --git a/src/scanners/mod.rs b/src/scanners/mod.rs new file mode 100644 index 0000000..adc606d --- /dev/null +++ b/src/scanners/mod.rs @@ -0,0 +1,90 @@ +pub mod sarif; + +use anyhow::Result; +use std::collections::HashMap; +use std::sync::Arc; + +/// A normalized finding produced by any scanner parser +#[derive(Debug, Clone)] +pub struct NormalizedFinding { + pub title: String, + pub description: String, + pub severity: Severity, + pub scanner: String, + pub rule_id: String, + pub file_path: Option, + pub line_start: Option, + pub line_end: Option, + pub cwe: Option, + pub cve: Option, + pub cvss_score: Option, + pub package_name: Option, + pub package_version: Option, + pub fixed_version: Option, + pub details_url: Option, + pub tags: Vec, + /// Scanner-provided stable fingerprint (e.g., from SARIF partialFingerprints). + /// When present, takes priority over computed fingerprint. + pub scanner_fingerprint: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Severity { + Info, + Low, + Medium, + High, + Critical, +} + +/// Result of parsing a scan report +#[derive(Debug)] +pub struct ParsedReport { + pub scanner_name: String, + pub findings: Vec, +} + +/// Trait implemented by all scanner parsers (built-in and plugin-contributed) +pub trait ScannerParser: Send + Sync { + /// Unique format identifier (e.g., "sarif", "cyclonedx-vex", "trivy-json") + fn format_id(&self) -> &str; + + /// Human-readable description + fn description(&self) -> &str; + + /// Parse raw scan data into normalized findings + fn parse(&self, data: &[u8]) -> Result; +} + +/// Registry of all available scanner parsers +pub struct ParserRegistry { + parsers: HashMap>, +} + +impl ParserRegistry { + /// Create a new registry with built-in parsers pre-registered + pub fn with_builtins() -> Self { + let mut registry = Self { + parsers: HashMap::new(), + }; + + registry.register(Arc::new(sarif::SarifParser)); + + registry + } + + /// Register a parser (used by plugins via contributed_parsers()) + pub fn register(&mut self, parser: Arc) { + self.parsers.insert(parser.format_id().to_string(), parser); + } + + /// Get a parser by format ID + pub fn get(&self, format_id: &str) -> Option<&Arc> { + self.parsers.get(format_id) + } + + /// List all registered format IDs + pub fn formats(&self) -> Vec<&str> { + self.parsers.keys().map(|s| s.as_str()).collect() + } +} diff --git a/src/scanners/sarif.rs b/src/scanners/sarif.rs new file mode 100644 index 0000000..11ef8fb --- /dev/null +++ b/src/scanners/sarif.rs @@ -0,0 +1,323 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use super::{NormalizedFinding, ParsedReport, ScannerParser, Severity}; + +pub struct SarifParser; + +impl ScannerParser for SarifParser { + fn format_id(&self) -> &str { + "sarif" + } + + fn description(&self) -> &str { + "SARIF v2.1.0 (Static Analysis Results Interchange Format)" + } + + fn parse(&self, data: &[u8]) -> Result { + let report: SarifReport = + serde_json::from_slice(data).context("Failed to parse SARIF JSON")?; + + let mut findings = Vec::new(); + + for run in &report.runs { + let tool_name = &run.tool.driver.name; + let rules = &run.tool.driver.rules; + + for result in &run.results { + let rule_id = result.rule_id.clone().unwrap_or_default(); + + let rule_meta = rules + .as_ref() + .and_then(|r| r.iter().find(|rule| rule.id == rule_id)); + + let title = result + .message + .text + .clone() + .unwrap_or_else(|| rule_id.clone()); + + let description = rule_meta + .and_then(|r| r.short_description.as_ref()) + .and_then(|d| d.text.clone()) + .unwrap_or_default(); + + let severity = resolve_severity(result.level.as_deref(), rule_meta); + + let (file_path, line_start, line_end) = extract_location(result); + + let details_url = rule_meta.and_then(|r| r.help_uri.clone()); + + let tags = rule_meta + .and_then(|r| r.properties.as_ref()) + .and_then(|p| p.tags.clone()) + .unwrap_or_default(); + + let cwe = extract_cwe(&tags); + + let scanner_fingerprint = extract_scanner_fingerprint(result); + + findings.push(NormalizedFinding { + title, + description, + severity, + scanner: tool_name.clone(), + rule_id, + file_path, + line_start, + line_end, + cwe, + cve: None, + cvss_score: None, + package_name: None, + package_version: None, + fixed_version: None, + details_url, + tags, + scanner_fingerprint, + }); + } + } + + Ok(ParsedReport { + scanner_name: report + .runs + .first() + .map(|r| r.tool.driver.name.clone()) + .unwrap_or_else(|| "unknown".to_string()), + findings, + }) + } +} + +fn resolve_severity(level: Option<&str>, rule_meta: Option<&SarifRule>) -> Severity { + let level = level.or_else(|| { + rule_meta + .and_then(|r| r.default_configuration.as_ref()) + .and_then(|c| c.level.as_deref()) + }); + + match level { + Some("error") => Severity::High, + Some("warning") => Severity::Medium, + Some("note") => Severity::Low, + Some("none") => Severity::Info, + _ => Severity::Medium, + } +} + +fn extract_location(result: &SarifResult) -> (Option, Option, Option) { + let location = match result.locations.as_ref().and_then(|l| l.first()) { + Some(loc) => loc, + None => return (None, None, None), + }; + + let artifact = match &location.physical_location { + Some(pl) => pl, + None => return (None, None, None), + }; + + let file_path = artifact + .artifact_location + .as_ref() + .and_then(|al| al.uri.clone()); + + let line_start = artifact.region.as_ref().and_then(|r| r.start_line); + let line_end = artifact.region.as_ref().and_then(|r| r.end_line); + + (file_path, line_start, line_end) +} + +fn extract_cwe(tags: &[String]) -> Option { + tags.iter() + .find(|t| t.starts_with("CWE-") || t.starts_with("cwe-")) + .cloned() +} + +/// Extract a stable fingerprint from SARIF partialFingerprints or fingerprints. +/// Scanners like Semgrep, CodeQL, and Checkov provide content-based hashes here +/// that survive line shifts. +fn extract_scanner_fingerprint(result: &SarifResult) -> Option { + if let Some(ref fps) = result.partial_fingerprints { + // Prefer primaryLocationLineHash (most common), then any first value + if let Some(v) = fps.get("primaryLocationLineHash") { + return Some(v.clone()); + } + if let Some(v) = fps.values().next() { + return Some(v.clone()); + } + } + if let Some(ref fps) = result.fingerprints { + if let Some(v) = fps.values().next() { + return Some(v.clone()); + } + } + None +} + +// SARIF v2.1.0 schema (subset relevant to findings) +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifReport { + #[serde(default)] + runs: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifRun { + tool: SarifTool, + #[serde(default)] + results: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifTool { + driver: SarifDriver, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifDriver { + name: String, + #[serde(default)] + rules: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifRule { + id: String, + short_description: Option, + help_uri: Option, + default_configuration: Option, + properties: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifConfiguration { + level: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifRuleProperties { + #[serde(default)] + tags: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifResult { + rule_id: Option, + level: Option, + message: SarifMessage, + locations: Option>, + partial_fingerprints: Option>, + fingerprints: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifMessage { + text: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifLocation { + physical_location: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifPhysicalLocation { + artifact_location: Option, + region: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifArtifactLocation { + uri: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SarifRegion { + start_line: Option, + end_line: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal_sarif() { + let sarif = r#"{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "TestScanner", + "rules": [{ + "id": "TEST-001", + "shortDescription": { "text": "Test rule description" }, + "defaultConfiguration": { "level": "error" } + }] + } + }, + "results": [{ + "ruleId": "TEST-001", + "level": "error", + "message": { "text": "Found a vulnerability" }, + "locations": [{ + "physicalLocation": { + "artifactLocation": { "uri": "src/main.rs" }, + "region": { "startLine": 42, "endLine": 42 } + } + }] + }] + }] + }"#; + + let parser = SarifParser; + let report = parser.parse(sarif.as_bytes()).unwrap(); + + assert_eq!(report.scanner_name, "TestScanner"); + assert_eq!(report.findings.len(), 1); + + let finding = &report.findings[0]; + assert_eq!(finding.title, "Found a vulnerability"); + assert_eq!(finding.rule_id, "TEST-001"); + assert_eq!(finding.severity, Severity::High); + assert_eq!(finding.file_path.as_deref(), Some("src/main.rs")); + assert_eq!(finding.line_start, Some(42)); + } + + #[test] + fn test_parse_sarif_no_locations() { + let sarif = r#"{ + "version": "2.1.0", + "runs": [{ + "tool": { "driver": { "name": "Scanner" } }, + "results": [{ + "ruleId": "RULE-1", + "message": { "text": "Global issue" } + }] + }] + }"#; + + let parser = SarifParser; + let report = parser.parse(sarif.as_bytes()).unwrap(); + + assert_eq!(report.findings.len(), 1); + assert!(report.findings[0].file_path.is_none()); + } +} diff --git a/src/security/audit.rs b/src/security/audit.rs new file mode 100644 index 0000000..0d110eb --- /dev/null +++ b/src/security/audit.rs @@ -0,0 +1,97 @@ +use super::identity::ClientIdentity; +use tracing::{error, info, warn}; + +pub struct AuditLogger { + enabled: bool, +} + +impl AuditLogger { + pub fn new(enabled: bool) -> Self { + Self { enabled } + } + + /// Log an allowed request + pub fn log_allowed( + &self, + identity: &ClientIdentity, + role: &str, + method: &str, + duration: std::time::Duration, + ) { + if !self.enabled { + return; + } + + info!( + event = "access.allowed", + identity.cn = %identity.common_name, + identity.ou = ?identity.organizational_unit, + identity.o = ?identity.organization, + identity.serial = %identity.certificate_serial, + role = %role, + method = %method, + duration_ms = duration.as_millis(), + "Request allowed" + ); + } + + /// Log a denied request + pub fn log_denied( + &self, + identity: &ClientIdentity, + role: Option<&str>, + method: &str, + reason: &str, + duration: std::time::Duration, + ) { + if !self.enabled { + return; + } + + warn!( + event = "access.denied", + identity.cn = %identity.common_name, + identity.ou = ?identity.organizational_unit, + identity.o = ?identity.organization, + identity.serial = %identity.certificate_serial, + role = ?role, + method = %method, + reason = %reason, + duration_ms = duration.as_millis(), + "Request denied" + ); + } + + /// Log an authentication failure + pub fn log_auth_failure(&self, reason: &str) { + if !self.enabled { + return; + } + + error!( + event = "auth.failure", + reason = %reason, + "Authentication failed" + ); + } + + /// Log a certificate validation error + pub fn log_cert_error(&self, reason: &str, serial: Option<&str>) { + if !self.enabled { + return; + } + + error!( + event = "cert.error", + reason = %reason, + serial = ?serial, + "Certificate validation error" + ); + } +} + +impl Default for AuditLogger { + fn default() -> Self { + Self::new(true) + } +} diff --git a/src/security/config.rs b/src/security/config.rs new file mode 100644 index 0000000..c6f750a --- /dev/null +++ b/src/security/config.rs @@ -0,0 +1,175 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SecurityConfig { + #[serde(default)] + pub mtls: MtlsConfig, + + #[serde(default)] + pub rbac: RbacConfig, +} + +impl Default for SecurityConfig { + fn default() -> Self { + Self { + mtls: MtlsConfig::default(), + rbac: RbacConfig::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MtlsConfig { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub server_cert: String, + + #[serde(default)] + pub server_key: String, + + #[serde(default)] + pub client_ca_cert: String, + + #[serde(default = "default_require_client_cert")] + pub require_client_cert: bool, + + #[serde(default)] + pub crl_file: Option, +} + +fn default_require_client_cert() -> bool { + true +} + +impl Default for MtlsConfig { + fn default() -> Self { + Self { + enabled: false, + server_cert: String::new(), + server_key: String::new(), + client_ca_cert: String::new(), + require_client_cert: true, + crl_file: None, + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RbacConfig { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub role_mappings: Vec, + + #[serde(default)] + pub permissions: HashMap>, + + #[serde(default)] + pub audit: AuditConfig, +} + +impl Default for RbacConfig { + fn default() -> Self { + Self { + enabled: false, + role_mappings: vec![], + permissions: HashMap::new(), + audit: AuditConfig::default(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RoleMapping { + pub role: String, + pub rules: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RoleRule { + pub subject: SubjectMatch, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubjectMatch { + #[serde(default)] + pub cn: Option, + + #[serde(default)] + pub ou: Option, + + #[serde(default)] + pub o: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuditConfig { + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub log_all_requests: bool, + + #[serde(default)] + pub log_denied_requests: bool, +} + +impl Default for AuditConfig { + fn default() -> Self { + Self { + enabled: true, + log_all_requests: true, + log_denied_requests: true, + } + } +} + +impl SecurityConfig { + /// Load from environment variables + pub fn from_env() -> Self { + let mtls_enabled = std::env::var("SECURITY_MTLS_ENABLED") + .unwrap_or_else(|_| "false".to_string()) + .parse() + .unwrap_or(false); + + let rbac_enabled = std::env::var("SECURITY_RBAC_ENABLED") + .unwrap_or_else(|_| "false".to_string()) + .parse() + .unwrap_or(false); + + Self { + mtls: MtlsConfig { + enabled: mtls_enabled, + server_cert: std::env::var("SECURITY_MTLS_SERVER_CERT").unwrap_or_default(), + server_key: std::env::var("SECURITY_MTLS_SERVER_KEY").unwrap_or_default(), + client_ca_cert: std::env::var("SECURITY_MTLS_CLIENT_CA").unwrap_or_default(), + require_client_cert: true, + crl_file: std::env::var("SECURITY_MTLS_CRL_FILE").ok(), + }, + rbac: RbacConfig { + enabled: rbac_enabled, + role_mappings: vec![], + permissions: HashMap::new(), + audit: AuditConfig::default(), + }, + } + } + + /// Create a development configuration with security disabled + pub fn development() -> Self { + Self { + mtls: MtlsConfig { + enabled: false, + ..Default::default() + }, + rbac: RbacConfig { + enabled: false, + ..Default::default() + }, + } + } +} diff --git a/src/security/identity.rs b/src/security/identity.rs new file mode 100644 index 0000000..8800821 --- /dev/null +++ b/src/security/identity.rs @@ -0,0 +1,122 @@ +use thiserror::Error; +use tracing::debug; + +#[derive(Error, Debug)] +pub enum SecurityError { + #[error("Invalid certificate: {0}")] + InvalidCertificate(String), + + #[error("Missing common name in certificate")] + MissingCommonName, + + #[error("Certificate parsing error: {0}")] + ParseError(String), +} + +/// Client identity extracted from x509 certificate +#[derive(Debug, Clone)] +pub struct ClientIdentity { + pub common_name: String, + pub organization: Option, + pub organizational_unit: Option, + pub certificate_serial: String, +} + +impl ClientIdentity { + /// Extract identity from DER-encoded certificate bytes + pub fn from_der(cert_der: &[u8]) -> Result { + use x509_parser::prelude::*; + + let (_, cert) = X509Certificate::from_der(cert_der) + .map_err(|e| SecurityError::InvalidCertificate(e.to_string()))?; + + let subject = cert.subject(); + + // Extract Common Name (CN) + let cn = subject + .iter_common_name() + .next() + .and_then(|cn| cn.as_str().ok()) + .ok_or(SecurityError::MissingCommonName)? + .to_string(); + + // Extract Organization (O) + let o = subject + .iter_organization() + .next() + .and_then(|o| o.as_str().ok()) + .map(|s| s.to_string()); + + // Extract Organizational Unit (OU) + let ou = subject + .iter_organizational_unit() + .next() + .and_then(|ou| ou.as_str().ok()) + .map(|s| s.to_string()); + + // Extract serial number + let serial = format!("{:X}", cert.serial); + + debug!( + cn = %cn, + o = ?o, + ou = ?ou, + serial = %serial, + "Extracted client identity from certificate" + ); + + Ok(ClientIdentity { + common_name: cn, + organization: o, + organizational_unit: ou, + certificate_serial: serial, + }) + } + + /// Display identity for logging + pub fn to_string(&self) -> String { + let mut parts = vec![format!("CN={}", self.common_name)]; + + if let Some(ref ou) = self.organizational_unit { + parts.push(format!("OU={}", ou)); + } + + if let Some(ref o) = self.organization { + parts.push(format!("O={}", o)); + } + + parts.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_identity_to_string() { + let identity = ClientIdentity { + common_name: "ci-pipeline".to_string(), + organization: Some("Company".to_string()), + organizational_unit: Some("automation".to_string()), + certificate_serial: "123456".to_string(), + }; + + assert_eq!( + identity.to_string(), + "CN=ci-pipeline, OU=automation, O=Company" + ); + } + + #[test] + fn test_identity_to_string_minimal() { + let identity = ClientIdentity { + common_name: "test".to_string(), + organization: None, + organizational_unit: None, + certificate_serial: "123456".to_string(), + }; + + assert_eq!(identity.to_string(), "CN=test"); + } +} diff --git a/src/security/interceptor.rs b/src/security/interceptor.rs new file mode 100644 index 0000000..e10e3a9 --- /dev/null +++ b/src/security/interceptor.rs @@ -0,0 +1,244 @@ +use super::audit::AuditLogger; +use super::identity::ClientIdentity; +use super::rbac::RbacEngine; +use std::sync::Arc; +use std::time::Instant; +use tonic::{Request, Status}; +use tracing::{debug, error, instrument}; + +/// gRPC interceptor for authentication and authorization +#[derive(Clone)] +pub struct AuthInterceptor { + rbac_engine: Arc, + audit_logger: Arc, +} + +impl AuthInterceptor { + pub fn new(rbac_engine: Arc, audit_logger: Arc) -> Self { + Self { + rbac_engine, + audit_logger, + } + } + + /// Authorize a gRPC request based on client certificate and RBAC + /// This method should be called from gRPC handlers with the method name + #[instrument(skip(self, request), fields(method = %method, client.cn, role))] + pub fn authorize_request( + &self, + mut request: Request, + method: &str, + ) -> Result, Status> { + let start = Instant::now(); + + debug!(method = %method, "Authorizing gRPC request"); + + // Extract client certificate from TLS connection metadata + let cert_der = self.extract_client_certificate(&request)?; + + // Extract identity from certificate + let identity = ClientIdentity::from_der(&cert_der).map_err(|e| { + self.audit_logger.log_cert_error(&e.to_string(), None); + Status::unauthenticated(format!("Invalid certificate: {}", e)) + })?; + + tracing::Span::current().record("client.cn", &identity.common_name); + + debug!( + identity = %identity.to_string(), + "Extracted client identity from certificate" + ); + + // Map identity to role + let role = self + .rbac_engine + .map_identity_to_role(&identity) + .ok_or_else(|| { + let duration = start.elapsed(); + self.audit_logger.log_denied( + &identity, + None, + method, + "No role assigned to identity", + duration, + ); + Status::permission_denied(format!( + "No role assigned to identity: {}", + identity.to_string() + )) + })?; + + tracing::Span::current().record("role", &role); + + // Determine required permission from request method + let permission = Self::method_to_permission(method); + + debug!( + role = %role, + permission = %permission, + "Checking permission" + ); + + // Check if role has required permission + if !self.rbac_engine.check_permission(&role, permission) { + let duration = start.elapsed(); + self.audit_logger.log_denied( + &identity, + Some(&role), + method, + &format!("Role '{}' lacks permission '{}'", role, permission), + duration, + ); + + return Err(Status::permission_denied(format!( + "Role '{}' does not have permission '{}' for method {}", + role, permission, method + ))); + } + + // Log successful authorization + let duration = start.elapsed(); + self.audit_logger + .log_allowed(&identity, &role, method, duration); + + // Inject identity and role into request metadata for handlers + let metadata = request.metadata_mut(); + + metadata.insert( + "x-client-cn", + identity + .common_name + .parse() + .map_err(|_| Status::internal("Failed to parse CN"))?, + ); + + metadata.insert( + "x-client-role", + role.parse() + .map_err(|_| Status::internal("Failed to parse role"))?, + ); + + if let Some(ref ou) = identity.organizational_unit { + metadata.insert( + "x-client-ou", + ou.parse() + .map_err(|_| Status::internal("Failed to parse OU"))?, + ); + } + + if let Some(ref o) = identity.organization { + metadata.insert( + "x-client-o", + o.parse() + .map_err(|_| Status::internal("Failed to parse O"))?, + ); + } + + debug!( + identity = %identity.to_string(), + role = %role, + "Request authorized" + ); + + Ok(request) + } + + /// Extract client certificate from request extensions (mTLS) or metadata (reverse proxy) + fn extract_client_certificate(&self, request: &Request) -> Result, Status> { + use tonic::transport::server::{TcpConnectInfo, TlsConnectInfo}; + + // Try 1: Extract from Tonic's TlsConnectInfo (direct mTLS) + // This requires the tls-connect-info feature enabled in Tonic + // Note: Must use TlsConnectInfo, not TlsConnectInfo + if let Some(connect_info) = request.extensions().get::>() { + if let Some(certs) = connect_info.peer_certs() { + if let Some(cert) = certs.first() { + debug!("Extracted certificate from TlsConnectInfo (direct mTLS)"); + return Ok(cert.as_ref().to_vec()); + } + } + } + + // Try 2: Extract from headers (reverse proxy injected certificate) + let cert_header = request + .metadata() + .get("x-forwarded-client-cert") + .or_else(|| request.metadata().get("x-client-cert")); + + if let Some(cert_value) = cert_header { + debug!("Extracting certificate from header (reverse proxy mode)"); + + let cert_str = cert_value + .to_str() + .map_err(|_| Status::unauthenticated("Invalid certificate header"))?; + + // Decode base64 + use base64::Engine; + let cert_der = base64::engine::general_purpose::STANDARD + .decode(cert_str) + .map_err(|e| { + error!(error = %e, "Failed to decode certificate from header"); + Status::unauthenticated("Invalid certificate encoding") + })?; + + return Ok(cert_der); + } + + // No certificate found in either location + error!("No client certificate found in request (neither TlsConnectInfo nor headers)"); + Err(Status::unauthenticated( + "Client certificate required but not provided. Ensure mTLS is configured.", + )) + } + + /// Map gRPC method to required permission + fn method_to_permission(method: &str) -> &str { + match method { + "/charybdis.entities.EntityService/CreateEntity" => "entity:create", + "/charybdis.entities.EntityService/GetEntity" => "entity:read", + "/charybdis.entities.EntityService/UpdateEntity" => "entity:update", + "/charybdis.entities.EntityService/DeleteEntity" => "entity:delete", + "/charybdis.entities.EntityService/ListEntities" => "entity:list", + "/charybdis.entities.EntityService/PartialUpdateEntity" => "entity:update", + _ => "unknown", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_method_to_permission_mapping() { + assert_eq!( + AuthInterceptor::method_to_permission("/charybdis.entities.EntityService/CreateEntity"), + "entity:create" + ); + + assert_eq!( + AuthInterceptor::method_to_permission("/charybdis.entities.EntityService/GetEntity"), + "entity:read" + ); + + assert_eq!( + AuthInterceptor::method_to_permission("/charybdis.entities.EntityService/UpdateEntity"), + "entity:update" + ); + + assert_eq!( + AuthInterceptor::method_to_permission("/charybdis.entities.EntityService/DeleteEntity"), + "entity:delete" + ); + + assert_eq!( + AuthInterceptor::method_to_permission("/charybdis.entities.EntityService/ListEntities"), + "entity:list" + ); + + assert_eq!( + AuthInterceptor::method_to_permission("/unknown/method"), + "unknown" + ); + } +} diff --git a/src/security/middleware.rs b/src/security/middleware.rs new file mode 100644 index 0000000..79d4845 --- /dev/null +++ b/src/security/middleware.rs @@ -0,0 +1,72 @@ +//! Middleware layer for extracting client certificates from TLS connections +//! +//! This middleware extracts the peer certificate from the TLS connection +//! and injects it into the request metadata so the AuthInterceptor can access it. + +use http::{Request, Response}; +use std::task::{Context, Poll}; +use tonic::body::BoxBody; +use tower::{Layer, Service}; +use tracing::{debug, warn}; + +/// Layer that extracts client certificates from TLS connections +#[derive(Clone)] +pub struct CertificateExtractorLayer; + +impl CertificateExtractorLayer { + pub fn new() -> Self { + Self + } +} + +impl Layer for CertificateExtractorLayer { + type Service = CertificateExtractorService; + + fn layer(&self, inner: S) -> Self::Service { + CertificateExtractorService { inner } + } +} + +/// Service that extracts client certificates and injects them into request metadata +#[derive(Clone)] +pub struct CertificateExtractorService { + inner: S, +} + +impl Service> for CertificateExtractorService +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = S::Future; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + // Try to extract peer certificate from connection info + // Note: In Tonic 0.11, peer certificates are not directly accessible + // This is a limitation of the current Tonic API + + // Check if we're using TLS + if let Some(uri) = req.uri().scheme() { + if uri == &http::uri::Scheme::HTTPS { + debug!("TLS connection detected"); + + // TODO: When Tonic adds peer certificate access, extract it here + // For now, we log that we detected a TLS connection + // The certificate validation still happens during the TLS handshake + + warn!( + "mTLS is active but peer certificate not accessible in Tonic 0.11. \ + Client is authenticated at TLS layer but RBAC cannot access certificate details." + ); + } + } + + self.inner.call(req) + } +} diff --git a/src/security/mod.rs b/src/security/mod.rs new file mode 100644 index 0000000..16eb4df --- /dev/null +++ b/src/security/mod.rs @@ -0,0 +1,13 @@ +pub mod audit; +pub mod config; +pub mod identity; +pub mod interceptor; +pub mod rbac; +pub mod tls; + +pub use audit::AuditLogger; +pub use config::{MtlsConfig, RbacConfig, SecurityConfig}; +pub use identity::{ClientIdentity, SecurityError}; +pub use interceptor::AuthInterceptor; +pub use rbac::RbacEngine; +pub use tls::{load_tls_files, setup_rustls_mtls, validate_cert_files}; diff --git a/src/security/rbac.rs b/src/security/rbac.rs new file mode 100644 index 0000000..3aaa102 --- /dev/null +++ b/src/security/rbac.rs @@ -0,0 +1,290 @@ +use super::config::{RbacConfig, RoleRule}; +use super::identity::ClientIdentity; +use tracing::{debug, warn}; +use wildmatch::WildMatch; + +pub struct RbacEngine { + config: RbacConfig, +} + +impl RbacEngine { + pub fn new(config: RbacConfig) -> Self { + Self { config } + } + + /// Map a client identity to a role + pub fn map_identity_to_role(&self, identity: &ClientIdentity) -> Option { + for mapping in &self.config.role_mappings { + if self.matches_any_rule(identity, &mapping.rules) { + debug!( + identity = %identity.to_string(), + role = %mapping.role, + "Mapped identity to role" + ); + return Some(mapping.role.clone()); + } + } + + warn!( + identity = %identity.to_string(), + "No role mapping found for identity" + ); + None + } + + /// Check if identity matches any of the rules + fn matches_any_rule(&self, identity: &ClientIdentity, rules: &[RoleRule]) -> bool { + rules.iter().any(|rule| self.matches_rule(identity, rule)) + } + + /// Check if identity matches a specific rule + fn matches_rule(&self, identity: &ClientIdentity, rule: &RoleRule) -> bool { + let mut matches = true; + + // Check Common Name (CN) with wildcard support + if let Some(ref cn_pattern) = rule.subject.cn { + matches &= Self::matches_pattern(&identity.common_name, cn_pattern); + if !matches { + debug!( + cn = %identity.common_name, + pattern = %cn_pattern, + "CN does not match pattern" + ); + return false; + } + } + + // Check Organization (O) with wildcard support + if let Some(ref o_pattern) = rule.subject.o { + matches &= identity + .organization + .as_ref() + .map(|o| Self::matches_pattern(o, o_pattern)) + .unwrap_or(false); + if !matches { + debug!( + o = ?identity.organization, + pattern = %o_pattern, + "Organization does not match pattern" + ); + return false; + } + } + + // Check Organizational Unit (OU) with wildcard support + if let Some(ref ou_pattern) = rule.subject.ou { + matches &= identity + .organizational_unit + .as_ref() + .map(|ou| Self::matches_pattern(ou, ou_pattern)) + .unwrap_or(false); + if !matches { + debug!( + ou = ?identity.organizational_unit, + pattern = %ou_pattern, + "Organizational unit does not match pattern" + ); + return false; + } + } + + matches + } + + /// Match a value against a pattern with wildcard support + fn matches_pattern(value: &str, pattern: &str) -> bool { + WildMatch::new(pattern).matches(value) + } + + /// Check if a role has a specific permission + pub fn check_permission(&self, role: &str, permission: &str) -> bool { + if let Some(permissions) = self.config.permissions.get(role) { + let has_permission = permissions.iter().any(|p| { + // Exact match + if p == permission { + return true; + } + + // Wildcard match (e.g., "entity:*" matches "entity:create") + if p.ends_with(":*") { + let prefix = &p[..p.len() - 2]; // Remove ":*" + if permission.starts_with(prefix) { + return true; + } + } + + // Wildcard match (e.g., "*" matches everything) + if p == "*" { + return true; + } + + false + }); + + if has_permission { + debug!( + role = %role, + permission = %permission, + "Permission check passed" + ); + } else { + debug!( + role = %role, + permission = %permission, + available_permissions = ?permissions, + "Permission check failed" + ); + } + + has_permission + } else { + warn!( + role = %role, + permission = %permission, + "Role not found in permissions configuration" + ); + false + } + } + + /// Get all permissions for a role + pub fn get_role_permissions(&self, role: &str) -> Option<&Vec> { + self.config.permissions.get(role) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::config::{RoleMapping, SubjectMatch}; + use std::collections::HashMap; + + fn create_test_identity() -> ClientIdentity { + ClientIdentity { + common_name: "ci-pipeline".to_string(), + organization: Some("Company".to_string()), + organizational_unit: Some("automation".to_string()), + certificate_serial: "123456".to_string(), + } + } + + fn create_test_config() -> RbacConfig { + let mut permissions = HashMap::new(); + permissions.insert("admin".to_string(), vec!["entity:*".to_string()]); + permissions.insert( + "service-writer".to_string(), + vec![ + "entity:create".to_string(), + "entity:read".to_string(), + "entity:update".to_string(), + "entity:list".to_string(), + ], + ); + permissions.insert( + "catalog-reader".to_string(), + vec!["entity:read".to_string(), "entity:list".to_string()], + ); + + RbacConfig { + enabled: true, + role_mappings: vec![ + RoleMapping { + role: "service-writer".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: None, + ou: Some("automation".to_string()), + o: Some("Company".to_string()), + }, + }], + }, + RoleMapping { + role: "admin".to_string(), + rules: vec![RoleRule { + subject: SubjectMatch { + cn: Some("admin-*".to_string()), + ou: None, + o: Some("Company".to_string()), + }, + }], + }, + ], + permissions, + audit: Default::default(), + } + } + + #[test] + fn test_role_mapping_by_ou() { + let engine = RbacEngine::new(create_test_config()); + let identity = create_test_identity(); + + let role = engine.map_identity_to_role(&identity); + assert_eq!(role, Some("service-writer".to_string())); + } + + #[test] + fn test_role_mapping_by_cn_wildcard() { + let engine = RbacEngine::new(create_test_config()); + let identity = ClientIdentity { + common_name: "admin-user".to_string(), + organization: Some("Company".to_string()), + organizational_unit: None, + certificate_serial: "123456".to_string(), + }; + + let role = engine.map_identity_to_role(&identity); + assert_eq!(role, Some("admin".to_string())); + } + + #[test] + fn test_permission_check_exact() { + let engine = RbacEngine::new(create_test_config()); + + assert!(engine.check_permission("service-writer", "entity:create")); + assert!(engine.check_permission("service-writer", "entity:read")); + assert!(!engine.check_permission("service-writer", "entity:delete")); + } + + #[test] + fn test_permission_check_wildcard() { + let engine = RbacEngine::new(create_test_config()); + + assert!(engine.check_permission("admin", "entity:create")); + assert!(engine.check_permission("admin", "entity:delete")); + assert!(engine.check_permission("admin", "entity:anything")); + } + + #[test] + fn test_catalog_reader_permissions() { + let engine = RbacEngine::new(create_test_config()); + + assert!(engine.check_permission("catalog-reader", "entity:read")); + assert!(engine.check_permission("catalog-reader", "entity:list")); + assert!(!engine.check_permission("catalog-reader", "entity:create")); + assert!(!engine.check_permission("catalog-reader", "entity:update")); + assert!(!engine.check_permission("catalog-reader", "entity:delete")); + } + + #[test] + fn test_no_role_mapping() { + let engine = RbacEngine::new(create_test_config()); + let identity = ClientIdentity { + common_name: "unknown".to_string(), + organization: Some("Other".to_string()), + organizational_unit: None, + certificate_serial: "123456".to_string(), + }; + + let role = engine.map_identity_to_role(&identity); + assert_eq!(role, None); + } + + #[test] + fn test_wildcard_pattern_matching() { + assert!(RbacEngine::matches_pattern("ci-pipeline", "ci-*")); + assert!(RbacEngine::matches_pattern("admin-user", "admin-*")); + assert!(RbacEngine::matches_pattern("defectdojo-plugin", "*-plugin")); + assert!(!RbacEngine::matches_pattern("other-service", "ci-*")); + } +} diff --git a/src/security/tls.rs b/src/security/tls.rs new file mode 100644 index 0000000..b4468b1 --- /dev/null +++ b/src/security/tls.rs @@ -0,0 +1,157 @@ +use super::config::MtlsConfig; +use anyhow::{Context, Result}; +use rustls::ServerConfig; +use rustls::pki_types::CertificateDer; +use rustls_pemfile::{certs, private_key}; +use std::fs::File; +use std::io::BufReader; +use std::sync::Arc; +use tracing::{debug, info}; + +/// Setup mTLS with rustls ServerConfig +/// This creates a complete TLS configuration for the gRPC server with client certificate verification +pub fn setup_rustls_mtls(config: &MtlsConfig) -> Result> { + info!("Setting up rustls mTLS configuration"); + + // Load server certificates + let cert_file = File::open(&config.server_cert) + .with_context(|| format!("Failed to open server certificate: {}", config.server_cert))?; + let mut cert_reader = BufReader::new(cert_file); + + let server_certs: Vec = certs(&mut cert_reader) + .collect::, _>>() + .with_context(|| "Failed to parse server certificates")?; + + debug!(count = server_certs.len(), "Loaded server certificates"); + + // Load server private key + let key_file = File::open(&config.server_key) + .with_context(|| format!("Failed to open server private key: {}", config.server_key))?; + let mut key_reader = BufReader::new(key_file); + + let server_key = private_key(&mut key_reader) + .with_context(|| "Failed to parse server private key")? + .ok_or_else(|| anyhow::anyhow!("No private key found in file"))?; + + debug!("Loaded server private key"); + + // Load client CA certificates + let ca_file = File::open(&config.client_ca_cert).with_context(|| { + format!( + "Failed to open client CA certificate: {}", + config.client_ca_cert + ) + })?; + let mut ca_reader = BufReader::new(ca_file); + + let ca_certs: Vec = certs(&mut ca_reader) + .collect::, _>>() + .with_context(|| "Failed to parse CA certificates")?; + + debug!(count = ca_certs.len(), "Loaded CA certificates"); + + // Create certificate verifier + let mut root_store = rustls::RootCertStore::empty(); + for cert in ca_certs { + root_store + .add(cert) + .with_context(|| "Failed to add CA certificate to root store")?; + } + + // Build client certificate verifier + let client_cert_verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store)) + .build() + .with_context(|| "Failed to build client certificate verifier")?; + + // Build server config with client certificate verification + let server_config = ServerConfig::builder() + .with_client_cert_verifier(client_cert_verifier) + .with_single_cert(server_certs, server_key) + .with_context(|| "Failed to build server TLS configuration")?; + + info!("rustls mTLS configuration completed successfully"); + + Ok(Arc::new(server_config)) +} + +/// Load certificate and key files as raw bytes for Tonic integration +/// Returns (server_cert, server_key, ca_cert) as PEM-encoded bytes +pub fn load_tls_files(config: &MtlsConfig) -> Result<(Vec, Vec, Vec)> { + debug!("Loading TLS files for Tonic"); + + let server_cert = std::fs::read(&config.server_cert) + .with_context(|| format!("Failed to read server certificate: {}", config.server_cert))?; + + let server_key = std::fs::read(&config.server_key) + .with_context(|| format!("Failed to read server private key: {}", config.server_key))?; + + let ca_cert = std::fs::read(&config.client_ca_cert).with_context(|| { + format!( + "Failed to read client CA certificate: {}", + config.client_ca_cert + ) + })?; + + debug!("Loaded all TLS files successfully"); + + Ok((server_cert, server_key, ca_cert)) +} + +/// Load a PEM certificate file +pub fn load_cert(path: &str) -> Result> { + std::fs::read(path).with_context(|| format!("Failed to read certificate from {}", path)) +} + +/// Load a PEM private key file +pub fn load_key(path: &str) -> Result> { + std::fs::read(path).with_context(|| format!("Failed to read private key from {}", path)) +} + +/// Validate that certificate files exist and are readable +pub fn validate_cert_files(config: &MtlsConfig) -> Result<()> { + debug!("Validating certificate files"); + + // Check server certificate + if !std::path::Path::new(&config.server_cert).exists() { + anyhow::bail!("Server certificate not found: {}", config.server_cert); + } + + // Check server key + if !std::path::Path::new(&config.server_key).exists() { + anyhow::bail!("Server private key not found: {}", config.server_key); + } + + // Check client CA certificate + if !std::path::Path::new(&config.client_ca_cert).exists() { + anyhow::bail!("Client CA certificate not found: {}", config.client_ca_cert); + } + + // Check CRL if specified + if let Some(ref crl_file) = config.crl_file { + if !std::path::Path::new(crl_file).exists() { + anyhow::bail!("CRL file not found: {}", crl_file); + } + } + + debug!("All certificate files validated successfully"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_cert_files_missing() { + let config = MtlsConfig { + enabled: true, + server_cert: "/nonexistent/cert.pem".to_string(), + server_key: "/nonexistent/key.pem".to_string(), + client_ca_cert: "/nonexistent/ca.pem".to_string(), + require_client_cert: true, + crl_file: None, + }; + + assert!(validate_cert_files(&config).is_err()); + } +} diff --git a/src/telemetry/config.rs b/src/telemetry/config.rs new file mode 100644 index 0000000..0c125a3 --- /dev/null +++ b/src/telemetry/config.rs @@ -0,0 +1,131 @@ +use serde::{Deserialize, Serialize}; +use std::env; + +/// Configuration for OpenTelemetry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelemetryConfig { + /// Service name for telemetry + #[serde(default = "default_service_name")] + pub service_name: String, + + /// Service version + #[serde(default = "default_service_version")] + pub service_version: String, + + /// OTLP endpoint (e.g., "http://localhost:4317") + #[serde(default)] + pub otlp_endpoint: Option, + + /// Enable console output for development + #[serde(default = "default_enable_console")] + pub enable_console: bool, + + /// Enable OTLP exporter + #[serde(default)] + pub enable_otlp: bool, + + /// Trace sampling ratio (0.0 to 1.0) + #[serde(default = "default_trace_sample_ratio")] + pub trace_sample_ratio: f64, + + /// Environment (production, staging, development) + #[serde(default = "default_environment")] + pub environment: String, +} + +fn default_service_name() -> String { + "charybdis".to_string() +} + +fn default_service_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +fn default_enable_console() -> bool { + true +} + +fn default_trace_sample_ratio() -> f64 { + 1.0 +} + +fn default_environment() -> String { + "development".to_string() +} + +impl Default for TelemetryConfig { + fn default() -> Self { + Self { + service_name: "charybdis".to_string(), + service_version: env!("CARGO_PKG_VERSION").to_string(), + otlp_endpoint: None, + enable_console: true, + enable_otlp: false, + trace_sample_ratio: 1.0, + environment: "development".to_string(), + } + } +} + +impl TelemetryConfig { + /// Create configuration from environment variables + pub fn from_env() -> Self { + let service_name = + env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| "charybdis".to_string()); + + let service_version = env::var("OTEL_SERVICE_VERSION") + .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string()); + + let otlp_endpoint = env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(); + + let enable_console = env::var("OTEL_ENABLE_CONSOLE") + .map(|v| v.parse().unwrap_or(true)) + .unwrap_or(true); + + let enable_otlp = otlp_endpoint.is_some() + || env::var("OTEL_ENABLE_OTLP") + .map(|v| v.parse().unwrap_or(false)) + .unwrap_or(false); + + let trace_sample_ratio = env::var("OTEL_TRACES_SAMPLER_ARG") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.0); + + let environment = env::var("ENVIRONMENT") + .or_else(|_| env::var("DEPLOYMENT_ENV")) + .unwrap_or_else(|_| "development".to_string()); + + Self { + service_name, + service_version, + otlp_endpoint, + enable_console, + enable_otlp, + trace_sample_ratio, + environment, + } + } + + /// Create a development configuration + pub fn development() -> Self { + Self { + enable_console: true, + enable_otlp: false, + ..Default::default() + } + } + + /// Create a production configuration + pub fn production(otlp_endpoint: String) -> Self { + Self { + service_name: "charybdis".to_string(), + service_version: env!("CARGO_PKG_VERSION").to_string(), + otlp_endpoint: Some(otlp_endpoint), + enable_console: false, + enable_otlp: true, + trace_sample_ratio: 0.1, // Sample 10% in production + environment: "production".to_string(), + } + } +} diff --git a/src/telemetry/metrics.rs b/src/telemetry/metrics.rs new file mode 100644 index 0000000..02c9a37 --- /dev/null +++ b/src/telemetry/metrics.rs @@ -0,0 +1,136 @@ +use opentelemetry::{ + KeyValue, + metrics::{Counter, Histogram, MeterProvider}, +}; +use opentelemetry_sdk::metrics::SdkMeterProvider; + +/// Application metrics for Charybdis +#[derive(Clone)] +pub struct Metrics { + // Entity operation metrics + pub entity_operations_total: Counter, + pub entity_operation_duration: Histogram, + + // Event metrics + pub events_published_total: Counter, + pub event_handler_duration: Histogram, + pub event_handler_errors_total: Counter, + + // Database metrics + pub db_query_duration: Histogram, + pub db_query_errors_total: Counter, +} + +impl Metrics { + /// Create a new Metrics instance from a MeterProvider + pub fn new(provider: &SdkMeterProvider) -> Self { + let meter = provider.meter("charybdis"); + + // In OpenTelemetry 0.31+, .init() is replaced with .build() and Unit is a string + let entity_operations_total = meter + .u64_counter("charybdis.entity.operations.total") + .with_description("Total number of entity operations") + .with_unit("{operation}") + .build(); + + let entity_operation_duration = meter + .f64_histogram("charybdis.entity.operation.duration") + .with_description("Duration of entity operations in seconds") + .with_unit("s") + .build(); + + let events_published_total = meter + .u64_counter("charybdis.events.published.total") + .with_description("Total number of events published") + .with_unit("{event}") + .build(); + + let event_handler_duration = meter + .f64_histogram("charybdis.event.handler.duration") + .with_description("Duration of event handler execution in seconds") + .with_unit("s") + .build(); + + let event_handler_errors_total = meter + .u64_counter("charybdis.event.handler.errors.total") + .with_description("Total number of event handler errors") + .with_unit("{error}") + .build(); + + let db_query_duration = meter + .f64_histogram("charybdis.db.query.duration") + .with_description("Duration of database queries in seconds") + .with_unit("s") + .build(); + + let db_query_errors_total = meter + .u64_counter("charybdis.db.query.errors.total") + .with_description("Total number of database query errors") + .with_unit("{error}") + .build(); + + Self { + entity_operations_total, + entity_operation_duration, + events_published_total, + event_handler_duration, + event_handler_errors_total, + db_query_duration, + db_query_errors_total, + } + } + + /// Record an entity operation + pub fn record_entity_operation(&self, operation: &str, kind: &str, duration_secs: f64) { + let attributes = &[ + KeyValue::new("operation", operation.to_string()), + KeyValue::new("entity.kind", kind.to_string()), + ]; + + self.entity_operations_total.add(1, attributes); + self.entity_operation_duration + .record(duration_secs, attributes); + } + + /// Record an event publication + pub fn record_event_published(&self, event_type: &str) { + let attributes = &[KeyValue::new("event.type", event_type.to_string())]; + self.events_published_total.add(1, attributes); + } + + /// Record event handler execution + pub fn record_event_handler( + &self, + handler: &str, + event_type: &str, + duration_secs: f64, + success: bool, + ) { + let attributes = &[ + KeyValue::new("handler", handler.to_string()), + KeyValue::new("event.type", event_type.to_string()), + KeyValue::new("success", success), + ]; + + self.event_handler_duration + .record(duration_secs, attributes); + + if !success { + self.event_handler_errors_total.add(1, attributes); + } + } + + /// Record a database query + pub fn record_db_query(&self, operation: &str, duration_secs: f64, success: bool) { + let attributes = &[ + KeyValue::new("operation", operation.to_string()), + KeyValue::new("success", success), + ]; + + self.db_query_duration.record(duration_secs, attributes); + + if !success { + self.db_query_errors_total.add(1, attributes); + } + } +} diff --git a/src/telemetry/mod.rs b/src/telemetry/mod.rs new file mode 100644 index 0000000..d85ee67 --- /dev/null +++ b/src/telemetry/mod.rs @@ -0,0 +1,7 @@ +pub mod config; +pub mod metrics; +pub mod setup; + +pub use config::TelemetryConfig; +pub use metrics::Metrics; +pub use setup::{init_telemetry, shutdown_telemetry}; diff --git a/src/telemetry/setup.rs b/src/telemetry/setup.rs new file mode 100644 index 0000000..0355587 --- /dev/null +++ b/src/telemetry/setup.rs @@ -0,0 +1,184 @@ +use super::{Metrics, TelemetryConfig}; +use opentelemetry::{KeyValue, trace::TracerProvider as _}; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::{ + Resource, + metrics::{PeriodicReader, SdkMeterProvider}, + trace::{RandomIdGenerator, Sampler, SdkTracerProvider}, +}; +use std::time::Duration; +use tracing::info; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +/// Initialize OpenTelemetry with traces, metrics, and logs +pub fn init_telemetry(config: TelemetryConfig) -> Result> { + info!("Initializing OpenTelemetry"); + info!( + " Service: {} v{}", + config.service_name, config.service_version + ); + info!(" Environment: {}", config.environment); + info!(" OTLP enabled: {}", config.enable_otlp); + info!(" Console enabled: {}", config.enable_console); + + // Create resource with service information + // In OpenTelemetry 0.31+, use Resource::builder() or from_detectors() + let detectors: Vec> = vec![ + Box::new(opentelemetry_sdk::resource::EnvResourceDetector::new()), + Box::new(opentelemetry_sdk::resource::SdkProvidedResourceDetector), + ]; + + let resource = Resource::builder() + .with_detectors(&detectors) + .with_service_name(config.service_name.clone()) + .with_attributes(vec![ + KeyValue::new( + opentelemetry_semantic_conventions::resource::SERVICE_VERSION, + config.service_version.clone(), + ), + KeyValue::new( + "deployment.environment", // DEPLOYMENT_ENVIRONMENT constant may be renamed in 0.31 + config.environment.clone(), + ), + ]) + .build(); + + // Initialize tracing + let tracer = init_tracer(&config, resource.clone())?; + + // Initialize metrics + let meter_provider = init_metrics(&config, resource.clone())?; + let metrics = Metrics::new(&meter_provider); + + // Initialize tracing subscriber + init_tracing_subscriber(&config, tracer)?; + + info!("OpenTelemetry initialization complete"); + Ok(metrics) +} + +/// Initialize the tracer with OTLP exporter +fn init_tracer( + config: &TelemetryConfig, + resource: Resource, +) -> Result> { + // Configure sampling + let sampler = match config.trace_sample_ratio { + ratio if ratio >= 1.0 => Sampler::AlwaysOn, + ratio if ratio <= 0.0 => Sampler::AlwaysOff, + ratio => Sampler::TraceIdRatioBased(ratio), + }; + + // In OpenTelemetry 0.31+, TracerProviderBuilder has individual setter methods + let mut builder = SdkTracerProvider::builder() + .with_sampler(sampler) + .with_id_generator(RandomIdGenerator::default()) + .with_resource(resource); + + // Add OTLP exporter if enabled + if config.enable_otlp { + if let Some(endpoint) = &config.otlp_endpoint { + info!("Configuring OTLP trace exporter: {}", endpoint); + + // In OpenTelemetry 0.31+, use SpanExporter::builder() + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(endpoint.clone()) + .with_timeout(Duration::from_secs(10)) + .build()?; + + // In OpenTelemetry 0.31+, runtime parameter is no longer needed + builder = builder.with_batch_exporter(exporter); + } + } + + let provider = builder.build(); + + // Set as global provider + opentelemetry::global::set_tracer_provider(provider.clone()); + + Ok(provider) +} + +/// Initialize the metrics provider with OTLP exporter +fn init_metrics( + config: &TelemetryConfig, + resource: Resource, +) -> Result> { + let mut meter_provider_builder = SdkMeterProvider::builder().with_resource(resource); + + // Add OTLP exporter if enabled + if config.enable_otlp { + if let Some(endpoint) = &config.otlp_endpoint { + info!("Configuring OTLP metrics exporter: {}", endpoint); + + // In OpenTelemetry 0.31+, use MetricExporter::builder() (singular, not plural) + let exporter = opentelemetry_otlp::MetricExporter::builder() + .with_tonic() + .with_endpoint(endpoint.clone()) + .with_timeout(Duration::from_secs(10)) + .build()?; + + // PeriodicReader::builder() now takes only exporter, runtime is handled internally + let reader = PeriodicReader::builder(exporter) + .with_interval(Duration::from_secs(30)) + .build(); + + meter_provider_builder = meter_provider_builder.with_reader(reader); + } + } + + let meter_provider = meter_provider_builder.build(); + + // Set as global provider + opentelemetry::global::set_meter_provider(meter_provider.clone()); + + Ok(meter_provider) +} + +/// Initialize tracing subscriber with OpenTelemetry layer +fn init_tracing_subscriber( + config: &TelemetryConfig, + tracer_provider: SdkTracerProvider, +) -> Result<(), Box> { + // Create OpenTelemetry tracing layer + let tracer = tracer_provider.tracer("charybdis"); + let telemetry_layer = tracing_opentelemetry::layer().with_tracer(tracer); + + // Create env filter + let env_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,charybdis=debug")); + + // Build subscriber with layers + let subscriber = tracing_subscriber::registry() + .with(env_filter) + .with(telemetry_layer); + + // Add console layer for development + if config.enable_console { + let fmt_layer = tracing_subscriber::fmt::layer() + .with_target(true) + .with_thread_ids(true) + .with_level(true) + .with_file(true) + .with_line_number(true); + + subscriber.with(fmt_layer).init(); + } else { + subscriber.init(); + } + + Ok(()) +} + +/// Shutdown OpenTelemetry and flush remaining telemetry +pub async fn shutdown_telemetry() { + info!("Shutting down OpenTelemetry"); + + // In OpenTelemetry 0.31+, shutdown is handled per-provider + // The global providers will be dropped when the application exits + // Just give time for final exports to complete + tokio::time::sleep(Duration::from_secs(2)).await; + + info!("OpenTelemetry shutdown complete"); +} diff --git a/tests/entity_crud.rs b/tests/entity_crud.rs new file mode 100644 index 0000000..1cfda41 --- /dev/null +++ b/tests/entity_crud.rs @@ -0,0 +1,203 @@ +mod harness; + +use charybdis::charybdis::core::{ComponentMetadata, ComponentSpec}; +use charybdis::charybdis::entities::Entity; +use charybdis::charybdis::entities::entity::{Metadata, Spec}; +use harness::TestDb; + +fn test_component(name: &str) -> Entity { + Entity { + kind: "Component".to_string(), + metadata: Some(Metadata::ComponentMetadata(ComponentMetadata { + name: name.to_string(), + namespace: "default".to_string(), + description: format!("{} service", name), + ..Default::default() + })), + spec: Some(Spec::ComponentSpec(ComponentSpec { + r#type: "service".to_string(), + lifecycle: "production".to_string(), + owner: "team-platform".to_string(), + ..Default::default() + })), + ..Default::default() + } +} + +#[tokio::test] +async fn create_and_get_entity() { + let db = TestDb::new().await; + + let created = db.repository.create(&test_component("payment-api")).await.unwrap(); + assert!(!created.id.is_empty()); + assert_eq!(created.kind, "Component"); + assert!(created.created_at.is_some()); + assert!(created.updated_at.is_some()); + + let fetched = db.repository.get_by_id(&created.id).await.unwrap().unwrap(); + assert_eq!(fetched.id, created.id); + assert_eq!(fetched.kind, "Component"); +} + +#[tokio::test] +async fn get_nonexistent_returns_none() { + let db = TestDb::new().await; + + let result = db + .repository + .get_by_id("00000000-0000-0000-0000-000000000000") + .await + .unwrap(); + assert!(result.is_none()); +} + +#[tokio::test] +async fn update_entity() { + let db = TestDb::new().await; + + let created = db.repository.create(&test_component("auth-svc")).await.unwrap(); + + let mut updated_data = test_component("auth-svc"); + if let Some(Metadata::ComponentMetadata(ref mut m)) = updated_data.metadata { + m.description = "Updated auth service".to_string(); + } + + let updated = db + .repository + .update(&created.id, &updated_data) + .await + .unwrap() + .unwrap(); + + assert_eq!(updated.id, created.id); + assert_eq!(updated.created_at, created.created_at); + assert_ne!(updated.updated_at, created.updated_at); + + if let Some(Metadata::ComponentMetadata(m)) = &updated.metadata { + assert_eq!(m.description, "Updated auth service"); + } else { + panic!("Expected ComponentMetadata"); + } +} + +#[tokio::test] +async fn delete_entity() { + let db = TestDb::new().await; + + let created = db.repository.create(&test_component("to-delete")).await.unwrap(); + let deleted = db.repository.delete(&created.id).await.unwrap(); + assert!(deleted); + + let gone = db.repository.get_by_id(&created.id).await.unwrap(); + assert!(gone.is_none()); + + // Double delete returns false + let deleted_again = db.repository.delete(&created.id).await.unwrap(); + assert!(!deleted_again); +} + +#[tokio::test] +async fn list_paginated() { + let db = TestDb::new().await; + + for i in 0..5 { + db.repository + .create(&test_component(&format!("svc-{}", i))) + .await + .unwrap(); + } + + // Page 1: get 2 entities + let page1 = db + .repository + .list_paginated(Some("Component"), None, 2, None) + .await + .unwrap(); + assert_eq!(page1.entities.len(), 2); + assert_eq!(page1.total_count, 5); + assert!(page1.next_page_token.is_some()); + + // Page 2: next 2 + let page2 = db + .repository + .list_paginated(Some("Component"), None, 2, page1.next_page_token.as_deref()) + .await + .unwrap(); + assert_eq!(page2.entities.len(), 2); + assert!(page2.next_page_token.is_some()); + + // Page 3: last 1 + let page3 = db + .repository + .list_paginated(Some("Component"), None, 2, page2.next_page_token.as_deref()) + .await + .unwrap(); + assert_eq!(page3.entities.len(), 1); + assert!(page3.next_page_token.is_none()); + + // No duplicates across pages + let all_ids: Vec = page1 + .entities + .iter() + .chain(page2.entities.iter()) + .chain(page3.entities.iter()) + .map(|e| e.id.clone()) + .collect(); + let unique: std::collections::HashSet<&String> = all_ids.iter().collect(); + assert_eq!(all_ids.len(), unique.len()); +} + +#[tokio::test] +async fn get_by_kind_and_name() { + let db = TestDb::new().await; + + db.repository.create(&test_component("unique-svc")).await.unwrap(); + db.repository.create(&test_component("other-svc")).await.unwrap(); + + let found = db + .repository + .get_by_kind_and_name("Component", "unique-svc") + .await + .unwrap(); + assert!(found.is_some()); + if let Some(Metadata::ComponentMetadata(m)) = &found.unwrap().metadata { + assert_eq!(m.name, "unique-svc"); + } + + let not_found = db + .repository + .get_by_kind_and_name("Component", "nonexistent") + .await + .unwrap(); + assert!(not_found.is_none()); +} + +#[tokio::test] +async fn atomic_annotation_update() { + let db = TestDb::new().await; + + let created = db.repository.create(&test_component("annotated-svc")).await.unwrap(); + + // First annotation update + let mut annotations1 = std::collections::HashMap::new(); + annotations1.insert("defectdojo.com/product-id".to_string(), "123".to_string()); + let updated = db + .repository + .update_annotations(&created.id, annotations1) + .await + .unwrap(); + assert!(updated); + + // Second annotation update (should merge, not overwrite) + let mut annotations2 = std::collections::HashMap::new(); + annotations2.insert("github.com/repo".to_string(), "org/repo".to_string()); + db.repository + .update_annotations(&created.id, annotations2) + .await + .unwrap(); + + // Verify both annotations exist + let entity = db.repository.get_by_id(&created.id).await.unwrap().unwrap(); + assert_eq!(entity.annotations.get("defectdojo.com/product-id").unwrap(), "123"); + assert_eq!(entity.annotations.get("github.com/repo").unwrap(), "org/repo"); +} diff --git a/tests/harness.rs b/tests/harness.rs new file mode 100644 index 0000000..8fb39f0 --- /dev/null +++ b/tests/harness.rs @@ -0,0 +1,31 @@ +use charybdis::database::{EntityRepository, ensure_schema}; +use sqlx::PgPool; +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::postgres::Postgres; + +pub struct TestDb { + pub pool: PgPool, + pub repository: EntityRepository, + _container: testcontainers::ContainerAsync, +} + +impl TestDb { + pub async fn new() -> Self { + // Testcontainers picks up DOCKER_HOST or TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE + // For Colima users: export DOCKER_HOST=unix:///Users/$USER/.colima/default/docker.sock + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgresql://postgres:postgres@127.0.0.1:{}/postgres", port); + + let pool = PgPool::connect(&url).await.unwrap(); + ensure_schema(&pool).await.unwrap(); + + let repository = EntityRepository::new(pool.clone()); + + Self { + pool, + repository, + _container: container, + } + } +}