Compare commits
15 Commits
feature/ra
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0065c7c4b2 | |||
| 46bf9de549 | |||
| 32e5fc21e7 | |||
|
|
d13cef94cb | ||
|
|
3a01a28591 | ||
|
|
d490359591 | ||
|
|
b95ce44fb9 | ||
|
|
175d303dc4 | ||
|
|
5a4af292fc | ||
|
|
04c8084943 | ||
|
|
d67a51db18 | ||
| 7e12d1433a | |||
| 65abc55915 | |||
| 0cb06d3d6d | |||
| 42cabf0582 |
14
.env.example
14
.env.example
@@ -37,3 +37,17 @@ GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos
|
|||||||
# Dashboard
|
# Dashboard
|
||||||
DASHBOARD_PORT=8080
|
DASHBOARD_PORT=8080
|
||||||
AGENT_API_URL=http://localhost:3001
|
AGENT_API_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# MCP Server
|
||||||
|
MCP_ENDPOINT_URL=http://localhost:8090
|
||||||
|
|
||||||
|
# Keycloak (required for authentication)
|
||||||
|
KEYCLOAK_URL=http://localhost:8080
|
||||||
|
KEYCLOAK_REALM=compliance
|
||||||
|
KEYCLOAK_CLIENT_ID=compliance-dashboard
|
||||||
|
REDIRECT_URI=http://localhost:8080/auth/callback
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# OpenTelemetry (optional - omit to disable)
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||||
|
# OTEL_SERVICE_NAME=compliance-agent
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ jobs:
|
|||||||
run: cargo clippy -p compliance-dashboard --features server --no-default-features -- -D warnings
|
run: cargo clippy -p compliance-dashboard --features server --no-default-features -- -D warnings
|
||||||
- name: Clippy (dashboard web)
|
- name: Clippy (dashboard web)
|
||||||
run: cargo clippy -p compliance-dashboard --features web --no-default-features -- -D warnings
|
run: cargo clippy -p compliance-dashboard --features web --no-default-features -- -D warnings
|
||||||
|
- name: Clippy (mcp)
|
||||||
|
run: cargo clippy -p compliance-mcp -- -D warnings
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
run: sccache --show-stats
|
run: sccache --show-stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -124,3 +126,119 @@ jobs:
|
|||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
run: sccache --show-stats
|
run: sccache --show-stats
|
||||||
if: always()
|
if: always()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stage 3: Deploy (only on main, after tests pass)
|
||||||
|
# Each service only deploys when its relevant files changed.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
detect-changes:
|
||||||
|
name: Detect Changes
|
||||||
|
runs-on: docker
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
needs: [test]
|
||||||
|
container:
|
||||||
|
image: alpine:latest
|
||||||
|
outputs:
|
||||||
|
agent: ${{ steps.changes.outputs.agent }}
|
||||||
|
dashboard: ${{ steps.changes.outputs.dashboard }}
|
||||||
|
docs: ${{ steps.changes.outputs.docs }}
|
||||||
|
mcp: ${{ steps.changes.outputs.mcp }}
|
||||||
|
steps:
|
||||||
|
- name: Install git
|
||||||
|
run: apk add --no-cache git
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git fetch --depth=2 origin "${GITHUB_SHA}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
- name: Detect changed paths
|
||||||
|
id: changes
|
||||||
|
run: |
|
||||||
|
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
|
||||||
|
echo "Changed files:"
|
||||||
|
echo "$CHANGED"
|
||||||
|
|
||||||
|
# Agent: core libs, agent code, agent Dockerfile
|
||||||
|
if echo "$CHANGED" | grep -qE '^(compliance-core/|compliance-agent/|compliance-graph/|compliance-dast/|Dockerfile\.agent|Cargo\.(toml|lock))'; then
|
||||||
|
echo "agent=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "agent=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dashboard: core libs, dashboard code, dashboard Dockerfile, assets
|
||||||
|
if echo "$CHANGED" | grep -qE '^(compliance-core/|compliance-dashboard/|Dockerfile\.dashboard|Dioxus\.toml|assets/|bin/|Cargo\.(toml|lock))'; then
|
||||||
|
echo "dashboard=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "dashboard=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Docs: docs folder, docs Dockerfile
|
||||||
|
if echo "$CHANGED" | grep -qE '^(docs/|Dockerfile\.docs)'; then
|
||||||
|
echo "docs=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "docs=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# MCP: core libs, mcp code, mcp Dockerfile
|
||||||
|
if echo "$CHANGED" | grep -qE '^(compliance-core/|compliance-mcp/|Dockerfile\.mcp|Cargo\.(toml|lock))'; then
|
||||||
|
echo "mcp=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "mcp=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy-agent:
|
||||||
|
name: Deploy Agent
|
||||||
|
runs-on: docker
|
||||||
|
needs: [detect-changes]
|
||||||
|
if: needs.detect-changes.outputs.agent == 'true'
|
||||||
|
container:
|
||||||
|
image: alpine:latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger Coolify deploy
|
||||||
|
run: |
|
||||||
|
apk add --no-cache curl
|
||||||
|
curl -sf "${{ secrets.COOLIFY_WEBHOOK_AGENT }}" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
|
|
||||||
|
deploy-dashboard:
|
||||||
|
name: Deploy Dashboard
|
||||||
|
runs-on: docker
|
||||||
|
needs: [detect-changes]
|
||||||
|
if: needs.detect-changes.outputs.dashboard == 'true'
|
||||||
|
container:
|
||||||
|
image: alpine:latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger Coolify deploy
|
||||||
|
run: |
|
||||||
|
apk add --no-cache curl
|
||||||
|
curl -sf "${{ secrets.COOLIFY_WEBHOOK_DASHBOARD }}" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
|
|
||||||
|
deploy-docs:
|
||||||
|
name: Deploy Docs
|
||||||
|
runs-on: docker
|
||||||
|
needs: [detect-changes]
|
||||||
|
if: needs.detect-changes.outputs.docs == 'true'
|
||||||
|
container:
|
||||||
|
image: alpine:latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger Coolify deploy
|
||||||
|
run: |
|
||||||
|
apk add --no-cache curl
|
||||||
|
curl -sf "${{ secrets.COOLIFY_WEBHOOK_DOCS }}" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
|
|
||||||
|
deploy-mcp:
|
||||||
|
name: Deploy MCP
|
||||||
|
runs-on: docker
|
||||||
|
needs: [detect-changes]
|
||||||
|
if: needs.detect-changes.outputs.mcp == 'true'
|
||||||
|
container:
|
||||||
|
image: alpine:latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger Coolify deploy
|
||||||
|
run: |
|
||||||
|
apk add --no-cache curl
|
||||||
|
curl -sf "${{ secrets.COOLIFY_WEBHOOK_MCP }}" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
|
|||||||
443
Cargo.lock
generated
443
Cargo.lock
generated
@@ -413,6 +413,17 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"rand_core 0.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "charset"
|
name = "charset"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -555,6 +566,7 @@ dependencies = [
|
|||||||
"git2",
|
"git2",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"jsonwebtoken",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
"octocrab",
|
"octocrab",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -582,11 +594,18 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-appender-tracing",
|
||||||
|
"opentelemetry-otlp",
|
||||||
|
"opentelemetry_sdk",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
"tracing-opentelemetry",
|
||||||
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -595,6 +614,8 @@ name = "compliance-dashboard"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
|
"bson",
|
||||||
"chrono",
|
"chrono",
|
||||||
"compliance-core",
|
"compliance-core",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
@@ -605,14 +626,20 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"gloo-timers",
|
"gloo-timers",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
|
"rand 0.9.2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
"tower-sessions",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"url",
|
||||||
|
"uuid",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -661,6 +688,27 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compliance-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"bson",
|
||||||
|
"chrono",
|
||||||
|
"compliance-core",
|
||||||
|
"dotenvy",
|
||||||
|
"mongodb",
|
||||||
|
"rmcp",
|
||||||
|
"schemars 1.2.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "console_error_panic_hook"
|
name = "console_error_panic_hook"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -792,7 +840,12 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"hmac",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
@@ -850,6 +903,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -953,8 +1015,18 @@ version = "0.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core 0.21.3",
|
||||||
"darling_macro",
|
"darling_macro 0.21.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core 0.23.0",
|
||||||
|
"darling_macro 0.23.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -971,13 +1043,37 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||||
|
dependencies = [
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling_macro"
|
name = "darling_macro"
|
||||||
version = "0.21.3"
|
version = "0.21.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core",
|
"darling_core 0.21.3",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core 0.23.0",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
@@ -1808,7 +1904,7 @@ version = "0.14.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
|
checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling 0.21.3",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
@@ -2085,6 +2181,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi",
|
||||||
|
"rand_core 0.10.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -2104,6 +2201,12 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gloo-net"
|
name = "gloo-net"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -3519,6 +3622,96 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"js-sys",
|
||||||
|
"pin-project-lite",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-appender-tracing"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e716f864eb23007bdd9dc4aec381e188a1cee28eecf22066772b5fd822b9727d"
|
||||||
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-http"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46d7ab32b827b5b495bd90fa95a6cb65ccc293555dcc3199ae2937d2d237c8ed"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"opentelemetry",
|
||||||
|
"reqwest",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-otlp"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d899720fe06916ccba71c01d04ecd77312734e2de3467fd30d9d580c8ce85656"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-http",
|
||||||
|
"opentelemetry-proto",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
"reqwest",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-proto"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c40da242381435e18570d5b9d50aca2a4f4f4d8e146231adb4e7768023309b3"
|
||||||
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
"tonic",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry_sdk"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-util",
|
||||||
|
"glob",
|
||||||
|
"opentelemetry",
|
||||||
|
"percent-encoding",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ownedbytes"
|
name = "ownedbytes"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3551,6 +3744,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pbkdf2"
|
name = "pbkdf2"
|
||||||
version = "0.12.2"
|
version = "0.12.2"
|
||||||
@@ -3752,6 +3951,29 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-derive"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"itertools",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psl-types"
|
name = "psl-types"
|
||||||
version = "2.0.11"
|
version = "2.0.11"
|
||||||
@@ -3865,6 +4087,17 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20",
|
||||||
|
"getrandom 0.4.1",
|
||||||
|
"rand_core 0.10.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -3903,6 +4136,12 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_distr"
|
name = "rand_distr"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -4007,6 +4246,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -4022,6 +4262,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4061,6 +4302,50 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmcp"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"futures",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"pastey",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rand 0.10.0",
|
||||||
|
"rmcp-macros",
|
||||||
|
"schemars 1.2.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sse-stream",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tokio-util",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rmcp-macros"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a"
|
||||||
|
dependencies = [
|
||||||
|
"darling 0.23.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde_json",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-stemmers"
|
name = "rust-stemmers"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -4224,12 +4509,26 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
"ref-cast",
|
"ref-cast",
|
||||||
|
"schemars_derive",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schemars_derive"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde_derive_internals",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -4369,6 +4668,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive_internals"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.149"
|
version = "1.0.149"
|
||||||
@@ -4453,7 +4763,7 @@ version = "3.17.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
|
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling",
|
"darling 0.21.3",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
@@ -4475,7 +4785,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4486,7 +4796,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4640,6 +4950,19 @@ version = "0.9.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sse-stream"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -5211,6 +5534,27 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project",
|
||||||
|
"prost",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -5228,6 +5572,22 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-cookies"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"cookie",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"parking_lot",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
@@ -5268,6 +5628,57 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"http",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tower-cookies",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tower-sessions-core",
|
||||||
|
"tower-sessions-memory-store",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions-core"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum-core",
|
||||||
|
"base64",
|
||||||
|
"futures",
|
||||||
|
"http",
|
||||||
|
"parking_lot",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions-memory-store"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tower-sessions-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -5322,6 +5733,24 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-opentelemetry"
|
||||||
|
version = "0.30.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd8e764bd6f5813fd8bebc3117875190c5b0415be8f7f8059bffb6ecd979c444"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"once_cell",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"smallvec",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.22"
|
version = "0.3.22"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ members = [
|
|||||||
"compliance-dashboard",
|
"compliance-dashboard",
|
||||||
"compliance-graph",
|
"compliance-graph",
|
||||||
"compliance-dast",
|
"compliance-dast",
|
||||||
|
"compliance-mcp",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,20 @@ COPY . .
|
|||||||
RUN cargo build --release -p compliance-agent
|
RUN cargo build --release -p compliance-agent
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 git && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl python3 python3-pip && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install syft for SBOM generation
|
||||||
|
RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||||
|
|
||||||
|
# Install gitleaks for secret detection
|
||||||
|
RUN curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
|
||||||
|
| tar -xz -C /usr/local/bin gitleaks
|
||||||
|
|
||||||
|
# Install semgrep for static analysis
|
||||||
|
RUN pip3 install --break-system-packages semgrep
|
||||||
|
|
||||||
|
# Install ruff for Python linting
|
||||||
|
RUN pip3 install --break-system-packages ruff
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
|
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ FROM rust:1.89-bookworm AS builder
|
|||||||
|
|
||||||
RUN cargo install dioxus-cli --version 0.7.3
|
RUN cargo install dioxus-cli --version 0.7.3
|
||||||
|
|
||||||
|
ARG DOCS_URL=/docs
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
ENV DOCS_URL=${DOCS_URL}
|
||||||
RUN dx build --release --package compliance-dashboard
|
RUN dx build --release --package compliance-dashboard
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
@@ -13,6 +16,7 @@ WORKDIR /app
|
|||||||
COPY --from=builder /app/target/dx/compliance-dashboard/release/web/compliance-dashboard /app/compliance-dashboard
|
COPY --from=builder /app/target/dx/compliance-dashboard/release/web/compliance-dashboard /app/compliance-dashboard
|
||||||
COPY --from=builder /app/target/dx/compliance-dashboard/release/web/public /app/public
|
COPY --from=builder /app/target/dx/compliance-dashboard/release/web/public /app/public
|
||||||
|
|
||||||
|
ENV IP=0.0.0.0
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
ENTRYPOINT ["./compliance-dashboard"]
|
ENTRYPOINT ["./compliance-dashboard"]
|
||||||
|
|||||||
14
Dockerfile.docs
Normal file
14
Dockerfile.docs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY docs/package.json docs/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY docs/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
COPY docs/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/.vitepress/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
16
Dockerfile.mcp
Normal file
16
Dockerfile.mcp
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM rust:1.89-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release -p compliance-mcp
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/compliance-mcp /usr/local/bin/compliance-mcp
|
||||||
|
|
||||||
|
EXPOSE 8090
|
||||||
|
|
||||||
|
ENV MCP_PORT=8090
|
||||||
|
|
||||||
|
ENTRYPOINT ["compliance-mcp"]
|
||||||
291
assets/main.css
291
assets/main.css
@@ -300,6 +300,84 @@ tr:hover {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar User Section */
|
||||||
|
.sidebar-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-collapsed {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 4px;
|
||||||
|
margin: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(56, 189, 248, 0.08));
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-initials {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn-collapsed {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
@@ -313,3 +391,216 @@ tr:hover {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Utility classes ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mb-3 { margin-bottom: 12px; }
|
||||||
|
.mb-4 { margin-bottom: 16px; }
|
||||||
|
.text-secondary { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 440px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog h3 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-warning {
|
||||||
|
color: var(--warning) !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── MCP Servers ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mcp-server-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-server-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-server-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-server-title h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-server-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-status-running {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-status-stopped {
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-status-error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-config-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-config-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-config-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tools-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tools-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-tool-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-token-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-token-value {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mcp-meta {
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
fn main() {
|
fn main() {
|
||||||
dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger");
|
|
||||||
|
|
||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
{
|
{
|
||||||
|
dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger");
|
||||||
dioxus::web::launch::launch_cfg(
|
dioxus::web::launch::launch_cfg(
|
||||||
compliance_dashboard::App,
|
compliance_dashboard::App,
|
||||||
dioxus::web::Config::new().hydrate(true),
|
dioxus::web::Config::new().hydrate(true),
|
||||||
@@ -14,6 +13,9 @@ fn main() {
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let _telemetry_guard = compliance_core::telemetry::init_telemetry("compliance-dashboard");
|
||||||
|
|
||||||
compliance_dashboard::infrastructure::server_start(compliance_dashboard::App)
|
compliance_dashboard::infrastructure::server_start(compliance_dashboard::App)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Unable to start server: {e}");
|
tracing::error!("Unable to start server: {e}");
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ edition = "2021"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
compliance-core = { workspace = true, features = ["mongodb"] }
|
compliance-core = { workspace = true, features = ["mongodb", "telemetry"] }
|
||||||
compliance-graph = { path = "../compliance-graph" }
|
compliance-graph = { path = "../compliance-graph" }
|
||||||
compliance-dast = { path = "../compliance-dast" }
|
compliance-dast = { path = "../compliance-dast" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -35,3 +35,4 @@ walkdir = "2"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ impl ComplianceAgent {
|
|||||||
config.litellm_url.clone(),
|
config.litellm_url.clone(),
|
||||||
config.litellm_api_key.clone(),
|
config.litellm_api_key.clone(),
|
||||||
config.litellm_model.clone(),
|
config.litellm_model.clone(),
|
||||||
|
config.litellm_embed_model.clone(),
|
||||||
));
|
));
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
|
|||||||
113
compliance-agent/src/api/auth_middleware.rs
Normal file
113
compliance-agent/src/api/auth_middleware.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::Request,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Cached JWKS from Keycloak for token validation.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JwksState {
|
||||||
|
pub jwks: Arc<RwLock<Option<JwkSet>>>,
|
||||||
|
pub jwks_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
sub: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"];
|
||||||
|
|
||||||
|
/// Middleware that validates Bearer JWT tokens against Keycloak's JWKS.
|
||||||
|
///
|
||||||
|
/// Skips validation for health check endpoints.
|
||||||
|
/// If `JwksState` is not present as an extension (keycloak not configured),
|
||||||
|
/// all requests pass through.
|
||||||
|
pub async fn require_jwt_auth(request: Request, next: Next) -> Response {
|
||||||
|
let path = request.uri().path();
|
||||||
|
|
||||||
|
if PUBLIC_ENDPOINTS.contains(&path) {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwks_state = match request.extensions().get::<JwksState>() {
|
||||||
|
Some(s) => s.clone(),
|
||||||
|
None => return next.run(request).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_header = match request.headers().get("authorization") {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return (StatusCode::UNAUTHORIZED, "Missing authorization header").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = match auth_header.to_str() {
|
||||||
|
Ok(s) if s.starts_with("Bearer ") => &s[7..],
|
||||||
|
_ => return (StatusCode::UNAUTHORIZED, "Invalid authorization header").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match validate_token(token, &jwks_state).await {
|
||||||
|
Ok(()) => next.run(request).await,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("JWT validation failed: {e}");
|
||||||
|
(StatusCode::UNAUTHORIZED, "Invalid token").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_token(token: &str, state: &JwksState) -> Result<(), String> {
|
||||||
|
let header = decode_header(token).map_err(|e| format!("failed to decode JWT header: {e}"))?;
|
||||||
|
|
||||||
|
let kid = header
|
||||||
|
.kid
|
||||||
|
.ok_or_else(|| "JWT missing kid header".to_string())?;
|
||||||
|
|
||||||
|
let jwks = fetch_or_get_jwks(state).await?;
|
||||||
|
|
||||||
|
let jwk = jwks
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.find(|k| k.common.key_id.as_deref() == Some(&kid))
|
||||||
|
.ok_or_else(|| "no matching key found in JWKS".to_string())?;
|
||||||
|
|
||||||
|
let decoding_key =
|
||||||
|
DecodingKey::from_jwk(jwk).map_err(|e| format!("failed to create decoding key: {e}"))?;
|
||||||
|
|
||||||
|
let mut validation = Validation::new(header.alg);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.validate_aud = false;
|
||||||
|
|
||||||
|
decode::<Claims>(token, &decoding_key, &validation)
|
||||||
|
.map_err(|e| format!("token validation failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_or_get_jwks(state: &JwksState) -> Result<JwkSet, String> {
|
||||||
|
{
|
||||||
|
let cached = state.jwks.read().await;
|
||||||
|
if let Some(ref jwks) = *cached {
|
||||||
|
return Ok(jwks.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = reqwest::get(&state.jwks_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to fetch JWKS: {e}"))?;
|
||||||
|
|
||||||
|
let jwks: JwkSet = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse JWKS: {e}"))?;
|
||||||
|
|
||||||
|
let mut cached = state.jwks.write().await;
|
||||||
|
*cached = Some(jwks.clone());
|
||||||
|
|
||||||
|
Ok(jwks)
|
||||||
|
}
|
||||||
244
compliance-agent/src/api/handlers/chat.rs
Normal file
244
compliance-agent/src/api/handlers/chat.rs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Extension, Path};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
|
use mongodb::bson::doc;
|
||||||
|
|
||||||
|
use compliance_core::models::chat::{ChatRequest, ChatResponse, SourceReference};
|
||||||
|
use compliance_core::models::embedding::EmbeddingBuildRun;
|
||||||
|
use compliance_graph::graph::embedding_store::EmbeddingStore;
|
||||||
|
|
||||||
|
use crate::agent::ComplianceAgent;
|
||||||
|
use crate::rag::pipeline::RagPipeline;
|
||||||
|
|
||||||
|
use super::ApiResponse;
|
||||||
|
|
||||||
|
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||||
|
|
||||||
|
/// POST /api/v1/chat/:repo_id — Send a chat message with RAG context
|
||||||
|
pub async fn chat(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(repo_id): Path<String>,
|
||||||
|
Json(req): Json<ChatRequest>,
|
||||||
|
) -> Result<Json<ApiResponse<ChatResponse>>, StatusCode> {
|
||||||
|
let pipeline = RagPipeline::new(agent.llm.clone(), agent.db.inner());
|
||||||
|
|
||||||
|
// Step 1: Embed the user's message
|
||||||
|
let query_vectors = agent
|
||||||
|
.llm
|
||||||
|
.embed(vec![req.message.clone()])
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Failed to embed query: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let query_embedding = query_vectors.into_iter().next().ok_or_else(|| {
|
||||||
|
tracing::error!("Empty embedding response");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Step 2: Vector search — retrieve top 8 chunks
|
||||||
|
let search_results = pipeline
|
||||||
|
.store()
|
||||||
|
.vector_search(&repo_id, query_embedding, 8, 0.5)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("Vector search failed: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Step 3: Build system prompt with code context
|
||||||
|
let mut context_parts = Vec::new();
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
|
||||||
|
for (embedding, score) in &search_results {
|
||||||
|
context_parts.push(format!(
|
||||||
|
"--- {} ({}, {}:L{}-L{}) ---\n{}",
|
||||||
|
embedding.qualified_name,
|
||||||
|
embedding.kind,
|
||||||
|
embedding.file_path,
|
||||||
|
embedding.start_line,
|
||||||
|
embedding.end_line,
|
||||||
|
embedding.content,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Truncate snippet for the response
|
||||||
|
let snippet: String = embedding
|
||||||
|
.content
|
||||||
|
.lines()
|
||||||
|
.take(10)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
sources.push(SourceReference {
|
||||||
|
file_path: embedding.file_path.clone(),
|
||||||
|
qualified_name: embedding.qualified_name.clone(),
|
||||||
|
start_line: embedding.start_line,
|
||||||
|
end_line: embedding.end_line,
|
||||||
|
language: embedding.language.clone(),
|
||||||
|
snippet,
|
||||||
|
score: *score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let code_context = if context_parts.is_empty() {
|
||||||
|
"No relevant code context found.".to_string()
|
||||||
|
} else {
|
||||||
|
context_parts.join("\n\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
let system_prompt = format!(
|
||||||
|
"You are an expert code assistant for a software repository. \
|
||||||
|
Answer the user's question based on the code context below. \
|
||||||
|
Reference specific files and functions when relevant. \
|
||||||
|
If the context doesn't contain enough information, say so.\n\n\
|
||||||
|
## Code Context\n\n{code_context}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: Build messages array with history
|
||||||
|
let mut messages: Vec<(String, String)> = Vec::new();
|
||||||
|
messages.push(("system".to_string(), system_prompt));
|
||||||
|
|
||||||
|
for msg in &req.history {
|
||||||
|
messages.push((msg.role.clone(), msg.content.clone()));
|
||||||
|
}
|
||||||
|
messages.push(("user".to_string(), req.message));
|
||||||
|
|
||||||
|
// Step 5: Call LLM
|
||||||
|
let response_text = agent
|
||||||
|
.llm
|
||||||
|
.chat_with_messages(messages, Some(0.3))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("LLM chat failed: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
data: ChatResponse {
|
||||||
|
message: response_text,
|
||||||
|
sources,
|
||||||
|
},
|
||||||
|
total: None,
|
||||||
|
page: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/chat/:repo_id/build-embeddings — Trigger embedding build
|
||||||
|
pub async fn build_embeddings(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(repo_id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let agent_clone = (*agent).clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let repo = match agent_clone
|
||||||
|
.db
|
||||||
|
.repositories()
|
||||||
|
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(r)) => r,
|
||||||
|
_ => {
|
||||||
|
tracing::error!("Repository {repo_id} not found for embedding build");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get latest graph build
|
||||||
|
let build = match agent_clone
|
||||||
|
.db
|
||||||
|
.graph_builds()
|
||||||
|
.find_one(doc! { "repo_id": &repo_id })
|
||||||
|
.sort(doc! { "started_at": -1 })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(b)) => b,
|
||||||
|
_ => {
|
||||||
|
tracing::error!("[{repo_id}] No graph build found — build graph first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let graph_build_id = build
|
||||||
|
.id
|
||||||
|
.map(|id| id.to_hex())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Get nodes
|
||||||
|
let nodes: Vec<compliance_core::models::graph::CodeNode> = match agent_clone
|
||||||
|
.db
|
||||||
|
.graph_nodes()
|
||||||
|
.find(doc! { "repo_id": &repo_id })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(cursor) => {
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut cursor = cursor;
|
||||||
|
while let Some(Ok(item)) = cursor.next().await {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[{repo_id}] Failed to fetch nodes: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let creds = crate::pipeline::git::RepoCredentials {
|
||||||
|
ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()),
|
||||||
|
auth_token: repo.auth_token.clone(),
|
||||||
|
auth_username: repo.auth_username.clone(),
|
||||||
|
};
|
||||||
|
let git_ops =
|
||||||
|
crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds);
|
||||||
|
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to clone repo for embedding build: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pipeline = RagPipeline::new(agent_clone.llm.clone(), agent_clone.db.inner());
|
||||||
|
match pipeline
|
||||||
|
.build_embeddings(&repo_id, &repo_path, &graph_build_id, &nodes)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(run) => {
|
||||||
|
tracing::info!(
|
||||||
|
"[{repo_id}] Embedding build complete: {}/{} chunks",
|
||||||
|
run.embedded_chunks,
|
||||||
|
run.total_chunks
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[{repo_id}] Embedding build failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({ "status": "embedding_build_triggered" }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/chat/:repo_id/status — Get latest embedding build status
|
||||||
|
pub async fn embedding_status(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(repo_id): Path<String>,
|
||||||
|
) -> Result<Json<ApiResponse<Option<EmbeddingBuildRun>>>, StatusCode> {
|
||||||
|
let store = EmbeddingStore::new(agent.db.inner());
|
||||||
|
let build = store.get_latest_build(&repo_id).await.map_err(|e| {
|
||||||
|
tracing::error!("Failed to get embedding status: {e}");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
data: build,
|
||||||
|
total: None,
|
||||||
|
page: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -103,8 +103,7 @@ pub async fn trigger_scan(
|
|||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
let oid =
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
||||||
|
|
||||||
let target = agent
|
let target = agent
|
||||||
.db
|
.db
|
||||||
@@ -207,8 +206,7 @@ pub async fn get_finding(
|
|||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<ApiResponse<DastFinding>>, StatusCode> {
|
) -> Result<Json<ApiResponse<DastFinding>>, StatusCode> {
|
||||||
let oid =
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
||||||
|
|
||||||
let finding = agent
|
let finding = agent
|
||||||
.db
|
.db
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub async fn get_graph(
|
|||||||
// so there is only one set of nodes/edges per repo.
|
// so there is only one set of nodes/edges per repo.
|
||||||
let filter = doc! { "repo_id": &repo_id };
|
let filter = doc! { "repo_id": &repo_id };
|
||||||
|
|
||||||
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter.clone()).await {
|
let all_nodes: Vec<CodeNode> = match db.graph_nodes().find(filter.clone()).await {
|
||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
Err(_) => Vec::new(),
|
Err(_) => Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -60,6 +60,17 @@ pub async fn get_graph(
|
|||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
Err(_) => Vec::new(),
|
Err(_) => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Remove disconnected nodes (no edges) to keep the graph clean
|
||||||
|
let connected: std::collections::HashSet<&str> = edges
|
||||||
|
.iter()
|
||||||
|
.flat_map(|e| [e.source.as_str(), e.target.as_str()])
|
||||||
|
.collect();
|
||||||
|
let nodes = all_nodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| connected.contains(n.qualified_name.as_str()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
(nodes, edges)
|
(nodes, edges)
|
||||||
} else {
|
} else {
|
||||||
(Vec::new(), Vec::new())
|
(Vec::new(), Vec::new())
|
||||||
@@ -235,12 +246,7 @@ pub async fn get_file_content(
|
|||||||
// Cap at 10,000 lines
|
// Cap at 10,000 lines
|
||||||
let truncated: String = content.lines().take(10_000).collect::<Vec<_>>().join("\n");
|
let truncated: String = content.lines().take(10_000).collect::<Vec<_>>().join("\n");
|
||||||
|
|
||||||
let language = params
|
let language = params.path.rsplit('.').next().unwrap_or("").to_string();
|
||||||
.path
|
|
||||||
.rsplit('.')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
data: FileContent {
|
data: FileContent {
|
||||||
@@ -285,7 +291,13 @@ pub async fn trigger_build(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path);
|
let creds = crate::pipeline::git::RepoCredentials {
|
||||||
|
ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()),
|
||||||
|
auth_token: repo.auth_token.clone(),
|
||||||
|
auth_username: repo.auth_username.clone(),
|
||||||
|
};
|
||||||
|
let git_ops =
|
||||||
|
crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds);
|
||||||
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod chat;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
|
|
||||||
@@ -5,7 +6,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use axum::extract::{Extension, Path, Query};
|
use axum::extract::{Extension, Path, Query};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use mongodb::bson::doc;
|
use mongodb::bson::doc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -39,6 +41,12 @@ pub struct FindingsFilter {
|
|||||||
pub scan_type: Option<String>,
|
pub scan_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub q: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sort_order: Option<String>,
|
||||||
#[serde(default = "default_page")]
|
#[serde(default = "default_page")]
|
||||||
pub page: u64,
|
pub page: u64,
|
||||||
#[serde(default = "default_limit")]
|
#[serde(default = "default_limit")]
|
||||||
@@ -74,6 +82,8 @@ pub struct AddRepositoryRequest {
|
|||||||
pub git_url: String,
|
pub git_url: String,
|
||||||
#[serde(default = "default_branch")]
|
#[serde(default = "default_branch")]
|
||||||
pub default_branch: String,
|
pub default_branch: String,
|
||||||
|
pub auth_token: Option<String>,
|
||||||
|
pub auth_username: Option<String>,
|
||||||
pub tracker_type: Option<TrackerType>,
|
pub tracker_type: Option<TrackerType>,
|
||||||
pub tracker_owner: Option<String>,
|
pub tracker_owner: Option<String>,
|
||||||
pub tracker_repo: Option<String>,
|
pub tracker_repo: Option<String>,
|
||||||
@@ -89,6 +99,83 @@ pub struct UpdateStatusRequest {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct BulkUpdateStatusRequest {
|
||||||
|
pub ids: Vec<String>,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UpdateFeedbackRequest {
|
||||||
|
pub feedback: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SbomFilter {
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub package_manager: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub q: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_vulns: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub license: Option<String>,
|
||||||
|
#[serde(default = "default_page")]
|
||||||
|
pub page: u64,
|
||||||
|
#[serde(default = "default_limit")]
|
||||||
|
pub limit: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SbomExportParams {
|
||||||
|
pub repo_id: String,
|
||||||
|
#[serde(default = "default_export_format")]
|
||||||
|
pub format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_export_format() -> String {
|
||||||
|
"cyclonedx".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SbomDiffParams {
|
||||||
|
pub repo_a: String,
|
||||||
|
pub repo_b: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LicenseSummary {
|
||||||
|
pub license: String,
|
||||||
|
pub count: u64,
|
||||||
|
pub is_copyleft: bool,
|
||||||
|
pub packages: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SbomDiffResult {
|
||||||
|
pub only_in_a: Vec<SbomDiffEntry>,
|
||||||
|
pub only_in_b: Vec<SbomDiffEntry>,
|
||||||
|
pub version_changed: Vec<SbomVersionDiff>,
|
||||||
|
pub common_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SbomDiffEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SbomVersionDiff {
|
||||||
|
pub name: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
pub version_a: String,
|
||||||
|
pub version_b: String,
|
||||||
|
}
|
||||||
|
|
||||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||||
type ApiResult<T> = Result<Json<ApiResponse<T>>, StatusCode>;
|
type ApiResult<T> = Result<Json<ApiResponse<T>>, StatusCode>;
|
||||||
|
|
||||||
@@ -199,9 +286,25 @@ pub async fn list_repositories(
|
|||||||
pub async fn add_repository(
|
pub async fn add_repository(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Json(req): Json<AddRepositoryRequest>,
|
Json(req): Json<AddRepositoryRequest>,
|
||||||
) -> Result<Json<ApiResponse<TrackedRepository>>, StatusCode> {
|
) -> Result<Json<ApiResponse<TrackedRepository>>, (StatusCode, String)> {
|
||||||
|
// Validate repository access before saving
|
||||||
|
let creds = crate::pipeline::git::RepoCredentials {
|
||||||
|
ssh_key_path: Some(agent.config.ssh_key_path.clone()),
|
||||||
|
auth_token: req.auth_token.clone(),
|
||||||
|
auth_username: req.auth_username.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = crate::pipeline::git::GitOps::test_access(&req.git_url, &creds) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("Cannot access repository: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mut repo = TrackedRepository::new(req.name, req.git_url);
|
let mut repo = TrackedRepository::new(req.name, req.git_url);
|
||||||
repo.default_branch = req.default_branch;
|
repo.default_branch = req.default_branch;
|
||||||
|
repo.auth_token = req.auth_token;
|
||||||
|
repo.auth_username = req.auth_username;
|
||||||
repo.tracker_type = req.tracker_type;
|
repo.tracker_type = req.tracker_type;
|
||||||
repo.tracker_owner = req.tracker_owner;
|
repo.tracker_owner = req.tracker_owner;
|
||||||
repo.tracker_repo = req.tracker_repo;
|
repo.tracker_repo = req.tracker_repo;
|
||||||
@@ -212,7 +315,12 @@ pub async fn add_repository(
|
|||||||
.repositories()
|
.repositories()
|
||||||
.insert_one(&repo)
|
.insert_one(&repo)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::CONFLICT)?;
|
.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Repository already exists".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
data: repo,
|
data: repo,
|
||||||
@@ -221,6 +329,14 @@ pub async fn add_repository(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_ssh_public_key(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let public_path = format!("{}.pub", agent.config.ssh_key_path);
|
||||||
|
let public_key = std::fs::read_to_string(&public_path).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
Ok(Json(serde_json::json!({ "public_key": public_key.trim() })))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn trigger_scan(
|
pub async fn trigger_scan(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
@@ -235,6 +351,52 @@ pub async fn trigger_scan(
|
|||||||
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_repository(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
let db = &agent.db;
|
||||||
|
|
||||||
|
// Delete the repository
|
||||||
|
let result = db
|
||||||
|
.repositories()
|
||||||
|
.delete_one(doc! { "_id": oid })
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if result.deleted_count == 0 {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade delete all related data
|
||||||
|
let _ = db.findings().delete_many(doc! { "repo_id": &id }).await;
|
||||||
|
let _ = db.sbom_entries().delete_many(doc! { "repo_id": &id }).await;
|
||||||
|
let _ = db.scan_runs().delete_many(doc! { "repo_id": &id }).await;
|
||||||
|
let _ = db.cve_alerts().delete_many(doc! { "repo_id": &id }).await;
|
||||||
|
let _ = db
|
||||||
|
.tracker_issues()
|
||||||
|
.delete_many(doc! { "repo_id": &id })
|
||||||
|
.await;
|
||||||
|
let _ = db.graph_nodes().delete_many(doc! { "repo_id": &id }).await;
|
||||||
|
let _ = db.graph_edges().delete_many(doc! { "repo_id": &id }).await;
|
||||||
|
let _ = db.graph_builds().delete_many(doc! { "repo_id": &id }).await;
|
||||||
|
let _ = db
|
||||||
|
.impact_analyses()
|
||||||
|
.delete_many(doc! { "repo_id": &id })
|
||||||
|
.await;
|
||||||
|
let _ = db
|
||||||
|
.code_embeddings()
|
||||||
|
.delete_many(doc! { "repo_id": &id })
|
||||||
|
.await;
|
||||||
|
let _ = db
|
||||||
|
.embedding_builds()
|
||||||
|
.delete_many(doc! { "repo_id": &id })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "status": "deleted" })))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_findings(
|
pub async fn list_findings(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Query(filter): Query<FindingsFilter>,
|
Query(filter): Query<FindingsFilter>,
|
||||||
@@ -253,6 +415,29 @@ pub async fn list_findings(
|
|||||||
if let Some(status) = &filter.status {
|
if let Some(status) = &filter.status {
|
||||||
query.insert("status", status);
|
query.insert("status", status);
|
||||||
}
|
}
|
||||||
|
// Text search across title, description, file_path, rule_id
|
||||||
|
if let Some(q) = &filter.q {
|
||||||
|
if !q.is_empty() {
|
||||||
|
let regex = doc! { "$regex": q, "$options": "i" };
|
||||||
|
query.insert(
|
||||||
|
"$or",
|
||||||
|
mongodb::bson::bson!([
|
||||||
|
{ "title": regex.clone() },
|
||||||
|
{ "description": regex.clone() },
|
||||||
|
{ "file_path": regex.clone() },
|
||||||
|
{ "rule_id": regex },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic sort
|
||||||
|
let sort_field = filter.sort_by.as_deref().unwrap_or("created_at");
|
||||||
|
let sort_dir: i32 = match filter.sort_order.as_deref() {
|
||||||
|
Some("asc") => 1,
|
||||||
|
_ => -1,
|
||||||
|
};
|
||||||
|
let sort_doc = doc! { sort_field: sort_dir };
|
||||||
|
|
||||||
let skip = (filter.page.saturating_sub(1)) * filter.limit as u64;
|
let skip = (filter.page.saturating_sub(1)) * filter.limit as u64;
|
||||||
let total = db
|
let total = db
|
||||||
@@ -264,7 +449,7 @@ pub async fn list_findings(
|
|||||||
let findings = match db
|
let findings = match db
|
||||||
.findings()
|
.findings()
|
||||||
.find(query)
|
.find(query)
|
||||||
.sort(doc! { "created_at": -1 })
|
.sort(sort_doc)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(filter.limit)
|
.limit(filter.limit)
|
||||||
.await
|
.await
|
||||||
@@ -320,23 +505,97 @@ pub async fn update_finding_status(
|
|||||||
Ok(Json(serde_json::json!({ "status": "updated" })))
|
Ok(Json(serde_json::json!({ "status": "updated" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn bulk_update_finding_status(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Json(req): Json<BulkUpdateStatusRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let oids: Vec<mongodb::bson::oid::ObjectId> = req
|
||||||
|
.ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| mongodb::bson::oid::ObjectId::parse_str(id).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if oids.is_empty() {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = agent
|
||||||
|
.db
|
||||||
|
.findings()
|
||||||
|
.update_many(
|
||||||
|
doc! { "_id": { "$in": oids } },
|
||||||
|
doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({ "status": "updated", "modified_count": result.modified_count }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_finding_feedback(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(req): Json<UpdateFeedbackRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
agent
|
||||||
|
.db
|
||||||
|
.findings()
|
||||||
|
.update_one(
|
||||||
|
doc! { "_id": oid },
|
||||||
|
doc! { "$set": { "developer_feedback": &req.feedback, "updated_at": mongodb::bson::DateTime::now() } },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "status": "updated" })))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_sbom(
|
pub async fn list_sbom(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Query(params): Query<PaginationParams>,
|
Query(filter): Query<SbomFilter>,
|
||||||
) -> ApiResult<Vec<SbomEntry>> {
|
) -> ApiResult<Vec<SbomEntry>> {
|
||||||
let db = &agent.db;
|
let db = &agent.db;
|
||||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
let mut query = doc! {};
|
||||||
|
|
||||||
|
if let Some(repo_id) = &filter.repo_id {
|
||||||
|
query.insert("repo_id", repo_id);
|
||||||
|
}
|
||||||
|
if let Some(pm) = &filter.package_manager {
|
||||||
|
query.insert("package_manager", pm);
|
||||||
|
}
|
||||||
|
if let Some(q) = &filter.q {
|
||||||
|
if !q.is_empty() {
|
||||||
|
query.insert("name", doc! { "$regex": q, "$options": "i" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(has_vulns) = filter.has_vulns {
|
||||||
|
if has_vulns {
|
||||||
|
query.insert("known_vulnerabilities", doc! { "$exists": true, "$ne": [] });
|
||||||
|
} else {
|
||||||
|
query.insert("known_vulnerabilities", doc! { "$size": 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(license) = &filter.license {
|
||||||
|
query.insert("license", license);
|
||||||
|
}
|
||||||
|
|
||||||
|
let skip = (filter.page.saturating_sub(1)) * filter.limit as u64;
|
||||||
let total = db
|
let total = db
|
||||||
.sbom_entries()
|
.sbom_entries()
|
||||||
.count_documents(doc! {})
|
.count_documents(query.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let entries = match db
|
let entries = match db
|
||||||
.sbom_entries()
|
.sbom_entries()
|
||||||
.find(doc! {})
|
.find(query)
|
||||||
|
.sort(doc! { "name": 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(params.limit)
|
.limit(filter.limit)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
@@ -346,7 +605,272 @@ pub async fn list_sbom(
|
|||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
data: entries,
|
data: entries,
|
||||||
total: Some(total),
|
total: Some(total),
|
||||||
page: Some(params.page),
|
page: Some(filter.page),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_sbom(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Query(params): Query<SbomExportParams>,
|
||||||
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
let db = &agent.db;
|
||||||
|
let entries: Vec<SbomEntry> = match db
|
||||||
|
.sbom_entries()
|
||||||
|
.find(doc! { "repo_id": ¶ms.repo_id })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = if params.format == "spdx" {
|
||||||
|
// SPDX 2.3 format
|
||||||
|
let packages: Vec<serde_json::Value> = entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, e)| {
|
||||||
|
serde_json::json!({
|
||||||
|
"SPDXID": format!("SPDXRef-Package-{i}"),
|
||||||
|
"name": e.name,
|
||||||
|
"versionInfo": e.version,
|
||||||
|
"downloadLocation": "NOASSERTION",
|
||||||
|
"licenseConcluded": e.license.as_deref().unwrap_or("NOASSERTION"),
|
||||||
|
"externalRefs": e.purl.as_ref().map(|p| vec![serde_json::json!({
|
||||||
|
"referenceCategory": "PACKAGE-MANAGER",
|
||||||
|
"referenceType": "purl",
|
||||||
|
"referenceLocator": p,
|
||||||
|
})]).unwrap_or_default(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"spdxVersion": "SPDX-2.3",
|
||||||
|
"dataLicense": "CC0-1.0",
|
||||||
|
"SPDXID": "SPDXRef-DOCUMENT",
|
||||||
|
"name": format!("sbom-{}", params.repo_id),
|
||||||
|
"documentNamespace": format!("https://compliance-scanner/sbom/{}", params.repo_id),
|
||||||
|
"packages": packages,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// CycloneDX 1.5 format
|
||||||
|
let components: Vec<serde_json::Value> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let mut comp = serde_json::json!({
|
||||||
|
"type": "library",
|
||||||
|
"name": e.name,
|
||||||
|
"version": e.version,
|
||||||
|
"group": e.package_manager,
|
||||||
|
});
|
||||||
|
if let Some(purl) = &e.purl {
|
||||||
|
comp["purl"] = serde_json::Value::String(purl.clone());
|
||||||
|
}
|
||||||
|
if let Some(license) = &e.license {
|
||||||
|
comp["licenses"] = serde_json::json!([{ "license": { "id": license } }]);
|
||||||
|
}
|
||||||
|
if !e.known_vulnerabilities.is_empty() {
|
||||||
|
comp["vulnerabilities"] = serde_json::json!(
|
||||||
|
e.known_vulnerabilities.iter().map(|v| serde_json::json!({
|
||||||
|
"id": v.id,
|
||||||
|
"source": { "name": v.source },
|
||||||
|
"ratings": v.severity.as_ref().map(|s| vec![serde_json::json!({"severity": s})]).unwrap_or_default(),
|
||||||
|
})).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
comp
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"bomFormat": "CycloneDX",
|
||||||
|
"specVersion": "1.5",
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"component": {
|
||||||
|
"type": "application",
|
||||||
|
"name": format!("repo-{}", params.repo_id),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": components,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let json_str =
|
||||||
|
serde_json::to_string_pretty(&body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let filename = if params.format == "spdx" {
|
||||||
|
format!("sbom-{}-spdx.json", params.repo_id)
|
||||||
|
} else {
|
||||||
|
format!("sbom-{}-cyclonedx.json", params.repo_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
header::HeaderValue::from_static("application/json"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
header::HeaderValue::from_str(&disposition)
|
||||||
|
.unwrap_or_else(|_| header::HeaderValue::from_static("attachment")),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
json_str,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const COPYLEFT_LICENSES: &[&str] = &[
|
||||||
|
"GPL-2.0",
|
||||||
|
"GPL-2.0-only",
|
||||||
|
"GPL-2.0-or-later",
|
||||||
|
"GPL-3.0",
|
||||||
|
"GPL-3.0-only",
|
||||||
|
"GPL-3.0-or-later",
|
||||||
|
"AGPL-3.0",
|
||||||
|
"AGPL-3.0-only",
|
||||||
|
"AGPL-3.0-or-later",
|
||||||
|
"LGPL-2.1",
|
||||||
|
"LGPL-2.1-only",
|
||||||
|
"LGPL-2.1-or-later",
|
||||||
|
"LGPL-3.0",
|
||||||
|
"LGPL-3.0-only",
|
||||||
|
"LGPL-3.0-or-later",
|
||||||
|
"MPL-2.0",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub async fn license_summary(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Query(params): Query<SbomFilter>,
|
||||||
|
) -> ApiResult<Vec<LicenseSummary>> {
|
||||||
|
let db = &agent.db;
|
||||||
|
let mut query = doc! {};
|
||||||
|
if let Some(repo_id) = ¶ms.repo_id {
|
||||||
|
query.insert("repo_id", repo_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: Vec<SbomEntry> = match db.sbom_entries().find(query).await {
|
||||||
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut license_map: std::collections::HashMap<String, Vec<String>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for entry in &entries {
|
||||||
|
let lic = entry.license.as_deref().unwrap_or("Unknown").to_string();
|
||||||
|
license_map.entry(lic).or_default().push(entry.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut summaries: Vec<LicenseSummary> = license_map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(license, packages)| {
|
||||||
|
let is_copyleft = COPYLEFT_LICENSES
|
||||||
|
.iter()
|
||||||
|
.any(|c| license.to_uppercase().contains(&c.to_uppercase()));
|
||||||
|
LicenseSummary {
|
||||||
|
license,
|
||||||
|
count: packages.len() as u64,
|
||||||
|
is_copyleft,
|
||||||
|
packages,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
summaries.sort_by(|a, b| b.count.cmp(&a.count));
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
data: summaries,
|
||||||
|
total: None,
|
||||||
|
page: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sbom_diff(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Query(params): Query<SbomDiffParams>,
|
||||||
|
) -> ApiResult<SbomDiffResult> {
|
||||||
|
let db = &agent.db;
|
||||||
|
|
||||||
|
let entries_a: Vec<SbomEntry> = match db
|
||||||
|
.sbom_entries()
|
||||||
|
.find(doc! { "repo_id": ¶ms.repo_a })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries_b: Vec<SbomEntry> = match db
|
||||||
|
.sbom_entries()
|
||||||
|
.find(doc! { "repo_id": ¶ms.repo_b })
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build maps by (name, package_manager) -> version
|
||||||
|
let map_a: std::collections::HashMap<(String, String), String> = entries_a
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
(
|
||||||
|
(e.name.clone(), e.package_manager.clone()),
|
||||||
|
e.version.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let map_b: std::collections::HashMap<(String, String), String> = entries_b
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
(
|
||||||
|
(e.name.clone(), e.package_manager.clone()),
|
||||||
|
e.version.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut only_in_a = Vec::new();
|
||||||
|
let mut version_changed = Vec::new();
|
||||||
|
let mut common_count: u64 = 0;
|
||||||
|
|
||||||
|
for (key, ver_a) in &map_a {
|
||||||
|
match map_b.get(key) {
|
||||||
|
None => only_in_a.push(SbomDiffEntry {
|
||||||
|
name: key.0.clone(),
|
||||||
|
version: ver_a.clone(),
|
||||||
|
package_manager: key.1.clone(),
|
||||||
|
}),
|
||||||
|
Some(ver_b) if ver_a != ver_b => {
|
||||||
|
version_changed.push(SbomVersionDiff {
|
||||||
|
name: key.0.clone(),
|
||||||
|
package_manager: key.1.clone(),
|
||||||
|
version_a: ver_a.clone(),
|
||||||
|
version_b: ver_b.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(_) => common_count += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let only_in_b: Vec<SbomDiffEntry> = map_b
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| !map_a.contains_key(key))
|
||||||
|
.map(|(key, ver)| SbomDiffEntry {
|
||||||
|
name: key.0.clone(),
|
||||||
|
version: ver.clone(),
|
||||||
|
package_manager: key.1.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
data: SbomDiffResult {
|
||||||
|
only_in_a,
|
||||||
|
only_in_b,
|
||||||
|
version_changed,
|
||||||
|
common_count,
|
||||||
|
},
|
||||||
|
total: None,
|
||||||
|
page: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod auth_middleware;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use axum::routing::{get, patch, post};
|
use axum::routing::{delete, get, patch, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use crate::api::handlers;
|
use crate::api::handlers;
|
||||||
@@ -7,26 +7,42 @@ pub fn build_router() -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/v1/health", get(handlers::health))
|
.route("/api/v1/health", get(handlers::health))
|
||||||
.route("/api/v1/stats/overview", get(handlers::stats_overview))
|
.route("/api/v1/stats/overview", get(handlers::stats_overview))
|
||||||
|
.route(
|
||||||
|
"/api/v1/settings/ssh-public-key",
|
||||||
|
get(handlers::get_ssh_public_key),
|
||||||
|
)
|
||||||
.route("/api/v1/repositories", get(handlers::list_repositories))
|
.route("/api/v1/repositories", get(handlers::list_repositories))
|
||||||
.route("/api/v1/repositories", post(handlers::add_repository))
|
.route("/api/v1/repositories", post(handlers::add_repository))
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/repositories/{id}/scan",
|
"/api/v1/repositories/{id}/scan",
|
||||||
post(handlers::trigger_scan),
|
post(handlers::trigger_scan),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/repositories/{id}",
|
||||||
|
delete(handlers::delete_repository),
|
||||||
|
)
|
||||||
.route("/api/v1/findings", get(handlers::list_findings))
|
.route("/api/v1/findings", get(handlers::list_findings))
|
||||||
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/findings/{id}/status",
|
"/api/v1/findings/{id}/status",
|
||||||
patch(handlers::update_finding_status),
|
patch(handlers::update_finding_status),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/findings/bulk-status",
|
||||||
|
patch(handlers::bulk_update_finding_status),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/findings/{id}/feedback",
|
||||||
|
patch(handlers::update_finding_feedback),
|
||||||
|
)
|
||||||
.route("/api/v1/sbom", get(handlers::list_sbom))
|
.route("/api/v1/sbom", get(handlers::list_sbom))
|
||||||
|
.route("/api/v1/sbom/export", get(handlers::export_sbom))
|
||||||
|
.route("/api/v1/sbom/licenses", get(handlers::license_summary))
|
||||||
|
.route("/api/v1/sbom/diff", get(handlers::sbom_diff))
|
||||||
.route("/api/v1/issues", get(handlers::list_issues))
|
.route("/api/v1/issues", get(handlers::list_issues))
|
||||||
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
||||||
// Graph API endpoints
|
// Graph API endpoints
|
||||||
.route(
|
.route("/api/v1/graph/{repo_id}", get(handlers::graph::get_graph))
|
||||||
"/api/v1/graph/{repo_id}",
|
|
||||||
get(handlers::graph::get_graph),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/graph/{repo_id}/nodes",
|
"/api/v1/graph/{repo_id}/nodes",
|
||||||
get(handlers::graph::get_nodes),
|
get(handlers::graph::get_nodes),
|
||||||
@@ -52,14 +68,8 @@ pub fn build_router() -> Router {
|
|||||||
post(handlers::graph::trigger_build),
|
post(handlers::graph::trigger_build),
|
||||||
)
|
)
|
||||||
// DAST API endpoints
|
// DAST API endpoints
|
||||||
.route(
|
.route("/api/v1/dast/targets", get(handlers::dast::list_targets))
|
||||||
"/api/v1/dast/targets",
|
.route("/api/v1/dast/targets", post(handlers::dast::add_target))
|
||||||
get(handlers::dast::list_targets),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/v1/dast/targets",
|
|
||||||
post(handlers::dast::add_target),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/dast/targets/{id}/scan",
|
"/api/v1/dast/targets/{id}/scan",
|
||||||
post(handlers::dast::trigger_scan),
|
post(handlers::dast::trigger_scan),
|
||||||
@@ -68,12 +78,19 @@ pub fn build_router() -> Router {
|
|||||||
"/api/v1/dast/scan-runs",
|
"/api/v1/dast/scan-runs",
|
||||||
get(handlers::dast::list_scan_runs),
|
get(handlers::dast::list_scan_runs),
|
||||||
)
|
)
|
||||||
.route(
|
.route("/api/v1/dast/findings", get(handlers::dast::list_findings))
|
||||||
"/api/v1/dast/findings",
|
|
||||||
get(handlers::dast::list_findings),
|
|
||||||
)
|
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/dast/findings/{id}",
|
"/api/v1/dast/findings/{id}",
|
||||||
get(handlers::dast::get_finding),
|
get(handlers::dast::get_finding),
|
||||||
)
|
)
|
||||||
|
// Chat / RAG API endpoints
|
||||||
|
.route("/api/v1/chat/{repo_id}", post(handlers::chat::chat))
|
||||||
|
.route(
|
||||||
|
"/api/v1/chat/{repo_id}/build-embeddings",
|
||||||
|
post(handlers::chat::build_embeddings),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/chat/{repo_id}/status",
|
||||||
|
get(handlers::chat::embedding_status),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::Extension;
|
use axum::{middleware, Extension};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
use crate::agent::ComplianceAgent;
|
use crate::agent::ComplianceAgent;
|
||||||
|
use crate::api::auth_middleware::{require_jwt_auth, JwksState};
|
||||||
use crate::api::routes;
|
use crate::api::routes;
|
||||||
use crate::error::AgentError;
|
use crate::error::AgentError;
|
||||||
|
|
||||||
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
|
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
|
||||||
let app = routes::build_router()
|
let mut app = routes::build_router()
|
||||||
.layer(Extension(Arc::new(agent)))
|
.layer(Extension(Arc::new(agent.clone())))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.layer(TraceLayer::new_for_http());
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
if let (Some(kc_url), Some(kc_realm)) =
|
||||||
|
(&agent.config.keycloak_url, &agent.config.keycloak_realm)
|
||||||
|
{
|
||||||
|
let jwks_url = format!("{kc_url}/realms/{kc_realm}/protocol/openid-connect/certs");
|
||||||
|
let jwks_state = JwksState {
|
||||||
|
jwks: Arc::new(RwLock::new(None)),
|
||||||
|
jwks_url,
|
||||||
|
};
|
||||||
|
tracing::info!("Keycloak JWT auth enabled for realm '{kc_realm}'");
|
||||||
|
app = app
|
||||||
|
.layer(Extension(jwks_state))
|
||||||
|
.layer(middleware::from_fn(require_jwt_auth));
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Keycloak not configured - API endpoints are unprotected");
|
||||||
|
}
|
||||||
|
|
||||||
let addr = format!("0.0.0.0:{port}");
|
let addr = format!("0.0.0.0:{port}");
|
||||||
let listener = tokio::net::TcpListener::bind(&addr)
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
|||||||
.unwrap_or_else(|| "http://localhost:4000".to_string()),
|
.unwrap_or_else(|| "http://localhost:4000".to_string()),
|
||||||
litellm_api_key: SecretString::from(env_var_opt("LITELLM_API_KEY").unwrap_or_default()),
|
litellm_api_key: SecretString::from(env_var_opt("LITELLM_API_KEY").unwrap_or_default()),
|
||||||
litellm_model: env_var_opt("LITELLM_MODEL").unwrap_or_else(|| "gpt-4o".to_string()),
|
litellm_model: env_var_opt("LITELLM_MODEL").unwrap_or_else(|| "gpt-4o".to_string()),
|
||||||
|
litellm_embed_model: env_var_opt("LITELLM_EMBED_MODEL")
|
||||||
|
.unwrap_or_else(|| "text-embedding-3-small".to_string()),
|
||||||
github_token: env_secret_opt("GITHUB_TOKEN"),
|
github_token: env_secret_opt("GITHUB_TOKEN"),
|
||||||
github_webhook_secret: env_secret_opt("GITHUB_WEBHOOK_SECRET"),
|
github_webhook_secret: env_secret_opt("GITHUB_WEBHOOK_SECRET"),
|
||||||
gitlab_url: env_var_opt("GITLAB_URL"),
|
gitlab_url: env_var_opt("GITLAB_URL"),
|
||||||
@@ -43,5 +45,9 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
|||||||
.unwrap_or_else(|| "0 0 0 * * *".to_string()),
|
.unwrap_or_else(|| "0 0 0 * * *".to_string()),
|
||||||
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
|
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
|
||||||
.unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()),
|
.unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()),
|
||||||
|
ssh_key_path: env_var_opt("SSH_KEY_PATH")
|
||||||
|
.unwrap_or_else(|| "/data/compliance-scanner/ssh/id_ed25519".to_string()),
|
||||||
|
keycloak_url: env_var_opt("KEYCLOAK_URL"),
|
||||||
|
keycloak_realm: env_var_opt("KEYCLOAK_REALM"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,11 +127,7 @@ impl Database {
|
|||||||
|
|
||||||
// dast_targets: index on repo_id
|
// dast_targets: index on repo_id
|
||||||
self.dast_targets()
|
self.dast_targets()
|
||||||
.create_index(
|
.create_index(IndexModel::builder().keys(doc! { "repo_id": 1 }).build())
|
||||||
IndexModel::builder()
|
|
||||||
.keys(doc! { "repo_id": 1 })
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// dast_scan_runs: compound (target_id, started_at DESC)
|
// dast_scan_runs: compound (target_id, started_at DESC)
|
||||||
@@ -152,6 +148,24 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// code_embeddings: compound (repo_id, graph_build_id)
|
||||||
|
self.code_embeddings()
|
||||||
|
.create_index(
|
||||||
|
IndexModel::builder()
|
||||||
|
.keys(doc! { "repo_id": 1, "graph_build_id": 1 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// embedding_builds: compound (repo_id, started_at DESC)
|
||||||
|
self.embedding_builds()
|
||||||
|
.create_index(
|
||||||
|
IndexModel::builder()
|
||||||
|
.keys(doc! { "repo_id": 1, "started_at": -1 })
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
tracing::info!("Database indexes ensured");
|
tracing::info!("Database indexes ensured");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -210,6 +224,17 @@ impl Database {
|
|||||||
self.inner.collection("dast_findings")
|
self.inner.collection("dast_findings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Embedding collections
|
||||||
|
pub fn code_embeddings(&self) -> Collection<compliance_core::models::embedding::CodeEmbedding> {
|
||||||
|
self.inner.collection("code_embeddings")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn embedding_builds(
|
||||||
|
&self,
|
||||||
|
) -> Collection<compliance_core::models::embedding::EmbeddingBuildRun> {
|
||||||
|
self.inner.collection("embedding_builds")
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
|
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
|
||||||
self.inner.collection(name)
|
self.inner.collection(name)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct LlmClient {
|
|||||||
base_url: String,
|
base_url: String,
|
||||||
api_key: SecretString,
|
api_key: SecretString,
|
||||||
model: String,
|
model: String,
|
||||||
|
embed_model: String,
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,16 +43,46 @@ struct ChatResponseMessage {
|
|||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request body for the embeddings API
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct EmbeddingRequest {
|
||||||
|
model: String,
|
||||||
|
input: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from the embeddings API
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EmbeddingResponse {
|
||||||
|
data: Vec<EmbeddingData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single embedding result
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EmbeddingData {
|
||||||
|
embedding: Vec<f64>,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
impl LlmClient {
|
impl LlmClient {
|
||||||
pub fn new(base_url: String, api_key: SecretString, model: String) -> Self {
|
pub fn new(
|
||||||
|
base_url: String,
|
||||||
|
api_key: SecretString,
|
||||||
|
model: String,
|
||||||
|
embed_model: String,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_url,
|
base_url,
|
||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
|
embed_model,
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn embed_model(&self) -> &str {
|
||||||
|
&self.embed_model
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn chat(
|
pub async fn chat(
|
||||||
&self,
|
&self,
|
||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
@@ -169,4 +200,49 @@ impl LlmClient {
|
|||||||
.map(|c| c.message.content.clone())
|
.map(|c| c.message.content.clone())
|
||||||
.ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string()))
|
.ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate embeddings for a batch of texts
|
||||||
|
pub async fn embed(&self, texts: Vec<String>) -> Result<Vec<Vec<f64>>, AgentError> {
|
||||||
|
let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let request_body = EmbeddingRequest {
|
||||||
|
model: self.embed_model.clone(),
|
||||||
|
input: texts,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut req = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.json(&request_body);
|
||||||
|
|
||||||
|
let key = self.api_key.expose_secret();
|
||||||
|
if !key.is_empty() {
|
||||||
|
req = req.header("Authorization", format!("Bearer {key}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = req
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Embedding request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(AgentError::Other(format!(
|
||||||
|
"Embedding API returned {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: EmbeddingResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to parse embedding response: {e}")))?;
|
||||||
|
|
||||||
|
// Sort by index to maintain input order
|
||||||
|
let mut data = body.data;
|
||||||
|
data.sort_by_key(|d| d.index);
|
||||||
|
|
||||||
|
Ok(data.into_iter().map(|d| d.embedding).collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod descriptions;
|
|||||||
pub mod fixes;
|
pub mod fixes;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub mod pr_review;
|
pub mod pr_review;
|
||||||
|
pub mod review_prompts;
|
||||||
pub mod triage;
|
pub mod triage;
|
||||||
|
|
||||||
pub use client::LlmClient;
|
pub use client::LlmClient;
|
||||||
|
|||||||
77
compliance-agent/src/llm/review_prompts.rs
Normal file
77
compliance-agent/src/llm/review_prompts.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// System prompts for multi-pass LLM code review.
|
||||||
|
// Each pass focuses on a different aspect to avoid overloading a single prompt.
|
||||||
|
|
||||||
|
pub const LOGIC_REVIEW_PROMPT: &str = r#"You are a senior software engineer reviewing code changes. Focus ONLY on logic and correctness issues.
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- Off-by-one errors, wrong comparisons, missing edge cases
|
||||||
|
- Incorrect control flow (unreachable code, missing returns, wrong loop conditions)
|
||||||
|
- Race conditions or concurrency bugs
|
||||||
|
- Resource leaks (unclosed handles, missing cleanup)
|
||||||
|
- Wrong variable used (copy-paste errors)
|
||||||
|
- Incorrect error handling (swallowed errors, wrong error type)
|
||||||
|
|
||||||
|
Ignore: style, naming, formatting, documentation, minor improvements.
|
||||||
|
|
||||||
|
For each issue found, respond with a JSON array:
|
||||||
|
[{"title": "...", "description": "...", "severity": "high|medium|low", "file": "...", "line": N, "suggestion": "..."}]
|
||||||
|
|
||||||
|
If no issues found, respond with: []"#;
|
||||||
|
|
||||||
|
pub const SECURITY_REVIEW_PROMPT: &str = r#"You are a security engineer reviewing code changes. Focus ONLY on security vulnerabilities.
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- Injection vulnerabilities (SQL, command, XSS, template injection)
|
||||||
|
- Authentication/authorization bypasses
|
||||||
|
- Sensitive data exposure (logging secrets, hardcoded credentials)
|
||||||
|
- Insecure cryptography (weak algorithms, predictable randomness)
|
||||||
|
- Path traversal, SSRF, open redirects
|
||||||
|
- Unsafe deserialization
|
||||||
|
- Missing input validation at trust boundaries
|
||||||
|
|
||||||
|
Ignore: code style, performance, general quality.
|
||||||
|
|
||||||
|
For each issue found, respond with a JSON array:
|
||||||
|
[{"title": "...", "description": "...", "severity": "critical|high|medium", "file": "...", "line": N, "cwe": "CWE-XXX", "suggestion": "..."}]
|
||||||
|
|
||||||
|
If no issues found, respond with: []"#;
|
||||||
|
|
||||||
|
pub const CONVENTION_REVIEW_PROMPT: &str = r#"You are a code reviewer checking adherence to project conventions. Focus ONLY on patterns that indicate likely bugs or maintenance problems.
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- Inconsistent error handling patterns within the same module
|
||||||
|
- Public API that doesn't follow the project's established patterns
|
||||||
|
- Missing or incorrect type annotations that could cause runtime issues
|
||||||
|
- Anti-patterns specific to the language (e.g. unwrap in Rust library code, any in TypeScript)
|
||||||
|
|
||||||
|
Do NOT report: minor style preferences, documentation gaps, formatting.
|
||||||
|
Only report issues with HIGH confidence that they deviate from the visible codebase conventions.
|
||||||
|
|
||||||
|
For each issue found, respond with a JSON array:
|
||||||
|
[{"title": "...", "description": "...", "severity": "medium|low", "file": "...", "line": N, "suggestion": "..."}]
|
||||||
|
|
||||||
|
If no issues found, respond with: []"#;
|
||||||
|
|
||||||
|
pub const COMPLEXITY_REVIEW_PROMPT: &str = r#"You are reviewing code changes for excessive complexity that could lead to bugs.
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
- Functions over 50 lines that should be decomposed
|
||||||
|
- Deeply nested control flow (4+ levels)
|
||||||
|
- Complex boolean expressions that are hard to reason about
|
||||||
|
- Functions with 5+ parameters
|
||||||
|
- Code duplication within the changed files
|
||||||
|
|
||||||
|
Only report complexity issues that are HIGH risk for future bugs. Ignore acceptable complexity in configuration, CLI argument parsing, or generated code.
|
||||||
|
|
||||||
|
For each issue found, respond with a JSON array:
|
||||||
|
[{"title": "...", "description": "...", "severity": "medium|low", "file": "...", "line": N, "suggestion": "..."}]
|
||||||
|
|
||||||
|
If no issues found, respond with: []"#;
|
||||||
|
|
||||||
|
/// All review types with their prompts
|
||||||
|
pub const REVIEW_PASSES: &[(&str, &str)] = &[
|
||||||
|
("logic", LOGIC_REVIEW_PROMPT),
|
||||||
|
("security", SECURITY_REVIEW_PROMPT),
|
||||||
|
("convention", CONVENTION_REVIEW_PROMPT),
|
||||||
|
("complexity", COMPLEXITY_REVIEW_PROMPT),
|
||||||
|
];
|
||||||
@@ -5,13 +5,22 @@ use compliance_core::models::{Finding, FindingStatus};
|
|||||||
use crate::llm::LlmClient;
|
use crate::llm::LlmClient;
|
||||||
use crate::pipeline::orchestrator::GraphContext;
|
use crate::pipeline::orchestrator::GraphContext;
|
||||||
|
|
||||||
const TRIAGE_SYSTEM_PROMPT: &str = r#"You are a security finding triage expert. Analyze the following security finding and determine:
|
const TRIAGE_SYSTEM_PROMPT: &str = r#"You are a security finding triage expert. Analyze the following security finding with its code context and determine the appropriate action.
|
||||||
1. Is this a true positive? (yes/no)
|
|
||||||
2. Confidence score (0-10, where 10 is highest confidence this is a real issue)
|
Actions:
|
||||||
3. Brief remediation suggestion (1-2 sentences)
|
- "confirm": The finding is a true positive at the reported severity. Keep as-is.
|
||||||
|
- "downgrade": The finding is real but over-reported. Lower severity recommended.
|
||||||
|
- "upgrade": The finding is under-reported. Higher severity recommended.
|
||||||
|
- "dismiss": The finding is a false positive. Should be removed.
|
||||||
|
|
||||||
|
Consider:
|
||||||
|
- Is the code in a test, example, or generated file? (lower confidence for test code)
|
||||||
|
- Does the surrounding code context confirm or refute the finding?
|
||||||
|
- Is the finding actionable by a developer?
|
||||||
|
- Would a real attacker be able to exploit this?
|
||||||
|
|
||||||
Respond in JSON format:
|
Respond in JSON format:
|
||||||
{"true_positive": true/false, "confidence": N, "remediation": "..."}"#;
|
{"action": "confirm|downgrade|upgrade|dismiss", "confidence": 0-10, "rationale": "brief explanation", "remediation": "optional fix suggestion"}"#;
|
||||||
|
|
||||||
pub async fn triage_findings(
|
pub async fn triage_findings(
|
||||||
llm: &Arc<LlmClient>,
|
llm: &Arc<LlmClient>,
|
||||||
@@ -21,8 +30,10 @@ pub async fn triage_findings(
|
|||||||
let mut passed = 0;
|
let mut passed = 0;
|
||||||
|
|
||||||
for finding in findings.iter_mut() {
|
for finding in findings.iter_mut() {
|
||||||
|
let file_classification = classify_file_path(finding.file_path.as_deref());
|
||||||
|
|
||||||
let mut user_prompt = format!(
|
let mut user_prompt = format!(
|
||||||
"Scanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}",
|
"Scanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}\nFile classification: {}",
|
||||||
finding.scanner,
|
finding.scanner,
|
||||||
finding.rule_id.as_deref().unwrap_or("N/A"),
|
finding.rule_id.as_deref().unwrap_or("N/A"),
|
||||||
finding.severity,
|
finding.severity,
|
||||||
@@ -31,8 +42,16 @@ pub async fn triage_findings(
|
|||||||
finding.file_path.as_deref().unwrap_or("N/A"),
|
finding.file_path.as_deref().unwrap_or("N/A"),
|
||||||
finding.line_number.map(|n| n.to_string()).unwrap_or_else(|| "N/A".to_string()),
|
finding.line_number.map(|n| n.to_string()).unwrap_or_else(|| "N/A".to_string()),
|
||||||
finding.code_snippet.as_deref().unwrap_or("N/A"),
|
finding.code_snippet.as_deref().unwrap_or("N/A"),
|
||||||
|
file_classification,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Enrich with surrounding code context if possible
|
||||||
|
if let Some(context) = read_surrounding_context(finding) {
|
||||||
|
user_prompt.push_str(&format!(
|
||||||
|
"\n\n--- Surrounding Code (50 lines) ---\n{context}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich with graph context if available
|
// Enrich with graph context if available
|
||||||
if let Some(ctx) = graph_context {
|
if let Some(ctx) = graph_context {
|
||||||
if let Some(impact) = ctx
|
if let Some(impact) = ctx
|
||||||
@@ -69,32 +88,55 @@ pub async fn triage_findings(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
// Strip markdown code fences if present (e.g. ```json ... ```)
|
|
||||||
let cleaned = response.trim();
|
let cleaned = response.trim();
|
||||||
let cleaned = if cleaned.starts_with("```") {
|
let cleaned = if cleaned.starts_with("```") {
|
||||||
let inner = cleaned
|
cleaned
|
||||||
.trim_start_matches("```json")
|
.trim_start_matches("```json")
|
||||||
.trim_start_matches("```")
|
.trim_start_matches("```")
|
||||||
.trim_end_matches("```")
|
.trim_end_matches("```")
|
||||||
.trim();
|
.trim()
|
||||||
inner
|
|
||||||
} else {
|
} else {
|
||||||
cleaned
|
cleaned
|
||||||
};
|
};
|
||||||
if let Ok(result) = serde_json::from_str::<TriageResult>(cleaned) {
|
if let Ok(result) = serde_json::from_str::<TriageResult>(cleaned) {
|
||||||
finding.confidence = Some(result.confidence);
|
// Apply file-path confidence adjustment
|
||||||
|
let adjusted_confidence =
|
||||||
|
adjust_confidence(result.confidence, &file_classification);
|
||||||
|
finding.confidence = Some(adjusted_confidence);
|
||||||
|
finding.triage_action = Some(result.action.clone());
|
||||||
|
finding.triage_rationale = Some(result.rationale);
|
||||||
|
|
||||||
if let Some(remediation) = result.remediation {
|
if let Some(remediation) = result.remediation {
|
||||||
finding.remediation = Some(remediation);
|
finding.remediation = Some(remediation);
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.confidence >= 3.0 {
|
match result.action.as_str() {
|
||||||
finding.status = FindingStatus::Triaged;
|
"dismiss" => {
|
||||||
passed += 1;
|
finding.status = FindingStatus::FalsePositive;
|
||||||
} else {
|
}
|
||||||
finding.status = FindingStatus::FalsePositive;
|
"downgrade" => {
|
||||||
|
// Downgrade severity by one level
|
||||||
|
finding.severity = downgrade_severity(&finding.severity);
|
||||||
|
finding.status = FindingStatus::Triaged;
|
||||||
|
passed += 1;
|
||||||
|
}
|
||||||
|
"upgrade" => {
|
||||||
|
finding.severity = upgrade_severity(&finding.severity);
|
||||||
|
finding.status = FindingStatus::Triaged;
|
||||||
|
passed += 1;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// "confirm" or unknown — keep as-is
|
||||||
|
if adjusted_confidence >= 3.0 {
|
||||||
|
finding.status = FindingStatus::Triaged;
|
||||||
|
passed += 1;
|
||||||
|
} else {
|
||||||
|
finding.status = FindingStatus::FalsePositive;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If LLM response doesn't parse, keep the finding
|
// Parse failure — keep the finding
|
||||||
finding.status = FindingStatus::Triaged;
|
finding.status = FindingStatus::Triaged;
|
||||||
passed += 1;
|
passed += 1;
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -117,12 +159,122 @@ pub async fn triage_findings(
|
|||||||
passed
|
passed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read ~50 lines of surrounding code from the file at the finding's location
|
||||||
|
fn read_surrounding_context(finding: &Finding) -> Option<String> {
|
||||||
|
let file_path = finding.file_path.as_deref()?;
|
||||||
|
let line = finding.line_number? as usize;
|
||||||
|
|
||||||
|
// Try to read the file — this works because the repo is cloned locally
|
||||||
|
let content = std::fs::read_to_string(file_path).ok()?;
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
||||||
|
let start = line.saturating_sub(25);
|
||||||
|
let end = (line + 25).min(lines.len());
|
||||||
|
|
||||||
|
Some(
|
||||||
|
lines[start..end]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| format!("{:>4} | {}", start + i + 1, l))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a file path to inform triage confidence adjustment
|
||||||
|
fn classify_file_path(path: Option<&str>) -> String {
|
||||||
|
let path = match path {
|
||||||
|
Some(p) => p.to_lowercase(),
|
||||||
|
None => return "unknown".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if path.contains("/test/")
|
||||||
|
|| path.contains("/tests/")
|
||||||
|
|| path.contains("_test.")
|
||||||
|
|| path.contains(".test.")
|
||||||
|
|| path.contains(".spec.")
|
||||||
|
|| path.contains("/fixtures/")
|
||||||
|
|| path.contains("/testdata/")
|
||||||
|
{
|
||||||
|
return "test".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.contains("/example")
|
||||||
|
|| path.contains("/examples/")
|
||||||
|
|| path.contains("/demo/")
|
||||||
|
|| path.contains("/sample")
|
||||||
|
{
|
||||||
|
return "example".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.contains("/generated/")
|
||||||
|
|| path.contains("/gen/")
|
||||||
|
|| path.contains(".generated.")
|
||||||
|
|| path.contains(".pb.go")
|
||||||
|
|| path.contains("_generated.rs")
|
||||||
|
{
|
||||||
|
return "generated".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.contains("/vendor/")
|
||||||
|
|| path.contains("/node_modules/")
|
||||||
|
|| path.contains("/third_party/")
|
||||||
|
{
|
||||||
|
return "vendored".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
"production".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjust confidence based on file classification
|
||||||
|
fn adjust_confidence(raw_confidence: f64, classification: &str) -> f64 {
|
||||||
|
let multiplier = match classification {
|
||||||
|
"test" => 0.5,
|
||||||
|
"example" => 0.6,
|
||||||
|
"generated" => 0.3,
|
||||||
|
"vendored" => 0.4,
|
||||||
|
_ => 1.0,
|
||||||
|
};
|
||||||
|
raw_confidence * multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
fn downgrade_severity(
|
||||||
|
severity: &compliance_core::models::Severity,
|
||||||
|
) -> compliance_core::models::Severity {
|
||||||
|
use compliance_core::models::Severity;
|
||||||
|
match severity {
|
||||||
|
Severity::Critical => Severity::High,
|
||||||
|
Severity::High => Severity::Medium,
|
||||||
|
Severity::Medium => Severity::Low,
|
||||||
|
Severity::Low => Severity::Info,
|
||||||
|
Severity::Info => Severity::Info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upgrade_severity(
|
||||||
|
severity: &compliance_core::models::Severity,
|
||||||
|
) -> compliance_core::models::Severity {
|
||||||
|
use compliance_core::models::Severity;
|
||||||
|
match severity {
|
||||||
|
Severity::Info => Severity::Low,
|
||||||
|
Severity::Low => Severity::Medium,
|
||||||
|
Severity::Medium => Severity::High,
|
||||||
|
Severity::High => Severity::Critical,
|
||||||
|
Severity::Critical => Severity::Critical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct TriageResult {
|
struct TriageResult {
|
||||||
#[serde(default)]
|
#[serde(default = "default_action")]
|
||||||
#[allow(dead_code)]
|
action: String,
|
||||||
true_positive: bool,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
confidence: f64,
|
confidence: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
rationale: String,
|
||||||
remediation: Option<String>,
|
remediation: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_action() -> String {
|
||||||
|
"confirm".to_string()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
mod api;
|
mod api;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -7,24 +5,28 @@ mod database;
|
|||||||
mod error;
|
mod error;
|
||||||
mod llm;
|
mod llm;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
mod rag;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
|
mod ssh;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod trackers;
|
mod trackers;
|
||||||
mod webhooks;
|
mod webhooks;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(
|
|
||||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let _telemetry_guard = compliance_core::telemetry::init_telemetry("compliance-agent");
|
||||||
|
|
||||||
tracing::info!("Loading configuration...");
|
tracing::info!("Loading configuration...");
|
||||||
let config = config::load_config()?;
|
let config = config::load_config()?;
|
||||||
|
|
||||||
|
// Ensure SSH key pair exists for cloning private repos
|
||||||
|
match ssh::ensure_ssh_key(&config.ssh_key_path) {
|
||||||
|
Ok(pubkey) => tracing::info!("SSH public key: {}", pubkey.trim()),
|
||||||
|
Err(e) => tracing::warn!("SSH key generation skipped: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!("Connecting to MongoDB...");
|
tracing::info!("Connecting to MongoDB...");
|
||||||
let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||||
db.ensure_indexes().await?;
|
db.ensure_indexes().await?;
|
||||||
|
|||||||
186
compliance-agent/src/pipeline/code_review.rs
Normal file
186
compliance-agent/src/pipeline/code_review.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use compliance_core::models::{Finding, ScanType, Severity};
|
||||||
|
use compliance_core::traits::ScanOutput;
|
||||||
|
|
||||||
|
use crate::llm::review_prompts::REVIEW_PASSES;
|
||||||
|
use crate::llm::LlmClient;
|
||||||
|
use crate::pipeline::dedup;
|
||||||
|
use crate::pipeline::git::{DiffFile, GitOps};
|
||||||
|
|
||||||
|
pub struct CodeReviewScanner {
|
||||||
|
llm: Arc<LlmClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodeReviewScanner {
|
||||||
|
pub fn new(llm: Arc<LlmClient>) -> Self {
|
||||||
|
Self { llm }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run multi-pass LLM code review on the diff between old and new commits.
|
||||||
|
pub async fn review_diff(
|
||||||
|
&self,
|
||||||
|
repo_path: &Path,
|
||||||
|
repo_id: &str,
|
||||||
|
old_sha: &str,
|
||||||
|
new_sha: &str,
|
||||||
|
) -> ScanOutput {
|
||||||
|
let diff_files = match GitOps::get_diff_content(repo_path, old_sha, new_sha) {
|
||||||
|
Ok(files) => files,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to extract diff for code review: {e}");
|
||||||
|
return ScanOutput::default();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if diff_files.is_empty() {
|
||||||
|
return ScanOutput::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_findings = Vec::new();
|
||||||
|
|
||||||
|
// Chunk diff files into groups to avoid exceeding context limits
|
||||||
|
let chunks = chunk_diff_files(&diff_files, 8000);
|
||||||
|
|
||||||
|
for (pass_name, system_prompt) in REVIEW_PASSES {
|
||||||
|
for chunk in &chunks {
|
||||||
|
let user_prompt = format!(
|
||||||
|
"Review the following code changes:\n\n{}",
|
||||||
|
chunk
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("--- {} ---\n{}", f.path, f.hunks))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
match self.llm.chat(system_prompt, &user_prompt, Some(0.1)).await {
|
||||||
|
Ok(response) => {
|
||||||
|
let parsed = parse_review_response(&response, pass_name, repo_id, chunk);
|
||||||
|
all_findings.extend(parsed);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Code review pass '{pass_name}' failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanOutput {
|
||||||
|
findings: all_findings,
|
||||||
|
sbom_entries: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group diff files into chunks that fit within a token budget (rough char estimate)
|
||||||
|
fn chunk_diff_files(files: &[DiffFile], max_chars: usize) -> Vec<Vec<&DiffFile>> {
|
||||||
|
let mut chunks: Vec<Vec<&DiffFile>> = Vec::new();
|
||||||
|
let mut current_chunk: Vec<&DiffFile> = Vec::new();
|
||||||
|
let mut current_size = 0;
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
if current_size + file.hunks.len() > max_chars && !current_chunk.is_empty() {
|
||||||
|
chunks.push(std::mem::take(&mut current_chunk));
|
||||||
|
current_size = 0;
|
||||||
|
}
|
||||||
|
current_chunk.push(file);
|
||||||
|
current_size += file.hunks.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current_chunk.is_empty() {
|
||||||
|
chunks.push(current_chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_review_response(
|
||||||
|
response: &str,
|
||||||
|
pass_name: &str,
|
||||||
|
repo_id: &str,
|
||||||
|
chunk: &[&DiffFile],
|
||||||
|
) -> Vec<Finding> {
|
||||||
|
let cleaned = response.trim();
|
||||||
|
let cleaned = if cleaned.starts_with("```") {
|
||||||
|
cleaned
|
||||||
|
.trim_start_matches("```json")
|
||||||
|
.trim_start_matches("```")
|
||||||
|
.trim_end_matches("```")
|
||||||
|
.trim()
|
||||||
|
} else {
|
||||||
|
cleaned
|
||||||
|
};
|
||||||
|
|
||||||
|
let issues: Vec<ReviewIssue> = match serde_json::from_str(cleaned) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
if cleaned != "[]" {
|
||||||
|
tracing::debug!("Failed to parse {pass_name} review response: {cleaned}");
|
||||||
|
}
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
issues
|
||||||
|
.into_iter()
|
||||||
|
.filter(|issue| {
|
||||||
|
// Verify the file exists in the diff chunk
|
||||||
|
chunk.iter().any(|f| f.path == issue.file)
|
||||||
|
})
|
||||||
|
.map(|issue| {
|
||||||
|
let severity = match issue.severity.as_str() {
|
||||||
|
"critical" => Severity::Critical,
|
||||||
|
"high" => Severity::High,
|
||||||
|
"medium" => Severity::Medium,
|
||||||
|
"low" => Severity::Low,
|
||||||
|
_ => Severity::Info,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fingerprint = dedup::compute_fingerprint(&[
|
||||||
|
repo_id,
|
||||||
|
"code-review",
|
||||||
|
pass_name,
|
||||||
|
&issue.file,
|
||||||
|
&issue.line.to_string(),
|
||||||
|
&issue.title,
|
||||||
|
]);
|
||||||
|
|
||||||
|
let description = if let Some(suggestion) = &issue.suggestion {
|
||||||
|
format!("{}\n\nSuggested fix: {}", issue.description, suggestion)
|
||||||
|
} else {
|
||||||
|
issue.description.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut finding = Finding::new(
|
||||||
|
repo_id.to_string(),
|
||||||
|
fingerprint,
|
||||||
|
format!("code-review/{pass_name}"),
|
||||||
|
ScanType::CodeReview,
|
||||||
|
issue.title,
|
||||||
|
description,
|
||||||
|
severity,
|
||||||
|
);
|
||||||
|
finding.rule_id = Some(format!("review/{pass_name}"));
|
||||||
|
finding.file_path = Some(issue.file);
|
||||||
|
finding.line_number = Some(issue.line);
|
||||||
|
finding.cwe = issue.cwe;
|
||||||
|
finding.suggested_fix = issue.suggestion;
|
||||||
|
finding
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct ReviewIssue {
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
severity: String,
|
||||||
|
file: String,
|
||||||
|
#[serde(default)]
|
||||||
|
line: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
cwe: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
suggestion: Option<String>,
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ impl CveScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn query_osv_batch(&self, entries: &[SbomEntry]) -> Result<Vec<Vec<OsvVuln>>, CoreError> {
|
async fn query_osv_batch(&self, entries: &[SbomEntry]) -> Result<Vec<Vec<OsvVuln>>, CoreError> {
|
||||||
|
const OSV_BATCH_SIZE: usize = 500;
|
||||||
|
|
||||||
let queries: Vec<_> = entries
|
let queries: Vec<_> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
@@ -79,32 +81,34 @@ impl CveScanner {
|
|||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = serde_json::json!({ "queries": queries });
|
let mut all_vulns: Vec<Vec<OsvVuln>> = Vec::with_capacity(queries.len());
|
||||||
|
|
||||||
let resp = self
|
for chunk in queries.chunks(OSV_BATCH_SIZE) {
|
||||||
.http
|
let body = serde_json::json!({ "queries": chunk });
|
||||||
.post("https://api.osv.dev/v1/querybatch")
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| CoreError::Http(format!("OSV.dev request failed: {e}")))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
let resp = self
|
||||||
let status = resp.status();
|
.http
|
||||||
let body = resp.text().await.unwrap_or_default();
|
.post("https://api.osv.dev/v1/querybatch")
|
||||||
tracing::warn!("OSV.dev returned {status}: {body}");
|
.json(&body)
|
||||||
return Ok(Vec::new());
|
.send()
|
||||||
}
|
.await
|
||||||
|
.map_err(|e| CoreError::Http(format!("OSV.dev request failed: {e}")))?;
|
||||||
|
|
||||||
let result: OsvBatchResponse = resp
|
if !resp.status().is_success() {
|
||||||
.json()
|
let status = resp.status();
|
||||||
.await
|
let body = resp.text().await.unwrap_or_default();
|
||||||
.map_err(|e| CoreError::Http(format!("Failed to parse OSV.dev response: {e}")))?;
|
tracing::warn!("OSV.dev returned {status}: {body}");
|
||||||
|
// Push empty results for this chunk so indices stay aligned
|
||||||
|
all_vulns.extend(std::iter::repeat_with(Vec::new).take(chunk.len()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let vulns = result
|
let result: OsvBatchResponse = resp
|
||||||
.results
|
.json()
|
||||||
.into_iter()
|
.await
|
||||||
.map(|r| {
|
.map_err(|e| CoreError::Http(format!("Failed to parse OSV.dev response: {e}")))?;
|
||||||
|
|
||||||
|
let chunk_vulns = result.results.into_iter().map(|r| {
|
||||||
r.vulns
|
r.vulns
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -116,10 +120,12 @@ impl CveScanner {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
});
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(vulns)
|
all_vulns.extend(chunk_vulns);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_vulns)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_nvd(&self, cve_id: &str) -> Result<Option<f64>, CoreError> {
|
async fn query_nvd(&self, cve_id: &str) -> Result<Option<f64>, CoreError> {
|
||||||
|
|||||||
@@ -1,17 +1,80 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use git2::{FetchOptions, Repository};
|
use git2::{Cred, FetchOptions, RemoteCallbacks, Repository};
|
||||||
|
|
||||||
use crate::error::AgentError;
|
use crate::error::AgentError;
|
||||||
|
|
||||||
|
/// Credentials for accessing a private repository
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RepoCredentials {
|
||||||
|
/// Path to the SSH private key (for SSH URLs)
|
||||||
|
pub ssh_key_path: Option<String>,
|
||||||
|
/// Auth token / password (for HTTPS URLs)
|
||||||
|
pub auth_token: Option<String>,
|
||||||
|
/// Username for HTTPS auth (defaults to "x-access-token")
|
||||||
|
pub auth_username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoCredentials {
|
||||||
|
pub(crate) fn make_callbacks(&self) -> RemoteCallbacks<'_> {
|
||||||
|
let mut callbacks = RemoteCallbacks::new();
|
||||||
|
let ssh_key = self.ssh_key_path.clone();
|
||||||
|
let token = self.auth_token.clone();
|
||||||
|
let username = self.auth_username.clone();
|
||||||
|
|
||||||
|
callbacks.credentials(move |_url, username_from_url, allowed_types| {
|
||||||
|
// SSH key authentication
|
||||||
|
if allowed_types.contains(git2::CredentialType::SSH_KEY) {
|
||||||
|
if let Some(ref key_path) = ssh_key {
|
||||||
|
let key = Path::new(key_path);
|
||||||
|
if key.exists() {
|
||||||
|
let user = username_from_url.unwrap_or("git");
|
||||||
|
return Cred::ssh_key(user, None, key, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS userpass authentication
|
||||||
|
if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
|
||||||
|
if let Some(ref tok) = token {
|
||||||
|
let user = username.as_deref().unwrap_or("x-access-token");
|
||||||
|
return Cred::userpass_plaintext(user, tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cred::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
callbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_options(&self) -> FetchOptions<'_> {
|
||||||
|
let mut fetch_opts = FetchOptions::new();
|
||||||
|
if self.has_credentials() {
|
||||||
|
fetch_opts.remote_callbacks(self.make_callbacks());
|
||||||
|
}
|
||||||
|
fetch_opts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_credentials(&self) -> bool {
|
||||||
|
self.ssh_key_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| Path::new(p).exists())
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| self.auth_token.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GitOps {
|
pub struct GitOps {
|
||||||
base_path: PathBuf,
|
base_path: PathBuf,
|
||||||
|
credentials: RepoCredentials,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GitOps {
|
impl GitOps {
|
||||||
pub fn new(base_path: &str) -> Self {
|
pub fn new(base_path: &str, credentials: RepoCredentials) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base_path: PathBuf::from(base_path),
|
base_path: PathBuf::from(base_path),
|
||||||
|
credentials,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,17 +85,25 @@ impl GitOps {
|
|||||||
self.fetch(&repo_path)?;
|
self.fetch(&repo_path)?;
|
||||||
} else {
|
} else {
|
||||||
std::fs::create_dir_all(&repo_path)?;
|
std::fs::create_dir_all(&repo_path)?;
|
||||||
Repository::clone(git_url, &repo_path)?;
|
self.clone_repo(git_url, &repo_path)?;
|
||||||
tracing::info!("Cloned {git_url} to {}", repo_path.display());
|
tracing::info!("Cloned {git_url} to {}", repo_path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(repo_path)
|
Ok(repo_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clone_repo(&self, git_url: &str, repo_path: &Path) -> Result<(), AgentError> {
|
||||||
|
let mut builder = git2::build::RepoBuilder::new();
|
||||||
|
let fetch_opts = self.credentials.fetch_options();
|
||||||
|
builder.fetch_options(fetch_opts);
|
||||||
|
builder.clone(git_url, repo_path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn fetch(&self, repo_path: &Path) -> Result<(), AgentError> {
|
fn fetch(&self, repo_path: &Path) -> Result<(), AgentError> {
|
||||||
let repo = Repository::open(repo_path)?;
|
let repo = Repository::open(repo_path)?;
|
||||||
let mut remote = repo.find_remote("origin")?;
|
let mut remote = repo.find_remote("origin")?;
|
||||||
let mut fetch_opts = FetchOptions::new();
|
let mut fetch_opts = self.credentials.fetch_options();
|
||||||
remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?;
|
remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?;
|
||||||
|
|
||||||
// Fast-forward to origin/HEAD
|
// Fast-forward to origin/HEAD
|
||||||
@@ -48,6 +119,15 @@ impl GitOps {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test that we can access a remote repository (used during add validation)
|
||||||
|
pub fn test_access(git_url: &str, credentials: &RepoCredentials) -> Result<(), AgentError> {
|
||||||
|
let mut remote = git2::Remote::create_detached(git_url)?;
|
||||||
|
let callbacks = credentials.make_callbacks();
|
||||||
|
remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?;
|
||||||
|
remote.disconnect()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_head_sha(repo_path: &Path) -> Result<String, AgentError> {
|
pub fn get_head_sha(repo_path: &Path) -> Result<String, AgentError> {
|
||||||
let repo = Repository::open(repo_path)?;
|
let repo = Repository::open(repo_path)?;
|
||||||
let head = repo.head()?;
|
let head = repo.head()?;
|
||||||
@@ -63,6 +143,62 @@ impl GitOps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract structured diff content between two commits
|
||||||
|
pub fn get_diff_content(
|
||||||
|
repo_path: &Path,
|
||||||
|
old_sha: &str,
|
||||||
|
new_sha: &str,
|
||||||
|
) -> Result<Vec<DiffFile>, AgentError> {
|
||||||
|
let repo = Repository::open(repo_path)?;
|
||||||
|
let old_commit = repo.find_commit(git2::Oid::from_str(old_sha)?)?;
|
||||||
|
let new_commit = repo.find_commit(git2::Oid::from_str(new_sha)?)?;
|
||||||
|
|
||||||
|
let old_tree = old_commit.tree()?;
|
||||||
|
let new_tree = new_commit.tree()?;
|
||||||
|
|
||||||
|
let diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;
|
||||||
|
|
||||||
|
let mut diff_files: Vec<DiffFile> = Vec::new();
|
||||||
|
|
||||||
|
diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
|
||||||
|
let file_path = delta
|
||||||
|
.new_file()
|
||||||
|
.path()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Find or create the DiffFile entry
|
||||||
|
let idx = if let Some(pos) = diff_files.iter().position(|f| f.path == file_path) {
|
||||||
|
pos
|
||||||
|
} else {
|
||||||
|
diff_files.push(DiffFile {
|
||||||
|
path: file_path,
|
||||||
|
hunks: String::new(),
|
||||||
|
});
|
||||||
|
diff_files.len() - 1
|
||||||
|
};
|
||||||
|
let diff_file = &mut diff_files[idx];
|
||||||
|
|
||||||
|
let prefix = match line.origin() {
|
||||||
|
'+' => "+",
|
||||||
|
'-' => "-",
|
||||||
|
' ' => " ",
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = std::str::from_utf8(line.content()).unwrap_or("");
|
||||||
|
diff_file.hunks.push_str(prefix);
|
||||||
|
diff_file.hunks.push_str(content);
|
||||||
|
|
||||||
|
true
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Filter out binary files and very large diffs
|
||||||
|
diff_files.retain(|f| !f.hunks.is_empty() && f.hunks.len() < 50_000);
|
||||||
|
|
||||||
|
Ok(diff_files)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn get_changed_files(
|
pub fn get_changed_files(
|
||||||
repo_path: &Path,
|
repo_path: &Path,
|
||||||
@@ -94,3 +230,10 @@ impl GitOps {
|
|||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file changed between two commits with its diff content
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiffFile {
|
||||||
|
pub path: String,
|
||||||
|
pub hunks: String,
|
||||||
|
}
|
||||||
|
|||||||
130
compliance-agent/src/pipeline/gitleaks.rs
Normal file
130
compliance-agent/src/pipeline/gitleaks.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use compliance_core::models::{Finding, ScanType, Severity};
|
||||||
|
use compliance_core::traits::{ScanOutput, Scanner};
|
||||||
|
use compliance_core::CoreError;
|
||||||
|
|
||||||
|
use crate::pipeline::dedup;
|
||||||
|
|
||||||
|
pub struct GitleaksScanner;
|
||||||
|
|
||||||
|
impl Scanner for GitleaksScanner {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"gitleaks"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_type(&self) -> ScanType {
|
||||||
|
ScanType::SecretDetection
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
|
||||||
|
let output = tokio::process::Command::new("gitleaks")
|
||||||
|
.args([
|
||||||
|
"detect",
|
||||||
|
"--source",
|
||||||
|
".",
|
||||||
|
"--report-format",
|
||||||
|
"json",
|
||||||
|
"--report-path",
|
||||||
|
"/dev/stdout",
|
||||||
|
"--no-banner",
|
||||||
|
"--exit-code",
|
||||||
|
"0",
|
||||||
|
])
|
||||||
|
.current_dir(repo_path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| CoreError::Scanner {
|
||||||
|
scanner: "gitleaks".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if output.stdout.is_empty() {
|
||||||
|
return Ok(ScanOutput::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: Vec<GitleaksResult> =
|
||||||
|
serde_json::from_slice(&output.stdout).unwrap_or_default();
|
||||||
|
|
||||||
|
let findings = results
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| !is_allowlisted(&r.file))
|
||||||
|
.map(|r| {
|
||||||
|
let severity = match r.rule_id.as_str() {
|
||||||
|
s if s.contains("private-key") => Severity::Critical,
|
||||||
|
s if s.contains("token") || s.contains("password") || s.contains("secret") => {
|
||||||
|
Severity::High
|
||||||
|
}
|
||||||
|
s if s.contains("api-key") => Severity::High,
|
||||||
|
_ => Severity::Medium,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fingerprint = dedup::compute_fingerprint(&[
|
||||||
|
repo_id,
|
||||||
|
&r.rule_id,
|
||||||
|
&r.file,
|
||||||
|
&r.start_line.to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let title = format!("Secret detected: {}", r.description);
|
||||||
|
let description = format!(
|
||||||
|
"Potential secret ({}) found in {}:{}. Match: {}",
|
||||||
|
r.rule_id,
|
||||||
|
r.file,
|
||||||
|
r.start_line,
|
||||||
|
r.r#match.chars().take(80).collect::<String>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut finding = Finding::new(
|
||||||
|
repo_id.to_string(),
|
||||||
|
fingerprint,
|
||||||
|
"gitleaks".to_string(),
|
||||||
|
ScanType::SecretDetection,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
severity,
|
||||||
|
);
|
||||||
|
finding.rule_id = Some(r.rule_id);
|
||||||
|
finding.file_path = Some(r.file);
|
||||||
|
finding.line_number = Some(r.start_line);
|
||||||
|
finding.code_snippet = Some(r.r#match);
|
||||||
|
finding
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(ScanOutput {
|
||||||
|
findings,
|
||||||
|
sbom_entries: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skip files that commonly contain example/placeholder secrets
|
||||||
|
fn is_allowlisted(file_path: &str) -> bool {
|
||||||
|
let lower = file_path.to_lowercase();
|
||||||
|
lower.ends_with(".env.example")
|
||||||
|
|| lower.ends_with(".env.sample")
|
||||||
|
|| lower.ends_with(".env.template")
|
||||||
|
|| lower.contains("/test/")
|
||||||
|
|| lower.contains("/tests/")
|
||||||
|
|| lower.contains("/fixtures/")
|
||||||
|
|| lower.contains("/testdata/")
|
||||||
|
|| lower.contains("mock")
|
||||||
|
|| lower.ends_with("_test.go")
|
||||||
|
|| lower.ends_with(".test.ts")
|
||||||
|
|| lower.ends_with(".test.js")
|
||||||
|
|| lower.ends_with(".spec.ts")
|
||||||
|
|| lower.ends_with(".spec.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct GitleaksResult {
|
||||||
|
description: String,
|
||||||
|
#[serde(rename = "RuleID")]
|
||||||
|
rule_id: String,
|
||||||
|
file: String,
|
||||||
|
start_line: u32,
|
||||||
|
#[serde(rename = "Match")]
|
||||||
|
r#match: String,
|
||||||
|
}
|
||||||
364
compliance-agent/src/pipeline/lint.rs
Normal file
364
compliance-agent/src/pipeline/lint.rs
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use compliance_core::models::{Finding, ScanType, Severity};
|
||||||
|
use compliance_core::traits::{ScanOutput, Scanner};
|
||||||
|
use compliance_core::CoreError;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use crate::pipeline::dedup;
|
||||||
|
|
||||||
|
/// Timeout for each individual lint command
|
||||||
|
const LINT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
pub struct LintScanner;
|
||||||
|
|
||||||
|
impl Scanner for LintScanner {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"lint"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_type(&self) -> ScanType {
|
||||||
|
ScanType::Lint
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
|
||||||
|
let mut all_findings = Vec::new();
|
||||||
|
|
||||||
|
// Detect which languages are present and run appropriate linters
|
||||||
|
if has_rust_project(repo_path) {
|
||||||
|
match run_clippy(repo_path, repo_id).await {
|
||||||
|
Ok(findings) => all_findings.extend(findings),
|
||||||
|
Err(e) => tracing::warn!("Clippy failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_js_project(repo_path) {
|
||||||
|
match run_eslint(repo_path, repo_id).await {
|
||||||
|
Ok(findings) => all_findings.extend(findings),
|
||||||
|
Err(e) => tracing::warn!("ESLint failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_python_project(repo_path) {
|
||||||
|
match run_ruff(repo_path, repo_id).await {
|
||||||
|
Ok(findings) => all_findings.extend(findings),
|
||||||
|
Err(e) => tracing::warn!("Ruff failed: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ScanOutput {
|
||||||
|
findings: all_findings,
|
||||||
|
sbom_entries: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_rust_project(repo_path: &Path) -> bool {
|
||||||
|
repo_path.join("Cargo.toml").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_js_project(repo_path: &Path) -> bool {
|
||||||
|
// Only run if eslint is actually installed in the project
|
||||||
|
repo_path.join("package.json").exists() && repo_path.join("node_modules/.bin/eslint").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_python_project(repo_path: &Path) -> bool {
|
||||||
|
repo_path.join("pyproject.toml").exists()
|
||||||
|
|| repo_path.join("setup.py").exists()
|
||||||
|
|| repo_path.join("requirements.txt").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a command with a timeout, returning its output or an error
|
||||||
|
async fn run_with_timeout(
|
||||||
|
child: tokio::process::Child,
|
||||||
|
scanner_name: &str,
|
||||||
|
) -> Result<std::process::Output, CoreError> {
|
||||||
|
let result = tokio::time::timeout(LINT_TIMEOUT, child.wait_with_output()).await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(output)) => Ok(output),
|
||||||
|
Ok(Err(e)) => Err(CoreError::Scanner {
|
||||||
|
scanner: scanner_name.to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
}),
|
||||||
|
Err(_) => {
|
||||||
|
// Process is dropped here which sends SIGKILL on Unix
|
||||||
|
Err(CoreError::Scanner {
|
||||||
|
scanner: scanner_name.to_string(),
|
||||||
|
source: Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::TimedOut,
|
||||||
|
format!("{scanner_name} timed out after {}s", LINT_TIMEOUT.as_secs()),
|
||||||
|
)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Clippy ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn run_clippy(repo_path: &Path, repo_id: &str) -> Result<Vec<Finding>, CoreError> {
|
||||||
|
let child = Command::new("cargo")
|
||||||
|
.args([
|
||||||
|
"clippy",
|
||||||
|
"--message-format=json",
|
||||||
|
"--quiet",
|
||||||
|
"--",
|
||||||
|
"-W",
|
||||||
|
"clippy::all",
|
||||||
|
])
|
||||||
|
.current_dir(repo_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| CoreError::Scanner {
|
||||||
|
scanner: "clippy".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let output = run_with_timeout(child, "clippy").await?;
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
|
||||||
|
for line in stdout.lines() {
|
||||||
|
let msg: serde_json::Value = match serde_json::from_str(line) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if msg.get("reason").and_then(|v| v.as_str()) != Some("compiler-message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = match msg.get("message") {
|
||||||
|
Some(m) => m,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let level = message.get("level").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
if level != "warning" && level != "error" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = message
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let code = message
|
||||||
|
.get("code")
|
||||||
|
.and_then(|v| v.get("code"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if text.starts_with("aborting due to") || code.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (file_path, line_number) = extract_primary_span(message);
|
||||||
|
|
||||||
|
let severity = if level == "error" {
|
||||||
|
Severity::High
|
||||||
|
} else {
|
||||||
|
Severity::Low
|
||||||
|
};
|
||||||
|
|
||||||
|
let fingerprint = dedup::compute_fingerprint(&[
|
||||||
|
repo_id,
|
||||||
|
"clippy",
|
||||||
|
&code,
|
||||||
|
&file_path,
|
||||||
|
&line_number.to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut finding = Finding::new(
|
||||||
|
repo_id.to_string(),
|
||||||
|
fingerprint,
|
||||||
|
"clippy".to_string(),
|
||||||
|
ScanType::Lint,
|
||||||
|
format!("[clippy] {text}"),
|
||||||
|
text,
|
||||||
|
severity,
|
||||||
|
);
|
||||||
|
finding.rule_id = Some(code);
|
||||||
|
if !file_path.is_empty() {
|
||||||
|
finding.file_path = Some(file_path);
|
||||||
|
}
|
||||||
|
if line_number > 0 {
|
||||||
|
finding.line_number = Some(line_number);
|
||||||
|
}
|
||||||
|
findings.push(finding);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_primary_span(message: &serde_json::Value) -> (String, u32) {
|
||||||
|
let spans = match message.get("spans").and_then(|v| v.as_array()) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return (String::new(), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
for span in spans {
|
||||||
|
if span.get("is_primary").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
|
let file = span
|
||||||
|
.get("file_name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let line = span.get("line_start").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||||
|
return (file, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(String::new(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ESLint ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn run_eslint(repo_path: &Path, repo_id: &str) -> Result<Vec<Finding>, CoreError> {
|
||||||
|
// Use the project-local eslint binary directly, not npx (which can hang downloading)
|
||||||
|
let eslint_bin = repo_path.join("node_modules/.bin/eslint");
|
||||||
|
let child = Command::new(eslint_bin)
|
||||||
|
.args([".", "--format", "json", "--no-error-on-unmatched-pattern"])
|
||||||
|
.current_dir(repo_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| CoreError::Scanner {
|
||||||
|
scanner: "eslint".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let output = run_with_timeout(child, "eslint").await?;
|
||||||
|
|
||||||
|
if output.stdout.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: Vec<EslintFileResult> = serde_json::from_slice(&output.stdout).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
for file_result in results {
|
||||||
|
for msg in file_result.messages {
|
||||||
|
let severity = match msg.severity {
|
||||||
|
2 => Severity::Medium,
|
||||||
|
_ => Severity::Low,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rule_id = msg.rule_id.unwrap_or_default();
|
||||||
|
let fingerprint = dedup::compute_fingerprint(&[
|
||||||
|
repo_id,
|
||||||
|
"eslint",
|
||||||
|
&rule_id,
|
||||||
|
&file_result.file_path,
|
||||||
|
&msg.line.to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut finding = Finding::new(
|
||||||
|
repo_id.to_string(),
|
||||||
|
fingerprint,
|
||||||
|
"eslint".to_string(),
|
||||||
|
ScanType::Lint,
|
||||||
|
format!("[eslint] {}", msg.message),
|
||||||
|
msg.message,
|
||||||
|
severity,
|
||||||
|
);
|
||||||
|
finding.rule_id = Some(rule_id);
|
||||||
|
finding.file_path = Some(file_result.file_path.clone());
|
||||||
|
finding.line_number = Some(msg.line);
|
||||||
|
findings.push(finding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct EslintFileResult {
|
||||||
|
#[serde(rename = "filePath")]
|
||||||
|
file_path: String,
|
||||||
|
messages: Vec<EslintMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct EslintMessage {
|
||||||
|
#[serde(rename = "ruleId")]
|
||||||
|
rule_id: Option<String>,
|
||||||
|
severity: u8,
|
||||||
|
message: String,
|
||||||
|
line: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ruff ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn run_ruff(repo_path: &Path, repo_id: &str) -> Result<Vec<Finding>, CoreError> {
|
||||||
|
let child = Command::new("ruff")
|
||||||
|
.args(["check", ".", "--output-format", "json", "--exit-zero"])
|
||||||
|
.current_dir(repo_path)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| CoreError::Scanner {
|
||||||
|
scanner: "ruff".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let output = run_with_timeout(child, "ruff").await?;
|
||||||
|
|
||||||
|
if output.stdout.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: Vec<RuffResult> = serde_json::from_slice(&output.stdout).unwrap_or_default();
|
||||||
|
|
||||||
|
let findings = results
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let severity = if r.code.starts_with('E') || r.code.starts_with('F') {
|
||||||
|
Severity::Medium
|
||||||
|
} else {
|
||||||
|
Severity::Low
|
||||||
|
};
|
||||||
|
|
||||||
|
let fingerprint = dedup::compute_fingerprint(&[
|
||||||
|
repo_id,
|
||||||
|
"ruff",
|
||||||
|
&r.code,
|
||||||
|
&r.filename,
|
||||||
|
&r.location.row.to_string(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut finding = Finding::new(
|
||||||
|
repo_id.to_string(),
|
||||||
|
fingerprint,
|
||||||
|
"ruff".to_string(),
|
||||||
|
ScanType::Lint,
|
||||||
|
format!("[ruff] {}: {}", r.code, r.message),
|
||||||
|
r.message,
|
||||||
|
severity,
|
||||||
|
);
|
||||||
|
finding.rule_id = Some(r.code);
|
||||||
|
finding.file_path = Some(r.filename);
|
||||||
|
finding.line_number = Some(r.location.row);
|
||||||
|
finding
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct RuffResult {
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
filename: String,
|
||||||
|
location: RuffLocation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct RuffLocation {
|
||||||
|
row: u32,
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
pub mod code_review;
|
||||||
pub mod cve;
|
pub mod cve;
|
||||||
pub mod dedup;
|
pub mod dedup;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod gitleaks;
|
||||||
|
pub mod lint;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod patterns;
|
pub mod patterns;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ use compliance_core::AgentConfig;
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::error::AgentError;
|
use crate::error::AgentError;
|
||||||
use crate::llm::LlmClient;
|
use crate::llm::LlmClient;
|
||||||
|
use crate::pipeline::code_review::CodeReviewScanner;
|
||||||
use crate::pipeline::cve::CveScanner;
|
use crate::pipeline::cve::CveScanner;
|
||||||
use crate::pipeline::git::GitOps;
|
use crate::pipeline::git::{GitOps, RepoCredentials};
|
||||||
|
use crate::pipeline::gitleaks::GitleaksScanner;
|
||||||
|
use crate::pipeline::lint::LintScanner;
|
||||||
use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner};
|
use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner};
|
||||||
use crate::pipeline::sbom::SbomScanner;
|
use crate::pipeline::sbom::SbomScanner;
|
||||||
use crate::pipeline::semgrep::SemgrepScanner;
|
use crate::pipeline::semgrep::SemgrepScanner;
|
||||||
@@ -114,7 +117,12 @@ impl PipelineOrchestrator {
|
|||||||
|
|
||||||
// Stage 0: Change detection
|
// Stage 0: Change detection
|
||||||
tracing::info!("[{repo_id}] Stage 0: Change detection");
|
tracing::info!("[{repo_id}] Stage 0: Change detection");
|
||||||
let git_ops = GitOps::new(&self.config.git_clone_base_path);
|
let creds = RepoCredentials {
|
||||||
|
ssh_key_path: Some(self.config.ssh_key_path.clone()),
|
||||||
|
auth_token: repo.auth_token.clone(),
|
||||||
|
auth_username: repo.auth_username.clone(),
|
||||||
|
};
|
||||||
|
let git_ops = GitOps::new(&self.config.git_clone_base_path, creds);
|
||||||
let repo_path = git_ops.clone_or_fetch(&repo.git_url, &repo.name)?;
|
let repo_path = git_ops.clone_or_fetch(&repo.git_url, &repo.name)?;
|
||||||
|
|
||||||
if !GitOps::has_new_commits(&repo_path, repo.last_scanned_commit.as_deref())? {
|
if !GitOps::has_new_commits(&repo_path, repo.last_scanned_commit.as_deref())? {
|
||||||
@@ -182,10 +190,41 @@ impl PipelineOrchestrator {
|
|||||||
Err(e) => tracing::warn!("[{repo_id}] OAuth pattern scan failed: {e}"),
|
Err(e) => tracing::warn!("[{repo_id}] OAuth pattern scan failed: {e}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stage 4a: Secret Detection (Gitleaks)
|
||||||
|
tracing::info!("[{repo_id}] Stage 4a: Secret Detection");
|
||||||
|
self.update_phase(scan_run_id, "secret_detection").await;
|
||||||
|
let gitleaks = GitleaksScanner;
|
||||||
|
match gitleaks.scan(&repo_path, &repo_id).await {
|
||||||
|
Ok(output) => all_findings.extend(output.findings),
|
||||||
|
Err(e) => tracing::warn!("[{repo_id}] Gitleaks failed: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 4b: Lint Scanning
|
||||||
|
tracing::info!("[{repo_id}] Stage 4b: Lint Scanning");
|
||||||
|
self.update_phase(scan_run_id, "lint_scanning").await;
|
||||||
|
let lint = LintScanner;
|
||||||
|
match lint.scan(&repo_path, &repo_id).await {
|
||||||
|
Ok(output) => all_findings.extend(output.findings),
|
||||||
|
Err(e) => tracing::warn!("[{repo_id}] Lint scanning failed: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 4c: LLM Code Review (only on incremental scans)
|
||||||
|
if let Some(old_sha) = &repo.last_scanned_commit {
|
||||||
|
tracing::info!("[{repo_id}] Stage 4c: LLM Code Review");
|
||||||
|
self.update_phase(scan_run_id, "code_review").await;
|
||||||
|
let reviewer = CodeReviewScanner::new(self.llm.clone());
|
||||||
|
let review_output = reviewer
|
||||||
|
.review_diff(&repo_path, &repo_id, old_sha, ¤t_sha)
|
||||||
|
.await;
|
||||||
|
all_findings.extend(review_output.findings);
|
||||||
|
}
|
||||||
|
|
||||||
// Stage 4.5: Graph Building
|
// Stage 4.5: Graph Building
|
||||||
tracing::info!("[{repo_id}] Stage 4.5: Graph Building");
|
tracing::info!("[{repo_id}] Stage 4.5: Graph Building");
|
||||||
self.update_phase(scan_run_id, "graph_building").await;
|
self.update_phase(scan_run_id, "graph_building").await;
|
||||||
let graph_context = match self.build_code_graph(&repo_path, &repo_id, &all_findings).await
|
let graph_context = match self
|
||||||
|
.build_code_graph(&repo_path, &repo_id, &all_findings)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(ctx) => Some(ctx),
|
Ok(ctx) => Some(ctx),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -296,9 +335,10 @@ impl PipelineOrchestrator {
|
|||||||
let graph_build_id = uuid::Uuid::new_v4().to_string();
|
let graph_build_id = uuid::Uuid::new_v4().to_string();
|
||||||
let engine = compliance_graph::GraphEngine::new(50_000);
|
let engine = compliance_graph::GraphEngine::new(50_000);
|
||||||
|
|
||||||
let (mut code_graph, build_run) = engine
|
let (mut code_graph, build_run) =
|
||||||
.build_graph(repo_path, repo_id, &graph_build_id)
|
engine
|
||||||
.map_err(|e| AgentError::Other(format!("Graph build error: {e}")))?;
|
.build_graph(repo_path, repo_id, &graph_build_id)
|
||||||
|
.map_err(|e| AgentError::Other(format!("Graph build error: {e}")))?;
|
||||||
|
|
||||||
// Apply community detection
|
// Apply community detection
|
||||||
compliance_graph::graph::community::apply_communities(&mut code_graph);
|
compliance_graph::graph::community::apply_communities(&mut code_graph);
|
||||||
@@ -348,15 +388,11 @@ impl PipelineOrchestrator {
|
|||||||
use futures_util::TryStreamExt;
|
use futures_util::TryStreamExt;
|
||||||
|
|
||||||
let filter = mongodb::bson::doc! { "repo_id": repo_id };
|
let filter = mongodb::bson::doc! { "repo_id": repo_id };
|
||||||
let targets: Vec<compliance_core::models::DastTarget> = match self
|
let targets: Vec<compliance_core::models::DastTarget> =
|
||||||
.db
|
match self.db.dast_targets().find(filter).await {
|
||||||
.dast_targets()
|
Ok(cursor) => cursor.try_collect().await.unwrap_or_default(),
|
||||||
.find(filter)
|
Err(_) => return,
|
||||||
.await
|
};
|
||||||
{
|
|
||||||
Ok(cursor) => cursor.try_collect().await.unwrap_or_default(),
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
tracing::info!("[{repo_id}] No DAST targets configured, skipping");
|
tracing::info!("[{repo_id}] No DAST targets configured, skipping");
|
||||||
@@ -379,10 +415,7 @@ impl PipelineOrchestrator {
|
|||||||
tracing::error!("Failed to store DAST finding: {e}");
|
tracing::error!("Failed to store DAST finding: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!("DAST scan complete: {} findings", findings.len());
|
||||||
"DAST scan complete: {} findings",
|
|
||||||
findings.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("DAST scan failed: {e}");
|
tracing::error!("DAST scan failed: {e}");
|
||||||
|
|||||||
1
compliance-agent/src/rag/mod.rs
Normal file
1
compliance-agent/src/rag/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod pipeline;
|
||||||
164
compliance-agent/src/rag/pipeline.rs
Normal file
164
compliance-agent/src/rag/pipeline.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use compliance_core::models::embedding::{CodeEmbedding, EmbeddingBuildRun, EmbeddingBuildStatus};
|
||||||
|
use compliance_core::models::graph::CodeNode;
|
||||||
|
use compliance_graph::graph::chunking::extract_chunks;
|
||||||
|
use compliance_graph::graph::embedding_store::EmbeddingStore;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::error::AgentError;
|
||||||
|
use crate::llm::LlmClient;
|
||||||
|
|
||||||
|
/// RAG pipeline for building embeddings and performing retrieval
|
||||||
|
pub struct RagPipeline {
|
||||||
|
llm: Arc<LlmClient>,
|
||||||
|
embedding_store: EmbeddingStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RagPipeline {
|
||||||
|
pub fn new(llm: Arc<LlmClient>, db: &mongodb::Database) -> Self {
|
||||||
|
Self {
|
||||||
|
llm,
|
||||||
|
embedding_store: EmbeddingStore::new(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store(&self) -> &EmbeddingStore {
|
||||||
|
&self.embedding_store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build embeddings for all code nodes in a repository
|
||||||
|
pub async fn build_embeddings(
|
||||||
|
&self,
|
||||||
|
repo_id: &str,
|
||||||
|
repo_path: &Path,
|
||||||
|
graph_build_id: &str,
|
||||||
|
nodes: &[CodeNode],
|
||||||
|
) -> Result<EmbeddingBuildRun, AgentError> {
|
||||||
|
let embed_model = self.llm.embed_model().to_string();
|
||||||
|
let mut build =
|
||||||
|
EmbeddingBuildRun::new(repo_id.to_string(), graph_build_id.to_string(), embed_model);
|
||||||
|
|
||||||
|
// Step 1: Extract chunks
|
||||||
|
let chunks = extract_chunks(repo_path, nodes, 2048);
|
||||||
|
build.total_chunks = chunks.len() as u32;
|
||||||
|
info!(
|
||||||
|
"[{repo_id}] Extracted {} chunks for embedding",
|
||||||
|
chunks.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store the initial build record
|
||||||
|
self.embedding_store
|
||||||
|
.store_build(&build)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to store build: {e}")))?;
|
||||||
|
|
||||||
|
if chunks.is_empty() {
|
||||||
|
build.status = EmbeddingBuildStatus::Completed;
|
||||||
|
build.completed_at = Some(Utc::now());
|
||||||
|
self.embedding_store
|
||||||
|
.update_build(
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
EmbeddingBuildStatus::Completed,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to update build: {e}")))?;
|
||||||
|
return Ok(build);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Delete old embeddings for this repo
|
||||||
|
self.embedding_store
|
||||||
|
.delete_repo_embeddings(repo_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to delete old embeddings: {e}")))?;
|
||||||
|
|
||||||
|
// Step 3: Batch embed (small batches to stay within model limits)
|
||||||
|
let batch_size = 20;
|
||||||
|
let mut all_embeddings = Vec::new();
|
||||||
|
let mut embedded_count = 0u32;
|
||||||
|
|
||||||
|
for batch_start in (0..chunks.len()).step_by(batch_size) {
|
||||||
|
let batch_end = (batch_start + batch_size).min(chunks.len());
|
||||||
|
let batch_chunks = &chunks[batch_start..batch_end];
|
||||||
|
|
||||||
|
// Prepare texts: context_header + content
|
||||||
|
let texts: Vec<String> = batch_chunks
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("{}\n{}", c.context_header, c.content))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match self.llm.embed(texts).await {
|
||||||
|
Ok(vectors) => {
|
||||||
|
for (chunk, embedding) in batch_chunks.iter().zip(vectors) {
|
||||||
|
all_embeddings.push(CodeEmbedding {
|
||||||
|
id: None,
|
||||||
|
repo_id: repo_id.to_string(),
|
||||||
|
graph_build_id: graph_build_id.to_string(),
|
||||||
|
qualified_name: chunk.qualified_name.clone(),
|
||||||
|
kind: chunk.kind.clone(),
|
||||||
|
file_path: chunk.file_path.clone(),
|
||||||
|
start_line: chunk.start_line,
|
||||||
|
end_line: chunk.end_line,
|
||||||
|
language: chunk.language.clone(),
|
||||||
|
content: chunk.content.clone(),
|
||||||
|
context_header: chunk.context_header.clone(),
|
||||||
|
embedding,
|
||||||
|
token_estimate: chunk.token_estimate,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
embedded_count += batch_chunks.len() as u32;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("[{repo_id}] Embedding batch failed: {e}");
|
||||||
|
build.status = EmbeddingBuildStatus::Failed;
|
||||||
|
build.error_message = Some(e.to_string());
|
||||||
|
build.completed_at = Some(Utc::now());
|
||||||
|
let _ = self
|
||||||
|
.embedding_store
|
||||||
|
.update_build(
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
EmbeddingBuildStatus::Failed,
|
||||||
|
embedded_count,
|
||||||
|
Some(e.to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Ok(build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Store all embeddings
|
||||||
|
self.embedding_store
|
||||||
|
.store_embeddings(&all_embeddings)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to store embeddings: {e}")))?;
|
||||||
|
|
||||||
|
// Step 5: Update build status
|
||||||
|
build.status = EmbeddingBuildStatus::Completed;
|
||||||
|
build.embedded_chunks = embedded_count;
|
||||||
|
build.completed_at = Some(Utc::now());
|
||||||
|
self.embedding_store
|
||||||
|
.update_build(
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
EmbeddingBuildStatus::Completed,
|
||||||
|
embedded_count,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AgentError::Other(format!("Failed to update build: {e}")))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[{repo_id}] Embedding build complete: {embedded_count}/{} chunks",
|
||||||
|
build.total_chunks
|
||||||
|
);
|
||||||
|
Ok(build)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
compliance-agent/src/ssh.rs
Normal file
53
compliance-agent/src/ssh.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::AgentError;
|
||||||
|
|
||||||
|
/// Ensure the SSH key pair exists at the given path, generating it if missing.
|
||||||
|
/// Returns the public key contents.
|
||||||
|
pub fn ensure_ssh_key(key_path: &str) -> Result<String, AgentError> {
|
||||||
|
let private_path = Path::new(key_path);
|
||||||
|
let public_path = private_path.with_extension("pub");
|
||||||
|
|
||||||
|
if private_path.exists() && public_path.exists() {
|
||||||
|
return std::fs::read_to_string(&public_path)
|
||||||
|
.map_err(|e| AgentError::Config(format!("Failed to read SSH public key: {e}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent directory
|
||||||
|
if let Some(parent) = private_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ed25519 key pair using ssh-keygen
|
||||||
|
let output = std::process::Command::new("ssh-keygen")
|
||||||
|
.args([
|
||||||
|
"-t",
|
||||||
|
"ed25519",
|
||||||
|
"-f",
|
||||||
|
key_path,
|
||||||
|
"-N",
|
||||||
|
"", // no passphrase
|
||||||
|
"-C",
|
||||||
|
"compliance-scanner-agent",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| AgentError::Config(format!("Failed to run ssh-keygen: {e}")))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(AgentError::Config(format!("ssh-keygen failed: {stderr}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set correct permissions
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(private_path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let public_key = std::fs::read_to_string(&public_path)
|
||||||
|
.map_err(|e| AgentError::Config(format!("Failed to read generated SSH public key: {e}")))?;
|
||||||
|
|
||||||
|
tracing::info!("Generated new SSH key pair at {key_path}");
|
||||||
|
Ok(public_key)
|
||||||
|
}
|
||||||
@@ -9,6 +9,15 @@ workspace = true
|
|||||||
[features]
|
[features]
|
||||||
default = ["mongodb"]
|
default = ["mongodb"]
|
||||||
mongodb = ["dep:mongodb"]
|
mongodb = ["dep:mongodb"]
|
||||||
|
telemetry = [
|
||||||
|
"dep:opentelemetry",
|
||||||
|
"dep:opentelemetry_sdk",
|
||||||
|
"dep:opentelemetry-otlp",
|
||||||
|
"dep:opentelemetry-appender-tracing",
|
||||||
|
"dep:tracing-opentelemetry",
|
||||||
|
"dep:tracing-subscriber",
|
||||||
|
"dep:tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -21,3 +30,10 @@ uuid = { workspace = true }
|
|||||||
secrecy = { workspace = true }
|
secrecy = { workspace = true }
|
||||||
bson = { version = "2", features = ["chrono-0_4"] }
|
bson = { version = "2", features = ["chrono-0_4"] }
|
||||||
mongodb = { workspace = true, optional = true }
|
mongodb = { workspace = true, optional = true }
|
||||||
|
opentelemetry = { version = "0.29", optional = true }
|
||||||
|
opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"], optional = true }
|
||||||
|
opentelemetry-otlp = { version = "0.29", features = ["http", "reqwest-rustls"], optional = true }
|
||||||
|
opentelemetry-appender-tracing = { version = "0.29", optional = true }
|
||||||
|
tracing-opentelemetry = { version = "0.30", optional = true }
|
||||||
|
tracing-subscriber = { workspace = true, optional = true }
|
||||||
|
tracing = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct AgentConfig {
|
|||||||
pub litellm_url: String,
|
pub litellm_url: String,
|
||||||
pub litellm_api_key: SecretString,
|
pub litellm_api_key: SecretString,
|
||||||
pub litellm_model: String,
|
pub litellm_model: String,
|
||||||
|
pub litellm_embed_model: String,
|
||||||
pub github_token: Option<SecretString>,
|
pub github_token: Option<SecretString>,
|
||||||
pub github_webhook_secret: Option<SecretString>,
|
pub github_webhook_secret: Option<SecretString>,
|
||||||
pub gitlab_url: Option<String>,
|
pub gitlab_url: Option<String>,
|
||||||
@@ -23,6 +24,9 @@ pub struct AgentConfig {
|
|||||||
pub scan_schedule: String,
|
pub scan_schedule: String,
|
||||||
pub cve_monitor_schedule: String,
|
pub cve_monitor_schedule: String,
|
||||||
pub git_clone_base_path: String,
|
pub git_clone_base_path: String,
|
||||||
|
pub ssh_key_path: String,
|
||||||
|
pub keycloak_url: Option<String>,
|
||||||
|
pub keycloak_realm: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
@@ -31,4 +35,5 @@ pub struct DashboardConfig {
|
|||||||
pub mongodb_database: String,
|
pub mongodb_database: String,
|
||||||
pub agent_api_url: String,
|
pub agent_api_url: String,
|
||||||
pub dashboard_port: u16,
|
pub dashboard_port: u16,
|
||||||
|
pub mcp_endpoint_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
#[cfg(feature = "telemetry")]
|
||||||
|
pub mod telemetry;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
|
||||||
pub use config::{AgentConfig, DashboardConfig};
|
pub use config::{AgentConfig, DashboardConfig};
|
||||||
|
|||||||
14
compliance-core/src/models/auth.rs
Normal file
14
compliance-core/src/models/auth.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Authentication state returned by the `check_auth` server function.
|
||||||
|
///
|
||||||
|
/// When no valid session exists, `authenticated` is `false` and all
|
||||||
|
/// other fields are empty strings.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct AuthInfo {
|
||||||
|
pub authenticated: bool,
|
||||||
|
pub sub: String,
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: String,
|
||||||
|
}
|
||||||
35
compliance-core/src/models/chat.rs
Normal file
35
compliance-core/src/models/chat.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A message in the chat history
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for the chat endpoint
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub history: Vec<ChatMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source reference from the RAG retrieval
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SourceReference {
|
||||||
|
pub file_path: String,
|
||||||
|
pub qualified_name: String,
|
||||||
|
pub start_line: u32,
|
||||||
|
pub end_line: u32,
|
||||||
|
pub language: String,
|
||||||
|
pub snippet: String,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from the chat endpoint
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatResponse {
|
||||||
|
pub message: String,
|
||||||
|
pub sources: Vec<SourceReference>,
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub struct CveAlert {
|
|||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
pub llm_impact_summary: Option<String>,
|
pub llm_impact_summary: Option<String>,
|
||||||
pub references: Vec<String>,
|
pub references: Vec<String>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ pub struct DastTarget {
|
|||||||
pub rate_limit: u32,
|
pub rate_limit: u32,
|
||||||
/// Whether destructive tests (DELETE, PUT) are allowed
|
/// Whether destructive tests (DELETE, PUT) are allowed
|
||||||
pub allow_destructive: bool,
|
pub allow_destructive: bool,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +137,9 @@ pub struct DastScanRun {
|
|||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
/// Linked SAST scan run ID (if triggered as part of pipeline)
|
/// Linked SAST scan run ID (if triggered as part of pipeline)
|
||||||
pub sast_scan_run_id: Option<String>,
|
pub sast_scan_run_id: Option<String>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub started_at: DateTime<Utc>,
|
pub started_at: DateTime<Utc>,
|
||||||
|
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||||
pub completed_at: Option<DateTime<Utc>>,
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,10 +244,12 @@ pub struct DastFinding {
|
|||||||
pub remediation: Option<String>,
|
pub remediation: Option<String>,
|
||||||
/// Linked SAST finding ID (if correlated)
|
/// Linked SAST finding ID (if correlated)
|
||||||
pub linked_sast_finding_id: Option<String>,
|
pub linked_sast_finding_id: Option<String>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DastFinding {
|
impl DastFinding {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
scan_run_id: String,
|
scan_run_id: String,
|
||||||
target_id: String,
|
target_id: String,
|
||||||
|
|||||||
100
compliance-core/src/models/embedding.rs
Normal file
100
compliance-core/src/models/embedding.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Status of an embedding build operation
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EmbeddingBuildStatus {
|
||||||
|
Running,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A code embedding stored in MongoDB Atlas Vector Search
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CodeEmbedding {
|
||||||
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<bson::oid::ObjectId>,
|
||||||
|
pub repo_id: String,
|
||||||
|
pub graph_build_id: String,
|
||||||
|
pub qualified_name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub start_line: u32,
|
||||||
|
pub end_line: u32,
|
||||||
|
pub language: String,
|
||||||
|
pub content: String,
|
||||||
|
pub context_header: String,
|
||||||
|
pub embedding: Vec<f64>,
|
||||||
|
pub token_estimate: u32,
|
||||||
|
#[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks an embedding build operation for a repository
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EmbeddingBuildRun {
|
||||||
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<bson::oid::ObjectId>,
|
||||||
|
pub repo_id: String,
|
||||||
|
pub graph_build_id: String,
|
||||||
|
pub status: EmbeddingBuildStatus,
|
||||||
|
pub total_chunks: u32,
|
||||||
|
pub embedded_chunks: u32,
|
||||||
|
pub embedding_model: String,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
#[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")]
|
||||||
|
pub started_at: DateTime<Utc>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
skip_serializing_if = "Option::is_none",
|
||||||
|
with = "opt_chrono_as_bson"
|
||||||
|
)]
|
||||||
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbeddingBuildRun {
|
||||||
|
pub fn new(repo_id: String, graph_build_id: String, embedding_model: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id: None,
|
||||||
|
repo_id,
|
||||||
|
graph_build_id,
|
||||||
|
status: EmbeddingBuildStatus::Running,
|
||||||
|
total_chunks: 0,
|
||||||
|
embedded_chunks: 0,
|
||||||
|
embedding_model,
|
||||||
|
error_message: None,
|
||||||
|
started_at: Utc::now(),
|
||||||
|
completed_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serde helper for Option<DateTime<Utc>> as BSON DateTime
|
||||||
|
mod opt_chrono_as_bson {
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct BsonDt(
|
||||||
|
#[serde(with = "bson::serde_helpers::chrono_datetime_as_bson_datetime")] DateTime<Utc>,
|
||||||
|
);
|
||||||
|
|
||||||
|
pub fn serialize<S>(value: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
match value {
|
||||||
|
Some(dt) => BsonDt(*dt).serialize(serializer),
|
||||||
|
None => serializer.serialize_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let opt: Option<BsonDt> = Option::deserialize(deserializer)?;
|
||||||
|
Ok(opt.map(|d| d.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,14 @@ pub struct Finding {
|
|||||||
pub status: FindingStatus,
|
pub status: FindingStatus,
|
||||||
pub tracker_issue_url: Option<String>,
|
pub tracker_issue_url: Option<String>,
|
||||||
pub scan_run_id: Option<String>,
|
pub scan_run_id: Option<String>,
|
||||||
|
/// LLM triage action and reasoning
|
||||||
|
pub triage_action: Option<String>,
|
||||||
|
pub triage_rationale: Option<String>,
|
||||||
|
/// Developer feedback on finding quality
|
||||||
|
pub developer_feedback: Option<String>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +115,9 @@ impl Finding {
|
|||||||
status: FindingStatus::Open,
|
status: FindingStatus::Open,
|
||||||
tracker_issue_url: None,
|
tracker_issue_url: None,
|
||||||
scan_run_id: None,
|
scan_run_id: None,
|
||||||
|
triage_action: None,
|
||||||
|
triage_rationale: None,
|
||||||
|
developer_feedback: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ pub struct GraphBuildRun {
|
|||||||
pub community_count: u32,
|
pub community_count: u32,
|
||||||
pub languages_parsed: Vec<String>,
|
pub languages_parsed: Vec<String>,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub started_at: DateTime<Utc>,
|
pub started_at: DateTime<Utc>,
|
||||||
|
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||||
pub completed_at: Option<DateTime<Utc>>,
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +166,7 @@ pub struct ImpactAnalysis {
|
|||||||
pub direct_callers: Vec<String>,
|
pub direct_callers: Vec<String>,
|
||||||
/// Direct callees of the affected function
|
/// Direct callees of the affected function
|
||||||
pub direct_callees: Vec<String>,
|
pub direct_callees: Vec<String>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ pub struct TrackerIssue {
|
|||||||
pub external_url: String,
|
pub external_url: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub status: IssueStatus,
|
pub status: IssueStatus,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
69
compliance-core/src/models/mcp.rs
Normal file
69
compliance-core/src/models/mcp.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Transport mode for MCP server
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum McpTransport {
|
||||||
|
Stdio,
|
||||||
|
Http,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for McpTransport {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Stdio => write!(f, "stdio"),
|
||||||
|
Self::Http => write!(f, "http"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status of a running MCP server
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum McpServerStatus {
|
||||||
|
Running,
|
||||||
|
Stopped,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for McpServerStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Running => write!(f, "running"),
|
||||||
|
Self::Stopped => write!(f, "stopped"),
|
||||||
|
Self::Error => write!(f, "error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a registered MCP server instance
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McpServerConfig {
|
||||||
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<bson::oid::ObjectId>,
|
||||||
|
/// Display name for this MCP server
|
||||||
|
pub name: String,
|
||||||
|
/// Endpoint URL (e.g. https://mcp.example.com/mcp)
|
||||||
|
pub endpoint_url: String,
|
||||||
|
/// Transport type
|
||||||
|
pub transport: McpTransport,
|
||||||
|
/// Port number (for HTTP transport)
|
||||||
|
pub port: Option<u16>,
|
||||||
|
/// Current status
|
||||||
|
pub status: McpServerStatus,
|
||||||
|
/// Bearer access token for authentication
|
||||||
|
pub access_token: String,
|
||||||
|
/// Which tools are enabled on this server
|
||||||
|
pub tools_enabled: Vec<String>,
|
||||||
|
/// Optional description / notes
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// MongoDB URI this server connects to
|
||||||
|
pub mongodb_uri: Option<String>,
|
||||||
|
/// Database name
|
||||||
|
pub mongodb_database: Option<String>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
@@ -1,23 +1,31 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod chat;
|
||||||
pub mod cve;
|
pub mod cve;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
|
pub mod embedding;
|
||||||
pub mod finding;
|
pub mod finding;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
|
pub mod mcp;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
|
pub(crate) mod serde_helpers;
|
||||||
|
|
||||||
|
pub use auth::AuthInfo;
|
||||||
|
pub use chat::{ChatMessage, ChatRequest, ChatResponse, SourceReference};
|
||||||
pub use cve::{CveAlert, CveSource};
|
pub use cve::{CveAlert, CveSource};
|
||||||
pub use dast::{
|
pub use dast::{
|
||||||
DastAuthConfig, DastEvidence, DastFinding, DastScanPhase, DastScanRun, DastScanStatus,
|
DastAuthConfig, DastEvidence, DastFinding, DastScanPhase, DastScanRun, DastScanStatus,
|
||||||
DastTarget, DastTargetType, DastVulnType,
|
DastTarget, DastTargetType, DastVulnType,
|
||||||
};
|
};
|
||||||
|
pub use embedding::{CodeEmbedding, EmbeddingBuildRun, EmbeddingBuildStatus};
|
||||||
pub use finding::{Finding, FindingStatus, Severity};
|
pub use finding::{Finding, FindingStatus, Severity};
|
||||||
pub use graph::{
|
pub use graph::{
|
||||||
CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus,
|
CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus, ImpactAnalysis,
|
||||||
ImpactAnalysis,
|
|
||||||
};
|
};
|
||||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||||
|
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
||||||
pub use repository::{ScanTrigger, TrackedRepository};
|
pub use repository::{ScanTrigger, TrackedRepository};
|
||||||
pub use sbom::{SbomEntry, VulnRef};
|
pub use sbom::{SbomEntry, VulnRef};
|
||||||
pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType};
|
pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType};
|
||||||
|
|||||||
@@ -28,12 +28,24 @@ pub struct TrackedRepository {
|
|||||||
pub tracker_type: Option<TrackerType>,
|
pub tracker_type: Option<TrackerType>,
|
||||||
pub tracker_owner: Option<String>,
|
pub tracker_owner: Option<String>,
|
||||||
pub tracker_repo: Option<String>,
|
pub tracker_repo: Option<String>,
|
||||||
|
/// Optional auth token for HTTPS private repos (PAT or password)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth_token: Option<String>,
|
||||||
|
/// Optional username for HTTPS auth (defaults to "x-access-token" for PATs)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub auth_username: Option<String>,
|
||||||
pub last_scanned_commit: Option<String>,
|
pub last_scanned_commit: Option<String>,
|
||||||
#[serde(default, deserialize_with = "deserialize_findings_count")]
|
#[serde(default, deserialize_with = "deserialize_findings_count")]
|
||||||
pub findings_count: u32,
|
pub findings_count: u32,
|
||||||
#[serde(default = "chrono::Utc::now", deserialize_with = "deserialize_datetime")]
|
#[serde(
|
||||||
|
default = "chrono::Utc::now",
|
||||||
|
with = "super::serde_helpers::bson_datetime"
|
||||||
|
)]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
#[serde(default = "chrono::Utc::now", deserialize_with = "deserialize_datetime")]
|
#[serde(
|
||||||
|
default = "chrono::Utc::now",
|
||||||
|
with = "super::serde_helpers::bson_datetime"
|
||||||
|
)]
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,25 +53,6 @@ fn default_branch() -> String {
|
|||||||
"main".to_string()
|
"main".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles findings_count stored as either a plain integer or a BSON Int64
|
|
||||||
/// which the driver may present as a map `{"low": N, "high": N, "unsigned": bool}`.
|
|
||||||
/// Handles datetime stored as either a BSON DateTime or an RFC 3339 string.
|
|
||||||
fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let bson = bson::Bson::deserialize(deserializer)?;
|
|
||||||
match bson {
|
|
||||||
bson::Bson::DateTime(dt) => Ok(dt.into()),
|
|
||||||
bson::Bson::String(s) => s
|
|
||||||
.parse::<DateTime<Utc>>()
|
|
||||||
.map_err(serde::de::Error::custom),
|
|
||||||
other => Err(serde::de::Error::custom(format!(
|
|
||||||
"expected DateTime or string, got: {other:?}"
|
|
||||||
))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deserialize_findings_count<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
fn deserialize_findings_count<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
@@ -83,6 +76,8 @@ impl TrackedRepository {
|
|||||||
default_branch: "main".to_string(),
|
default_branch: "main".to_string(),
|
||||||
local_path: None,
|
local_path: None,
|
||||||
scan_schedule: None,
|
scan_schedule: None,
|
||||||
|
auth_token: None,
|
||||||
|
auth_username: None,
|
||||||
webhook_enabled: false,
|
webhook_enabled: false,
|
||||||
tracker_type: None,
|
tracker_type: None,
|
||||||
tracker_owner: None,
|
tracker_owner: None,
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ pub struct SbomEntry {
|
|||||||
pub license: Option<String>,
|
pub license: Option<String>,
|
||||||
pub purl: Option<String>,
|
pub purl: Option<String>,
|
||||||
pub known_vulnerabilities: Vec<VulnRef>,
|
pub known_vulnerabilities: Vec<VulnRef>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ pub enum ScanType {
|
|||||||
OAuth,
|
OAuth,
|
||||||
Graph,
|
Graph,
|
||||||
Dast,
|
Dast,
|
||||||
|
SecretDetection,
|
||||||
|
Lint,
|
||||||
|
CodeReview,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for ScanType {
|
impl std::fmt::Display for ScanType {
|
||||||
@@ -25,6 +28,9 @@ impl std::fmt::Display for ScanType {
|
|||||||
Self::OAuth => write!(f, "oauth"),
|
Self::OAuth => write!(f, "oauth"),
|
||||||
Self::Graph => write!(f, "graph"),
|
Self::Graph => write!(f, "graph"),
|
||||||
Self::Dast => write!(f, "dast"),
|
Self::Dast => write!(f, "dast"),
|
||||||
|
Self::SecretDetection => write!(f, "secret_detection"),
|
||||||
|
Self::Lint => write!(f, "lint"),
|
||||||
|
Self::CodeReview => write!(f, "code_review"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +51,9 @@ pub enum ScanPhase {
|
|||||||
SbomGeneration,
|
SbomGeneration,
|
||||||
CveScanning,
|
CveScanning,
|
||||||
PatternScanning,
|
PatternScanning,
|
||||||
|
SecretDetection,
|
||||||
|
LintScanning,
|
||||||
|
CodeReview,
|
||||||
GraphBuilding,
|
GraphBuilding,
|
||||||
LlmTriage,
|
LlmTriage,
|
||||||
IssueCreation,
|
IssueCreation,
|
||||||
@@ -64,7 +73,9 @@ pub struct ScanRun {
|
|||||||
pub phases_completed: Vec<ScanPhase>,
|
pub phases_completed: Vec<ScanPhase>,
|
||||||
pub new_findings_count: u32,
|
pub new_findings_count: u32,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub started_at: DateTime<Utc>,
|
pub started_at: DateTime<Utc>,
|
||||||
|
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||||
pub completed_at: Option<DateTime<Utc>>,
|
pub completed_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
compliance-core/src/models/serde_helpers.rs
Normal file
68
compliance-core/src/models/serde_helpers.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
|
/// Serialize/deserialize `DateTime<Utc>` as BSON DateTime.
|
||||||
|
/// Handles both BSON DateTime objects and RFC 3339 strings on deserialization.
|
||||||
|
pub mod bson_datetime {
|
||||||
|
use super::*;
|
||||||
|
use serde::Serialize as _;
|
||||||
|
|
||||||
|
pub fn serialize<S>(dt: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let bson_dt: bson::DateTime = (*dt).into();
|
||||||
|
bson_dt.serialize(serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let bson_val = bson::Bson::deserialize(deserializer)?;
|
||||||
|
match bson_val {
|
||||||
|
bson::Bson::DateTime(dt) => Ok(dt.into()),
|
||||||
|
bson::Bson::String(s) => s.parse::<DateTime<Utc>>().map_err(serde::de::Error::custom),
|
||||||
|
other => Err(serde::de::Error::custom(format!(
|
||||||
|
"expected DateTime or string, got: {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize/deserialize `Option<DateTime<Utc>>` as BSON DateTime.
|
||||||
|
pub mod opt_bson_datetime {
|
||||||
|
use super::*;
|
||||||
|
use serde::Serialize as _;
|
||||||
|
|
||||||
|
pub fn serialize<S>(dt: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
match dt {
|
||||||
|
Some(dt) => {
|
||||||
|
let bson_dt: bson::DateTime = (*dt).into();
|
||||||
|
bson_dt.serialize(serializer)
|
||||||
|
}
|
||||||
|
None => serializer.serialize_none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let bson_val = Option::<bson::Bson>::deserialize(deserializer)?;
|
||||||
|
match bson_val {
|
||||||
|
Some(bson::Bson::DateTime(dt)) => Ok(Some(dt.into())),
|
||||||
|
Some(bson::Bson::String(s)) => s
|
||||||
|
.parse::<DateTime<Utc>>()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(serde::de::Error::custom),
|
||||||
|
Some(bson::Bson::Null) | None => Ok(None),
|
||||||
|
Some(other) => Err(serde::de::Error::custom(format!(
|
||||||
|
"expected DateTime, string, or null, got: {other:?}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
compliance-core/src/telemetry.rs
Normal file
152
compliance-core/src/telemetry.rs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
//! OpenTelemetry initialization for traces and logs.
|
||||||
|
//!
|
||||||
|
//! Exports traces and logs via OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||||
|
//! is set. Always includes a `tracing_subscriber::fmt` layer for console output.
|
||||||
|
//!
|
||||||
|
//! Compatible with SigNoz, Grafana Tempo/Loki, Jaeger, and any OTLP-compatible
|
||||||
|
//! collector.
|
||||||
|
//!
|
||||||
|
//! # Environment Variables
|
||||||
|
//!
|
||||||
|
//! | Variable | Description | Default |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint (e.g. `https://otel.example.com`) | *(disabled)* |
|
||||||
|
//! | `OTEL_SERVICE_NAME` | Service name for resource | `service_name` param |
|
||||||
|
//! | `RUST_LOG` / standard `EnvFilter` | Log level filter | `info` |
|
||||||
|
|
||||||
|
use opentelemetry::global;
|
||||||
|
use opentelemetry::trace::TracerProvider as _;
|
||||||
|
use opentelemetry::KeyValue;
|
||||||
|
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
||||||
|
use opentelemetry_otlp::{LogExporter, SpanExporter, WithExportConfig};
|
||||||
|
use opentelemetry_sdk::{logs::SdkLoggerProvider, trace::SdkTracerProvider, Resource};
|
||||||
|
use tracing_opentelemetry::OpenTelemetryLayer;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer as _};
|
||||||
|
|
||||||
|
/// Guard that shuts down OTel providers on drop.
|
||||||
|
///
|
||||||
|
/// Must be held for the lifetime of the application. When dropped,
|
||||||
|
/// flushes and shuts down the tracer and logger providers.
|
||||||
|
pub struct TelemetryGuard {
|
||||||
|
tracer_provider: Option<SdkTracerProvider>,
|
||||||
|
logger_provider: Option<SdkLoggerProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TelemetryGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(tp) = self.tracer_provider.take() {
|
||||||
|
if let Err(e) = tp.shutdown() {
|
||||||
|
eprintln!("Failed to shutdown tracer provider: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(lp) = self.logger_provider.take() {
|
||||||
|
if let Err(e) = lp.shutdown() {
|
||||||
|
eprintln!("Failed to shutdown logger provider: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_resource(service_name: &str) -> Resource {
|
||||||
|
let name = std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| service_name.to_string());
|
||||||
|
Resource::builder()
|
||||||
|
.with_service_name(name)
|
||||||
|
.with_attributes([KeyValue::new("service.version", env!("CARGO_PKG_VERSION"))])
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize telemetry (tracing + logging).
|
||||||
|
///
|
||||||
|
/// If `OTEL_EXPORTER_OTLP_ENDPOINT` is set, traces and logs are exported
|
||||||
|
/// via OTLP/HTTP. Console fmt output is always enabled.
|
||||||
|
///
|
||||||
|
/// Returns a [`TelemetryGuard`] that must be held alive for the application
|
||||||
|
/// lifetime. Dropping it triggers a graceful shutdown of OTel providers.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the tracing subscriber cannot be initialized (e.g. called twice).
|
||||||
|
pub fn init_telemetry(service_name: &str) -> TelemetryGuard {
|
||||||
|
let otel_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok();
|
||||||
|
|
||||||
|
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
let fmt_layer = tracing_subscriber::fmt::layer();
|
||||||
|
|
||||||
|
match otel_endpoint {
|
||||||
|
Some(ref endpoint) => {
|
||||||
|
let resource = build_resource(service_name);
|
||||||
|
|
||||||
|
let traces_endpoint = format!("{endpoint}/v1/traces");
|
||||||
|
let logs_endpoint = format!("{endpoint}/v1/logs");
|
||||||
|
|
||||||
|
// Traces
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
let span_exporter = SpanExporter::builder()
|
||||||
|
.with_http()
|
||||||
|
.with_endpoint(&traces_endpoint)
|
||||||
|
.build()
|
||||||
|
.expect("failed to create OTLP span exporter");
|
||||||
|
|
||||||
|
let tracer_provider = SdkTracerProvider::builder()
|
||||||
|
.with_batch_exporter(span_exporter)
|
||||||
|
.with_resource(resource.clone())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
global::set_tracer_provider(tracer_provider.clone());
|
||||||
|
let tracer = tracer_provider.tracer(service_name.to_string());
|
||||||
|
let otel_trace_layer = OpenTelemetryLayer::new(tracer);
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
let log_exporter = LogExporter::builder()
|
||||||
|
.with_http()
|
||||||
|
.with_endpoint(&logs_endpoint)
|
||||||
|
.build()
|
||||||
|
.expect("failed to create OTLP log exporter");
|
||||||
|
|
||||||
|
let logger_provider = SdkLoggerProvider::builder()
|
||||||
|
.with_batch_exporter(log_exporter)
|
||||||
|
.with_resource(resource)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let otel_log_layer = OpenTelemetryTracingBridge::new(&logger_provider);
|
||||||
|
|
||||||
|
// Filter to prevent telemetry-induced-telemetry loops
|
||||||
|
let otel_filter = EnvFilter::new("info")
|
||||||
|
.add_directive("hyper=off".parse().unwrap_or_default())
|
||||||
|
.add_directive("h2=off".parse().unwrap_or_default())
|
||||||
|
.add_directive("reqwest=off".parse().unwrap_or_default());
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt_layer)
|
||||||
|
.with(otel_trace_layer)
|
||||||
|
.with(otel_log_layer.with_filter(otel_filter))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
endpoint = endpoint.as_str(),
|
||||||
|
service = service_name,
|
||||||
|
"OpenTelemetry OTLP/HTTP export enabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
TelemetryGuard {
|
||||||
|
tracer_provider: Some(tracer_provider),
|
||||||
|
logger_provider: Some(logger_provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt_layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("OpenTelemetry disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)");
|
||||||
|
|
||||||
|
TelemetryGuard {
|
||||||
|
tracer_provider: None,
|
||||||
|
logger_provider: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ server = [
|
|||||||
"dioxus/router",
|
"dioxus/router",
|
||||||
"dioxus/fullstack",
|
"dioxus/fullstack",
|
||||||
"compliance-core/mongodb",
|
"compliance-core/mongodb",
|
||||||
|
"compliance-core/telemetry",
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
"dep:mongodb",
|
"dep:mongodb",
|
||||||
"dep:reqwest",
|
"dep:reqwest",
|
||||||
@@ -27,6 +28,14 @@ server = [
|
|||||||
"dep:dioxus-cli-config",
|
"dep:dioxus-cli-config",
|
||||||
"dep:dioxus-fullstack",
|
"dep:dioxus-fullstack",
|
||||||
"dep:tokio",
|
"dep:tokio",
|
||||||
|
"dep:tower-sessions",
|
||||||
|
"dep:time",
|
||||||
|
"dep:rand",
|
||||||
|
"dep:url",
|
||||||
|
"dep:sha2",
|
||||||
|
"dep:base64",
|
||||||
|
"dep:uuid",
|
||||||
|
"dep:bson",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -54,3 +63,11 @@ dotenvy = { version = "0.15", optional = true }
|
|||||||
tokio = { workspace = true, optional = true }
|
tokio = { workspace = true, optional = true }
|
||||||
dioxus-cli-config = { version = "=0.7.3", optional = true }
|
dioxus-cli-config = { version = "=0.7.3", optional = true }
|
||||||
dioxus-fullstack = { version = "=0.7.3", optional = true }
|
dioxus-fullstack = { version = "=0.7.3", optional = true }
|
||||||
|
tower-sessions = { version = "0.15", default-features = false, features = ["axum-core", "memory-store", "signed"], optional = true }
|
||||||
|
time = { version = "0.3", default-features = false, optional = true }
|
||||||
|
rand = { version = "0.9", optional = true }
|
||||||
|
url = { version = "2", optional = true }
|
||||||
|
sha2 = { workspace = true, optional = true }
|
||||||
|
base64 = { version = "0.22", optional = true }
|
||||||
|
uuid = { workspace = true, optional = true }
|
||||||
|
bson = { version = "2", features = ["chrono-0_4"], optional = true }
|
||||||
|
|||||||
@@ -169,20 +169,20 @@
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
solver: "forceAtlas2Based",
|
solver: "forceAtlas2Based",
|
||||||
forceAtlas2Based: {
|
forceAtlas2Based: {
|
||||||
gravitationalConstant: -60,
|
gravitationalConstant: -80,
|
||||||
centralGravity: 0.012,
|
centralGravity: 0.005,
|
||||||
springLength: 80,
|
springLength: 120,
|
||||||
springConstant: 0.06,
|
springConstant: 0.04,
|
||||||
damping: 0.4,
|
damping: 0.5,
|
||||||
avoidOverlap: 0.5,
|
avoidOverlap: 0.6,
|
||||||
},
|
},
|
||||||
stabilization: {
|
stabilization: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
iterations: 1000,
|
iterations: 1500,
|
||||||
updateInterval: 25,
|
updateInterval: 25,
|
||||||
},
|
},
|
||||||
maxVelocity: 40,
|
maxVelocity: 50,
|
||||||
minVelocity: 0.1,
|
minVelocity: 0.75,
|
||||||
},
|
},
|
||||||
interaction: {
|
interaction: {
|
||||||
hover: true,
|
hover: true,
|
||||||
@@ -252,7 +252,24 @@
|
|||||||
overlay.style.display = "none";
|
overlay.style.display = "none";
|
||||||
}, 900);
|
}, 900);
|
||||||
}
|
}
|
||||||
network.setOptions({ physics: { enabled: false } });
|
// Keep physics running so nodes float and respond to dragging,
|
||||||
|
// but reduce forces for a calm, settled feel
|
||||||
|
network.setOptions({
|
||||||
|
physics: {
|
||||||
|
enabled: true,
|
||||||
|
solver: "forceAtlas2Based",
|
||||||
|
forceAtlas2Based: {
|
||||||
|
gravitationalConstant: -40,
|
||||||
|
centralGravity: 0.003,
|
||||||
|
springLength: 120,
|
||||||
|
springConstant: 0.03,
|
||||||
|
damping: 0.7,
|
||||||
|
avoidOverlap: 0.6,
|
||||||
|
},
|
||||||
|
maxVelocity: 20,
|
||||||
|
minVelocity: 0.75,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,10 @@ pub enum Route {
|
|||||||
GraphExplorerPage { repo_id: String },
|
GraphExplorerPage { repo_id: String },
|
||||||
#[route("/graph/:repo_id/impact/:finding_id")]
|
#[route("/graph/:repo_id/impact/:finding_id")]
|
||||||
ImpactAnalysisPage { repo_id: String, finding_id: String },
|
ImpactAnalysisPage { repo_id: String, finding_id: String },
|
||||||
|
#[route("/chat")]
|
||||||
|
ChatIndexPage {},
|
||||||
|
#[route("/chat/:repo_id")]
|
||||||
|
ChatPage { repo_id: String },
|
||||||
#[route("/dast")]
|
#[route("/dast")]
|
||||||
DastOverviewPage {},
|
DastOverviewPage {},
|
||||||
#[route("/dast/targets")]
|
#[route("/dast/targets")]
|
||||||
@@ -34,6 +38,8 @@ pub enum Route {
|
|||||||
DastFindingsPage {},
|
DastFindingsPage {},
|
||||||
#[route("/dast/findings/:id")]
|
#[route("/dast/findings/:id")]
|
||||||
DastFindingDetailPage { id: String },
|
DastFindingDetailPage { id: String },
|
||||||
|
#[route("/mcp-servers")]
|
||||||
|
McpServersPage {},
|
||||||
#[route("/settings")]
|
#[route("/settings")]
|
||||||
SettingsPage {},
|
SettingsPage {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,41 @@ use dioxus::prelude::*;
|
|||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::components::sidebar::Sidebar;
|
use crate::components::sidebar::Sidebar;
|
||||||
use crate::components::toast::{ToastContainer, Toasts};
|
use crate::components::toast::{ToastContainer, Toasts};
|
||||||
|
use crate::infrastructure::auth_check::check_auth;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AppShell() -> Element {
|
pub fn AppShell() -> Element {
|
||||||
use_context_provider(Toasts::new);
|
use_context_provider(Toasts::new);
|
||||||
rsx! {
|
|
||||||
div { class: "app-shell",
|
let auth = use_server_future(check_auth)?;
|
||||||
Sidebar {}
|
|
||||||
main { class: "main-content",
|
match auth() {
|
||||||
Outlet::<Route> {}
|
Some(Ok(info)) if info.authenticated => {
|
||||||
|
use_context_provider(|| Signal::new(info.clone()));
|
||||||
|
rsx! {
|
||||||
|
div { class: "app-shell",
|
||||||
|
Sidebar {}
|
||||||
|
main { class: "main-content",
|
||||||
|
Outlet::<Route> {}
|
||||||
|
}
|
||||||
|
ToastContainer {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(_)) | Some(Err(_)) => {
|
||||||
|
// Not authenticated — redirect to Keycloak login
|
||||||
|
rsx! {
|
||||||
|
document::Script {
|
||||||
|
dangerous_inner_html: "window.location.href = '/auth';"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
rsx! {
|
||||||
|
div { class: "flex items-center justify-center h-screen bg-gray-950",
|
||||||
|
p { class: "text-gray-400", "Loading..." }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ToastContainer {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,17 +47,19 @@ fn insert_path(
|
|||||||
let name = parts[0].to_string();
|
let name = parts[0].to_string();
|
||||||
let is_leaf = parts.len() == 1;
|
let is_leaf = parts.len() == 1;
|
||||||
|
|
||||||
let entry = children.entry(name.clone()).or_insert_with(|| FileTreeNode {
|
let entry = children
|
||||||
name: name.clone(),
|
.entry(name.clone())
|
||||||
path: if is_leaf {
|
.or_insert_with(|| FileTreeNode {
|
||||||
full_path.to_string()
|
name: name.clone(),
|
||||||
} else {
|
path: if is_leaf {
|
||||||
String::new()
|
full_path.to_string()
|
||||||
},
|
} else {
|
||||||
is_dir: !is_leaf,
|
String::new()
|
||||||
node_count: 0,
|
},
|
||||||
children: Vec::new(),
|
is_dir: !is_leaf,
|
||||||
});
|
node_count: 0,
|
||||||
|
children: Vec::new(),
|
||||||
|
});
|
||||||
|
|
||||||
if is_leaf {
|
if is_leaf {
|
||||||
entry.node_count = node_count;
|
entry.node_count = node_count;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use compliance_core::models::auth::AuthInfo;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus_free_icons::icons::bs_icons::*;
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
use dioxus_free_icons::Icon;
|
use dioxus_free_icons::Icon;
|
||||||
@@ -41,11 +42,6 @@ pub fn Sidebar() -> Element {
|
|||||||
route: Route::IssuesPage {},
|
route: Route::IssuesPage {},
|
||||||
icon: rsx! { Icon { icon: BsListTask, width: 18, height: 18 } },
|
icon: rsx! { Icon { icon: BsListTask, width: 18, height: 18 } },
|
||||||
},
|
},
|
||||||
NavItem {
|
|
||||||
label: "Code Graph",
|
|
||||||
route: Route::GraphIndexPage {},
|
|
||||||
icon: rsx! { Icon { icon: BsDiagram3, width: 18, height: 18 } },
|
|
||||||
},
|
|
||||||
NavItem {
|
NavItem {
|
||||||
label: "DAST",
|
label: "DAST",
|
||||||
route: Route::DastOverviewPage {},
|
route: Route::DastOverviewPage {},
|
||||||
@@ -58,7 +54,13 @@ pub fn Sidebar() -> Element {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let sidebar_class = if collapsed() { "sidebar collapsed" } else { "sidebar" };
|
let docs_url = option_env!("DOCS_URL").unwrap_or("/docs");
|
||||||
|
|
||||||
|
let sidebar_class = if collapsed() {
|
||||||
|
"sidebar collapsed"
|
||||||
|
} else {
|
||||||
|
"sidebar"
|
||||||
|
};
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
nav { class: "{sidebar_class}",
|
nav { class: "{sidebar_class}",
|
||||||
@@ -73,9 +75,6 @@ pub fn Sidebar() -> Element {
|
|||||||
{
|
{
|
||||||
let is_active = match (¤t_route, &item.route) {
|
let is_active = match (¤t_route, &item.route) {
|
||||||
(Route::FindingDetailPage { .. }, Route::FindingsPage {}) => true,
|
(Route::FindingDetailPage { .. }, Route::FindingsPage {}) => true,
|
||||||
(Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true,
|
|
||||||
(Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true,
|
|
||||||
(Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true,
|
|
||||||
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
|
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
|
||||||
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
||||||
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
||||||
@@ -95,6 +94,15 @@ pub fn Sidebar() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
a {
|
||||||
|
href: "{docs_url}",
|
||||||
|
target: "_blank",
|
||||||
|
class: "nav-item",
|
||||||
|
Icon { icon: BsBook, width: 18, height: 18 }
|
||||||
|
if !collapsed() {
|
||||||
|
span { "Docs" }
|
||||||
|
}
|
||||||
|
}
|
||||||
button {
|
button {
|
||||||
class: "sidebar-toggle",
|
class: "sidebar-toggle",
|
||||||
onclick: move |_| collapsed.set(!collapsed()),
|
onclick: move |_| collapsed.set(!collapsed()),
|
||||||
@@ -104,8 +112,31 @@ pub fn Sidebar() -> Element {
|
|||||||
Icon { icon: BsChevronLeft, width: 14, height: 14 }
|
Icon { icon: BsChevronLeft, width: 14, height: 14 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !collapsed() {
|
{
|
||||||
div { class: "sidebar-footer", "v0.1.0" }
|
let auth_info = use_context::<Signal<AuthInfo>>();
|
||||||
|
let info = auth_info();
|
||||||
|
let initials = info.name.chars().next().unwrap_or('U').to_uppercase().to_string();
|
||||||
|
let user_class = if collapsed() { "sidebar-user sidebar-user-collapsed" } else { "sidebar-user" };
|
||||||
|
rsx! {
|
||||||
|
div { class: "{user_class}",
|
||||||
|
div { class: "user-avatar",
|
||||||
|
if info.avatar_url.is_empty() {
|
||||||
|
span { class: "avatar-initials", "{initials}" }
|
||||||
|
} else {
|
||||||
|
img { src: "{info.avatar_url}", alt: "avatar", class: "avatar-img" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !collapsed() {
|
||||||
|
span { class: "user-name", "{info.name}" }
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
href: "/logout",
|
||||||
|
class: if collapsed() { "logout-btn logout-btn-collapsed" } else { "logout-btn" },
|
||||||
|
title: "Sign out",
|
||||||
|
Icon { icon: BsBoxArrowRight, width: 16, height: 16 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ pub struct Toasts {
|
|||||||
next_id: Signal<usize>,
|
next_id: Signal<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for Toasts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Toasts {
|
impl Toasts {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -39,11 +45,11 @@ impl Toasts {
|
|||||||
|
|
||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
{
|
{
|
||||||
let mut items = self.items;
|
let mut items = self.items;
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
gloo_timers::future::TimeoutFuture::new(4_000).await;
|
gloo_timers::future::TimeoutFuture::new(4_000).await;
|
||||||
items.write().retain(|t| t.id != id);
|
items.write().retain(|t| t.id != id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
234
compliance-dashboard/src/infrastructure/auth.rs
Normal file
234
compliance-dashboard/src/infrastructure/auth.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::Query,
|
||||||
|
response::{IntoResponse, Redirect},
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
error::DashboardError,
|
||||||
|
server_state::ServerState,
|
||||||
|
user_state::{User, UserStateInner},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PendingOAuthEntry {
|
||||||
|
pub(crate) redirect_url: Option<String>,
|
||||||
|
pub(crate) code_verifier: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
||||||
|
|
||||||
|
impl PendingOAuthStore {
|
||||||
|
pub(crate) fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
self.0
|
||||||
|
.write()
|
||||||
|
.expect("pending oauth store lock poisoned")
|
||||||
|
.insert(state, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
self.0
|
||||||
|
.write()
|
||||||
|
.expect("pending oauth store lock poisoned")
|
||||||
|
.remove(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_state() -> String {
|
||||||
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
|
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(acc, "{b:02x}");
|
||||||
|
acc
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_code_verifier() -> String {
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
|
URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn derive_code_challenge(verifier: &str) -> String {
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let digest = Sha256::digest(verifier.as_bytes());
|
||||||
|
URL_SAFE_NO_PAD.encode(digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn auth_login(
|
||||||
|
Extension(state): Extension<ServerState>,
|
||||||
|
Extension(pending): Extension<PendingOAuthStore>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Result<impl IntoResponse, DashboardError> {
|
||||||
|
let kc = state
|
||||||
|
.keycloak
|
||||||
|
.ok_or(DashboardError::Other("Keycloak not configured".into()))?;
|
||||||
|
let csrf_state = generate_state();
|
||||||
|
let code_verifier = generate_code_verifier();
|
||||||
|
let code_challenge = derive_code_challenge(&code_verifier);
|
||||||
|
|
||||||
|
let redirect_url = params.get("redirect_url").cloned();
|
||||||
|
pending.insert(
|
||||||
|
csrf_state.clone(),
|
||||||
|
PendingOAuthEntry {
|
||||||
|
redirect_url,
|
||||||
|
code_verifier,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut url = Url::parse(&kc.auth_endpoint())
|
||||||
|
.map_err(|e| DashboardError::Other(format!("invalid auth endpoint URL: {e}")))?;
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("client_id", &kc.client_id)
|
||||||
|
.append_pair("redirect_uri", &kc.redirect_uri)
|
||||||
|
.append_pair("response_type", "code")
|
||||||
|
.append_pair("scope", "openid profile email")
|
||||||
|
.append_pair("state", &csrf_state)
|
||||||
|
.append_pair("code_challenge", &code_challenge)
|
||||||
|
.append_pair("code_challenge_method", "S256");
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct UserinfoResponse {
|
||||||
|
sub: String,
|
||||||
|
email: Option<String>,
|
||||||
|
preferred_username: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
picture: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn auth_callback(
|
||||||
|
session: Session,
|
||||||
|
Extension(state): Extension<ServerState>,
|
||||||
|
Extension(pending): Extension<PendingOAuthStore>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Result<impl IntoResponse, DashboardError> {
|
||||||
|
let kc = state
|
||||||
|
.keycloak
|
||||||
|
.ok_or(DashboardError::Other("Keycloak not configured".into()))?;
|
||||||
|
|
||||||
|
let returned_state = params
|
||||||
|
.get("state")
|
||||||
|
.ok_or_else(|| DashboardError::Other("missing state parameter".into()))?;
|
||||||
|
|
||||||
|
let entry = pending
|
||||||
|
.take(returned_state)
|
||||||
|
.ok_or_else(|| DashboardError::Other("unknown or expired oauth state".into()))?;
|
||||||
|
|
||||||
|
let code = params
|
||||||
|
.get("code")
|
||||||
|
.ok_or_else(|| DashboardError::Other("missing code parameter".into()))?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let token_resp = client
|
||||||
|
.post(kc.token_endpoint())
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
("client_id", kc.client_id.as_str()),
|
||||||
|
("redirect_uri", kc.redirect_uri.as_str()),
|
||||||
|
("code", code),
|
||||||
|
("code_verifier", &entry.code_verifier),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("token request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !token_resp.status().is_success() {
|
||||||
|
let body = token_resp.text().await.unwrap_or_default();
|
||||||
|
return Err(DashboardError::Other(format!(
|
||||||
|
"token exchange failed: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens: TokenResponse = token_resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("token parse failed: {e}")))?;
|
||||||
|
|
||||||
|
let userinfo: UserinfoResponse = client
|
||||||
|
.get(kc.userinfo_endpoint())
|
||||||
|
.bearer_auth(&tokens.access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("userinfo request failed: {e}")))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("userinfo parse failed: {e}")))?;
|
||||||
|
|
||||||
|
let display_name = userinfo
|
||||||
|
.name
|
||||||
|
.or(userinfo.preferred_username)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let user_state = UserStateInner {
|
||||||
|
sub: userinfo.sub,
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token.unwrap_or_default(),
|
||||||
|
user: User {
|
||||||
|
email: userinfo.email.unwrap_or_default(),
|
||||||
|
name: display_name,
|
||||||
|
avatar_url: userinfo.picture.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert(LOGGED_IN_USER_SESS_KEY, user_state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("session insert failed: {e}")))?;
|
||||||
|
|
||||||
|
let target = entry
|
||||||
|
.redirect_url
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.unwrap_or_else(|| "/".into());
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(&target))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn logout(
|
||||||
|
session: Session,
|
||||||
|
Extension(state): Extension<ServerState>,
|
||||||
|
) -> Result<impl IntoResponse, DashboardError> {
|
||||||
|
let kc = state
|
||||||
|
.keycloak
|
||||||
|
.ok_or(DashboardError::Other("Keycloak not configured".into()))?;
|
||||||
|
|
||||||
|
session
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("session flush failed: {e}")))?;
|
||||||
|
|
||||||
|
let mut url = Url::parse(&kc.logout_endpoint())
|
||||||
|
.map_err(|e| DashboardError::Other(format!("invalid logout endpoint URL: {e}")))?;
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("client_id", &kc.client_id)
|
||||||
|
.append_pair("post_logout_redirect_uri", &kc.app_url);
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(url.as_str()))
|
||||||
|
}
|
||||||
44
compliance-dashboard/src/infrastructure/auth_check.rs
Normal file
44
compliance-dashboard/src/infrastructure/auth_check.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use compliance_core::models::auth::AuthInfo;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Check the current user's authentication state.
|
||||||
|
///
|
||||||
|
/// Reads the tower-sessions session on the server and returns an
|
||||||
|
/// [`AuthInfo`] describing the logged-in user. When no valid session
|
||||||
|
/// exists, `authenticated` is `false` and all other fields are empty.
|
||||||
|
#[server(endpoint = "check-auth")]
|
||||||
|
pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
||||||
|
use super::auth::LOGGED_IN_USER_SESS_KEY;
|
||||||
|
use super::server_state::ServerState;
|
||||||
|
use super::user_state::UserStateInner;
|
||||||
|
use dioxus_fullstack::FullstackContext;
|
||||||
|
|
||||||
|
let state: ServerState = FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
// When Keycloak is not configured, treat as always authenticated
|
||||||
|
if state.keycloak.is_none() {
|
||||||
|
return Ok(AuthInfo {
|
||||||
|
authenticated: true,
|
||||||
|
name: "Local User".into(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: tower_sessions::Session = FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let user_state: Option<UserStateInner> = session
|
||||||
|
.get(LOGGED_IN_USER_SESS_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
||||||
|
|
||||||
|
match user_state {
|
||||||
|
Some(u) => Ok(AuthInfo {
|
||||||
|
authenticated: true,
|
||||||
|
sub: u.sub,
|
||||||
|
email: u.user.email,
|
||||||
|
name: u.user.name,
|
||||||
|
avatar_url: u.user.avatar_url,
|
||||||
|
}),
|
||||||
|
None => Ok(AuthInfo::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
45
compliance-dashboard/src/infrastructure/auth_middleware.rs
Normal file
45
compliance-dashboard/src/infrastructure/auth_middleware.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::Request,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
use super::auth::LOGGED_IN_USER_SESS_KEY;
|
||||||
|
use super::server_state::ServerState;
|
||||||
|
use super::user_state::UserStateInner;
|
||||||
|
|
||||||
|
const PUBLIC_API_ENDPOINTS: &[&str] = &["/api/check-auth"];
|
||||||
|
|
||||||
|
/// Axum middleware that enforces authentication on `/api/` server
|
||||||
|
/// function endpoints. Skips auth entirely when Keycloak is not configured.
|
||||||
|
pub async fn require_auth(
|
||||||
|
Extension(state): Extension<ServerState>,
|
||||||
|
session: Session,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
// Skip auth when Keycloak is not configured
|
||||||
|
if state.keycloak.is_none() {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = request.uri().path();
|
||||||
|
|
||||||
|
if path.starts_with("/api/") && !PUBLIC_API_ENDPOINTS.contains(&path) {
|
||||||
|
let is_authed = session
|
||||||
|
.get::<UserStateInner>(LOGGED_IN_USER_SESS_KEY)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if !is_authed {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
126
compliance-dashboard/src/infrastructure/chat.rs
Normal file
126
compliance-dashboard/src/infrastructure/chat.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ── Response types ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ChatApiResponse {
|
||||||
|
pub data: ChatResponseData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ChatResponseData {
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sources: Vec<SourceRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SourceRef {
|
||||||
|
pub file_path: String,
|
||||||
|
pub qualified_name: String,
|
||||||
|
pub start_line: u32,
|
||||||
|
pub end_line: u32,
|
||||||
|
pub language: String,
|
||||||
|
pub snippet: String,
|
||||||
|
pub score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct EmbeddingStatusResponse {
|
||||||
|
pub data: Option<EmbeddingBuildData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct EmbeddingBuildData {
|
||||||
|
pub repo_id: String,
|
||||||
|
pub status: String,
|
||||||
|
pub total_chunks: u32,
|
||||||
|
pub embedded_chunks: u32,
|
||||||
|
pub embedding_model: String,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub started_at: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub completed_at: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat message history type ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatHistoryMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server functions ──
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn send_chat_message(
|
||||||
|
repo_id: String,
|
||||||
|
message: String,
|
||||||
|
history: Vec<ChatHistoryMessage>,
|
||||||
|
) -> Result<ChatApiResponse, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/chat/{repo_id}", state.agent_api_url);
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let resp = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"message": message,
|
||||||
|
"history": history,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Request failed: {e}")))?;
|
||||||
|
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to read response: {e}")))?;
|
||||||
|
|
||||||
|
let body: ChatApiResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to parse response: {e} — body: {text}")))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn trigger_embedding_build(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/chat/{repo_id}/build-embeddings",
|
||||||
|
state.agent_api_url
|
||||||
|
);
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client
|
||||||
|
.post(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_embedding_status(
|
||||||
|
repo_id: String,
|
||||||
|
) -> Result<EmbeddingStatusResponse, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/chat/{repo_id}/status", state.agent_api_url);
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: EmbeddingStatusResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
@@ -14,5 +14,8 @@ pub fn load_config() -> Result<DashboardConfig, DashboardError> {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parse().ok())
|
.and_then(|p| p.parse().ok())
|
||||||
.unwrap_or(8080),
|
.unwrap_or(8080),
|
||||||
|
mcp_endpoint_url: std::env::var("MCP_ENDPOINT_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,7 @@ pub async fn fetch_dast_finding_detail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn add_dast_target(
|
pub async fn add_dast_target(name: String, base_url: String) -> Result<(), ServerFnError> {
|
||||||
name: String,
|
|
||||||
base_url: String,
|
|
||||||
) -> Result<(), ServerFnError> {
|
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
|
let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
|
||||||
|
|||||||
@@ -42,4 +42,8 @@ impl Database {
|
|||||||
pub fn tracker_issues(&self) -> Collection<TrackerIssue> {
|
pub fn tracker_issues(&self) -> Collection<TrackerIssue> {
|
||||||
self.inner.collection("tracker_issues")
|
self.inner.collection("tracker_issues")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mcp_servers(&self) -> Collection<McpServerConfig> {
|
||||||
|
self.inner.collection("mcp_servers")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,14 @@ impl From<DashboardError> for ServerFnError {
|
|||||||
ServerFnError::new(err.to_string())
|
ServerFnError::new(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
impl axum::response::IntoResponse for DashboardError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
(
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
self.to_string(),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,32 +10,50 @@ pub struct FindingsListResponse {
|
|||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct FindingsQuery {
|
||||||
|
pub page: u64,
|
||||||
|
pub severity: String,
|
||||||
|
pub scan_type: String,
|
||||||
|
pub status: String,
|
||||||
|
pub repo_id: String,
|
||||||
|
pub q: String,
|
||||||
|
pub sort_by: String,
|
||||||
|
pub sort_order: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn fetch_findings(
|
pub async fn fetch_findings(query: FindingsQuery) -> Result<FindingsListResponse, ServerFnError> {
|
||||||
page: u64,
|
|
||||||
severity: String,
|
|
||||||
scan_type: String,
|
|
||||||
status: String,
|
|
||||||
repo_id: String,
|
|
||||||
) -> Result<FindingsListResponse, ServerFnError> {
|
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
let mut url = format!(
|
let mut url = format!(
|
||||||
"{}/api/v1/findings?page={page}&limit=20",
|
"{}/api/v1/findings?page={}&limit=20",
|
||||||
state.agent_api_url
|
state.agent_api_url, query.page
|
||||||
);
|
);
|
||||||
if !severity.is_empty() {
|
if !query.severity.is_empty() {
|
||||||
url.push_str(&format!("&severity={severity}"));
|
url.push_str(&format!("&severity={}", query.severity));
|
||||||
}
|
}
|
||||||
if !scan_type.is_empty() {
|
if !query.scan_type.is_empty() {
|
||||||
url.push_str(&format!("&scan_type={scan_type}"));
|
url.push_str(&format!("&scan_type={}", query.scan_type));
|
||||||
}
|
}
|
||||||
if !status.is_empty() {
|
if !query.status.is_empty() {
|
||||||
url.push_str(&format!("&status={status}"));
|
url.push_str(&format!("&status={}", query.status));
|
||||||
}
|
}
|
||||||
if !repo_id.is_empty() {
|
if !query.repo_id.is_empty() {
|
||||||
url.push_str(&format!("&repo_id={repo_id}"));
|
url.push_str(&format!("&repo_id={}", query.repo_id));
|
||||||
|
}
|
||||||
|
if !query.q.is_empty() {
|
||||||
|
url.push_str(&format!(
|
||||||
|
"&q={}",
|
||||||
|
url::form_urlencoded::byte_serialize(query.q.as_bytes()).collect::<String>()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !query.sort_by.is_empty() {
|
||||||
|
url.push_str(&format!("&sort_by={}", query.sort_by));
|
||||||
|
}
|
||||||
|
if !query.sort_order.is_empty() {
|
||||||
|
url.push_str(&format!("&sort_order={}", query.sort_order));
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = reqwest::get(&url)
|
let resp = reqwest::get(&url)
|
||||||
@@ -82,3 +100,40 @@ pub async fn update_finding_status(id: String, status: String) -> Result<(), Ser
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn bulk_update_finding_status(
|
||||||
|
ids: Vec<String>,
|
||||||
|
status: String,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
let url = format!("{}/api/v1/findings/bulk-status", state.agent_api_url);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client
|
||||||
|
.patch(&url)
|
||||||
|
.json(&serde_json::json!({ "ids": ids, "status": status }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn update_finding_feedback(id: String, feedback: String) -> Result<(), ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
let url = format!("{}/api/v1/findings/{id}/feedback", state.agent_api_url);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
client
|
||||||
|
.patch(&url)
|
||||||
|
.json(&serde_json::json!({ "feedback": feedback }))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,10 +121,7 @@ pub async fn fetch_file_content(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn search_nodes(
|
pub async fn search_nodes(repo_id: String, query: String) -> Result<SearchResponse, ServerFnError> {
|
||||||
repo_id: String,
|
|
||||||
query: String,
|
|
||||||
) -> Result<SearchResponse, ServerFnError> {
|
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
let url = format!(
|
let url = format!(
|
||||||
|
|||||||
54
compliance-dashboard/src/infrastructure/keycloak_config.rs
Normal file
54
compliance-dashboard/src/infrastructure/keycloak_config.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/// Keycloak OpenID Connect settings.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct KeycloakConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub realm: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
pub app_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeycloakConfig {
|
||||||
|
pub fn from_env() -> Option<Self> {
|
||||||
|
let url = std::env::var("KEYCLOAK_URL").ok()?;
|
||||||
|
let realm = std::env::var("KEYCLOAK_REALM").ok()?;
|
||||||
|
let client_id = std::env::var("KEYCLOAK_CLIENT_ID").ok()?;
|
||||||
|
let redirect_uri = std::env::var("REDIRECT_URI").ok()?;
|
||||||
|
let app_url = std::env::var("APP_URL").ok()?;
|
||||||
|
Some(Self {
|
||||||
|
url,
|
||||||
|
realm,
|
||||||
|
client_id,
|
||||||
|
redirect_uri,
|
||||||
|
app_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_endpoint(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/realms/{}/protocol/openid-connect/auth",
|
||||||
|
self.url, self.realm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token_endpoint(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/realms/{}/protocol/openid-connect/token",
|
||||||
|
self.url, self.realm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn userinfo_endpoint(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/realms/{}/protocol/openid-connect/userinfo",
|
||||||
|
self.url, self.realm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logout_endpoint(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/realms/{}/protocol/openid-connect/logout",
|
||||||
|
self.url, self.realm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
160
compliance-dashboard/src/infrastructure/mcp.rs
Normal file
160
compliance-dashboard/src/infrastructure/mcp.rs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use compliance_core::models::McpServerConfig;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct McpServersResponse {
|
||||||
|
pub data: Vec<McpServerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_mcp_servers() -> Result<McpServersResponse, ServerFnError> {
|
||||||
|
use mongodb::bson::doc;
|
||||||
|
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let mut cursor = state
|
||||||
|
.db
|
||||||
|
.mcp_servers()
|
||||||
|
.find(doc! {})
|
||||||
|
.sort(doc! { "created_at": -1 })
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
while cursor
|
||||||
|
.advance()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
{
|
||||||
|
let server = cursor
|
||||||
|
.deserialize_current()
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
data.push(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(McpServersResponse { data })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn add_mcp_server(
|
||||||
|
name: String,
|
||||||
|
endpoint_url: String,
|
||||||
|
transport: String,
|
||||||
|
port: String,
|
||||||
|
description: String,
|
||||||
|
mongodb_uri: String,
|
||||||
|
mongodb_database: String,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
use chrono::Utc;
|
||||||
|
use compliance_core::models::{McpServerStatus, McpTransport};
|
||||||
|
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let transport = match transport.as_str() {
|
||||||
|
"http" => McpTransport::Http,
|
||||||
|
_ => McpTransport::Stdio,
|
||||||
|
};
|
||||||
|
|
||||||
|
let port_num: Option<u16> = port.parse().ok();
|
||||||
|
|
||||||
|
// Generate a random access token
|
||||||
|
let token = format!("mcp_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||||
|
|
||||||
|
let all_tools = vec![
|
||||||
|
"list_findings".to_string(),
|
||||||
|
"get_finding".to_string(),
|
||||||
|
"findings_summary".to_string(),
|
||||||
|
"list_sbom_packages".to_string(),
|
||||||
|
"sbom_vuln_report".to_string(),
|
||||||
|
"list_dast_findings".to_string(),
|
||||||
|
"dast_scan_summary".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let server = McpServerConfig {
|
||||||
|
id: None,
|
||||||
|
name,
|
||||||
|
endpoint_url,
|
||||||
|
transport,
|
||||||
|
port: port_num,
|
||||||
|
status: McpServerStatus::Stopped,
|
||||||
|
access_token: token,
|
||||||
|
tools_enabled: all_tools,
|
||||||
|
description: if description.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(description)
|
||||||
|
},
|
||||||
|
mongodb_uri: if mongodb_uri.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(mongodb_uri)
|
||||||
|
},
|
||||||
|
mongodb_database: if mongodb_database.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(mongodb_database)
|
||||||
|
},
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.mcp_servers()
|
||||||
|
.insert_one(server)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn delete_mcp_server(server_id: String) -> Result<(), ServerFnError> {
|
||||||
|
use mongodb::bson::doc;
|
||||||
|
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let oid = bson::oid::ObjectId::parse_str(&server_id)
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.mcp_servers()
|
||||||
|
.delete_one(doc! { "_id": oid })
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn regenerate_mcp_token(server_id: String) -> Result<String, ServerFnError> {
|
||||||
|
use chrono::Utc;
|
||||||
|
use mongodb::bson::doc;
|
||||||
|
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let oid = bson::oid::ObjectId::parse_str(&server_id)
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let new_token = format!("mcp_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.mcp_servers()
|
||||||
|
.update_one(
|
||||||
|
doc! { "_id": oid },
|
||||||
|
doc! { "$set": { "access_token": &new_token, "updated_at": Utc::now().to_rfc3339() } },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(new_token)
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
// Server function modules (compiled for both web and server;
|
// Server function modules (compiled for both web and server;
|
||||||
// the #[server] macro generates client stubs for the web target)
|
// the #[server] macro generates client stubs for the web target)
|
||||||
|
pub mod auth_check;
|
||||||
|
pub mod chat;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
pub mod findings;
|
pub mod findings;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
|
pub mod mcp;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod scans;
|
pub mod scans;
|
||||||
@@ -11,15 +14,27 @@ pub mod stats;
|
|||||||
|
|
||||||
// Server-only modules
|
// Server-only modules
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
mod auth;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod auth_middleware;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub mod config;
|
pub mod config;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod database;
|
pub mod database;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod error;
|
pub mod error;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod server;
|
pub mod keycloak_config;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod server;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod server_state;
|
pub mod server_state;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod user_state;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub use auth::{auth_callback, auth_login, logout, PendingOAuthStore};
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub use auth_middleware::require_auth;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub use server::server_start;
|
pub use server::server_start;
|
||||||
|
|||||||
@@ -34,19 +34,29 @@ pub async fn add_repository(
|
|||||||
name: String,
|
name: String,
|
||||||
git_url: String,
|
git_url: String,
|
||||||
default_branch: String,
|
default_branch: String,
|
||||||
|
auth_token: Option<String>,
|
||||||
|
auth_username: Option<String>,
|
||||||
) -> Result<(), ServerFnError> {
|
) -> Result<(), ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
let url = format!("{}/api/v1/repositories", state.agent_api_url);
|
let url = format!("{}/api/v1/repositories", state.agent_api_url);
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"git_url": git_url,
|
||||||
|
"default_branch": default_branch,
|
||||||
|
});
|
||||||
|
if let Some(token) = auth_token.filter(|t| !t.is_empty()) {
|
||||||
|
body["auth_token"] = serde_json::Value::String(token);
|
||||||
|
}
|
||||||
|
if let Some(username) = auth_username.filter(|u| !u.is_empty()) {
|
||||||
|
body["auth_username"] = serde_json::Value::String(username);
|
||||||
|
}
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.json(&serde_json::json!({
|
.json(&body)
|
||||||
"name": name,
|
|
||||||
"git_url": git_url,
|
|
||||||
"default_branch": default_branch,
|
|
||||||
}))
|
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
@@ -61,6 +71,55 @@ pub async fn add_repository(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_ssh_public_key() -> Result<String, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
let url = format!("{}/api/v1/settings/ssh-public-key", state.agent_api_url);
|
||||||
|
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ServerFnError::new("SSH key not available".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(body
|
||||||
|
.get("public_key")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
let url = format!("{}/api/v1/repositories/{repo_id}", state.agent_api_url);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.delete(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ServerFnError::new(format!(
|
||||||
|
"Failed to delete repository: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
@@ -76,3 +135,32 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if a repository has any running scans
|
||||||
|
#[server]
|
||||||
|
pub async fn check_repo_scanning(repo_id: String) -> Result<bool, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
let url = format!("{}/api/v1/scan-runs?page=1&limit=1", state.agent_api_url);
|
||||||
|
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
// Check if the most recent scan for this repo is still running
|
||||||
|
if let Some(scans) = body.get("data").and_then(|d| d.as_array()) {
|
||||||
|
for scan in scans {
|
||||||
|
let scan_repo = scan.get("repo_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let status = scan.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if scan_repo == repo_id && status == "running" {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,202 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use compliance_core::models::SbomEntry;
|
// ── Local types (no bson dependency, WASM-safe) ──
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct VulnRefData {
|
||||||
|
pub id: String,
|
||||||
|
pub source: String,
|
||||||
|
pub severity: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomEntryData {
|
||||||
|
#[serde(rename = "_id", default)]
|
||||||
|
pub id: Option<serde_json::Value>,
|
||||||
|
pub repo_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
pub license: Option<String>,
|
||||||
|
pub purl: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub known_vulnerabilities: Vec<VulnRefData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct SbomListResponse {
|
pub struct SbomListResponse {
|
||||||
pub data: Vec<SbomEntry>,
|
pub data: Vec<SbomEntryData>,
|
||||||
pub total: Option<u64>,
|
pub total: Option<u64>,
|
||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LicenseSummaryData {
|
||||||
|
pub license: String,
|
||||||
|
pub count: u64,
|
||||||
|
pub is_copyleft: bool,
|
||||||
|
pub packages: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LicenseSummaryResponse {
|
||||||
|
pub data: Vec<LicenseSummaryData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomDiffEntryData {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomVersionDiffData {
|
||||||
|
pub name: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
pub version_a: String,
|
||||||
|
pub version_b: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomDiffResultData {
|
||||||
|
pub only_in_a: Vec<SbomDiffEntryData>,
|
||||||
|
pub only_in_b: Vec<SbomDiffEntryData>,
|
||||||
|
pub version_changed: Vec<SbomVersionDiffData>,
|
||||||
|
pub common_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomDiffResponse {
|
||||||
|
pub data: SbomDiffResultData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server functions ──
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn fetch_sbom(page: u64) -> Result<SbomListResponse, ServerFnError> {
|
pub async fn fetch_sbom_filtered(
|
||||||
|
repo_id: Option<String>,
|
||||||
|
package_manager: Option<String>,
|
||||||
|
q: Option<String>,
|
||||||
|
has_vulns: Option<bool>,
|
||||||
|
license: Option<String>,
|
||||||
|
page: u64,
|
||||||
|
) -> Result<SbomListResponse, ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
let url = format!("{}/api/v1/sbom?page={page}&limit=50", state.agent_api_url);
|
|
||||||
|
let mut params = vec![format!("page={page}"), "limit=50".to_string()];
|
||||||
|
if let Some(r) = &repo_id {
|
||||||
|
if !r.is_empty() {
|
||||||
|
params.push(format!("repo_id={r}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(pm) = &package_manager {
|
||||||
|
if !pm.is_empty() {
|
||||||
|
params.push(format!("package_manager={pm}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(q) = &q {
|
||||||
|
if !q.is_empty() {
|
||||||
|
params.push(format!("q={}", q.replace(' ', "%20")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(hv) = has_vulns {
|
||||||
|
params.push(format!("has_vulns={hv}"));
|
||||||
|
}
|
||||||
|
if let Some(l) = &license {
|
||||||
|
if !l.is_empty() {
|
||||||
|
params.push(format!("license={}", l.replace(' ', "%20")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/sbom?{}", state.agent_api_url, params.join("&"));
|
||||||
|
|
||||||
let resp = reqwest::get(&url)
|
let resp = reqwest::get(&url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
let body: SbomListResponse = resp
|
let text = resp
|
||||||
.json()
|
.text()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: SbomListResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_sbom_export(repo_id: String, format: String) -> Result<String, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/sbom/export?repo_id={}&format={}",
|
||||||
|
state.agent_api_url, repo_id, format
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_license_summary(
|
||||||
|
repo_id: Option<String>,
|
||||||
|
) -> Result<LicenseSummaryResponse, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let mut url = format!("{}/api/v1/sbom/licenses", state.agent_api_url);
|
||||||
|
if let Some(r) = &repo_id {
|
||||||
|
if !r.is_empty() {
|
||||||
|
url = format!("{url}?repo_id={r}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: LicenseSummaryResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?;
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn fetch_sbom_diff(
|
||||||
|
repo_a: String,
|
||||||
|
repo_b: String,
|
||||||
|
) -> Result<SbomDiffResponse, ServerFnError> {
|
||||||
|
let state: super::server_state::ServerState =
|
||||||
|
dioxus_fullstack::FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/sbom/diff?repo_a={}&repo_b={}",
|
||||||
|
state.agent_api_url, repo_a, repo_b
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = reqwest::get(&url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let body: SbomDiffResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?;
|
||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
use axum::routing::get;
|
||||||
|
use axum::{middleware, Extension};
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use time::Duration;
|
||||||
|
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
||||||
|
|
||||||
|
use compliance_core::models::{McpServerConfig, McpServerStatus, McpTransport};
|
||||||
|
use mongodb::bson::doc;
|
||||||
|
|
||||||
use super::config;
|
use super::config;
|
||||||
use super::database::Database;
|
use super::database::Database;
|
||||||
use super::error::DashboardError;
|
use super::error::DashboardError;
|
||||||
|
use super::keycloak_config::KeycloakConfig;
|
||||||
use super::server_state::{ServerState, ServerStateInner};
|
use super::server_state::{ServerState, ServerStateInner};
|
||||||
|
use super::{auth_callback, auth_login, logout, require_auth, PendingOAuthStore};
|
||||||
|
|
||||||
pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||||
tokio::runtime::Runtime::new()
|
tokio::runtime::Runtime::new()
|
||||||
@@ -12,16 +21,38 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
let config = config::load_config()?;
|
let config = config::load_config()?;
|
||||||
|
let keycloak: Option<&'static KeycloakConfig> =
|
||||||
|
KeycloakConfig::from_env().map(|kc| &*Box::leak(Box::new(kc)));
|
||||||
let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||||
|
|
||||||
|
// Seed default MCP server configs
|
||||||
|
seed_default_mcp_servers(&db, config.mcp_endpoint_url.as_deref()).await;
|
||||||
|
|
||||||
|
if let Some(kc) = keycloak {
|
||||||
|
tracing::info!("Keycloak configured for realm '{}'", kc.realm);
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Keycloak not configured - dashboard is unprotected");
|
||||||
|
}
|
||||||
|
|
||||||
let server_state: ServerState = ServerStateInner {
|
let server_state: ServerState = ServerStateInner {
|
||||||
agent_api_url: config.agent_api_url.clone(),
|
agent_api_url: config.agent_api_url.clone(),
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
|
keycloak,
|
||||||
}
|
}
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
// Session layer
|
||||||
|
let key = Key::generate();
|
||||||
|
let store = MemoryStore::default();
|
||||||
|
let session = SessionManagerLayer::new(store)
|
||||||
|
.with_secure(false)
|
||||||
|
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||||
|
.with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24)))
|
||||||
|
.with_signed(key);
|
||||||
|
|
||||||
|
let port = dioxus_cli_config::server_port().unwrap_or(8080);
|
||||||
|
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = tokio::net::TcpListener::bind(addr)
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DashboardError::Other(format!("Failed to bind: {e}")))?;
|
.map_err(|e| DashboardError::Other(format!("Failed to bind: {e}")))?;
|
||||||
@@ -29,8 +60,14 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
|||||||
tracing::info!("Dashboard server listening on {addr}");
|
tracing::info!("Dashboard server listening on {addr}");
|
||||||
|
|
||||||
let router = axum::Router::new()
|
let router = axum::Router::new()
|
||||||
|
.route("/auth", get(auth_login))
|
||||||
|
.route("/auth/callback", get(auth_callback))
|
||||||
|
.route("/logout", get(logout))
|
||||||
.serve_dioxus_application(ServeConfig::new(), app)
|
.serve_dioxus_application(ServeConfig::new(), app)
|
||||||
.layer(axum::Extension(server_state));
|
.layer(Extension(PendingOAuthStore::default()))
|
||||||
|
.layer(middleware::from_fn(require_auth))
|
||||||
|
.layer(Extension(server_state))
|
||||||
|
.layer(session);
|
||||||
|
|
||||||
axum::serve(listener, router.into_make_service())
|
axum::serve(listener, router.into_make_service())
|
||||||
.await
|
.await
|
||||||
@@ -39,3 +76,66 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seed three default MCP server configs (Findings, SBOM, DAST) if they don't already exist.
|
||||||
|
async fn seed_default_mcp_servers(db: &Database, mcp_endpoint_url: Option<&str>) {
|
||||||
|
let endpoint = mcp_endpoint_url.unwrap_or("http://localhost:8090");
|
||||||
|
|
||||||
|
let defaults = [
|
||||||
|
(
|
||||||
|
"Findings MCP",
|
||||||
|
"Exposes security findings, triage data, and finding summaries to LLM agents",
|
||||||
|
vec!["list_findings", "get_finding", "findings_summary"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"SBOM MCP",
|
||||||
|
"Exposes software bill of materials and vulnerability reports to LLM agents",
|
||||||
|
vec!["list_sbom_packages", "sbom_vuln_report"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DAST MCP",
|
||||||
|
"Exposes DAST scan findings and scan summaries to LLM agents",
|
||||||
|
vec!["list_dast_findings", "dast_scan_summary"],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let collection = db.mcp_servers();
|
||||||
|
|
||||||
|
for (name, description, tools) in defaults {
|
||||||
|
// Skip if already exists
|
||||||
|
let exists = collection
|
||||||
|
.find_one(doc! { "name": name })
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let token = format!("mcp_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
|
||||||
|
|
||||||
|
let server = McpServerConfig {
|
||||||
|
id: None,
|
||||||
|
name: name.to_string(),
|
||||||
|
endpoint_url: format!("{endpoint}/mcp"),
|
||||||
|
transport: McpTransport::Http,
|
||||||
|
port: Some(8090),
|
||||||
|
status: McpServerStatus::Stopped,
|
||||||
|
access_token: token,
|
||||||
|
tools_enabled: tools.into_iter().map(|s| s.to_string()).collect(),
|
||||||
|
description: Some(description.to_string()),
|
||||||
|
mongodb_uri: None,
|
||||||
|
mongodb_database: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
match collection.insert_one(server).await {
|
||||||
|
Ok(_) => tracing::info!("Seeded default MCP server: {name}"),
|
||||||
|
Err(e) => tracing::warn!("Failed to seed MCP server '{name}': {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||||||
use compliance_core::DashboardConfig;
|
use compliance_core::DashboardConfig;
|
||||||
|
|
||||||
use super::database::Database;
|
use super::database::Database;
|
||||||
|
use super::keycloak_config::KeycloakConfig;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ServerState(Arc<ServerStateInner>);
|
pub struct ServerState(Arc<ServerStateInner>);
|
||||||
@@ -19,6 +20,7 @@ pub struct ServerStateInner {
|
|||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub config: DashboardConfig,
|
pub config: DashboardConfig,
|
||||||
pub agent_api_url: String,
|
pub agent_api_url: String,
|
||||||
|
pub keycloak: Option<&'static KeycloakConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ServerStateInner> for ServerState {
|
impl From<ServerStateInner> for ServerState {
|
||||||
|
|||||||
18
compliance-dashboard/src/infrastructure/user_state.rs
Normal file
18
compliance-dashboard/src/infrastructure/user_state.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Per-session user data stored in the tower-sessions session store.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct UserStateInner {
|
||||||
|
pub sub: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub user: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basic user profile stored alongside the session.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct User {
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: String,
|
||||||
|
}
|
||||||
299
compliance-dashboard/src/pages/chat.rs
Normal file
299
compliance-dashboard/src/pages/chat.rs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::components::page_header::PageHeader;
|
||||||
|
use crate::infrastructure::chat::{
|
||||||
|
fetch_embedding_status, send_chat_message, trigger_embedding_build, ChatHistoryMessage,
|
||||||
|
SourceRef,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A UI-level chat message
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct UiChatMessage {
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
sources: Vec<SourceRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ChatPage(repo_id: String) -> Element {
|
||||||
|
let mut messages: Signal<Vec<UiChatMessage>> = use_signal(Vec::new);
|
||||||
|
let mut input_text = use_signal(String::new);
|
||||||
|
let mut loading = use_signal(|| false);
|
||||||
|
let mut building = use_signal(|| false);
|
||||||
|
|
||||||
|
let repo_id_for_status = repo_id.clone();
|
||||||
|
let mut embedding_status = use_resource(move || {
|
||||||
|
let rid = repo_id_for_status.clone();
|
||||||
|
async move { fetch_embedding_status(rid).await.ok() }
|
||||||
|
});
|
||||||
|
|
||||||
|
let has_embeddings = {
|
||||||
|
let status = embedding_status.read();
|
||||||
|
match &*status {
|
||||||
|
Some(Some(resp)) => resp
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.status == "completed")
|
||||||
|
.unwrap_or(false),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_running = {
|
||||||
|
let status = embedding_status.read();
|
||||||
|
match &*status {
|
||||||
|
Some(Some(resp)) => resp
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.status == "running")
|
||||||
|
.unwrap_or(false),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let embed_progress = {
|
||||||
|
let status = embedding_status.read();
|
||||||
|
match &*status {
|
||||||
|
Some(Some(resp)) => resp
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| {
|
||||||
|
if d.total_chunks > 0 {
|
||||||
|
(d.embedded_chunks as f64 / d.total_chunks as f64 * 100.0) as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(0),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let embedding_status_text = {
|
||||||
|
let status = embedding_status.read();
|
||||||
|
match &*status {
|
||||||
|
Some(Some(resp)) => match &resp.data {
|
||||||
|
Some(d) => match d.status.as_str() {
|
||||||
|
"completed" => format!(
|
||||||
|
"Embeddings ready: {}/{} chunks",
|
||||||
|
d.embedded_chunks, d.total_chunks
|
||||||
|
),
|
||||||
|
"running" => format!(
|
||||||
|
"Building embeddings: {}/{} chunks ({}%)",
|
||||||
|
d.embedded_chunks, d.total_chunks, embed_progress
|
||||||
|
),
|
||||||
|
"failed" => format!(
|
||||||
|
"Embedding build failed: {}",
|
||||||
|
d.error_message.as_deref().unwrap_or("unknown error")
|
||||||
|
),
|
||||||
|
s => format!("Status: {s}"),
|
||||||
|
},
|
||||||
|
None => "No embeddings built yet".to_string(),
|
||||||
|
},
|
||||||
|
Some(None) => "Failed to check embedding status".to_string(),
|
||||||
|
None => "Checking embedding status...".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-poll embedding status every 3s while building/running
|
||||||
|
use_effect(move || {
|
||||||
|
if is_running || *building.read() {
|
||||||
|
spawn(async move {
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
gloo_timers::future::TimeoutFuture::new(3_000).await;
|
||||||
|
#[cfg(not(feature = "web"))]
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
|
embedding_status.restart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let repo_id_for_build = repo_id.clone();
|
||||||
|
let on_build = move |_| {
|
||||||
|
let rid = repo_id_for_build.clone();
|
||||||
|
building.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
let _ = trigger_embedding_build(rid).await;
|
||||||
|
building.set(false);
|
||||||
|
embedding_status.restart();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let repo_id_for_send = repo_id.clone();
|
||||||
|
let mut do_send = move || {
|
||||||
|
let text = input_text.read().trim().to_string();
|
||||||
|
if text.is_empty() || *loading.read() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rid = repo_id_for_send.clone();
|
||||||
|
let user_msg = text.clone();
|
||||||
|
|
||||||
|
// Add user message to UI
|
||||||
|
messages.write().push(UiChatMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: user_msg.clone(),
|
||||||
|
sources: Vec::new(),
|
||||||
|
});
|
||||||
|
input_text.set(String::new());
|
||||||
|
loading.set(true);
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
// Build history from existing messages
|
||||||
|
let history: Vec<ChatHistoryMessage> = messages
|
||||||
|
.read()
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.role == "user" || m.role == "assistant")
|
||||||
|
.rev()
|
||||||
|
.skip(1) // skip the message we just added
|
||||||
|
.take(10) // limit history
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.map(|m| ChatHistoryMessage {
|
||||||
|
role: m.role.clone(),
|
||||||
|
content: m.content.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match send_chat_message(rid, user_msg, history).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
messages.write().push(UiChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: resp.data.message,
|
||||||
|
sources: resp.data.sources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
messages.write().push(UiChatMessage {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: format!("Error: {e}"),
|
||||||
|
sources: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut do_send_click = do_send.clone();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "back-nav",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-back",
|
||||||
|
onclick: move |_| { navigator().go_back(); },
|
||||||
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PageHeader { title: "AI Chat" }
|
||||||
|
|
||||||
|
// Embedding status banner
|
||||||
|
div { class: if is_running || *building.read() { "chat-embedding-banner chat-embedding-building" } else { "chat-embedding-banner" },
|
||||||
|
div { class: "chat-embedding-status",
|
||||||
|
if is_running || *building.read() {
|
||||||
|
span { class: "chat-spinner" }
|
||||||
|
}
|
||||||
|
span { "{embedding_status_text}" }
|
||||||
|
}
|
||||||
|
if is_running || *building.read() {
|
||||||
|
div { class: "chat-progress-bar",
|
||||||
|
div {
|
||||||
|
class: "chat-progress-fill",
|
||||||
|
style: "width: {embed_progress}%;",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm",
|
||||||
|
disabled: *building.read() || is_running,
|
||||||
|
onclick: on_build,
|
||||||
|
if *building.read() || is_running { "Building..." } else { "Build Embeddings" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "chat-container",
|
||||||
|
// Message list
|
||||||
|
div { class: "chat-messages",
|
||||||
|
if messages.read().is_empty() && !*loading.read() {
|
||||||
|
div { class: "chat-empty",
|
||||||
|
h3 { "Ask anything about your codebase" }
|
||||||
|
p { "Build embeddings first, then ask questions about functions, architecture, patterns, and more." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, msg) in messages.read().iter().enumerate() {
|
||||||
|
{
|
||||||
|
let class = if msg.role == "user" {
|
||||||
|
"chat-message chat-message-user"
|
||||||
|
} else {
|
||||||
|
"chat-message chat-message-assistant"
|
||||||
|
};
|
||||||
|
let content = msg.content.clone();
|
||||||
|
let sources = msg.sources.clone();
|
||||||
|
rsx! {
|
||||||
|
div { class: class, key: "{i}",
|
||||||
|
div { class: "chat-message-role",
|
||||||
|
if msg.role == "user" { "You" } else { "Assistant" }
|
||||||
|
}
|
||||||
|
div { class: "chat-message-content", "{content}" }
|
||||||
|
if !sources.is_empty() {
|
||||||
|
div { class: "chat-sources",
|
||||||
|
span { class: "chat-sources-label", "Sources:" }
|
||||||
|
for src in sources {
|
||||||
|
div { class: "chat-source-card",
|
||||||
|
div { class: "chat-source-header",
|
||||||
|
span { class: "chat-source-name",
|
||||||
|
"{src.qualified_name}"
|
||||||
|
}
|
||||||
|
span { class: "chat-source-location",
|
||||||
|
"{src.file_path}:{src.start_line}-{src.end_line}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pre { class: "chat-source-snippet",
|
||||||
|
code { "{src.snippet}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *loading.read() {
|
||||||
|
div { class: "chat-message chat-message-assistant",
|
||||||
|
div { class: "chat-message-role", "Assistant" }
|
||||||
|
div { class: "chat-message-content chat-typing", "Thinking..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input area
|
||||||
|
div { class: "chat-input-area",
|
||||||
|
textarea {
|
||||||
|
class: "chat-input",
|
||||||
|
placeholder: "Ask about your codebase...",
|
||||||
|
value: "{input_text}",
|
||||||
|
disabled: !has_embeddings,
|
||||||
|
oninput: move |e| input_text.set(e.value()),
|
||||||
|
onkeydown: move |e: Event<KeyboardData>| {
|
||||||
|
if e.key() == Key::Enter && !e.modifiers().shift() {
|
||||||
|
e.prevent_default();
|
||||||
|
do_send();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn chat-send-btn",
|
||||||
|
disabled: *loading.read() || !has_embeddings,
|
||||||
|
onclick: move |_| do_send_click(),
|
||||||
|
"Send"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
compliance-dashboard/src/pages/chat_index.rs
Normal file
70
compliance-dashboard/src/pages/chat_index.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::app::Route;
|
||||||
|
use crate::components::page_header::PageHeader;
|
||||||
|
use crate::infrastructure::repositories::fetch_repositories;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ChatIndexPage() -> Element {
|
||||||
|
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
PageHeader {
|
||||||
|
title: "AI Chat",
|
||||||
|
description: "Ask questions about your codebase using RAG-augmented AI",
|
||||||
|
}
|
||||||
|
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(data)) => {
|
||||||
|
let repo_list = &data.data;
|
||||||
|
if repo_list.is_empty() {
|
||||||
|
rsx! {
|
||||||
|
div { class: "card",
|
||||||
|
p { "No repositories found. Add a repository first." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {
|
||||||
|
div { class: "graph-index-grid",
|
||||||
|
for repo in repo_list {
|
||||||
|
{
|
||||||
|
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
let url = repo.git_url.clone();
|
||||||
|
let branch = repo.default_branch.clone();
|
||||||
|
rsx! {
|
||||||
|
Link {
|
||||||
|
to: Route::ChatPage { repo_id },
|
||||||
|
class: "graph-repo-card",
|
||||||
|
div { class: "graph-repo-card-header",
|
||||||
|
div { class: "graph-repo-card-icon", "\u{1F4AC}" }
|
||||||
|
h3 { class: "graph-repo-card-name", "{name}" }
|
||||||
|
}
|
||||||
|
if !url.is_empty() {
|
||||||
|
p { class: "graph-repo-card-url", "{url}" }
|
||||||
|
}
|
||||||
|
div { class: "graph-repo-card-meta",
|
||||||
|
span { class: "graph-repo-card-tag",
|
||||||
|
"\u{E0A0} {branch}"
|
||||||
|
}
|
||||||
|
span { class: "graph-repo-card-tag",
|
||||||
|
"AI Chat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! {
|
||||||
|
div { class: "card", p { "Failed to load repositories." } }
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Loading repositories..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::severity_badge::SeverityBadge;
|
use crate::components::severity_badge::SeverityBadge;
|
||||||
@@ -12,6 +14,15 @@ pub fn DastFindingDetailPage(id: String) -> Element {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
|
div { class: "back-nav",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-back",
|
||||||
|
onclick: move |_| { navigator().go_back(); },
|
||||||
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "DAST Finding Detail",
|
title: "DAST Finding Detail",
|
||||||
description: "Full evidence and details for a dynamic security finding",
|
description: "Full evidence and details for a dynamic security finding",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
@@ -10,6 +12,15 @@ pub fn DastFindingsPage() -> Element {
|
|||||||
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
|
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
|
div { class: "back-nav",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-back",
|
||||||
|
onclick: move |_| { navigator().go_back(); },
|
||||||
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "DAST Findings",
|
title: "DAST Findings",
|
||||||
description: "Vulnerabilities discovered through dynamic application security testing",
|
description: "Vulnerabilities discovered through dynamic application security testing",
|
||||||
@@ -49,7 +60,7 @@ pub fn DastFindingsPage() -> Element {
|
|||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
Link {
|
Link {
|
||||||
to: Route::DastFindingDetailPage { id: id },
|
to: Route::DastFindingDetailPage { id },
|
||||||
"{finding.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"-\")}"
|
"{finding.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"-\")}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
@@ -15,9 +17,9 @@ pub fn DastOverviewPage() -> Element {
|
|||||||
description: "Dynamic Application Security Testing — scan running applications for vulnerabilities",
|
description: "Dynamic Application Security Testing — scan running applications for vulnerabilities",
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "grid grid-cols-3 gap-4 mb-6",
|
div { class: "stat-cards", style: "margin-bottom: 24px;",
|
||||||
div { class: "stat-card",
|
div { class: "stat-card-item",
|
||||||
div { class: "stat-value",
|
div { class: "stat-card-value",
|
||||||
match &*scan_runs.read() {
|
match &*scan_runs.read() {
|
||||||
Some(Some(data)) => {
|
Some(Some(data)) => {
|
||||||
let count = data.total.unwrap_or(0);
|
let count = data.total.unwrap_or(0);
|
||||||
@@ -26,10 +28,13 @@ pub fn DastOverviewPage() -> Element {
|
|||||||
_ => rsx! { "—" },
|
_ => rsx! { "—" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div { class: "stat-label", "Total Scans" }
|
div { class: "stat-card-label",
|
||||||
|
Icon { icon: BsPlayCircle, width: 14, height: 14 }
|
||||||
|
" Total Scans"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div { class: "stat-card",
|
div { class: "stat-card-item",
|
||||||
div { class: "stat-value",
|
div { class: "stat-card-value",
|
||||||
match &*findings.read() {
|
match &*findings.read() {
|
||||||
Some(Some(data)) => {
|
Some(Some(data)) => {
|
||||||
let count = data.total.unwrap_or(0);
|
let count = data.total.unwrap_or(0);
|
||||||
@@ -38,29 +43,37 @@ pub fn DastOverviewPage() -> Element {
|
|||||||
_ => rsx! { "—" },
|
_ => rsx! { "—" },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div { class: "stat-label", "DAST Findings" }
|
div { class: "stat-card-label",
|
||||||
|
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
||||||
|
" DAST Findings"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div { class: "stat-card",
|
div { class: "stat-card-item",
|
||||||
div { class: "stat-value", "—" }
|
div { class: "stat-card-value", "—" }
|
||||||
div { class: "stat-label", "Active Targets" }
|
div { class: "stat-card-label",
|
||||||
|
Icon { icon: BsBullseye, width: 14, height: 14 }
|
||||||
|
" Active Targets"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "flex gap-4 mb-4",
|
div { style: "display: flex; gap: 12px; margin-bottom: 24px;",
|
||||||
Link {
|
Link {
|
||||||
to: Route::DastTargetsPage {},
|
to: Route::DastTargetsPage {},
|
||||||
class: "btn btn-primary",
|
class: "btn btn-primary",
|
||||||
"Manage Targets"
|
Icon { icon: BsBullseye, width: 14, height: 14 }
|
||||||
|
" Manage Targets"
|
||||||
}
|
}
|
||||||
Link {
|
Link {
|
||||||
to: Route::DastFindingsPage {},
|
to: Route::DastFindingsPage {},
|
||||||
class: "btn btn-secondary",
|
class: "btn btn-secondary",
|
||||||
"View Findings"
|
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
||||||
|
" View Findings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
h3 { "Recent Scan Runs" }
|
div { class: "card-header", "Recent Scan Runs" }
|
||||||
match &*scan_runs.read() {
|
match &*scan_runs.read() {
|
||||||
Some(Some(data)) => {
|
Some(Some(data)) => {
|
||||||
let runs = &data.data;
|
let runs = &data.data;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::toast::{ToastType, Toasts};
|
use crate::components::toast::{ToastType, Toasts};
|
||||||
@@ -14,6 +16,15 @@ pub fn DastTargetsPage() -> Element {
|
|||||||
let mut new_url = use_signal(String::new);
|
let mut new_url = use_signal(String::new);
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
|
div { class: "back-nav",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-back",
|
||||||
|
onclick: move |_| { navigator().go_back(); },
|
||||||
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "DAST Targets",
|
title: "DAST Targets",
|
||||||
description: "Configure target applications for dynamic security testing",
|
description: "Configure target applications for dynamic security testing",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::code_snippet::CodeSnippet;
|
use crate::components::code_snippet::CodeSnippet;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
@@ -8,7 +10,7 @@ use crate::components::severity_badge::SeverityBadge;
|
|||||||
pub fn FindingDetailPage(id: String) -> Element {
|
pub fn FindingDetailPage(id: String) -> Element {
|
||||||
let finding_id = id.clone();
|
let finding_id = id.clone();
|
||||||
|
|
||||||
let finding = use_resource(move || {
|
let mut finding = use_resource(move || {
|
||||||
let fid = finding_id.clone();
|
let fid = finding_id.clone();
|
||||||
async move {
|
async move {
|
||||||
crate::infrastructure::findings::fetch_finding_detail(fid)
|
crate::infrastructure::findings::fetch_finding_detail(fid)
|
||||||
@@ -22,7 +24,18 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
match snapshot {
|
match snapshot {
|
||||||
Some(Some(f)) => {
|
Some(Some(f)) => {
|
||||||
let finding_id_for_status = id.clone();
|
let finding_id_for_status = id.clone();
|
||||||
|
let finding_id_for_feedback = id.clone();
|
||||||
|
let existing_feedback = f.developer_feedback.clone().unwrap_or_default();
|
||||||
rsx! {
|
rsx! {
|
||||||
|
div { class: "back-nav",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-back",
|
||||||
|
onclick: move |_| { navigator().go_back(); },
|
||||||
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: f.title.clone(),
|
title: f.title.clone(),
|
||||||
description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status),
|
description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status),
|
||||||
@@ -39,6 +52,9 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
if let Some(score) = f.cvss_score {
|
if let Some(score) = f.cvss_score {
|
||||||
span { class: "badge badge-medium", "CVSS: {score}" }
|
span { class: "badge badge-medium", "CVSS: {score}" }
|
||||||
}
|
}
|
||||||
|
if let Some(confidence) = f.confidence {
|
||||||
|
span { class: "badge badge-info", "Confidence: {confidence:.1}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
@@ -46,6 +62,19 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
p { "{f.description}" }
|
p { "{f.description}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(rationale) = &f.triage_rationale {
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header", "Triage Rationale" }
|
||||||
|
div {
|
||||||
|
style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;",
|
||||||
|
if let Some(action) = &f.triage_action {
|
||||||
|
span { class: "badge badge-info", "{action}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p { style: "color: var(--text-secondary); font-size: 14px;", "{rationale}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(code) = &f.code_snippet {
|
if let Some(code) = &f.code_snippet {
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
div { class: "card-header", "Code Evidence" }
|
div { class: "card-header", "Code Evidence" }
|
||||||
@@ -90,23 +119,60 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
{
|
{
|
||||||
let status_str = status.to_string();
|
let status_str = status.to_string();
|
||||||
let id_clone = finding_id_for_status.clone();
|
let id_clone = finding_id_for_status.clone();
|
||||||
|
let label = match status {
|
||||||
|
"open" => "Open",
|
||||||
|
"triaged" => "Triaged",
|
||||||
|
"resolved" => "Resolved",
|
||||||
|
"false_positive" => "False Positive",
|
||||||
|
"ignored" => "Ignored",
|
||||||
|
_ => status,
|
||||||
|
};
|
||||||
rsx! {
|
rsx! {
|
||||||
button {
|
button {
|
||||||
class: "btn btn-ghost",
|
class: "btn btn-ghost",
|
||||||
|
title: "{label}",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let s = status_str.clone();
|
let s = status_str.clone();
|
||||||
let id = id_clone.clone();
|
let id = id_clone.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
let _ = crate::infrastructure::findings::update_finding_status(id, s).await;
|
let _ = crate::infrastructure::findings::update_finding_status(id, s).await;
|
||||||
});
|
});
|
||||||
|
finding.restart();
|
||||||
},
|
},
|
||||||
"{status}"
|
match status {
|
||||||
|
"open" => rsx! { Icon { icon: BsCircle, width: 14, height: 14 } },
|
||||||
|
"triaged" => rsx! { Icon { icon: BsEye, width: 14, height: 14 } },
|
||||||
|
"resolved" => rsx! { Icon { icon: BsCheckCircle, width: 14, height: 14 } },
|
||||||
|
"false_positive" => rsx! { Icon { icon: BsXCircle, width: 14, height: 14 } },
|
||||||
|
"ignored" => rsx! { Icon { icon: BsDashCircle, width: 14, height: 14 } },
|
||||||
|
_ => rsx! {},
|
||||||
|
}
|
||||||
|
" {label}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header", "Developer Feedback" }
|
||||||
|
p {
|
||||||
|
style: "font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;",
|
||||||
|
"Share your assessment of this finding (e.g. false positive, actionable, needs context)"
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
style: "width: 100%; min-height: 80px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; color: var(--text-primary); font-size: 14px; resize: vertical;",
|
||||||
|
value: "{existing_feedback}",
|
||||||
|
oninput: move |e| {
|
||||||
|
let feedback = e.value();
|
||||||
|
let id = finding_id_for_feedback.clone();
|
||||||
|
spawn(async move {
|
||||||
|
let _ = crate::infrastructure::findings::update_finding_feedback(id, feedback).await;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(None) => rsx! {
|
Some(None) => rsx! {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
@@ -12,24 +14,63 @@ pub fn FindingsPage() -> Element {
|
|||||||
let mut type_filter = use_signal(String::new);
|
let mut type_filter = use_signal(String::new);
|
||||||
let mut status_filter = use_signal(String::new);
|
let mut status_filter = use_signal(String::new);
|
||||||
let mut repo_filter = use_signal(String::new);
|
let mut repo_filter = use_signal(String::new);
|
||||||
|
let mut search_query = use_signal(String::new);
|
||||||
|
let mut sort_by = use_signal(|| "created_at".to_string());
|
||||||
|
let mut sort_order = use_signal(|| "desc".to_string());
|
||||||
|
let mut selected_ids = use_signal(Vec::<String>::new);
|
||||||
|
|
||||||
let repos = use_resource(|| async {
|
let repos = use_resource(|| async {
|
||||||
crate::infrastructure::repositories::fetch_repositories(1).await.ok()
|
crate::infrastructure::repositories::fetch_repositories(1)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
});
|
});
|
||||||
|
|
||||||
let findings = use_resource(move || {
|
let mut findings = use_resource(move || {
|
||||||
let p = page();
|
let query = crate::infrastructure::findings::FindingsQuery {
|
||||||
let sev = severity_filter();
|
page: page(),
|
||||||
let typ = type_filter();
|
severity: severity_filter(),
|
||||||
let stat = status_filter();
|
scan_type: type_filter(),
|
||||||
let repo = repo_filter();
|
status: status_filter(),
|
||||||
|
repo_id: repo_filter(),
|
||||||
|
q: search_query(),
|
||||||
|
sort_by: sort_by(),
|
||||||
|
sort_order: sort_order(),
|
||||||
|
};
|
||||||
async move {
|
async move {
|
||||||
crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo)
|
crate::infrastructure::findings::fetch_findings(query)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let toggle_sort = move |field: &'static str| {
|
||||||
|
move |_: MouseEvent| {
|
||||||
|
if sort_by() == field {
|
||||||
|
sort_order.set(if sort_order() == "asc" {
|
||||||
|
"desc".to_string()
|
||||||
|
} else {
|
||||||
|
"asc".to_string()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
sort_by.set(field.to_string());
|
||||||
|
sort_order.set("desc".to_string());
|
||||||
|
}
|
||||||
|
page.set(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sort_indicator = move |field: &str| -> String {
|
||||||
|
if sort_by() == field {
|
||||||
|
if sort_order() == "asc" {
|
||||||
|
" \u{25B2}".to_string()
|
||||||
|
} else {
|
||||||
|
" \u{25BC}".to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "Findings",
|
title: "Findings",
|
||||||
@@ -37,6 +78,12 @@ pub fn FindingsPage() -> Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div { class: "filter-bar",
|
div { class: "filter-bar",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Search findings...",
|
||||||
|
style: "min-width: 200px;",
|
||||||
|
oninput: move |e| { search_query.set(e.value()); page.set(1); },
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
|
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
|
||||||
option { value: "", "All Repositories" }
|
option { value: "", "All Repositories" }
|
||||||
@@ -74,6 +121,9 @@ pub fn FindingsPage() -> Element {
|
|||||||
option { value: "cve", "CVE" }
|
option { value: "cve", "CVE" }
|
||||||
option { value: "gdpr", "GDPR" }
|
option { value: "gdpr", "GDPR" }
|
||||||
option { value: "oauth", "OAuth" }
|
option { value: "oauth", "OAuth" }
|
||||||
|
option { value: "secret_detection", "Secrets" }
|
||||||
|
option { value: "lint", "Lint" }
|
||||||
|
option { value: "code_review", "Code Review" }
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
onchange: move |e| { status_filter.set(e.value()); page.set(1); },
|
onchange: move |e| { status_filter.set(e.value()); page.set(1); },
|
||||||
@@ -86,29 +136,132 @@ pub fn FindingsPage() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk action bar
|
||||||
|
if !selected_ids().is_empty() {
|
||||||
|
div {
|
||||||
|
class: "card",
|
||||||
|
style: "display: flex; align-items: center; gap: 12px; padding: 12px 16px; margin-bottom: 16px; background: rgba(56, 189, 248, 0.08); border-color: rgba(56, 189, 248, 0.2);",
|
||||||
|
span {
|
||||||
|
style: "font-size: 14px; color: var(--text-secondary);",
|
||||||
|
"{selected_ids().len()} selected"
|
||||||
|
}
|
||||||
|
for status in ["triaged", "resolved", "false_positive", "ignored"] {
|
||||||
|
{
|
||||||
|
let status_str = status.to_string();
|
||||||
|
let label = match status {
|
||||||
|
"false_positive" => "False Positive",
|
||||||
|
other => {
|
||||||
|
// Capitalize first letter
|
||||||
|
let mut s = other.to_string();
|
||||||
|
if let Some(c) = s.get_mut(0..1) { c.make_ascii_uppercase(); }
|
||||||
|
// Leak to get a &str that lives long enough - this is fine for static-ish UI strings
|
||||||
|
&*Box::leak(s.into_boxed_str())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rsx! {
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
title: "Mark {label}",
|
||||||
|
onclick: move |_| {
|
||||||
|
let ids = selected_ids();
|
||||||
|
let s = status_str.clone();
|
||||||
|
spawn(async move {
|
||||||
|
let _ = crate::infrastructure::findings::bulk_update_finding_status(ids, s).await;
|
||||||
|
findings.restart();
|
||||||
|
});
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
},
|
||||||
|
match status {
|
||||||
|
"triaged" => rsx! { Icon { icon: BsEye, width: 14, height: 14 } },
|
||||||
|
"resolved" => rsx! { Icon { icon: BsCheckCircle, width: 14, height: 14 } },
|
||||||
|
"false_positive" => rsx! { Icon { icon: BsXCircle, width: 14, height: 14 } },
|
||||||
|
"ignored" => rsx! { Icon { icon: BsDashCircle, width: 14, height: 14 } },
|
||||||
|
_ => rsx! {},
|
||||||
|
}
|
||||||
|
" {label}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
onclick: move |_| { selected_ids.set(Vec::new()); },
|
||||||
|
"Clear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
match &*findings.read() {
|
match &*findings.read() {
|
||||||
Some(Some(resp)) => {
|
Some(Some(resp)) => {
|
||||||
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
||||||
|
let all_ids: Vec<String> = resp.data.iter().filter_map(|f| f.id.as_ref().map(|id| id.to_hex())).collect();
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
div { class: "table-wrapper",
|
div { class: "table-wrapper",
|
||||||
table {
|
table {
|
||||||
thead {
|
thead {
|
||||||
tr {
|
tr {
|
||||||
th { "Severity" }
|
th {
|
||||||
th { "Title" }
|
style: "width: 40px;",
|
||||||
th { "Type" }
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: !all_ids.is_empty() && selected_ids().len() == all_ids.len(),
|
||||||
|
onchange: move |_| {
|
||||||
|
if selected_ids().len() == all_ids.len() {
|
||||||
|
selected_ids.set(Vec::new());
|
||||||
|
} else {
|
||||||
|
selected_ids.set(all_ids.clone());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
style: "cursor: pointer; user-select: none;",
|
||||||
|
onclick: toggle_sort("severity"),
|
||||||
|
"Severity{sort_indicator(\"severity\")}"
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
style: "cursor: pointer; user-select: none;",
|
||||||
|
onclick: toggle_sort("title"),
|
||||||
|
"Title{sort_indicator(\"title\")}"
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
style: "cursor: pointer; user-select: none;",
|
||||||
|
onclick: toggle_sort("scan_type"),
|
||||||
|
"Type{sort_indicator(\"scan_type\")}"
|
||||||
|
}
|
||||||
th { "Scanner" }
|
th { "Scanner" }
|
||||||
th { "File" }
|
th { "File" }
|
||||||
th { "Status" }
|
th {
|
||||||
|
style: "cursor: pointer; user-select: none;",
|
||||||
|
onclick: toggle_sort("status"),
|
||||||
|
"Status{sort_indicator(\"status\")}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tbody {
|
tbody {
|
||||||
for finding in &resp.data {
|
for finding in &resp.data {
|
||||||
{
|
{
|
||||||
let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let id_for_check = id.clone();
|
||||||
|
let is_selected = selected_ids().contains(&id);
|
||||||
rsx! {
|
rsx! {
|
||||||
tr {
|
tr {
|
||||||
|
td {
|
||||||
|
input {
|
||||||
|
r#type: "checkbox",
|
||||||
|
checked: is_selected,
|
||||||
|
onchange: move |_| {
|
||||||
|
let mut ids = selected_ids();
|
||||||
|
if ids.contains(&id_for_check) {
|
||||||
|
ids.retain(|i| i != &id_for_check);
|
||||||
|
} else {
|
||||||
|
ids.push(id_for_check.clone());
|
||||||
|
}
|
||||||
|
selected_ids.set(ids);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
td { SeverityBadge { severity: finding.severity.to_string() } }
|
td { SeverityBadge { severity: finding.severity.to_string() } }
|
||||||
td {
|
td {
|
||||||
Link {
|
Link {
|
||||||
@@ -118,13 +271,29 @@ pub fn FindingsPage() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { "{finding.scan_type}" }
|
td { "{finding.scan_type}" }
|
||||||
td { "{finding.scanner}" }
|
|
||||||
td {
|
td {
|
||||||
style: "font-family: monospace; font-size: 12px;",
|
Icon { icon: BsCpu, width: 14, height: 14 }
|
||||||
"{finding.file_path.as_deref().unwrap_or(\"-\")}"
|
" {finding.scanner}"
|
||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
span { class: "badge badge-info", "{finding.status}" }
|
style: "font-family: monospace; font-size: 12px;",
|
||||||
|
Icon { icon: BsFileEarmarkCode, width: 14, height: 14 }
|
||||||
|
" {finding.file_path.as_deref().unwrap_or(\"-\")}"
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
span { class: "badge badge-info",
|
||||||
|
{
|
||||||
|
use compliance_core::models::FindingStatus;
|
||||||
|
match &finding.status {
|
||||||
|
FindingStatus::Open => rsx! { Icon { icon: BsCircle, width: 12, height: 12 } },
|
||||||
|
FindingStatus::Triaged => rsx! { Icon { icon: BsEye, width: 12, height: 12 } },
|
||||||
|
FindingStatus::Resolved => rsx! { Icon { icon: BsCheckCircle, width: 12, height: 12 } },
|
||||||
|
FindingStatus::FalsePositive => rsx! { Icon { icon: BsXCircle, width: 12, height: 12 } },
|
||||||
|
FindingStatus::Ignored => rsx! { Icon { icon: BsDashCircle, width: 12, height: 12 } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
" {finding.status}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::code_inspector::CodeInspector;
|
use crate::components::code_inspector::CodeInspector;
|
||||||
use crate::components::file_tree::{build_file_tree, FileTree};
|
use crate::components::file_tree::{build_file_tree, FileTree};
|
||||||
@@ -8,6 +10,36 @@ use crate::infrastructure::graph::{fetch_graph, search_nodes, trigger_graph_buil
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn GraphExplorerPage(repo_id: String) -> Element {
|
pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "back-nav",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-back",
|
||||||
|
onclick: move |_| { navigator().go_back(); },
|
||||||
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PageHeader {
|
||||||
|
title: "Code Knowledge Graph",
|
||||||
|
description: "Interactive visualization of code structure and relationships",
|
||||||
|
}
|
||||||
|
|
||||||
|
GraphExplorerBody { repo_id: repo_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inline variant without back button and page header — for embedding in other pages.
|
||||||
|
#[component]
|
||||||
|
pub fn GraphExplorerInline(repo_id: String) -> Element {
|
||||||
|
rsx! {
|
||||||
|
GraphExplorerBody { repo_id: repo_id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared graph explorer body used by both the full page and inline variants.
|
||||||
|
#[component]
|
||||||
|
fn GraphExplorerBody(repo_id: String) -> Element {
|
||||||
let repo_id_clone = repo_id.clone();
|
let repo_id_clone = repo_id.clone();
|
||||||
let mut graph_data = use_resource(move || {
|
let mut graph_data = use_resource(move || {
|
||||||
let rid = repo_id_clone.clone();
|
let rid = repo_id_clone.clone();
|
||||||
@@ -21,22 +53,15 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
|
|
||||||
let mut building = use_signal(|| false);
|
let mut building = use_signal(|| false);
|
||||||
let mut toasts = use_context::<Toasts>();
|
let mut toasts = use_context::<Toasts>();
|
||||||
|
|
||||||
// Selected node state
|
|
||||||
let mut selected_node = use_signal(|| Option::<serde_json::Value>::None);
|
let mut selected_node = use_signal(|| Option::<serde_json::Value>::None);
|
||||||
let mut inspector_open = use_signal(|| false);
|
let mut inspector_open = use_signal(|| false);
|
||||||
|
let mut search_query = use_signal(String::new);
|
||||||
// Search state
|
let mut search_results = use_signal(Vec::<serde_json::Value>::new);
|
||||||
let mut search_query = use_signal(|| String::new());
|
let mut file_filter = use_signal(String::new);
|
||||||
let mut search_results = use_signal(|| Vec::<serde_json::Value>::new());
|
let mut nodes_json = use_signal(String::new);
|
||||||
let mut file_filter = use_signal(|| String::new());
|
let mut edges_json = use_signal(String::new);
|
||||||
|
|
||||||
// Store serialized graph JSON in signals so use_effect can react to them
|
|
||||||
let mut nodes_json = use_signal(|| String::new());
|
|
||||||
let mut edges_json = use_signal(|| String::new());
|
|
||||||
let mut graph_ready = use_signal(|| false);
|
let mut graph_ready = use_signal(|| false);
|
||||||
|
|
||||||
// When resource resolves, serialize the data into signals
|
|
||||||
let graph_data_read = graph_data.read();
|
let graph_data_read = graph_data.read();
|
||||||
if let Some(Some(data)) = &*graph_data_read {
|
if let Some(Some(data)) = &*graph_data_read {
|
||||||
if !data.data.nodes.is_empty() && !graph_ready() {
|
if !data.data.nodes.is_empty() && !graph_ready() {
|
||||||
@@ -48,7 +73,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive stats and file tree
|
|
||||||
let (node_count, edge_count, community_count, languages, file_tree_data) =
|
let (node_count, edge_count, community_count, languages, file_tree_data) =
|
||||||
if let Some(Some(data)) = &*graph_data_read {
|
if let Some(Some(data)) = &*graph_data_read {
|
||||||
let build = data.data.build.clone().unwrap_or_default();
|
let build = data.data.build.clone().unwrap_or_default();
|
||||||
@@ -80,11 +104,8 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let has_graph_data = matches!(&*graph_data_read, Some(Some(d)) if !d.data.nodes.is_empty());
|
let has_graph_data = matches!(&*graph_data_read, Some(Some(d)) if !d.data.nodes.is_empty());
|
||||||
|
|
||||||
// Drop the read guard before rendering
|
|
||||||
drop(graph_data_read);
|
drop(graph_data_read);
|
||||||
|
|
||||||
// use_effect runs AFTER DOM commit — this is when #graph-canvas exists
|
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let ready = graph_ready();
|
let ready = graph_ready();
|
||||||
if !ready {
|
if !ready {
|
||||||
@@ -96,7 +117,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
// Register the click callback + load graph with a small delay for DOM paint
|
|
||||||
let js = format!(
|
let js = format!(
|
||||||
r#"
|
r#"
|
||||||
window.__onNodeClick = function(nodeJson) {{
|
window.__onNodeClick = function(nodeJson) {{
|
||||||
@@ -109,8 +129,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
setTimeout(function() {{
|
setTimeout(function() {{
|
||||||
if (window.__loadGraph) {{
|
if (window.__loadGraph) {{
|
||||||
window.__loadGraph({nj}, {ej});
|
window.__loadGraph({nj}, {ej});
|
||||||
}} else {{
|
|
||||||
console.error('[graph-viz] __loadGraph not found — vis-network may not be loaded');
|
|
||||||
}}
|
}}
|
||||||
}}, 300);
|
}}, 300);
|
||||||
"#
|
"#
|
||||||
@@ -119,7 +137,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract selected node fields
|
|
||||||
let sel = selected_node();
|
let sel = selected_node();
|
||||||
let sel_file = sel
|
let sel_file = sel
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -146,11 +163,6 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
.unwrap_or(0) as u32;
|
.unwrap_or(0) as u32;
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
PageHeader {
|
|
||||||
title: "Code Knowledge Graph",
|
|
||||||
description: "Interactive visualization of code structure and relationships",
|
|
||||||
}
|
|
||||||
|
|
||||||
if repo_id.is_empty() {
|
if repo_id.is_empty() {
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
p { "Select a repository to view its code graph." }
|
p { "Select a repository to view its code graph." }
|
||||||
@@ -404,7 +416,7 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
|||||||
} else if node_count > 0 {
|
} else if node_count > 0 {
|
||||||
// Data exists but nodes array was empty (shouldn't happen)
|
// Data exists but nodes array was empty (shouldn't happen)
|
||||||
div { class: "loading", "Loading graph visualization..." }
|
div { class: "loading", "Loading graph visualization..." }
|
||||||
} else if matches!(&*graph_data.read(), None) {
|
} else if (*graph_data.read()).is_none() {
|
||||||
div { class: "loading", "Loading graph data..." }
|
div { class: "loading", "Loading graph data..." }
|
||||||
} else {
|
} else {
|
||||||
div { class: "graph-empty-state",
|
div { class: "graph-empty-state",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::infrastructure::graph::fetch_impact;
|
use crate::infrastructure::graph::fetch_impact;
|
||||||
@@ -12,6 +14,15 @@ pub fn ImpactAnalysisPage(repo_id: String, finding_id: String) -> Element {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
|
div { class: "back-nav",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-back",
|
||||||
|
onclick: move |_| { navigator().go_back(); },
|
||||||
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "Impact Analysis",
|
title: "Impact Analysis",
|
||||||
description: "Blast radius and affected entry points for a security finding",
|
description: "Blast radius and affected entry points for a security finding",
|
||||||
|
|||||||
351
compliance-dashboard/src/pages/mcp_servers.rs
Normal file
351
compliance-dashboard/src/pages/mcp_servers.rs
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::components::page_header::PageHeader;
|
||||||
|
use crate::components::toast::{ToastType, Toasts};
|
||||||
|
use crate::infrastructure::mcp::{
|
||||||
|
add_mcp_server, delete_mcp_server, fetch_mcp_servers, regenerate_mcp_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn McpServersPage() -> Element {
|
||||||
|
let mut servers = use_resource(|| async { fetch_mcp_servers().await.ok() });
|
||||||
|
let mut toasts = use_context::<Toasts>();
|
||||||
|
|
||||||
|
let mut show_form = use_signal(|| false);
|
||||||
|
let mut new_name = use_signal(String::new);
|
||||||
|
let mut new_endpoint = use_signal(String::new);
|
||||||
|
let mut new_transport = use_signal(|| "http".to_string());
|
||||||
|
let mut new_port = use_signal(|| "8090".to_string());
|
||||||
|
let mut new_description = use_signal(String::new);
|
||||||
|
let mut new_mongo_uri = use_signal(String::new);
|
||||||
|
let mut new_mongo_db = use_signal(String::new);
|
||||||
|
|
||||||
|
// Track which server's token is visible
|
||||||
|
let mut visible_token: Signal<Option<String>> = use_signal(|| None);
|
||||||
|
// Track which server is pending delete confirmation
|
||||||
|
let mut confirm_delete: Signal<Option<(String, String)>> = use_signal(|| None);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "back-nav",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-back",
|
||||||
|
onclick: move |_| { navigator().go_back(); },
|
||||||
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||||
|
"Back"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PageHeader {
|
||||||
|
title: "MCP Servers",
|
||||||
|
description: "Manage Model Context Protocol servers for LLM integrations",
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "mb-4",
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: move |_| show_form.set(!show_form()),
|
||||||
|
if show_form() { "Cancel" } else { "Register Server" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if show_form() {
|
||||||
|
div { class: "card mb-4",
|
||||||
|
div { class: "card-header", "Register MCP Server" }
|
||||||
|
div { class: "mcp-form-grid",
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Name" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Production MCP",
|
||||||
|
value: "{new_name}",
|
||||||
|
oninput: move |e| new_name.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Endpoint URL" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "https://mcp.example.com/mcp",
|
||||||
|
value: "{new_endpoint}",
|
||||||
|
oninput: move |e| new_endpoint.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Transport" }
|
||||||
|
select {
|
||||||
|
value: "{new_transport}",
|
||||||
|
oninput: move |e| new_transport.set(e.value()),
|
||||||
|
option { value: "http", "HTTP (Streamable)" }
|
||||||
|
option { value: "stdio", "Stdio" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Port" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "8090",
|
||||||
|
value: "{new_port}",
|
||||||
|
oninput: move |e| new_port.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "MongoDB URI" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "mongodb://localhost:27017",
|
||||||
|
value: "{new_mongo_uri}",
|
||||||
|
oninput: move |e| new_mongo_uri.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Database Name" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "compliance_scanner",
|
||||||
|
value: "{new_mongo_db}",
|
||||||
|
oninput: move |e| new_mongo_db.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Description" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Optional notes about this server",
|
||||||
|
value: "{new_description}",
|
||||||
|
oninput: move |e| new_description.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
onclick: move |_| {
|
||||||
|
let name = new_name();
|
||||||
|
let endpoint = new_endpoint();
|
||||||
|
let transport = new_transport();
|
||||||
|
let port = new_port();
|
||||||
|
let desc = new_description();
|
||||||
|
let mongo_uri = new_mongo_uri();
|
||||||
|
let mongo_db = new_mongo_db();
|
||||||
|
spawn(async move {
|
||||||
|
match add_mcp_server(name, endpoint, transport, port, desc, mongo_uri, mongo_db).await {
|
||||||
|
Ok(_) => {
|
||||||
|
toasts.push(ToastType::Success, "MCP server registered");
|
||||||
|
servers.restart();
|
||||||
|
}
|
||||||
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
show_form.set(false);
|
||||||
|
new_name.set(String::new());
|
||||||
|
new_endpoint.set(String::new());
|
||||||
|
new_transport.set("http".to_string());
|
||||||
|
new_port.set("8090".to_string());
|
||||||
|
new_description.set(String::new());
|
||||||
|
new_mongo_uri.set(String::new());
|
||||||
|
new_mongo_db.set(String::new());
|
||||||
|
},
|
||||||
|
"Register"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation modal
|
||||||
|
if let Some((ref del_id, ref del_name)) = *confirm_delete.read() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
onclick: move |_| confirm_delete.set(None),
|
||||||
|
div { class: "modal-dialog",
|
||||||
|
onclick: move |e| e.stop_propagation(),
|
||||||
|
h3 { "Delete MCP Server" }
|
||||||
|
p { "Are you sure you want to remove " strong { "{del_name}" } "?" }
|
||||||
|
p { class: "text-secondary", "Connected LLM clients will lose access." }
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
onclick: move |_| confirm_delete.set(None),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: {
|
||||||
|
let id = del_id.clone();
|
||||||
|
move |_| {
|
||||||
|
let id = id.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match delete_mcp_server(id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
toasts.push(ToastType::Success, "Server removed");
|
||||||
|
servers.restart();
|
||||||
|
}
|
||||||
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
confirm_delete.set(None);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match &*servers.read() {
|
||||||
|
Some(Some(resp)) => {
|
||||||
|
if resp.data.is_empty() {
|
||||||
|
rsx! {
|
||||||
|
div { class: "card",
|
||||||
|
p { style: "padding: 1rem; color: var(--text-secondary);", "No MCP servers registered. Add one to get started." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {
|
||||||
|
div { class: "mcp-cards-grid",
|
||||||
|
for server in resp.data.iter() {
|
||||||
|
{
|
||||||
|
let sid = server.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = server.name.clone();
|
||||||
|
let status_class = match server.status {
|
||||||
|
compliance_core::models::McpServerStatus::Running => "running",
|
||||||
|
compliance_core::models::McpServerStatus::Stopped => "stopped",
|
||||||
|
compliance_core::models::McpServerStatus::Error => "error",
|
||||||
|
};
|
||||||
|
let status_label = format!("{}", server.status);
|
||||||
|
let is_token_visible = visible_token().as_deref() == Some(sid.as_str());
|
||||||
|
let created_str = server.created_at.format("%Y-%m-%d %H:%M").to_string();
|
||||||
|
let tools_count = server.tools_enabled.len();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "mcp-card",
|
||||||
|
// Header row: status dot + name + actions
|
||||||
|
div { class: "mcp-card-header",
|
||||||
|
div { class: "mcp-card-title",
|
||||||
|
span { class: "mcp-status-dot {status_class}" }
|
||||||
|
h3 { "{server.name}" }
|
||||||
|
span { class: "mcp-card-status {status_class}", "{status_label}" }
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost btn-ghost-danger",
|
||||||
|
title: "Delete server",
|
||||||
|
onclick: {
|
||||||
|
let id = sid.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
move |_| {
|
||||||
|
confirm_delete.set(Some((id.clone(), name.clone())));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Icon { icon: BsTrash, width: 14, height: 14 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref desc) = server.description {
|
||||||
|
p { class: "mcp-card-desc", "{desc}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config details
|
||||||
|
div { class: "mcp-card-details",
|
||||||
|
div { class: "mcp-detail-row",
|
||||||
|
Icon { icon: BsGlobe, width: 13, height: 13 }
|
||||||
|
span { class: "mcp-detail-label", "Endpoint" }
|
||||||
|
code { class: "mcp-detail-value", "{server.endpoint_url}" }
|
||||||
|
}
|
||||||
|
div { class: "mcp-detail-row",
|
||||||
|
Icon { icon: BsHddNetwork, width: 13, height: 13 }
|
||||||
|
span { class: "mcp-detail-label", "Transport" }
|
||||||
|
span { class: "mcp-detail-value", "{server.transport}" }
|
||||||
|
}
|
||||||
|
if let Some(port) = server.port {
|
||||||
|
div { class: "mcp-detail-row",
|
||||||
|
Icon { icon: BsPlug, width: 13, height: 13 }
|
||||||
|
span { class: "mcp-detail-label", "Port" }
|
||||||
|
span { class: "mcp-detail-value", "{port}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
div { class: "mcp-card-tools",
|
||||||
|
span { class: "mcp-detail-label",
|
||||||
|
Icon { icon: BsTools, width: 13, height: 13 }
|
||||||
|
" {tools_count} tools"
|
||||||
|
}
|
||||||
|
div { class: "mcp-tools-list",
|
||||||
|
for tool in server.tools_enabled.iter() {
|
||||||
|
span { class: "mcp-tool-chip", "{tool}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token section
|
||||||
|
div { class: "mcp-card-token",
|
||||||
|
div { class: "mcp-token-display",
|
||||||
|
Icon { icon: BsKey, width: 13, height: 13 }
|
||||||
|
code { class: "mcp-token-code",
|
||||||
|
if is_token_visible {
|
||||||
|
"{server.access_token}"
|
||||||
|
} else {
|
||||||
|
"mcp_••••••••••••••••••••"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "mcp-token-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
title: if is_token_visible { "Hide token" } else { "Reveal token" },
|
||||||
|
onclick: {
|
||||||
|
let id = sid.clone();
|
||||||
|
move |_| {
|
||||||
|
if visible_token().as_deref() == Some(id.as_str()) {
|
||||||
|
visible_token.set(None);
|
||||||
|
} else {
|
||||||
|
visible_token.set(Some(id.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if is_token_visible {
|
||||||
|
Icon { icon: BsEyeSlash, width: 14, height: 14 }
|
||||||
|
} else {
|
||||||
|
Icon { icon: BsEye, width: 14, height: 14 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
title: "Regenerate token",
|
||||||
|
onclick: {
|
||||||
|
let id = sid.clone();
|
||||||
|
move |_| {
|
||||||
|
let id = id.clone();
|
||||||
|
spawn(async move {
|
||||||
|
match regenerate_mcp_token(id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
toasts.push(ToastType::Success, "Token regenerated");
|
||||||
|
servers.restart();
|
||||||
|
}
|
||||||
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Icon { icon: BsArrowRepeat, width: 14, height: 14 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
div { class: "mcp-card-footer",
|
||||||
|
span { "Created {created_str}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! { div { class: "card", p { style: "padding: 1rem;", "Failed to load MCP servers." } } },
|
||||||
|
None => rsx! { div { class: "loading", "Loading..." } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod chat;
|
||||||
|
pub mod chat_index;
|
||||||
pub mod dast_finding_detail;
|
pub mod dast_finding_detail;
|
||||||
pub mod dast_findings;
|
pub mod dast_findings;
|
||||||
pub mod dast_overview;
|
pub mod dast_overview;
|
||||||
@@ -8,11 +10,14 @@ pub mod graph_explorer;
|
|||||||
pub mod graph_index;
|
pub mod graph_index;
|
||||||
pub mod impact_analysis;
|
pub mod impact_analysis;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
|
pub mod mcp_servers;
|
||||||
pub mod overview;
|
pub mod overview;
|
||||||
pub mod repositories;
|
pub mod repositories;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|
||||||
|
pub use chat::ChatPage;
|
||||||
|
pub use chat_index::ChatIndexPage;
|
||||||
pub use dast_finding_detail::DastFindingDetailPage;
|
pub use dast_finding_detail::DastFindingDetailPage;
|
||||||
pub use dast_findings::DastFindingsPage;
|
pub use dast_findings::DastFindingsPage;
|
||||||
pub use dast_overview::DastOverviewPage;
|
pub use dast_overview::DastOverviewPage;
|
||||||
@@ -23,6 +28,7 @@ pub use graph_explorer::GraphExplorerPage;
|
|||||||
pub use graph_index::GraphIndexPage;
|
pub use graph_index::GraphIndexPage;
|
||||||
pub use impact_analysis::ImpactAnalysisPage;
|
pub use impact_analysis::ImpactAnalysisPage;
|
||||||
pub use issues::IssuesPage;
|
pub use issues::IssuesPage;
|
||||||
|
pub use mcp_servers::McpServersPage;
|
||||||
pub use overview::OverviewPage;
|
pub use overview::OverviewPage;
|
||||||
pub use repositories::RepositoriesPage;
|
pub use repositories::RepositoriesPage;
|
||||||
pub use sbom::SbomPage;
|
pub use sbom::SbomPage;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
|
use crate::app::Route;
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::stat_card::StatCard;
|
use crate::components::stat_card::StatCard;
|
||||||
|
use crate::infrastructure::mcp::fetch_mcp_servers;
|
||||||
|
use crate::infrastructure::repositories::fetch_repositories;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use crate::infrastructure::stats::fetch_overview_stats;
|
use crate::infrastructure::stats::fetch_overview_stats;
|
||||||
@@ -21,6 +26,9 @@ pub fn OverviewPage() -> Element {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||||
|
let mcp_servers = use_resource(|| async { fetch_mcp_servers().await.ok() });
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "Overview",
|
title: "Overview",
|
||||||
@@ -66,6 +74,125 @@ pub fn OverviewPage() -> Element {
|
|||||||
SeverityBar { label: "Low", count: s.low_findings, max: s.total_findings, color: "var(--success)" }
|
SeverityBar { label: "Low", count: s.low_findings, max: s.total_findings, color: "var(--success)" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Chat section
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header", "AI Chat" }
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(data)) => {
|
||||||
|
let repo_list = &data.data;
|
||||||
|
if repo_list.is_empty() {
|
||||||
|
rsx! {
|
||||||
|
p { style: "padding: 1rem; color: var(--text-secondary);",
|
||||||
|
"No repositories found. Add a repository to start chatting."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: "grid",
|
||||||
|
style: "display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1rem;",
|
||||||
|
for repo in repo_list {
|
||||||
|
{
|
||||||
|
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
rsx! {
|
||||||
|
Link {
|
||||||
|
to: Route::ChatPage { repo_id },
|
||||||
|
class: "graph-repo-card",
|
||||||
|
div { class: "graph-repo-card-header",
|
||||||
|
div { class: "graph-repo-card-icon",
|
||||||
|
Icon { icon: BsChatDots, width: 20, height: 20 }
|
||||||
|
}
|
||||||
|
h3 { class: "graph-repo-card-name", "{name}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! {
|
||||||
|
p { style: "padding: 1rem; color: var(--text-secondary);",
|
||||||
|
"Failed to load repositories."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Loading repositories..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP Servers section
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "card-header", "MCP Servers" }
|
||||||
|
match &*mcp_servers.read() {
|
||||||
|
Some(Some(resp)) => {
|
||||||
|
if resp.data.is_empty() {
|
||||||
|
rsx! {
|
||||||
|
p { style: "padding: 1rem; color: var(--text-secondary);",
|
||||||
|
"No MCP servers registered."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
style: "display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1rem;",
|
||||||
|
for server in resp.data.iter() {
|
||||||
|
{
|
||||||
|
let status_color = match server.status {
|
||||||
|
compliance_core::models::McpServerStatus::Running => "var(--success)",
|
||||||
|
compliance_core::models::McpServerStatus::Stopped => "var(--text-secondary)",
|
||||||
|
compliance_core::models::McpServerStatus::Error => "var(--danger)",
|
||||||
|
};
|
||||||
|
let status_label = format!("{}", server.status);
|
||||||
|
let endpoint = server.endpoint_url.clone();
|
||||||
|
let name = server.name.clone();
|
||||||
|
rsx! {
|
||||||
|
div { class: "card",
|
||||||
|
style: "padding: 0.75rem;",
|
||||||
|
div {
|
||||||
|
style: "display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;",
|
||||||
|
span {
|
||||||
|
style: "width: 8px; height: 8px; border-radius: 50%; background: {status_color}; display: inline-block;",
|
||||||
|
}
|
||||||
|
strong { "{name}" }
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
style: "font-size: 0.8rem; color: var(--text-secondary); margin: 0; word-break: break-all;",
|
||||||
|
"{endpoint}"
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
style: "font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;",
|
||||||
|
"{status_label}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { style: "padding: 0 1rem 1rem;",
|
||||||
|
Link {
|
||||||
|
to: Route::McpServersPage {},
|
||||||
|
class: "btn btn-primary btn-sm",
|
||||||
|
"Manage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! {
|
||||||
|
p { style: "padding: 1rem; color: var(--text-secondary);",
|
||||||
|
"Failed to load MCP servers."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Loading..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Some(None) => rsx! {
|
Some(None) => rsx! {
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::app::Route;
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::pagination::Pagination;
|
use crate::components::pagination::Pagination;
|
||||||
use crate::components::toast::{ToastType, Toasts};
|
use crate::components::toast::{ToastType, Toasts};
|
||||||
|
use crate::pages::graph_explorer::GraphExplorerInline;
|
||||||
|
|
||||||
|
async fn async_sleep_5s() {
|
||||||
|
#[cfg(feature = "web")]
|
||||||
|
{
|
||||||
|
gloo_timers::future::TimeoutFuture::new(5_000).await;
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "web"))]
|
||||||
|
{
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn RepositoriesPage() -> Element {
|
pub fn RepositoriesPage() -> Element {
|
||||||
@@ -12,7 +25,16 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
let mut name = use_signal(String::new);
|
let mut name = use_signal(String::new);
|
||||||
let mut git_url = use_signal(String::new);
|
let mut git_url = use_signal(String::new);
|
||||||
let mut branch = use_signal(|| "main".to_string());
|
let mut branch = use_signal(|| "main".to_string());
|
||||||
|
let mut auth_token = use_signal(String::new);
|
||||||
|
let mut auth_username = use_signal(String::new);
|
||||||
|
let mut show_auth = use_signal(|| false);
|
||||||
|
let mut show_ssh_key = use_signal(|| false);
|
||||||
|
let mut ssh_public_key = use_signal(String::new);
|
||||||
|
let mut adding = use_signal(|| false);
|
||||||
let mut toasts = use_context::<Toasts>();
|
let mut toasts = use_context::<Toasts>();
|
||||||
|
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||||
|
let mut scanning_ids = use_signal(Vec::<String>::new);
|
||||||
|
let mut graph_repo_id = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
let mut repos = use_resource(move || {
|
let mut repos = use_resource(move || {
|
||||||
let p = page();
|
let p = page();
|
||||||
@@ -53,7 +75,7 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
label { "Git URL" }
|
label { "Git URL" }
|
||||||
input {
|
input {
|
||||||
r#type: "text",
|
r#type: "text",
|
||||||
placeholder: "https://github.com/org/repo.git",
|
placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
|
||||||
value: "{git_url}",
|
value: "{git_url}",
|
||||||
oninput: move |e| git_url.set(e.value()),
|
oninput: move |e| git_url.set(e.value()),
|
||||||
}
|
}
|
||||||
@@ -67,26 +89,147 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
oninput: move |e| branch.set(e.value()),
|
oninput: move |e| branch.set(e.value()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Private repo auth section
|
||||||
|
div { style: "margin-top: 8px;",
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost",
|
||||||
|
style: "font-size: 12px; padding: 4px 8px;",
|
||||||
|
onclick: move |_| {
|
||||||
|
show_auth.toggle();
|
||||||
|
if !show_ssh_key() {
|
||||||
|
// Fetch SSH key on first open
|
||||||
|
show_ssh_key.set(true);
|
||||||
|
spawn(async move {
|
||||||
|
match crate::infrastructure::repositories::fetch_ssh_public_key().await {
|
||||||
|
Ok(key) => ssh_public_key.set(key),
|
||||||
|
Err(_) => ssh_public_key.set("(not available)".to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
if show_auth() { "Hide auth options" } else { "Private repository?" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if show_auth() {
|
||||||
|
div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;",
|
||||||
|
// SSH deploy key display
|
||||||
|
div { style: "margin-bottom: 12px;",
|
||||||
|
label { style: "font-size: 12px; color: var(--text-secondary);",
|
||||||
|
"For SSH URLs: add this deploy key (read-only) to your repository"
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px; font-family: monospace; font-size: 11px; word-break: break-all; user-select: all;",
|
||||||
|
if ssh_public_key().is_empty() {
|
||||||
|
"Loading..."
|
||||||
|
} else {
|
||||||
|
"{ssh_public_key}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS auth fields
|
||||||
|
p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;",
|
||||||
|
"For HTTPS URLs: provide an access token (PAT) or username/password"
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Auth Token / Password" }
|
||||||
|
input {
|
||||||
|
r#type: "password",
|
||||||
|
placeholder: "ghp_xxxx or personal access token",
|
||||||
|
value: "{auth_token}",
|
||||||
|
oninput: move |e| auth_token.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "form-group",
|
||||||
|
label { "Username (optional, defaults to x-access-token)" }
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "x-access-token",
|
||||||
|
value: "{auth_username}",
|
||||||
|
oninput: move |e| auth_username.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
class: "btn btn-primary",
|
class: "btn btn-primary",
|
||||||
|
disabled: adding(),
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let n = name();
|
let n = name();
|
||||||
let u = git_url();
|
let u = git_url();
|
||||||
let b = branch();
|
let b = branch();
|
||||||
|
let tok = {
|
||||||
|
let v = auth_token();
|
||||||
|
if v.is_empty() { None } else { Some(v) }
|
||||||
|
};
|
||||||
|
let usr = {
|
||||||
|
let v = auth_username();
|
||||||
|
if v.is_empty() { None } else { Some(v) }
|
||||||
|
};
|
||||||
|
adding.set(true);
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match crate::infrastructure::repositories::add_repository(n, u, b).await {
|
match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
toasts.push(ToastType::Success, "Repository added");
|
toasts.push(ToastType::Success, "Repository added");
|
||||||
repos.restart();
|
repos.restart();
|
||||||
}
|
}
|
||||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||||
}
|
}
|
||||||
|
adding.set(false);
|
||||||
});
|
});
|
||||||
show_add_form.set(false);
|
show_add_form.set(false);
|
||||||
|
show_auth.set(false);
|
||||||
name.set(String::new());
|
name.set(String::new());
|
||||||
git_url.set(String::new());
|
git_url.set(String::new());
|
||||||
|
auth_token.set(String::new());
|
||||||
|
auth_username.set(String::new());
|
||||||
},
|
},
|
||||||
"Add"
|
if adding() { "Validating..." } else { "Add" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete confirmation dialog ──
|
||||||
|
if let Some((del_id, del_name)) = confirm_delete() {
|
||||||
|
div { class: "modal-overlay",
|
||||||
|
div { class: "modal-dialog",
|
||||||
|
h3 { "Delete Repository" }
|
||||||
|
p {
|
||||||
|
"Are you sure you want to delete "
|
||||||
|
strong { "{del_name}" }
|
||||||
|
"?"
|
||||||
|
}
|
||||||
|
p { class: "modal-warning",
|
||||||
|
"This will permanently remove all associated findings, SBOM entries, scan runs, graph data, embeddings, and CVE alerts."
|
||||||
|
}
|
||||||
|
div { class: "modal-actions",
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: move |_| confirm_delete.set(None),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-danger",
|
||||||
|
onclick: move |_| {
|
||||||
|
let id = del_id.clone();
|
||||||
|
let name = del_name.clone();
|
||||||
|
confirm_delete.set(None);
|
||||||
|
spawn(async move {
|
||||||
|
match crate::infrastructure::repositories::delete_repository(id).await {
|
||||||
|
Ok(_) => {
|
||||||
|
toasts.push(ToastType::Success, format!("{name} deleted"));
|
||||||
|
repos.restart();
|
||||||
|
}
|
||||||
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +255,10 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
for repo in &resp.data {
|
for repo in &resp.data {
|
||||||
{
|
{
|
||||||
let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
let repo_id_clone = repo_id.clone();
|
let repo_id_scan = repo_id.clone();
|
||||||
|
let repo_id_del = repo_id.clone();
|
||||||
|
let repo_name_del = repo.name.clone();
|
||||||
|
let is_scanning = scanning_ids().contains(&repo_id);
|
||||||
rsx! {
|
rsx! {
|
||||||
tr {
|
tr {
|
||||||
td { "{repo.name}" }
|
td { "{repo.name}" }
|
||||||
@@ -141,23 +287,68 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
td { style: "display: flex; gap: 4px;",
|
td { style: "display: flex; gap: 4px;",
|
||||||
Link {
|
button {
|
||||||
to: Route::GraphExplorerPage { repo_id: repo_id.clone() },
|
class: if graph_repo_id().as_deref() == Some(repo_id.as_str()) { "btn btn-ghost btn-active" } else { "btn btn-ghost" },
|
||||||
class: "btn btn-ghost",
|
title: "View graph",
|
||||||
"Graph"
|
onclick: {
|
||||||
|
let rid = repo_id.clone();
|
||||||
|
move |_| {
|
||||||
|
if graph_repo_id().as_deref() == Some(rid.as_str()) {
|
||||||
|
graph_repo_id.set(None);
|
||||||
|
} else {
|
||||||
|
graph_repo_id.set(Some(rid.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Icon { icon: BsDiagram3, width: 16, height: 16 }
|
||||||
}
|
}
|
||||||
button {
|
button {
|
||||||
class: "btn btn-ghost",
|
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
||||||
|
title: "Trigger scan",
|
||||||
|
disabled: is_scanning,
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let id = repo_id_clone.clone();
|
let id = repo_id_scan.clone();
|
||||||
|
// Add to scanning set
|
||||||
|
let mut ids = scanning_ids();
|
||||||
|
ids.push(id.clone());
|
||||||
|
scanning_ids.set(ids);
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
match crate::infrastructure::repositories::trigger_repo_scan(id.clone()).await {
|
||||||
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
Ok(_) => {
|
||||||
|
toasts.push(ToastType::Success, "Scan triggered");
|
||||||
|
// Poll until scan completes
|
||||||
|
loop {
|
||||||
|
async_sleep_5s().await;
|
||||||
|
match crate::infrastructure::repositories::check_repo_scanning(id.clone()).await {
|
||||||
|
Ok(false) => break,
|
||||||
|
Ok(true) => continue,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toasts.push(ToastType::Success, "Scan complete");
|
||||||
|
repos.restart();
|
||||||
|
}
|
||||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||||
}
|
}
|
||||||
|
// Remove from scanning set
|
||||||
|
let mut ids = scanning_ids();
|
||||||
|
ids.retain(|i| i != &id);
|
||||||
|
scanning_ids.set(ids);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
"Scan"
|
if is_scanning {
|
||||||
|
span { class: "spinner" }
|
||||||
|
} else {
|
||||||
|
Icon { icon: BsPlayCircle, width: 16, height: 16 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-ghost-danger",
|
||||||
|
title: "Delete repository",
|
||||||
|
onclick: move |_| {
|
||||||
|
confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone())));
|
||||||
|
},
|
||||||
|
Icon { icon: BsTrash, width: 16, height: 16 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +364,22 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
on_page_change: move |p| page.set(p),
|
on_page_change: move |p| page.set(p),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline graph explorer
|
||||||
|
if let Some(rid) = graph_repo_id() {
|
||||||
|
div { class: "card", style: "margin-top: 16px;",
|
||||||
|
div { class: "card-header", style: "display: flex; justify-content: space-between; align-items: center;",
|
||||||
|
span { "Code Graph" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-sm btn-ghost",
|
||||||
|
title: "Close graph",
|
||||||
|
onclick: move |_| { graph_repo_id.set(None); },
|
||||||
|
Icon { icon: BsX, width: 18, height: 18 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GraphExplorerInline { repo_id: rid }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(None) => rsx! {
|
Some(None) => rsx! {
|
||||||
|
|||||||
@@ -2,60 +2,335 @@ use dioxus::prelude::*;
|
|||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::pagination::Pagination;
|
use crate::components::pagination::Pagination;
|
||||||
|
use crate::infrastructure::sbom::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SbomPage() -> Element {
|
pub fn SbomPage() -> Element {
|
||||||
|
// ── Filter signals ──
|
||||||
let mut page = use_signal(|| 1u64);
|
let mut page = use_signal(|| 1u64);
|
||||||
|
let mut repo_filter = use_signal(String::new);
|
||||||
|
let mut pm_filter = use_signal(String::new);
|
||||||
|
let mut search_q = use_signal(String::new);
|
||||||
|
let mut vuln_toggle = use_signal(|| Option::<bool>::None);
|
||||||
|
let mut license_filter = use_signal(String::new);
|
||||||
|
|
||||||
|
// ── Active tab: "packages" | "licenses" | "diff" ──
|
||||||
|
let mut active_tab = use_signal(|| "packages".to_string());
|
||||||
|
|
||||||
|
// ── Vuln drill-down: track expanded row by (name, version) ──
|
||||||
|
let mut expanded_row = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// ── Export state ──
|
||||||
|
let mut show_export = use_signal(|| false);
|
||||||
|
let mut export_format = use_signal(|| "cyclonedx".to_string());
|
||||||
|
let mut export_result = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
|
// ── Diff state ──
|
||||||
|
let mut diff_repo_a = use_signal(String::new);
|
||||||
|
let mut diff_repo_b = use_signal(String::new);
|
||||||
|
|
||||||
|
// ── Repos for dropdowns ──
|
||||||
|
let repos = use_resource(|| async {
|
||||||
|
crate::infrastructure::repositories::fetch_repositories(1)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SBOM list (filtered) ──
|
||||||
let sbom = use_resource(move || {
|
let sbom = use_resource(move || {
|
||||||
let p = page();
|
let p = page();
|
||||||
async move { crate::infrastructure::sbom::fetch_sbom(p).await.ok() }
|
let repo = repo_filter();
|
||||||
|
let pm = pm_filter();
|
||||||
|
let q = search_q();
|
||||||
|
let hv = vuln_toggle();
|
||||||
|
let lic = license_filter();
|
||||||
|
async move {
|
||||||
|
fetch_sbom_filtered(
|
||||||
|
if repo.is_empty() { None } else { Some(repo) },
|
||||||
|
if pm.is_empty() { None } else { Some(pm) },
|
||||||
|
if q.is_empty() { None } else { Some(q) },
|
||||||
|
hv,
|
||||||
|
if lic.is_empty() { None } else { Some(lic) },
|
||||||
|
p,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── License summary ──
|
||||||
|
let license_data = use_resource(move || {
|
||||||
|
let repo = repo_filter();
|
||||||
|
async move {
|
||||||
|
fetch_license_summary(if repo.is_empty() { None } else { Some(repo) })
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Diff data ──
|
||||||
|
let diff_data = use_resource(move || {
|
||||||
|
let a = diff_repo_a();
|
||||||
|
let b = diff_repo_b();
|
||||||
|
async move {
|
||||||
|
if a.is_empty() || b.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
fetch_sbom_diff(a, b).await.ok()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "SBOM",
|
title: "SBOM",
|
||||||
description: "Software Bill of Materials - dependency inventory across all repositories",
|
description: "Software Bill of Materials — dependency inventory, license compliance, and vulnerability analysis",
|
||||||
}
|
}
|
||||||
|
|
||||||
match &*sbom.read() {
|
// ── Tab bar ──
|
||||||
Some(Some(resp)) => {
|
div { class: "sbom-tab-bar",
|
||||||
let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1);
|
button {
|
||||||
rsx! {
|
class: if active_tab() == "packages" { "sbom-tab active" } else { "sbom-tab" },
|
||||||
div { class: "card",
|
onclick: move |_| active_tab.set("packages".to_string()),
|
||||||
div { class: "table-wrapper",
|
"Packages"
|
||||||
table {
|
}
|
||||||
thead {
|
button {
|
||||||
tr {
|
class: if active_tab() == "licenses" { "sbom-tab active" } else { "sbom-tab" },
|
||||||
th { "Package" }
|
onclick: move |_| active_tab.set("licenses".to_string()),
|
||||||
th { "Version" }
|
"License Compliance"
|
||||||
th { "Manager" }
|
}
|
||||||
th { "License" }
|
button {
|
||||||
th { "Vulnerabilities" }
|
class: if active_tab() == "diff" { "sbom-tab active" } else { "sbom-tab" },
|
||||||
|
onclick: move |_| active_tab.set("diff".to_string()),
|
||||||
|
"Compare"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════ PACKAGES TAB ═══════════════
|
||||||
|
if active_tab() == "packages" {
|
||||||
|
// ── Filter bar ──
|
||||||
|
div { class: "sbom-filter-bar",
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
|
||||||
|
option { value: "", "All Repositories" }
|
||||||
|
{
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(resp)) => rsx! {
|
||||||
|
for repo in &resp.data {
|
||||||
|
{
|
||||||
|
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
rsx! { option { value: "{id}", "{name}" } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tbody {
|
},
|
||||||
for entry in &resp.data {
|
_ => rsx! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| { pm_filter.set(e.value()); page.set(1); },
|
||||||
|
option { value: "", "All Managers" }
|
||||||
|
option { value: "npm", "npm" }
|
||||||
|
option { value: "cargo", "Cargo" }
|
||||||
|
option { value: "pip", "pip" }
|
||||||
|
option { value: "go", "Go" }
|
||||||
|
option { value: "maven", "Maven" }
|
||||||
|
option { value: "nuget", "NuGet" }
|
||||||
|
option { value: "composer", "Composer" }
|
||||||
|
option { value: "gem", "RubyGems" }
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
class: "sbom-filter-input",
|
||||||
|
r#type: "text",
|
||||||
|
placeholder: "Search packages...",
|
||||||
|
oninput: move |e| { search_q.set(e.value()); page.set(1); },
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| {
|
||||||
|
let val = e.value();
|
||||||
|
vuln_toggle.set(match val.as_str() {
|
||||||
|
"true" => Some(true),
|
||||||
|
"false" => Some(false),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
page.set(1);
|
||||||
|
},
|
||||||
|
option { value: "", "All Packages" }
|
||||||
|
option { value: "true", "With Vulnerabilities" }
|
||||||
|
option { value: "false", "No Vulnerabilities" }
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| { license_filter.set(e.value()); page.set(1); },
|
||||||
|
option { value: "", "All Licenses" }
|
||||||
|
option { value: "MIT", "MIT" }
|
||||||
|
option { value: "Apache-2.0", "Apache 2.0" }
|
||||||
|
option { value: "BSD-3-Clause", "BSD 3-Clause" }
|
||||||
|
option { value: "ISC", "ISC" }
|
||||||
|
option { value: "GPL-3.0", "GPL 3.0" }
|
||||||
|
option { value: "GPL-2.0", "GPL 2.0" }
|
||||||
|
option { value: "LGPL-2.1", "LGPL 2.1" }
|
||||||
|
option { value: "MPL-2.0", "MPL 2.0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export button ──
|
||||||
|
div { class: "sbom-export-wrapper",
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary sbom-export-btn",
|
||||||
|
onclick: move |_| show_export.toggle(),
|
||||||
|
"Export"
|
||||||
|
}
|
||||||
|
if show_export() {
|
||||||
|
div { class: "sbom-export-dropdown",
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
value: "{export_format}",
|
||||||
|
onchange: move |e| export_format.set(e.value()),
|
||||||
|
option { value: "cyclonedx", "CycloneDX 1.5" }
|
||||||
|
option { value: "spdx", "SPDX 2.3" }
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-primary",
|
||||||
|
disabled: repo_filter().is_empty(),
|
||||||
|
onclick: move |_| {
|
||||||
|
let repo = repo_filter();
|
||||||
|
let fmt = export_format();
|
||||||
|
spawn(async move {
|
||||||
|
match fetch_sbom_export(repo, fmt).await {
|
||||||
|
Ok(json) => export_result.set(Some(json)),
|
||||||
|
Err(e) => tracing::error!("Export failed: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Download"
|
||||||
|
}
|
||||||
|
if repo_filter().is_empty() {
|
||||||
|
span { class: "sbom-export-hint", "Select a repo first" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export result display ──
|
||||||
|
if let Some(json) = export_result() {
|
||||||
|
div { class: "card sbom-export-result",
|
||||||
|
div { class: "sbom-export-result-header",
|
||||||
|
strong { "Exported SBOM" }
|
||||||
|
button {
|
||||||
|
class: "btn btn-secondary",
|
||||||
|
onclick: move |_| export_result.set(None),
|
||||||
|
"Close"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
style: "max-height: 400px; overflow: auto; font-size: 12px;",
|
||||||
|
"{json}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SBOM table ──
|
||||||
|
match &*sbom.read() {
|
||||||
|
Some(Some(resp)) => {
|
||||||
|
let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1);
|
||||||
|
rsx! {
|
||||||
|
if let Some(total) = resp.total {
|
||||||
|
div { class: "sbom-result-count",
|
||||||
|
"{total} package(s) found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "card",
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
tr {
|
tr {
|
||||||
td {
|
th { "Package" }
|
||||||
style: "font-weight: 500;",
|
th { "Version" }
|
||||||
"{entry.name}"
|
th { "Manager" }
|
||||||
}
|
th { "License" }
|
||||||
td {
|
th { "Vulnerabilities" }
|
||||||
style: "font-family: monospace; font-size: 13px;",
|
}
|
||||||
"{entry.version}"
|
}
|
||||||
}
|
tbody {
|
||||||
td { "{entry.package_manager}" }
|
for entry in &resp.data {
|
||||||
td { "{entry.license.as_deref().unwrap_or(\"-\")}" }
|
{
|
||||||
td {
|
let row_key = format!("{}@{}", entry.name, entry.version);
|
||||||
if entry.known_vulnerabilities.is_empty() {
|
let is_expanded = expanded_row() == Some(row_key.clone());
|
||||||
span {
|
let has_vulns = !entry.known_vulnerabilities.is_empty();
|
||||||
style: "color: var(--success);",
|
let license_class = license_css_class(entry.license.as_deref());
|
||||||
"None"
|
let row_key_click = row_key.clone();
|
||||||
|
rsx! {
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
style: "font-weight: 500;",
|
||||||
|
"{entry.name}"
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
style: "font-family: var(--font-mono, monospace); font-size: 13px;",
|
||||||
|
"{entry.version}"
|
||||||
|
}
|
||||||
|
td { "{entry.package_manager}" }
|
||||||
|
td {
|
||||||
|
span { class: "sbom-license-badge {license_class}",
|
||||||
|
"{entry.license.as_deref().unwrap_or(\"-\")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
if has_vulns {
|
||||||
|
span {
|
||||||
|
class: "badge badge-high sbom-vuln-toggle",
|
||||||
|
onclick: move |_| {
|
||||||
|
let key = row_key_click.clone();
|
||||||
|
if expanded_row() == Some(key.clone()) {
|
||||||
|
expanded_row.set(None);
|
||||||
|
} else {
|
||||||
|
expanded_row.set(Some(key));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"{entry.known_vulnerabilities.len()} vuln(s) ▾"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
span {
|
||||||
|
style: "color: var(--success);",
|
||||||
|
"None"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
// ── Vulnerability drill-down row ──
|
||||||
span { class: "badge badge-high",
|
if is_expanded && has_vulns {
|
||||||
"{entry.known_vulnerabilities.len()} vuln(s)"
|
tr { class: "sbom-vuln-detail-row",
|
||||||
|
td { colspan: "5",
|
||||||
|
div { class: "sbom-vuln-detail",
|
||||||
|
for vuln in &entry.known_vulnerabilities {
|
||||||
|
div { class: "sbom-vuln-card",
|
||||||
|
div { class: "sbom-vuln-card-header",
|
||||||
|
span { class: "sbom-vuln-id", "{vuln.id}" }
|
||||||
|
span { class: "sbom-vuln-source", "{vuln.source}" }
|
||||||
|
if let Some(sev) = &vuln.severity {
|
||||||
|
span {
|
||||||
|
class: "badge badge-{sev}",
|
||||||
|
"{sev}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(url) = &vuln.url {
|
||||||
|
a {
|
||||||
|
href: "{url}",
|
||||||
|
target: "_blank",
|
||||||
|
class: "sbom-vuln-link",
|
||||||
|
"View Advisory →"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,21 +338,321 @@ pub fn SbomPage() -> Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Pagination {
|
||||||
|
current_page: page(),
|
||||||
|
total_pages: total_pages,
|
||||||
|
on_page_change: move |p| page.set(p),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Pagination {
|
}
|
||||||
current_page: page(),
|
},
|
||||||
total_pages: total_pages,
|
Some(None) => rsx! {
|
||||||
on_page_change: move |p| page.set(p),
|
div { class: "card", p { "Failed to load SBOM." } }
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Loading SBOM..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════ LICENSE COMPLIANCE TAB ═══════════════
|
||||||
|
if active_tab() == "licenses" {
|
||||||
|
match &*license_data.read() {
|
||||||
|
Some(Some(resp)) => {
|
||||||
|
let total_pkgs: u64 = resp.data.iter().map(|l| l.count).sum();
|
||||||
|
let has_copyleft = resp.data.iter().any(|l| l.is_copyleft);
|
||||||
|
let copyleft_items: Vec<_> = resp.data.iter().filter(|l| l.is_copyleft).collect();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
if has_copyleft {
|
||||||
|
div { class: "license-copyleft-warning",
|
||||||
|
strong { "⚠ Copyleft Licenses Detected" }
|
||||||
|
p { "The following copyleft-licensed packages may impose distribution requirements on your software." }
|
||||||
|
for item in ©left_items {
|
||||||
|
div { class: "license-copyleft-item",
|
||||||
|
span { class: "sbom-license-badge license-copyleft", "{item.license}" }
|
||||||
|
span { " — {item.count} package(s): " }
|
||||||
|
span { class: "license-pkg-list",
|
||||||
|
"{item.packages.join(\", \")}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "card",
|
||||||
|
h3 { style: "margin-bottom: 16px;", "License Distribution" }
|
||||||
|
if total_pkgs > 0 {
|
||||||
|
div { class: "license-bar-chart",
|
||||||
|
for item in &resp.data {
|
||||||
|
{
|
||||||
|
let pct = (item.count as f64 / total_pkgs as f64 * 100.0).max(2.0);
|
||||||
|
let bar_class = if item.is_copyleft { "license-bar license-copyleft" } else { "license-bar license-permissive" };
|
||||||
|
rsx! {
|
||||||
|
div { class: "license-bar-row",
|
||||||
|
span { class: "license-bar-label", "{item.license}" }
|
||||||
|
div { class: "license-bar-track",
|
||||||
|
div {
|
||||||
|
class: "{bar_class}",
|
||||||
|
style: "width: {pct}%;",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span { class: "license-bar-count", "{item.count}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p { "No license data available." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div { class: "card",
|
||||||
|
h3 { style: "margin-bottom: 16px;", "All Licenses" }
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "License" }
|
||||||
|
th { "Type" }
|
||||||
|
th { "Packages" }
|
||||||
|
th { "Count" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for item in &resp.data {
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
span {
|
||||||
|
class: "sbom-license-badge {license_type_class(item.is_copyleft)}",
|
||||||
|
"{item.license}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
if item.is_copyleft {
|
||||||
|
span { class: "badge badge-high", "Copyleft" }
|
||||||
|
} else {
|
||||||
|
span { class: "badge badge-info", "Permissive" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
style: "max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;",
|
||||||
|
"{item.packages.join(\", \")}"
|
||||||
|
}
|
||||||
|
td { "{item.count}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! {
|
||||||
|
div { class: "card", p { "Failed to load license summary." } }
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Loading license data..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════ DIFF TAB ═══════════════
|
||||||
|
if active_tab() == "diff" {
|
||||||
|
div { class: "card",
|
||||||
|
h3 { style: "margin-bottom: 16px;", "Compare SBOMs Between Repositories" }
|
||||||
|
div { class: "sbom-diff-controls",
|
||||||
|
div { class: "sbom-diff-select-group",
|
||||||
|
label { "Repository A" }
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| diff_repo_a.set(e.value()),
|
||||||
|
option { value: "", "Select repository..." }
|
||||||
|
{
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(resp)) => rsx! {
|
||||||
|
for repo in &resp.data {
|
||||||
|
{
|
||||||
|
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
rsx! { option { value: "{id}", "{name}" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => rsx! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "sbom-diff-select-group",
|
||||||
|
label { "Repository B" }
|
||||||
|
select {
|
||||||
|
class: "sbom-filter-select",
|
||||||
|
onchange: move |e| diff_repo_b.set(e.value()),
|
||||||
|
option { value: "", "Select repository..." }
|
||||||
|
{
|
||||||
|
match &*repos.read() {
|
||||||
|
Some(Some(resp)) => rsx! {
|
||||||
|
for repo in &resp.data {
|
||||||
|
{
|
||||||
|
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||||
|
let name = repo.name.clone();
|
||||||
|
rsx! { option { value: "{id}", "{name}" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => rsx! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
Some(None) => rsx! {
|
|
||||||
div { class: "card", p { "Failed to load SBOM." } }
|
if !diff_repo_a().is_empty() && !diff_repo_b().is_empty() {
|
||||||
},
|
match &*diff_data.read() {
|
||||||
None => rsx! {
|
Some(Some(resp)) => {
|
||||||
div { class: "loading", "Loading SBOM..." }
|
let d = &resp.data;
|
||||||
},
|
rsx! {
|
||||||
|
div { class: "sbom-diff-summary",
|
||||||
|
div { class: "sbom-diff-stat sbom-diff-added",
|
||||||
|
span { class: "sbom-diff-stat-num", "{d.only_in_a.len()}" }
|
||||||
|
span { "Only in A" }
|
||||||
|
}
|
||||||
|
div { class: "sbom-diff-stat sbom-diff-removed",
|
||||||
|
span { class: "sbom-diff-stat-num", "{d.only_in_b.len()}" }
|
||||||
|
span { "Only in B" }
|
||||||
|
}
|
||||||
|
div { class: "sbom-diff-stat sbom-diff-changed",
|
||||||
|
span { class: "sbom-diff-stat-num", "{d.version_changed.len()}" }
|
||||||
|
span { "Version Diffs" }
|
||||||
|
}
|
||||||
|
div { class: "sbom-diff-stat",
|
||||||
|
span { class: "sbom-diff-stat-num", "{d.common_count}" }
|
||||||
|
span { "Common" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.only_in_a.is_empty() {
|
||||||
|
div { class: "card",
|
||||||
|
h4 { style: "margin-bottom: 12px; color: var(--success);", "Only in Repository A" }
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Package" }
|
||||||
|
th { "Version" }
|
||||||
|
th { "Manager" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for e in &d.only_in_a {
|
||||||
|
tr { class: "sbom-diff-row-added",
|
||||||
|
td { "{e.name}" }
|
||||||
|
td { "{e.version}" }
|
||||||
|
td { "{e.package_manager}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.only_in_b.is_empty() {
|
||||||
|
div { class: "card",
|
||||||
|
h4 { style: "margin-bottom: 12px; color: var(--danger);", "Only in Repository B" }
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Package" }
|
||||||
|
th { "Version" }
|
||||||
|
th { "Manager" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for e in &d.only_in_b {
|
||||||
|
tr { class: "sbom-diff-row-removed",
|
||||||
|
td { "{e.name}" }
|
||||||
|
td { "{e.version}" }
|
||||||
|
td { "{e.package_manager}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !d.version_changed.is_empty() {
|
||||||
|
div { class: "card",
|
||||||
|
h4 { style: "margin-bottom: 12px; color: var(--warning);", "Version Differences" }
|
||||||
|
div { class: "table-wrapper",
|
||||||
|
table {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "Package" }
|
||||||
|
th { "Manager" }
|
||||||
|
th { "Version A" }
|
||||||
|
th { "Version B" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for e in &d.version_changed {
|
||||||
|
tr { class: "sbom-diff-row-changed",
|
||||||
|
td { "{e.name}" }
|
||||||
|
td { "{e.package_manager}" }
|
||||||
|
td { "{e.version_a}" }
|
||||||
|
td { "{e.version_b}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.only_in_a.is_empty() && d.only_in_b.is_empty() && d.version_changed.is_empty() {
|
||||||
|
div { class: "card",
|
||||||
|
p { "Both repositories have identical SBOM entries." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some(None) => rsx! {
|
||||||
|
div { class: "card", p { "Failed to load diff." } }
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { class: "loading", "Computing diff..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn license_css_class(license: Option<&str>) -> &'static str {
|
||||||
|
match license {
|
||||||
|
Some(l) => {
|
||||||
|
let upper = l.to_uppercase();
|
||||||
|
if upper.contains("GPL") || upper.contains("AGPL") {
|
||||||
|
"license-copyleft"
|
||||||
|
} else if upper.contains("LGPL") || upper.contains("MPL") {
|
||||||
|
"license-weak-copyleft"
|
||||||
|
} else {
|
||||||
|
"license-permissive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn license_type_class(is_copyleft: bool) -> &'static str {
|
||||||
|
if is_copyleft {
|
||||||
|
"license-copyleft"
|
||||||
|
} else {
|
||||||
|
"license-permissive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,10 +234,7 @@ impl ApiFuzzerAgent {
|
|||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
let headers = response.headers();
|
let headers = response.headers();
|
||||||
let acao = headers
|
let acao = headers.get("access-control-allow-origin")?.to_str().ok()?;
|
||||||
.get("access-control-allow-origin")?
|
|
||||||
.to_str()
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
if acao == "*" || acao == "https://evil.com" {
|
if acao == "*" || acao == "https://evil.com" {
|
||||||
let acac = headers
|
let acac = headers
|
||||||
@@ -265,12 +262,9 @@ impl ApiFuzzerAgent {
|
|||||||
request_body: None,
|
request_body: None,
|
||||||
response_status: response.status().as_u16(),
|
response_status: response.status().as_u16(),
|
||||||
response_headers: Some(
|
response_headers: Some(
|
||||||
[(
|
[("Access-Control-Allow-Origin".to_string(), acao.to_string())]
|
||||||
"Access-Control-Allow-Origin".to_string(),
|
.into_iter()
|
||||||
acao.to_string(),
|
.collect(),
|
||||||
)]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
),
|
),
|
||||||
response_snippet: None,
|
response_snippet: None,
|
||||||
screenshot_path: None,
|
screenshot_path: None,
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ impl DastAgent for AuthBypassAgent {
|
|||||||
String::new(),
|
String::new(),
|
||||||
target_id.clone(),
|
target_id.clone(),
|
||||||
DastVulnType::AuthBypass,
|
DastVulnType::AuthBypass,
|
||||||
format!("HTTP method tampering: {} accepted on {}", method, endpoint.url),
|
format!(
|
||||||
|
"HTTP method tampering: {} accepted on {}",
|
||||||
|
method, endpoint.url
|
||||||
|
),
|
||||||
format!(
|
format!(
|
||||||
"Endpoint {} accepts {} requests which may bypass access controls.",
|
"Endpoint {} accepts {} requests which may bypass access controls.",
|
||||||
endpoint.url, method
|
endpoint.url, method
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ impl SsrfAgent {
|
|||||||
("http://[::1]", "localhost IPv6"),
|
("http://[::1]", "localhost IPv6"),
|
||||||
("http://0.0.0.0", "zero address"),
|
("http://0.0.0.0", "zero address"),
|
||||||
("http://169.254.169.254/latest/meta-data/", "AWS metadata"),
|
("http://169.254.169.254/latest/meta-data/", "AWS metadata"),
|
||||||
(
|
("http://metadata.google.internal/", "GCP metadata"),
|
||||||
"http://metadata.google.internal/",
|
|
||||||
"GCP metadata",
|
|
||||||
),
|
|
||||||
("http://127.0.0.1:22", "SSH port probe"),
|
("http://127.0.0.1:22", "SSH port probe"),
|
||||||
("http://127.0.0.1:3306", "MySQL port probe"),
|
("http://127.0.0.1:3306", "MySQL port probe"),
|
||||||
("http://localhost/admin", "localhost admin"),
|
("http://localhost/admin", "localhost admin"),
|
||||||
@@ -91,10 +88,7 @@ impl DastAgent for SsrfAgent {
|
|||||||
.post(&endpoint.url)
|
.post(&endpoint.url)
|
||||||
.form(&[(param.name.as_str(), payload)])
|
.form(&[(param.name.as_str(), payload)])
|
||||||
} else {
|
} else {
|
||||||
let test_url = format!(
|
let test_url = format!("{}?{}={}", endpoint.url, param.name, payload);
|
||||||
"{}?{}={}",
|
|
||||||
endpoint.url, param.name, payload
|
|
||||||
);
|
|
||||||
self.http.get(&test_url)
|
self.http.get(&test_url)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,10 +127,7 @@ impl DastAgent for SsrfAgent {
|
|||||||
String::new(),
|
String::new(),
|
||||||
target_id.clone(),
|
target_id.clone(),
|
||||||
DastVulnType::Ssrf,
|
DastVulnType::Ssrf,
|
||||||
format!(
|
format!("SSRF ({technique}) via parameter '{}'", param.name),
|
||||||
"SSRF ({technique}) via parameter '{}'",
|
|
||||||
param.name
|
|
||||||
),
|
|
||||||
format!(
|
format!(
|
||||||
"Server-side request forgery detected in parameter '{}' at {}. \
|
"Server-side request forgery detected in parameter '{}' at {}. \
|
||||||
The application made a request to an internal resource ({}).",
|
The application made a request to an internal resource ({}).",
|
||||||
|
|||||||
@@ -17,26 +17,11 @@ impl XssAgent {
|
|||||||
fn payloads(&self) -> Vec<(&str, &str)> {
|
fn payloads(&self) -> Vec<(&str, &str)> {
|
||||||
vec![
|
vec![
|
||||||
("<script>alert(1)</script>", "basic script injection"),
|
("<script>alert(1)</script>", "basic script injection"),
|
||||||
(
|
("<img src=x onerror=alert(1)>", "event handler injection"),
|
||||||
"<img src=x onerror=alert(1)>",
|
("<svg/onload=alert(1)>", "svg event handler"),
|
||||||
"event handler injection",
|
("javascript:alert(1)", "javascript protocol"),
|
||||||
),
|
("'\"><script>alert(1)</script>", "attribute breakout"),
|
||||||
(
|
("<body onload=alert(1)>", "body event handler"),
|
||||||
"<svg/onload=alert(1)>",
|
|
||||||
"svg event handler",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"javascript:alert(1)",
|
|
||||||
"javascript protocol",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"'\"><script>alert(1)</script>",
|
|
||||||
"attribute breakout",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"<body onload=alert(1)>",
|
|
||||||
"body event handler",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,10 +50,7 @@ impl DastAgent for XssAgent {
|
|||||||
for param in &endpoint.parameters {
|
for param in &endpoint.parameters {
|
||||||
for (payload, technique) in self.payloads() {
|
for (payload, technique) in self.payloads() {
|
||||||
let test_url = if endpoint.method == "GET" {
|
let test_url = if endpoint.method == "GET" {
|
||||||
format!(
|
format!("{}?{}={}", endpoint.url, param.name, payload)
|
||||||
"{}?{}={}",
|
|
||||||
endpoint.url, param.name, payload
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
endpoint.url.clone()
|
endpoint.url.clone()
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user