201 lines
7.2 KiB
Rust
201 lines
7.2 KiB
Rust
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<Self> {
|
|
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> {
|
|
Self::from_config(&DtrackConfig::from_env()?)
|
|
}
|
|
|
|
async fn get(&self, path: &str) -> Result<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
self.get(&format!("/finding/project/{uuid}")).await
|
|
}
|
|
|
|
/// GET /metrics/project/{uuid}/current -- aktuelle Sicherheits-Metriken.
|
|
pub async fn project_metrics(&self, uuid: &str) -> Result<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<bool>,
|
|
state: Option<&str>,
|
|
justification: Option<&str>,
|
|
comment: Option<&str>,
|
|
) -> Result<String> {
|
|
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");
|
|
}
|
|
}
|