Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09439a7f53 | |||
| 2b7ec544da | |||
| 271a15cd1f | |||
| bd4fd412da | |||
| 69ef02fa59 | |||
| 9be73422bb | |||
| df99a46f95 | |||
| 1c2a1f50dc | |||
| 1719f610f5 | |||
| 473a754357 | |||
| 106bb61842 | |||
| d4efd66af8 |
@@ -9,8 +9,9 @@ name: Release Images
|
||||
# - dtrack-http amd64 ("nicht Mac" / Intel)
|
||||
# - dtrack-http arm64 (Apple Silicon Mac)
|
||||
#
|
||||
# Spiegelt den Godforge/deploy.yml-Stil (host-Runner, curl+jq, secrets.GITEATOKEN).
|
||||
# Auth ueber das automatische Actions-Token (gitea.token) -- kein Secret noetig.
|
||||
# Hinweis: braucht das buildx-Plugin und privilegiertes QEMU-binfmt fuer arm64.
|
||||
# nodejs ist noetig, weil actions/checkout eine Node-Action ist.
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -27,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Install deps
|
||||
run: apk add --no-cache curl jq git docker-cli docker-cli-buildx
|
||||
run: apk add --no-cache curl jq git nodejs docker-cli docker-cli-buildx
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -50,18 +51,32 @@ jobs:
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build, save & attach
|
||||
env:
|
||||
# Automatisches, schreibberechtigtes Token des Laufs.
|
||||
TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
set -eu
|
||||
TAG="${{ steps.vars.outputs.tag }}"
|
||||
REPO="${{ gitea.repository }}"
|
||||
API="https://git.sithies.de/api/v1/repos/${REPO}/releases"
|
||||
AUTH="Authorization: token ${{ secrets.GITEATOKEN }}"
|
||||
AUTH="Authorization: token ${TOKEN}"
|
||||
|
||||
REL_ID=$(curl -s "${API}/tags/${TAG}" -H "$AUTH" | jq -r '.id')
|
||||
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
|
||||
echo "ERROR: Kein Release fuer Tag ${TAG} gefunden. Erst Release veroeffentlichen."
|
||||
# Release-Lookup mit Diagnose.
|
||||
HTTP=$(curl -s -o /tmp/rel.json -w "%{http_code}" "${API}/tags/${TAG}" -H "$AUTH")
|
||||
REL_ID=$(jq -r '.id // empty' /tmp/rel.json 2>/dev/null || true)
|
||||
if [ -z "$REL_ID" ]; then
|
||||
echo "ERROR: Release-Lookup fehlgeschlagen (HTTP ${HTTP}) fuer Tag '${TAG}'."
|
||||
if [ -z "${TOKEN}" ]; then
|
||||
echo " -> gitea.token ist leer (Actions-Token nicht verfuegbar)."
|
||||
else
|
||||
echo " -> Token vorhanden; evtl. fehlt dem Workflow-Token Schreibrecht"
|
||||
echo " (Repo Settings -> Actions -> General -> Workflow permissions = Read/Write),"
|
||||
echo " oder der Tag '${TAG}' stimmt nicht."
|
||||
fi
|
||||
echo " Antwort (gekuerzt): $(head -c 300 /tmp/rel.json)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ${TAG} gefunden (id ${REL_ID})."
|
||||
|
||||
build_one() {
|
||||
name="$1"; file="$2"; platform="$3"; suffix="$4"
|
||||
|
||||
@@ -1,84 +1,229 @@
|
||||
# dependencyTrack-MCP
|
||||
|
||||
MCP-Server fuer [OWASP Dependency-Track](https://dependencytrack.org/), in Rust.
|
||||
Read-only Zugriff auf Projekte, Findings und Metriken.
|
||||
Ueberwiegend lesender Zugriff (Projekte, Findings, Metriken, VEX/SBOM u.a.) plus
|
||||
eine geschuetzte Schreib-Operation: Finding-Suppression/Analyse.
|
||||
|
||||
Zwei Frontends, eine gemeinsame Tool-Logik:
|
||||
|
||||
| Modus | Binary | Einsatz |
|
||||
|---|---|---|
|
||||
| **stdio** | `dtrack-stdio` | Arbeitsrechner: von Claude Desktop via `podman run -i` gestartet |
|
||||
| **HTTP** | `dtrack-http` | Server: StreamableHTTP `/mcp`, Multi-Client, Bearer-Auth |
|
||||
| **stdio** | `dtrack-stdio` | Lokal/Arbeitsrechner: von Claude Desktop gestartet (`docker run -i` oder native `.exe`) |
|
||||
| **HTTP** | `dtrack-http` | Server: StreamableHTTP `/mcp`, Multi-Client, Bearer-Auth/RBAC, Admin-UI |
|
||||
|
||||
> **Status: Stage 2** -- HTTP-Frontend + Bearer-Auth stehen. Admin-UI +
|
||||
> Permission-Engine + SSO folgen (siehe Roadmap).
|
||||
> **Status: Stage 3** -- HTTP-Frontend, Bearer-Auth, persistente Config,
|
||||
> Admin-UI und RBAC-Enforcement stehen. Tool-Sichtbarkeit pro Client (Stufe 2)
|
||||
> und SSO folgen (siehe Roadmap).
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
crates/dtrack-config Config (TOML laden/speichern, Secret-Masking)
|
||||
crates/dtrack-core DT-REST-Client (reqwest+rustls, read-only)
|
||||
crates/dtrack-config Config (TOML laden/speichern, Secret-Masking, Persistenz)
|
||||
crates/dtrack-perms RBAC-Policy (Rollen/Clients, Deny-overrides-Allow)
|
||||
crates/dtrack-core DT-REST-Client (reqwest+rustls)
|
||||
crates/dtrack-tools rmcp-Server (die Tools) -- transport-unabhaengig, geteilt
|
||||
crates/dtrack-stdio Bin: stdio-Frontend
|
||||
crates/dtrack-http Bin: HTTP-Frontend (axum + StreamableHTTP)
|
||||
crates/dtrack-http Bin: HTTP-Frontend (axum + StreamableHTTP + Admin-UI)
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
Lesend:
|
||||
|
||||
| Tool | Argument | DT-Endpoint |
|
||||
|---|---|---|
|
||||
| `list_projects` | -- | `GET /project` |
|
||||
| `lookup_project` | `name`, `version?` | `GET /project/lookup` |
|
||||
| `project_components` | `uuid` | `GET /component/project/{uuid}` |
|
||||
| `project_findings` | `uuid` | `GET /finding/project/{uuid}` |
|
||||
| `project_metrics` | `uuid` | `GET /metrics/project/{uuid}/current` |
|
||||
| `project_violations` | `uuid` | `GET /violation/project/{uuid}` (braucht `VIEW_POLICY_VIOLATION`) |
|
||||
| `project_vex` | `uuid` | `GET /vex/cyclonedx/project/{uuid}` |
|
||||
| `project_bom` | `uuid` | `GET /bom/cyclonedx/project/{uuid}` |
|
||||
|
||||
Schreibend (braucht das DT-Recht `VULNERABILITY_ANALYSIS`):
|
||||
|
||||
| Tool | Argument | DT-Endpoint |
|
||||
|---|---|---|
|
||||
| `suppress_finding` | `project`, `component`, `vulnerability`, `suppressed?`, `state?`, `justification?`, `comment?` | `PUT /analysis` |
|
||||
|
||||
> `suppress_finding` ist die einzige schreibende Operation. Die drei UUIDs
|
||||
> (project/component/vulnerability) stammen aus der `project_findings`-Antwort.
|
||||
> `suppressed` ist standardmaessig `true` (`false` hebt die Unterdrueckung auf).
|
||||
> Wer nur lesen soll, bekommt einen Key **ohne** `VULNERABILITY_ANALYSIS` --
|
||||
> dann scheitert der Write serverseitig mit HTTP 403. Im HTTP-Modus zusaetzlich
|
||||
> per RBAC eingrenzbar (eigene Rolle / `deny`).
|
||||
|
||||
## Konfiguration (Umgebungsvariablen)
|
||||
|
||||
| Var | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `DTRACK_URL` | ja* | Basis-URL der DT-Instanz |
|
||||
| `DTRACK_API_KEY` | ja* | API-Key eines **read-only** Teams (`VIEW_PORTFOLIO`, `VIEW_VULNERABILITY`) |
|
||||
| `DTRACK_URL` | ja* | Basis-URL der DT-Instanz (ohne `/api/v1`, wird angehaengt) |
|
||||
| `DTRACK_API_KEY` | ja* | API-Key eines Teams. Lesen: `VIEW_PORTFOLIO` + `VIEW_VULNERABILITY` (Policy-Verstoesse: `VIEW_POLICY_VIOLATION`); fuer `suppress_finding`: `VULNERABILITY_ANALYSIS` |
|
||||
| `DTRACK_INSECURE` | nein | `1`/`true` -> TLS-Pruefung aus (Notbehelf bei internem CA-Cert) |
|
||||
| `DTRACK_CONFIG` | nein | Pfad zu einer TOML-Config; hat Vorrang vor den env-Werten |
|
||||
| `DTRACK_CONFIG` | nein | Pfad zu einer TOML-Config; einmal aus env gebootstrappt, danach massgeblich |
|
||||
| `DTRACK_PERMISSIONS` | nein | (nur HTTP) Pfad zur RBAC-Policy; gesetzt -> RBAC statt flacher Token-Liste |
|
||||
| `DTRACK_HTTP_TOKENS` | nein | (nur HTTP) kommagetrennte erlaubte Bearer-Tokens; leer = ungeschuetzt |
|
||||
| `DTRACK_HTTP_ADDR` | nein | (nur HTTP) Bind-Adresse, Default `0.0.0.0:8080` |
|
||||
| `DTRACK_ADMIN_USER` | nein | (nur HTTP) Basic-Auth-User der Admin-UI; leer = UI deaktiviert |
|
||||
| `DTRACK_ADMIN_PASSWORD` | nein | (nur HTTP) Basic-Auth-Passwort der Admin-UI |
|
||||
|
||||
\* oder ueber `DTRACK_CONFIG`-Datei.
|
||||
|
||||
## Server-Modus testen (z.B. Gaming-PC)
|
||||
---
|
||||
|
||||
## Schnellstart: lokal testen (Mac/Linux mit Docker)
|
||||
|
||||
> Beispiele mit `docker`; `podman` funktioniert identisch.
|
||||
|
||||
### Variante A -- aus dem Quellcode bauen (baut nativ fuer deine Architektur)
|
||||
|
||||
```sh
|
||||
cp .env.example .env # DTRACK_URL / DTRACK_API_KEY ausfuellen
|
||||
podman compose up --build
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
MCP-Client auf `http://<host>:8080/mcp` zeigen lassen; falls `DTRACK_HTTP_TOKENS`
|
||||
gesetzt ist, `Authorization: Bearer <token>` mitgeben. Health-Check:
|
||||
`curl http://localhost:8080/health`.
|
||||
### Variante B -- vorgebautes Release-Image laden
|
||||
|
||||
## stdio-Modus (Arbeitsrechner)
|
||||
Am Release haengen pro Architektur Tarballs. Passenden waehlen
|
||||
(**arm64** = Apple Silicon, **amd64** = Intel / "nicht Mac"):
|
||||
|
||||
```sh
|
||||
# bauen
|
||||
cargo build --release --bin dtrack-stdio
|
||||
podman build -t dtrack-mcp:latest -f Containerfile .
|
||||
docker load -i dtrack-http-0.1.0-arm64.tar.gz
|
||||
|
||||
# transfer ohne Registry
|
||||
podman save dtrack-mcp:latest | gzip > dtrack-mcp.tar.gz # -> uebertragen
|
||||
podman load < dtrack-mcp.tar.gz # auf dem Laptop
|
||||
docker run --rm -p 8080:8080 \
|
||||
-e DTRACK_URL=http://host.docker.internal:8081 \
|
||||
-e DTRACK_API_KEY=odt_dein_key \
|
||||
-e DTRACK_ADMIN_USER=admin -e DTRACK_ADMIN_PASSWORD=geheim \
|
||||
dtrack-http:0.1.0-arm64
|
||||
```
|
||||
|
||||
Claude Desktop (`--network=host` fuer DT ueber die VPN-Route des Hosts):
|
||||
Pruefen:
|
||||
- `curl http://localhost:8080/health` -> `OK`
|
||||
- `http://localhost:8080/admin` -> Admin-UI (Basic-Auth)
|
||||
|
||||
> Der Server bootet auch ohne erreichbare DT -- nur die Tool-Calls brauchen eine
|
||||
> echte Instanz. Tipp fuer Persistenz: `-v ~/dtrack-data:/data -e DTRACK_CONFIG=/data/config.toml`
|
||||
> ergaenzen, dann ueberleben Admin-Aenderungen den Neustart.
|
||||
|
||||
### (optional) Dummy-DependencyTrack zum Testen
|
||||
|
||||
`docker-compose.yml` in einem eigenen Ordner:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
dtrack-apiserver:
|
||||
image: dependencytrack/apiserver
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4608m
|
||||
ports:
|
||||
- "8081:8080" # API -> Host-Port 8081
|
||||
volumes:
|
||||
- dtrack-data:/data
|
||||
dtrack-frontend:
|
||||
image: dependencytrack/frontend
|
||||
environment:
|
||||
- API_BASE_URL=http://localhost:8081
|
||||
ports:
|
||||
- "8082:8080" # Web-UI -> Host-Port 8082
|
||||
volumes:
|
||||
dtrack-data: {}
|
||||
```
|
||||
|
||||
`docker compose up -d` (apiserver braucht ~4 GB RAM + ein paar Minuten beim
|
||||
Erststart). Dann `http://localhost:8082` (Login `admin`/`admin`), unter
|
||||
**Administration -> Access Management -> Teams** einen API-Key mit
|
||||
`VIEW_PORTFOLIO` + `VIEW_VULNERABILITY` erzeugen (fuer den Suppression-Test
|
||||
zusaetzlich `VULNERABILITY_ANALYSIS`) und ein Projekt anlegen.
|
||||
Aus dem MCP-Container ist die DT unter `http://host.docker.internal:8081`
|
||||
erreichbar (nicht `localhost`).
|
||||
|
||||
---
|
||||
|
||||
## Mit Claude Desktop verbinden
|
||||
|
||||
> **Wichtig:** Der UI-Weg *"Add custom connector"* (URL eingeben) funktioniert
|
||||
> fuer einen **lokalen** Server **nicht** -- Claude verbindet sich dabei aus
|
||||
> Anthropics Cloud, die deinen `localhost` nicht erreicht. Fuer lokal geht der
|
||||
> Weg ueber die `claude_desktop_config.json`.
|
||||
|
||||
Config-Datei (macOS): `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||
(oder in Desktop: **Settings -> Developer -> Edit Config**). Den `mcpServers`-Block
|
||||
neben `preferences` einfuegen, danach Desktop **komplett neu starten** (Cmd+Q).
|
||||
|
||||
### Empfohlen lokal: stdio
|
||||
|
||||
stdio hat keinen HTTP-Session-Handshake -> zuverlaessig. Den HTTP-Container
|
||||
braucht man dafuer nicht (nur eine laufende DT).
|
||||
|
||||
```sh
|
||||
docker load -i dtrack-stdio-0.1.0-amd64.tar.gz
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dependency-track": {
|
||||
"command": "podman",
|
||||
"args": [
|
||||
"run", "-i", "--rm", "--network=host",
|
||||
"-e", "DTRACK_URL", "-e", "DTRACK_API_KEY", "-e", "DTRACK_INSECURE",
|
||||
"dtrack-mcp:latest"
|
||||
],
|
||||
"command": "docker",
|
||||
"args": ["run","-i","--rm",
|
||||
"-e","DTRACK_URL","-e","DTRACK_API_KEY",
|
||||
"dtrack-stdio:0.1.0-amd64"],
|
||||
"env": {
|
||||
"DTRACK_URL": "http://host.docker.internal:8081",
|
||||
"DTRACK_API_KEY": "odt_dein_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Findet Desktop `docker` nicht (eingeschraenkter PATH), `which docker` ausfuehren
|
||||
> und den vollen Pfad als `command` setzen (z.B. `/usr/local/bin/docker`).
|
||||
> Das stdio-Release-Image ist amd64 -> laeuft auf Apple Silicon emuliert (ok zum Testen).
|
||||
|
||||
### Windows (Arbeitsrechner): native `.exe` -- empfohlen hinter Firmen-VPN
|
||||
|
||||
Auf Windows ist die native `.exe` der zuverlaessigste Weg: sie laeuft im
|
||||
Netz-Kontext des Windows-Hosts, also greift die **VPN-Route zur Firmen-DT
|
||||
direkt**. Container (Docker/Podman Desktop) laufen dagegen in einer WSL2-VM --
|
||||
`--network=host` bindet dort an die VM, und die DT ueber die Host-VPN ist von da
|
||||
drin oft nicht erreichbar.
|
||||
|
||||
**1. `.exe` bauen** -- Cross-Compile von CachyOS aus. reqwest nutzt rustls (kein
|
||||
OpenSSL), darum keine nativen TLS-Libs noetig, nur der MinGW-Linker:
|
||||
|
||||
```sh
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
sudo pacman -S --needed mingw-w64-gcc
|
||||
cargo build --release --target x86_64-pc-windows-gnu --bin dtrack-stdio
|
||||
# -> target/x86_64-pc-windows-gnu/release/dtrack-stdio.exe
|
||||
```
|
||||
|
||||
Findet cargo den Linker nicht, in `.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
```
|
||||
|
||||
> **Fallback**, falls der rustls-Crypto-Provider beim `windows-gnu`-Cross zickt:
|
||||
> direkt auf dem Windows-Rechner mit rustup/MSVC bauen --
|
||||
> `cargo build --release --bin dtrack-stdio` erzeugt dieselbe `.exe`.
|
||||
|
||||
Die `dtrack-stdio.exe` dann auf den Arbeitsrechner ziehen, z.B. nach
|
||||
`C:\Tools\dtrack-mcp\dtrack-stdio.exe`.
|
||||
|
||||
**2. Claude Desktop einbinden** -- Config-Datei unter Windows:
|
||||
`%APPDATA%\Claude\claude_desktop_config.json`
|
||||
(oder **Settings -> Developer -> Edit Config**):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"dependency-track": {
|
||||
"command": "C:\\Tools\\dtrack-mcp\\dtrack-stdio.exe",
|
||||
"env": {
|
||||
"DTRACK_URL": "https://dtrack.firma.intern",
|
||||
"DTRACK_API_KEY": "odt_...",
|
||||
@@ -89,6 +234,68 @@ Claude Desktop (`--network=host` fuer DT ueber die VPN-Route des Hosts):
|
||||
}
|
||||
```
|
||||
|
||||
Pfad mit doppelten Backslashes (oder Forward-Slashes). Bei internem CA-Cert
|
||||
`DTRACK_INSECURE` auf `"1"` setzen. Danach Desktop **komplett neu starten**.
|
||||
|
||||
**3. Testen** -- VPN verbinden, im Chat z.B. *"liste die DT-Projekte"* ->
|
||||
`list_projects` muss Treffer liefern. Kommt nichts: `DTRACK_URL`/Key pruefen,
|
||||
bei TLS-Fehler `DTRACK_INSECURE=1`, VPN-Verbindung pruefen. Logs:
|
||||
`%APPDATA%\Claude\logs\mcp*.log`.
|
||||
|
||||
### HTTP als echter Remote-Connector
|
||||
|
||||
Der HTTP-Modus ist fuer den Betrieb als **oeffentlich erreichbarer** Remote-MCP
|
||||
gedacht (Custom Connector ueber eine echte URL, z.B. hinter Reverse-Proxy/Tunnel).
|
||||
Der Bridge-Versuch `npx mcp-remote http://localhost:8080/mcp` fuer lokal scheitert
|
||||
aktuell am rmcp-StreamableHTTP-Handshake (`tools/list` vor `initialized`) -- darum
|
||||
lokal stdio nutzen.
|
||||
|
||||
Logs bei Problemen: `~/Library/Logs/Claude/mcp*.log`.
|
||||
|
||||
---
|
||||
|
||||
## RBAC (optional, nur HTTP)
|
||||
|
||||
Standardmaessig regelt `DTRACK_HTTP_TOKENS` den Zugang (flache Token-Liste,
|
||||
leer = offen). Fuer feinere Rechte `permissions.toml.example` nach
|
||||
`./data/permissions.toml` kopieren, Tokens/Rollen eintragen und
|
||||
`DTRACK_PERMISSIONS=/data/permissions.toml` setzen.
|
||||
|
||||
Modell: benannte Rollen mit `allow`/`deny` (`"*"` = alle Tools, **deny schlaegt
|
||||
allow**), Clients ordnen einen Bearer-Token einer Rolle zu. Ein nicht erlaubter
|
||||
`tools/call` wird mit **403** abgewiesen. Tipp: das schreibende `suppress_finding`
|
||||
gezielt nur Auditor-Rollen erlauben (oder breit per `deny` ausschliessen).
|
||||
|
||||
```toml
|
||||
[[roles]]
|
||||
name = "read-only"
|
||||
allow = ["*"]
|
||||
deny = ["suppress_finding"]
|
||||
|
||||
[[roles]]
|
||||
name = "auditor"
|
||||
allow = ["*"]
|
||||
|
||||
[[clients]]
|
||||
name = "dashboard-bot"
|
||||
token = "GEHEIM"
|
||||
role = "read-only"
|
||||
```
|
||||
|
||||
## Admin-UI
|
||||
|
||||
Unter `/admin` (Basic-Auth via `DTRACK_ADMIN_USER`/`_PASSWORD`): zeigt URL +
|
||||
maskierten API-Key, erlaubt Aendern (Client wird **live** neu gebaut) und
|
||||
persistiert bei gesetztem `DTRACK_CONFIG`. Aktuell bewusst minimal -- Styling
|
||||
und Client-/Rollen-Verwaltung folgen.
|
||||
|
||||
## Release bauen (Maintainer)
|
||||
|
||||
In Gitea einen Release veroeffentlichen (oder **Actions -> Release Images ->
|
||||
Run workflow** mit Tag). Der Workflow baut via buildx Multi-Arch und haengt an:
|
||||
`dtrack-stdio-<tag>-amd64`, `dtrack-http-<tag>-amd64`, `dtrack-http-<tag>-arm64`
|
||||
(je `.tar.gz`). Auth laeuft ueber das automatische `gitea.token`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Stage | Inhalt | Status |
|
||||
@@ -96,5 +303,6 @@ Claude Desktop (`--network=host` fuer DT ueber die VPN-Route des Hosts):
|
||||
| 0 | stdio, read-only, 3 Tools, CI, Container | ✅ |
|
||||
| 1 | Config-Layer + geteiltes Tool-Crate | ✅ |
|
||||
| 2 | HTTP-Frontend + Bearer-Auth | ✅ |
|
||||
| 3 | Admin-UI (URL/Key/Rechte pflegbar, maskiert) + Permission-Engine + Audit | … |
|
||||
| 3 | Admin-UI + Persistenz + RBAC-Enforcement | ✅ |
|
||||
| 3.2 | Tool-Sichtbarkeit pro Client (`tools/list` filtern) | … |
|
||||
| 4 | SSO (Gitea-OIDC) | … |
|
||||
|
||||
@@ -9,4 +9,5 @@ dtrack-config = { path = "../dtrack-config" }
|
||||
# reqwest 0.13 nutzt per Default rustls (kein OpenSSL) -> musl-tauglich,
|
||||
# identisch zum Nazarick-Stack.
|
||||
reqwest = { version = "0.13", features = ["json"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
|
||||
@@ -3,7 +3,11 @@ use reqwest::Client;
|
||||
|
||||
pub use dtrack_config::DtrackConfig;
|
||||
|
||||
/// Read-only Client fuer die Dependency-Track REST-API (v1).
|
||||
/// 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
|
||||
@@ -42,15 +46,43 @@ impl DtrackClient {
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<String> {
|
||||
self.get_query(path, &[]).await
|
||||
}
|
||||
|
||||
/// Wie `get`, aber mit Query-Parametern. reqwest 0.13 hat kein
|
||||
/// `RequestBuilder::query` mehr -- daher die URL ueber `Url::parse_with_params`
|
||||
/// bauen; das encodet z.B. Leerzeichen in `lookup_project`-Namen sauber.
|
||||
async fn get_query(&self, path: &str, params: &[(&str, &str)]) -> Result<String> {
|
||||
let url = reqwest::Url::parse_with_params(&format!("{}{}", self.base_url, path), params)
|
||||
.with_context(|| format!("Ungueltige URL fuer {path}"))?;
|
||||
let resp = self
|
||||
.http
|
||||
.get(url)
|
||||
.header("X-Api-Key", &self.api_key)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Request an {path} 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
|
||||
.get(&url)
|
||||
.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() {
|
||||
@@ -64,6 +96,22 @@ impl DtrackClient {
|
||||
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
|
||||
@@ -73,6 +121,68 @@ impl DtrackClient {
|
||||
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)]
|
||||
|
||||
@@ -40,10 +40,47 @@ pub struct DtrackServer {
|
||||
/// Argument fuer projektbezogene Tools.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ProjectArg {
|
||||
/// UUID des Projekts (aus `list_projects`).
|
||||
/// UUID des Projekts (aus `list_projects` oder `lookup_project`).
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
/// Argument fuer die Projekt-Suche per Name.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct LookupArg {
|
||||
/// Projektname (exakt).
|
||||
pub name: String,
|
||||
/// Optional: Projektversion. Weglassen sucht ohne Versionsfilter.
|
||||
#[serde(default)]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Argument fuer `suppress_finding` (SCHREIBEND). Adressiert ein Finding ueber
|
||||
/// das Tripel (project, component, vulnerability) -- alle drei UUIDs stehen in
|
||||
/// der `project_findings`-Antwort.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SuppressArg {
|
||||
/// UUID des Projekts.
|
||||
pub project: String,
|
||||
/// UUID der betroffenen Komponente.
|
||||
pub component: String,
|
||||
/// UUID der Schwachstelle (vulnerability).
|
||||
pub vulnerability: String,
|
||||
/// `true` = unterdruecken (Default), `false` = Unterdrueckung aufheben.
|
||||
#[serde(default)]
|
||||
pub suppressed: Option<bool>,
|
||||
/// Optionaler Analyse-Status (DT-Enum: `FALSE_POSITIVE`, `NOT_AFFECTED`,
|
||||
/// `RESOLVED`, `EXPLOITABLE`, `IN_TRIAGE`, `NOT_SET`).
|
||||
#[serde(default)]
|
||||
pub state: Option<String>,
|
||||
/// Optionale Begruendung (DT-Enum, z.B. `CODE_NOT_REACHABLE`,
|
||||
/// `CODE_NOT_PRESENT`, `REQUIRES_CONFIGURATION`). Passt zu `NOT_AFFECTED`.
|
||||
#[serde(default)]
|
||||
pub justification: Option<String>,
|
||||
/// Optionaler Kommentar fuer den Audit-Trail. Dringend empfohlen.
|
||||
#[serde(default)]
|
||||
pub comment: Option<String>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl DtrackServer {
|
||||
pub fn new(client: SharedClient) -> Self {
|
||||
@@ -63,6 +100,32 @@ impl DtrackServer {
|
||||
Ok(CallToolResult::success(vec![Content::text(body)]))
|
||||
}
|
||||
|
||||
#[tool(description = "Findet ein Projekt per Name (optional Version) und liefert u.a. dessen UUID.")]
|
||||
async fn lookup_project(
|
||||
&self,
|
||||
Parameters(arg): Parameters<LookupArg>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let client = self.client.read().await.clone();
|
||||
let body = client
|
||||
.lookup_project(&arg.name, arg.version.as_deref())
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body)]))
|
||||
}
|
||||
|
||||
#[tool(description = "Komponenten/Abhaengigkeiten eines Projekts anhand seiner UUID.")]
|
||||
async fn project_components(
|
||||
&self,
|
||||
Parameters(arg): Parameters<ProjectArg>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let client = self.client.read().await.clone();
|
||||
let body = client
|
||||
.project_components(&arg.uuid)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body)]))
|
||||
}
|
||||
|
||||
#[tool(description = "Findings (Schwachstellen) eines Projekts anhand seiner UUID.")]
|
||||
async fn project_findings(
|
||||
&self,
|
||||
@@ -88,6 +151,66 @@ impl DtrackServer {
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body)]))
|
||||
}
|
||||
|
||||
#[tool(description = "Policy-Verstoesse eines Projekts (UUID). Braucht das DT-Recht VIEW_POLICY_VIOLATION.")]
|
||||
async fn project_violations(
|
||||
&self,
|
||||
Parameters(arg): Parameters<ProjectArg>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let client = self.client.read().await.clone();
|
||||
let body = client
|
||||
.project_violations(&arg.uuid)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body)]))
|
||||
}
|
||||
|
||||
#[tool(description = "VEX (CycloneDX) eines Projekts (UUID): Exploitability-/Analyse-Status der Schwachstellen.")]
|
||||
async fn project_vex(
|
||||
&self,
|
||||
Parameters(arg): Parameters<ProjectArg>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let client = self.client.read().await.clone();
|
||||
let body = client
|
||||
.project_vex(&arg.uuid)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body)]))
|
||||
}
|
||||
|
||||
#[tool(description = "SBOM (CycloneDX, JSON) eines Projekts anhand seiner UUID.")]
|
||||
async fn project_bom(
|
||||
&self,
|
||||
Parameters(arg): Parameters<ProjectArg>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let client = self.client.read().await.clone();
|
||||
let body = client
|
||||
.project_bom(&arg.uuid)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body)]))
|
||||
}
|
||||
|
||||
#[tool(description = "SCHREIBEND: setzt Suppression/Analyse fuer ein Finding (project+component+vulnerability UUIDs aus project_findings). suppressed default true; optional state/justification/comment. Braucht das DT-Recht VULNERABILITY_ANALYSIS.")]
|
||||
async fn suppress_finding(
|
||||
&self,
|
||||
Parameters(arg): Parameters<SuppressArg>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let client = self.client.read().await.clone();
|
||||
let body = client
|
||||
.set_analysis(
|
||||
&arg.project,
|
||||
&arg.component,
|
||||
&arg.vulnerability,
|
||||
Some(arg.suppressed.unwrap_or(true)),
|
||||
arg.state.as_deref(),
|
||||
arg.justification.as_deref(),
|
||||
arg.comment.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
Ok(CallToolResult::success(vec![Content::text(body)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
@@ -98,7 +221,9 @@ impl ServerHandler for DtrackServer {
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some(
|
||||
"Read-only Zugriff auf Dependency-Track (Projekte, Findings, Metriken)."
|
||||
"Dependency-Track-Zugriff: lesend (Projekte Liste/Lookup, Komponenten, \
|
||||
Findings, Metriken, Policy-Verstoesse, VEX, SBOM) plus eine Schreib-Operation \
|
||||
(suppress_finding: Suppression/Analyse setzen, braucht VULNERABILITY_ANALYSIS)."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user