use anyhow::{Context, Result}; use reqwest::Client; pub use dtrack_config::DtrackConfig; /// Client fuer die Dependency-Track REST-API (v1). /// /// Ueberwiegend read-only (Projekte, Findings, Metriken, VEX/SBOM ...). Einzige /// Schreib-Operation ist `set_analysis` (Suppression/Analyse setzen) -- diese /// braucht einen API-Key mit dem DT-Recht VULNERABILITY_ANALYSIS. /// /// 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 einer aufgeloesten [`DtrackConfig`]. pub fn from_config(cfg: &DtrackConfig) -> Result { let http = Client::builder() .danger_accept_invalid_certs(cfg.insecure) .build() .context("HTTP-Client konnte nicht erstellt werden")?; Ok(Self { http, base_url: normalize_base(&cfg.url), api_key: cfg.api_key.clone(), }) } /// Convenience: Config aus Umgebungsvariablen lesen und Client bauen. pub fn from_env() -> Result { Self::from_config(&DtrackConfig::from_env()?) } async fn get(&self, path: &str) -> Result { self.get_query(path, &[]).await } /// Wie `get`, aber mit Query-Parametern, die reqwest sauber URL-encodet /// (z.B. fuer Namen mit Leerzeichen in `lookup_project`). async fn get_query(&self, path: &str, params: &[(&str, &str)]) -> Result { let url = format!("{}{}", self.base_url, path); let resp = self .http .get(&url) .header("X-Api-Key", &self.api_key) .query(params) .send() .await .with_context(|| format!("Request an {url} fehlgeschlagen"))?; self.handle(path, resp).await } /// PUT mit JSON-Body. Einzige Schreib-Operation des Clients. async fn put_json(&self, path: &str, body: serde_json::Value) -> Result { let url = format!("{}{}", self.base_url, path); let resp = self .http .put(&url) .header("X-Api-Key", &self.api_key) .json(&body) .send() .await .with_context(|| format!("Request an {url} fehlgeschlagen"))?; self.handle(path, resp).await } /// Gemeinsame Status-/Body-Behandlung fuer GET und PUT. async fn handle(&self, path: &str, resp: reqwest::Response) -> Result { 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 /project/lookup -- Projekt per Name (+optional Version) finden; /// liefert u.a. dessen UUID. pub async fn lookup_project(&self, name: &str, version: Option<&str>) -> Result { let mut params: Vec<(&str, &str)> = vec![("name", name)]; if let Some(v) = version { params.push(("version", v)); } self.get_query("/project/lookup", ¶ms).await } /// GET /component/project/{uuid} -- Komponenten/Abhaengigkeiten eines Projekts. pub async fn project_components(&self, uuid: &str) -> Result { self.get(&format!("/component/project/{uuid}?pageSize=100")) .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 } /// GET /violation/project/{uuid} -- Policy-Verstoesse eines Projekts. /// Braucht das DT-Recht VIEW_POLICY_VIOLATION. pub async fn project_violations(&self, uuid: &str) -> Result { self.get(&format!("/violation/project/{uuid}")).await } /// GET /vex/cyclonedx/project/{uuid} -- VEX (CycloneDX) eines Projekts: /// Exploitability-/Analyse-Status der Schwachstellen. pub async fn project_vex(&self, uuid: &str) -> Result { self.get(&format!("/vex/cyclonedx/project/{uuid}")).await } /// GET /bom/cyclonedx/project/{uuid} -- SBOM (CycloneDX, JSON) eines Projekts. pub async fn project_bom(&self, uuid: &str) -> Result { self.get(&format!("/bom/cyclonedx/project/{uuid}?format=json")) .await } /// PUT /analysis -- Analyse-/Suppression-Entscheidung fuer ein Finding setzen. /// /// Adressiert wird das Finding ueber das Tripel (project, component, /// vulnerability) -- alle drei UUIDs stehen in der `project_findings`-Antwort. /// `suppressed` schaltet die Unterdrueckung an/aus; `state`/`justification`/ /// `comment` sind optionale Analyse-Felder (DT-Enums, z.B. state /// `FALSE_POSITIVE`/`NOT_AFFECTED`). Braucht das DT-Recht VULNERABILITY_ANALYSIS. #[allow(clippy::too_many_arguments)] pub async fn set_analysis( &self, project: &str, component: &str, vulnerability: &str, suppressed: Option, state: Option<&str>, justification: Option<&str>, comment: Option<&str>, ) -> Result { let mut body = serde_json::json!({ "project": project, "component": component, "vulnerability": vulnerability, }); let obj = body .as_object_mut() .expect("json! erzeugt immer ein Objekt"); if let Some(s) = suppressed { obj.insert("suppressed".into(), serde_json::Value::Bool(s)); } if let Some(s) = state { obj.insert("analysisState".into(), serde_json::Value::String(s.into())); } if let Some(j) = justification { obj.insert( "analysisJustification".into(), serde_json::Value::String(j.into()), ); } if let Some(c) = comment { obj.insert("comment".into(), serde_json::Value::String(c.into())); } self.put_json("/analysis", body).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"); } }