diff --git a/crates/dtrack-core/src/lib.rs b/crates/dtrack-core/src/lib.rs new file mode 100644 index 0000000..e199a9f --- /dev/null +++ b/crates/dtrack-core/src/lib.rs @@ -0,0 +1,94 @@ +use anyhow::{Context, Result}; +use reqwest::Client; + +/// Read-only Client fuer die Dependency-Track REST-API (v1). +/// +/// Bewusst duenn: liefert die rohen JSON-Antworten als String zurueck. Das +/// Modellieren einzelner DT-Typen (serde-Structs) heben wir uns fuer spaeter +/// auf -- fuers erste reicht JSON-Durchreichen an den MCP-Client. +#[derive(Clone)] +pub struct DtrackClient { + http: Client, + base_url: String, + api_key: String, +} + +/// Haengt `/api/v1` an die Basis-URL an und entfernt einen ggf. vorhandenen +/// abschliessenden Slash. Separat gehalten, damit es ohne Netzwerk testbar ist. +pub fn normalize_base(base: &str) -> String { + format!("{}/api/v1", base.trim_end_matches('/')) +} + +impl DtrackClient { + /// Baut den Client aus Umgebungsvariablen: + /// - `DTRACK_URL` Basis-URL der DT-Instanz (z.B. https://dtrack.firma.intern) + /// - `DTRACK_API_KEY` API-Key eines read-only Teams + /// - `DTRACK_INSECURE` optional "1"/"true" -> TLS-Zertpruefung aus + /// (nur als Notbehelf bei internem CA-Cert; nicht fuer Prod) + pub fn from_env() -> Result { + let base = std::env::var("DTRACK_URL").context("DTRACK_URL nicht gesetzt")?; + let api_key = std::env::var("DTRACK_API_KEY").context("DTRACK_API_KEY nicht gesetzt")?; + let insecure = std::env::var("DTRACK_INSECURE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + + let http = Client::builder() + .danger_accept_invalid_certs(insecure) + .build() + .context("HTTP-Client konnte nicht erstellt werden")?; + + Ok(Self { + http, + base_url: normalize_base(&base), + api_key, + }) + } + + async fn get(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + let resp = self + .http + .get(&url) + .header("X-Api-Key", &self.api_key) + .send() + .await + .with_context(|| format!("Request an {url} fehlgeschlagen"))?; + + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Dependency-Track {path} -> HTTP {status}: {body}"); + } + Ok(body) + } + + /// GET /project -- aktive Projekte (Name, Version, UUID ...). + pub async fn list_projects(&self) -> Result { + self.get("/project?pageSize=100&excludeInactive=true").await + } + + /// GET /finding/project/{uuid} -- Findings (Schwachstellen) eines Projekts. + pub async fn project_findings(&self, uuid: &str) -> Result { + self.get(&format!("/finding/project/{uuid}")).await + } + + /// GET /metrics/project/{uuid}/current -- aktuelle Sicherheits-Metriken. + pub async fn project_metrics(&self, uuid: &str) -> Result { + self.get(&format!("/metrics/project/{uuid}/current")).await + } +} + +#[cfg(test)] +mod tests { + use super::normalize_base; + + #[test] + fn normalize_base_appends_api_path() { + assert_eq!(normalize_base("https://dt.example"), "https://dt.example/api/v1"); + } + + #[test] + fn normalize_base_trims_trailing_slash() { + assert_eq!(normalize_base("https://dt.example/"), "https://dt.example/api/v1"); + } +}