Compare commits
3 Commits
main
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5a45c958 | ||
|
|
b8b0f13d8d | ||
|
|
94552d1626 |
@@ -38,9 +38,6 @@ GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos
|
||||
DASHBOARD_PORT=8080
|
||||
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
|
||||
|
||||
@@ -70,8 +70,6 @@ jobs:
|
||||
run: cargo clippy -p compliance-dashboard --features server --no-default-features -- -D warnings
|
||||
- name: Clippy (dashboard web)
|
||||
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
|
||||
run: sccache --show-stats
|
||||
if: always()
|
||||
@@ -126,119 +124,3 @@ jobs:
|
||||
- name: Show sccache stats
|
||||
run: sccache --show-stats
|
||||
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 }}"
|
||||
|
||||
237
Cargo.lock
generated
237
Cargo.lock
generated
@@ -167,7 +167,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -413,17 +413,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "charset"
|
||||
version = "0.1.5"
|
||||
@@ -615,7 +604,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"bson",
|
||||
"chrono",
|
||||
"compliance-core",
|
||||
"dioxus",
|
||||
@@ -639,7 +627,6 @@ dependencies = [
|
||||
"tower-sessions",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -688,27 +675,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
@@ -903,15 +869,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -1015,18 +972,8 @@ version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
|
||||
dependencies = [
|
||||
"darling_core 0.21.3",
|
||||
"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",
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1043,37 +990,13 @@ dependencies = [
|
||||
"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]]
|
||||
name = "darling_macro"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
|
||||
dependencies = [
|
||||
"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",
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
@@ -1412,7 +1335,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.27.0",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tower-layer",
|
||||
"tracing",
|
||||
@@ -1703,7 +1626,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.27.0",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-futures",
|
||||
@@ -1904,7 +1827,7 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce"
|
||||
dependencies = [
|
||||
"darling 0.21.3",
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -2181,7 +2104,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -3575,7 +3497,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"snafu",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -3677,6 +3599,8 @@ dependencies = [
|
||||
"prost",
|
||||
"reqwest",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tonic",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3744,12 +3668,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
@@ -4087,17 +4005,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -4136,12 +4043,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rand_distr"
|
||||
version = "0.4.3"
|
||||
@@ -4262,7 +4163,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4271,7 +4171,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
@@ -4302,50 +4202,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rust-stemmers"
|
||||
version = "1.2.0"
|
||||
@@ -4509,26 +4365,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"schemars_derive",
|
||||
"serde",
|
||||
"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]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -4668,17 +4510,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
@@ -4763,7 +4594,7 @@ version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
|
||||
dependencies = [
|
||||
"darling 0.21.3",
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -4785,7 +4616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4796,7 +4627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -4950,19 +4781,6 @@ version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -5546,10 +5364,35 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower 0.4.13",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap 1.9.3",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"rand 0.8.5",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -5610,7 +5453,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
||||
@@ -5,7 +5,6 @@ members = [
|
||||
"compliance-dashboard",
|
||||
"compliance-graph",
|
||||
"compliance-dast",
|
||||
"compliance-mcp",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -5,21 +5,11 @@ COPY . .
|
||||
RUN cargo build --release -p compliance-agent
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl python3 python3-pip && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl && 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
|
||||
|
||||
EXPOSE 3001 3002
|
||||
|
||||
@@ -16,7 +16,6 @@ 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/public /app/public
|
||||
|
||||
ENV IP=0.0.0.0
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./compliance-dashboard"]
|
||||
|
||||
@@ -8,7 +8,5 @@ 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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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,84 +300,6 @@ tr:hover {
|
||||
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) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
@@ -391,216 +313,3 @@ tr:hover {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -187,13 +187,7 @@ pub async fn build_embeddings(
|
||||
}
|
||||
};
|
||||
|
||||
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 git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path);
|
||||
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
|
||||
@@ -291,13 +291,7 @@ pub async fn trigger_build(
|
||||
}
|
||||
};
|
||||
|
||||
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 git_ops = crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path);
|
||||
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
|
||||
@@ -41,12 +41,6 @@ pub struct FindingsFilter {
|
||||
pub scan_type: Option<String>,
|
||||
#[serde(default)]
|
||||
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")]
|
||||
pub page: u64,
|
||||
#[serde(default = "default_limit")]
|
||||
@@ -82,8 +76,6 @@ pub struct AddRepositoryRequest {
|
||||
pub git_url: String,
|
||||
#[serde(default = "default_branch")]
|
||||
pub default_branch: String,
|
||||
pub auth_token: Option<String>,
|
||||
pub auth_username: Option<String>,
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
pub tracker_owner: Option<String>,
|
||||
pub tracker_repo: Option<String>,
|
||||
@@ -99,17 +91,6 @@ pub struct UpdateStatusRequest {
|
||||
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)]
|
||||
@@ -286,25 +267,9 @@ pub async fn list_repositories(
|
||||
pub async fn add_repository(
|
||||
Extension(agent): AgentExt,
|
||||
Json(req): Json<AddRepositoryRequest>,
|
||||
) -> 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}"),
|
||||
));
|
||||
}
|
||||
|
||||
) -> Result<Json<ApiResponse<TrackedRepository>>, StatusCode> {
|
||||
let mut repo = TrackedRepository::new(req.name, req.git_url);
|
||||
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_owner = req.tracker_owner;
|
||||
repo.tracker_repo = req.tracker_repo;
|
||||
@@ -315,12 +280,7 @@ pub async fn add_repository(
|
||||
.repositories()
|
||||
.insert_one(&repo)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
"Repository already exists".to_string(),
|
||||
)
|
||||
})?;
|
||||
.map_err(|_| StatusCode::CONFLICT)?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: repo,
|
||||
@@ -329,14 +289,6 @@ 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(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
@@ -415,29 +367,6 @@ pub async fn list_findings(
|
||||
if let Some(status) = &filter.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 total = db
|
||||
@@ -449,7 +378,7 @@ pub async fn list_findings(
|
||||
let findings = match db
|
||||
.findings()
|
||||
.find(query)
|
||||
.sort(sort_doc)
|
||||
.sort(doc! { "created_at": -1 })
|
||||
.skip(skip)
|
||||
.limit(filter.limit)
|
||||
.await
|
||||
@@ -505,55 +434,6 @@ pub async fn update_finding_status(
|
||||
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(
|
||||
Extension(agent): AgentExt,
|
||||
Query(filter): Query<SbomFilter>,
|
||||
|
||||
@@ -7,10 +7,6 @@ pub fn build_router() -> Router {
|
||||
Router::new()
|
||||
.route("/api/v1/health", get(handlers::health))
|
||||
.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", post(handlers::add_repository))
|
||||
.route(
|
||||
@@ -27,14 +23,6 @@ pub fn build_router() -> Router {
|
||||
"/api/v1/findings/{id}/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/export", get(handlers::export_sbom))
|
||||
.route("/api/v1/sbom/licenses", get(handlers::license_summary))
|
||||
|
||||
@@ -45,8 +45,6 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
||||
.unwrap_or_else(|| "0 0 0 * * *".to_string()),
|
||||
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
|
||||
.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"),
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ pub mod descriptions;
|
||||
pub mod fixes;
|
||||
#[allow(dead_code)]
|
||||
pub mod pr_review;
|
||||
pub mod review_prompts;
|
||||
pub mod triage;
|
||||
|
||||
pub use client::LlmClient;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
// 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,22 +5,13 @@ use compliance_core::models::{Finding, FindingStatus};
|
||||
use crate::llm::LlmClient;
|
||||
use crate::pipeline::orchestrator::GraphContext;
|
||||
|
||||
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.
|
||||
|
||||
Actions:
|
||||
- "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?
|
||||
const TRIAGE_SYSTEM_PROMPT: &str = r#"You are a security finding triage expert. Analyze the following security finding and determine:
|
||||
1. Is this a true positive? (yes/no)
|
||||
2. Confidence score (0-10, where 10 is highest confidence this is a real issue)
|
||||
3. Brief remediation suggestion (1-2 sentences)
|
||||
|
||||
Respond in JSON format:
|
||||
{"action": "confirm|downgrade|upgrade|dismiss", "confidence": 0-10, "rationale": "brief explanation", "remediation": "optional fix suggestion"}"#;
|
||||
{"true_positive": true/false, "confidence": N, "remediation": "..."}"#;
|
||||
|
||||
pub async fn triage_findings(
|
||||
llm: &Arc<LlmClient>,
|
||||
@@ -30,10 +21,8 @@ pub async fn triage_findings(
|
||||
let mut passed = 0;
|
||||
|
||||
for finding in findings.iter_mut() {
|
||||
let file_classification = classify_file_path(finding.file_path.as_deref());
|
||||
|
||||
let mut user_prompt = format!(
|
||||
"Scanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}\nFile classification: {}",
|
||||
"Scanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}",
|
||||
finding.scanner,
|
||||
finding.rule_id.as_deref().unwrap_or("N/A"),
|
||||
finding.severity,
|
||||
@@ -42,16 +31,8 @@ pub async fn triage_findings(
|
||||
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.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
|
||||
if let Some(ctx) = graph_context {
|
||||
if let Some(impact) = ctx
|
||||
@@ -88,55 +69,32 @@ pub async fn triage_findings(
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
// Strip markdown code fences if present (e.g. ```json ... ```)
|
||||
let cleaned = response.trim();
|
||||
let cleaned = if cleaned.starts_with("```") {
|
||||
cleaned
|
||||
let inner = cleaned
|
||||
.trim_start_matches("```json")
|
||||
.trim_start_matches("```")
|
||||
.trim_end_matches("```")
|
||||
.trim()
|
||||
.trim();
|
||||
inner
|
||||
} else {
|
||||
cleaned
|
||||
};
|
||||
if let Ok(result) = serde_json::from_str::<TriageResult>(cleaned) {
|
||||
// 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);
|
||||
|
||||
finding.confidence = Some(result.confidence);
|
||||
if let Some(remediation) = result.remediation {
|
||||
finding.remediation = Some(remediation);
|
||||
}
|
||||
|
||||
match result.action.as_str() {
|
||||
"dismiss" => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
if result.confidence >= 3.0 {
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
} else {
|
||||
finding.status = FindingStatus::FalsePositive;
|
||||
}
|
||||
} else {
|
||||
// Parse failure — keep the finding
|
||||
// If LLM response doesn't parse, keep the finding
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
tracing::warn!(
|
||||
@@ -159,122 +117,12 @@ pub async fn triage_findings(
|
||||
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)]
|
||||
struct TriageResult {
|
||||
#[serde(default = "default_action")]
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
true_positive: bool,
|
||||
#[serde(default)]
|
||||
confidence: f64,
|
||||
#[serde(default)]
|
||||
rationale: String,
|
||||
remediation: Option<String>,
|
||||
}
|
||||
|
||||
fn default_action() -> String {
|
||||
"confirm".to_string()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ mod llm;
|
||||
mod pipeline;
|
||||
mod rag;
|
||||
mod scheduler;
|
||||
mod ssh;
|
||||
#[allow(dead_code)]
|
||||
mod trackers;
|
||||
mod webhooks;
|
||||
@@ -21,12 +20,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing::info!("Loading configuration...");
|
||||
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...");
|
||||
let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||
db.ensure_indexes().await?;
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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,8 +64,6 @@ impl CveScanner {
|
||||
}
|
||||
|
||||
async fn query_osv_batch(&self, entries: &[SbomEntry]) -> Result<Vec<Vec<OsvVuln>>, CoreError> {
|
||||
const OSV_BATCH_SIZE: usize = 500;
|
||||
|
||||
let queries: Vec<_> = entries
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
@@ -81,34 +79,32 @@ impl CveScanner {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut all_vulns: Vec<Vec<OsvVuln>> = Vec::with_capacity(queries.len());
|
||||
let body = serde_json::json!({ "queries": queries });
|
||||
|
||||
for chunk in queries.chunks(OSV_BATCH_SIZE) {
|
||||
let body = serde_json::json!({ "queries": chunk });
|
||||
let resp = self
|
||||
.http
|
||||
.post("https://api.osv.dev/v1/querybatch")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::Http(format!("OSV.dev request failed: {e}")))?;
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.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 status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!("OSV.dev returned {status}: {body}");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
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 result: OsvBatchResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CoreError::Http(format!("Failed to parse OSV.dev response: {e}")))?;
|
||||
|
||||
let result: OsvBatchResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| CoreError::Http(format!("Failed to parse OSV.dev response: {e}")))?;
|
||||
|
||||
let chunk_vulns = result.results.into_iter().map(|r| {
|
||||
let vulns = result
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
r.vulns
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -120,12 +116,10 @@ impl CveScanner {
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
})
|
||||
.collect();
|
||||
|
||||
all_vulns.extend(chunk_vulns);
|
||||
}
|
||||
|
||||
Ok(all_vulns)
|
||||
Ok(vulns)
|
||||
}
|
||||
|
||||
async fn query_nvd(&self, cve_id: &str) -> Result<Option<f64>, CoreError> {
|
||||
|
||||
@@ -1,80 +1,17 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use git2::{Cred, FetchOptions, RemoteCallbacks, Repository};
|
||||
use git2::{FetchOptions, Repository};
|
||||
|
||||
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 {
|
||||
base_path: PathBuf,
|
||||
credentials: RepoCredentials,
|
||||
}
|
||||
|
||||
impl GitOps {
|
||||
pub fn new(base_path: &str, credentials: RepoCredentials) -> Self {
|
||||
pub fn new(base_path: &str) -> Self {
|
||||
Self {
|
||||
base_path: PathBuf::from(base_path),
|
||||
credentials,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,25 +22,17 @@ impl GitOps {
|
||||
self.fetch(&repo_path)?;
|
||||
} else {
|
||||
std::fs::create_dir_all(&repo_path)?;
|
||||
self.clone_repo(git_url, &repo_path)?;
|
||||
Repository::clone(git_url, &repo_path)?;
|
||||
tracing::info!("Cloned {git_url} to {}", repo_path.display());
|
||||
}
|
||||
|
||||
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> {
|
||||
let repo = Repository::open(repo_path)?;
|
||||
let mut remote = repo.find_remote("origin")?;
|
||||
let mut fetch_opts = self.credentials.fetch_options();
|
||||
let mut fetch_opts = FetchOptions::new();
|
||||
remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?;
|
||||
|
||||
// Fast-forward to origin/HEAD
|
||||
@@ -119,15 +48,6 @@ impl GitOps {
|
||||
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> {
|
||||
let repo = Repository::open(repo_path)?;
|
||||
let head = repo.head()?;
|
||||
@@ -143,62 +63,6 @@ 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)]
|
||||
pub fn get_changed_files(
|
||||
repo_path: &Path,
|
||||
@@ -230,10 +94,3 @@ impl GitOps {
|
||||
Ok(files)
|
||||
}
|
||||
}
|
||||
|
||||
/// A file changed between two commits with its diff content
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiffFile {
|
||||
pub path: String,
|
||||
pub hunks: String,
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
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,9 +1,6 @@
|
||||
pub mod code_review;
|
||||
pub mod cve;
|
||||
pub mod dedup;
|
||||
pub mod git;
|
||||
pub mod gitleaks;
|
||||
pub mod lint;
|
||||
pub mod orchestrator;
|
||||
pub mod patterns;
|
||||
pub mod sbom;
|
||||
|
||||
@@ -9,11 +9,8 @@ use compliance_core::AgentConfig;
|
||||
use crate::database::Database;
|
||||
use crate::error::AgentError;
|
||||
use crate::llm::LlmClient;
|
||||
use crate::pipeline::code_review::CodeReviewScanner;
|
||||
use crate::pipeline::cve::CveScanner;
|
||||
use crate::pipeline::git::{GitOps, RepoCredentials};
|
||||
use crate::pipeline::gitleaks::GitleaksScanner;
|
||||
use crate::pipeline::lint::LintScanner;
|
||||
use crate::pipeline::git::GitOps;
|
||||
use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner};
|
||||
use crate::pipeline::sbom::SbomScanner;
|
||||
use crate::pipeline::semgrep::SemgrepScanner;
|
||||
@@ -117,12 +114,7 @@ impl PipelineOrchestrator {
|
||||
|
||||
// Stage 0: Change detection
|
||||
tracing::info!("[{repo_id}] Stage 0: Change detection");
|
||||
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 git_ops = GitOps::new(&self.config.git_clone_base_path);
|
||||
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())? {
|
||||
@@ -190,35 +182,6 @@ impl PipelineOrchestrator {
|
||||
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
|
||||
tracing::info!("[{repo_id}] Stage 4.5: Graph Building");
|
||||
self.update_phase(scan_run_id, "graph_building").await;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ bson = { version = "2", features = ["chrono-0_4"] }
|
||||
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-otlp = { version = "0.29", features = ["grpc-tonic"], optional = true }
|
||||
opentelemetry-appender-tracing = { version = "0.29", optional = true }
|
||||
tracing-opentelemetry = { version = "0.30", optional = true }
|
||||
tracing-subscriber = { workspace = true, optional = true }
|
||||
|
||||
@@ -24,7 +24,6 @@ pub struct AgentConfig {
|
||||
pub scan_schedule: String,
|
||||
pub cve_monitor_schedule: String,
|
||||
pub git_clone_base_path: String,
|
||||
pub ssh_key_path: String,
|
||||
pub keycloak_url: Option<String>,
|
||||
pub keycloak_realm: Option<String>,
|
||||
}
|
||||
@@ -35,5 +34,4 @@ pub struct DashboardConfig {
|
||||
pub mongodb_database: String,
|
||||
pub agent_api_url: String,
|
||||
pub dashboard_port: u16,
|
||||
pub mcp_endpoint_url: Option<String>,
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ pub struct CveAlert {
|
||||
pub summary: Option<String>,
|
||||
pub llm_impact_summary: Option<String>,
|
||||
pub references: Vec<String>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,7 @@ pub struct DastTarget {
|
||||
pub rate_limit: u32,
|
||||
/// Whether destructive tests (DELETE, PUT) are allowed
|
||||
pub allow_destructive: bool,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -137,9 +135,7 @@ pub struct DastScanRun {
|
||||
pub error_message: Option<String>,
|
||||
/// Linked SAST scan run ID (if triggered as part of pipeline)
|
||||
pub sast_scan_run_id: Option<String>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub started_at: DateTime<Utc>,
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -244,7 +240,6 @@ pub struct DastFinding {
|
||||
pub remediation: Option<String>,
|
||||
/// Linked SAST finding ID (if correlated)
|
||||
pub linked_sast_finding_id: Option<String>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
@@ -71,14 +71,7 @@ pub struct Finding {
|
||||
pub status: FindingStatus,
|
||||
pub tracker_issue_url: 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>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -115,9 +108,6 @@ impl Finding {
|
||||
status: FindingStatus::Open,
|
||||
tracker_issue_url: None,
|
||||
scan_run_id: None,
|
||||
triage_action: None,
|
||||
triage_rationale: None,
|
||||
developer_feedback: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
@@ -122,9 +122,7 @@ pub struct GraphBuildRun {
|
||||
pub community_count: u32,
|
||||
pub languages_parsed: Vec<String>,
|
||||
pub error_message: Option<String>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub started_at: DateTime<Utc>,
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -166,7 +164,6 @@ pub struct ImpactAnalysis {
|
||||
pub direct_callers: Vec<String>,
|
||||
/// Direct callees of the affected function
|
||||
pub direct_callees: Vec<String>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,7 @@ pub struct TrackerIssue {
|
||||
pub external_url: String,
|
||||
pub title: String,
|
||||
pub status: IssueStatus,
|
||||
#[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,69 +0,0 @@
|
||||
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>,
|
||||
}
|
||||
@@ -6,11 +6,9 @@ pub mod embedding;
|
||||
pub mod finding;
|
||||
pub mod graph;
|
||||
pub mod issue;
|
||||
pub mod mcp;
|
||||
pub mod repository;
|
||||
pub mod sbom;
|
||||
pub mod scan;
|
||||
pub(crate) mod serde_helpers;
|
||||
|
||||
pub use auth::AuthInfo;
|
||||
pub use chat::{ChatMessage, ChatRequest, ChatResponse, SourceReference};
|
||||
@@ -25,7 +23,6 @@ pub use graph::{
|
||||
CodeEdge, CodeEdgeKind, CodeNode, CodeNodeKind, GraphBuildRun, GraphBuildStatus, ImpactAnalysis,
|
||||
};
|
||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
||||
pub use repository::{ScanTrigger, TrackedRepository};
|
||||
pub use sbom::{SbomEntry, VulnRef};
|
||||
pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType};
|
||||
|
||||
@@ -28,23 +28,17 @@ pub struct TrackedRepository {
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
pub tracker_owner: 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>,
|
||||
#[serde(default, deserialize_with = "deserialize_findings_count")]
|
||||
pub findings_count: u32,
|
||||
#[serde(
|
||||
default = "chrono::Utc::now",
|
||||
with = "super::serde_helpers::bson_datetime"
|
||||
deserialize_with = "deserialize_datetime"
|
||||
)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(
|
||||
default = "chrono::Utc::now",
|
||||
with = "super::serde_helpers::bson_datetime"
|
||||
deserialize_with = "deserialize_datetime"
|
||||
)]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -53,6 +47,23 @@ fn default_branch() -> 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>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
@@ -76,8 +87,6 @@ impl TrackedRepository {
|
||||
default_branch: "main".to_string(),
|
||||
local_path: None,
|
||||
scan_schedule: None,
|
||||
auth_token: None,
|
||||
auth_username: None,
|
||||
webhook_enabled: false,
|
||||
tracker_type: None,
|
||||
tracker_owner: None,
|
||||
|
||||
@@ -20,9 +20,7 @@ pub struct SbomEntry {
|
||||
pub license: Option<String>,
|
||||
pub purl: Option<String>,
|
||||
pub known_vulnerabilities: Vec<VulnRef>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ pub enum ScanType {
|
||||
OAuth,
|
||||
Graph,
|
||||
Dast,
|
||||
SecretDetection,
|
||||
Lint,
|
||||
CodeReview,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ScanType {
|
||||
@@ -28,9 +25,6 @@ impl std::fmt::Display for ScanType {
|
||||
Self::OAuth => write!(f, "oauth"),
|
||||
Self::Graph => write!(f, "graph"),
|
||||
Self::Dast => write!(f, "dast"),
|
||||
Self::SecretDetection => write!(f, "secret_detection"),
|
||||
Self::Lint => write!(f, "lint"),
|
||||
Self::CodeReview => write!(f, "code_review"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +45,6 @@ pub enum ScanPhase {
|
||||
SbomGeneration,
|
||||
CveScanning,
|
||||
PatternScanning,
|
||||
SecretDetection,
|
||||
LintScanning,
|
||||
CodeReview,
|
||||
GraphBuilding,
|
||||
LlmTriage,
|
||||
IssueCreation,
|
||||
@@ -73,9 +64,7 @@ pub struct ScanRun {
|
||||
pub phases_completed: Vec<ScanPhase>,
|
||||
pub new_findings_count: u32,
|
||||
pub error_message: Option<String>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub started_at: DateTime<Utc>,
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! OpenTelemetry initialization for traces and logs.
|
||||
//!
|
||||
//! Exports traces and logs via OTLP/HTTP when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||
//! Exports traces and logs via OTLP (gRPC) 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
|
||||
@@ -10,7 +10,7 @@
|
||||
//!
|
||||
//! | Variable | Description | Default |
|
||||
//! |---|---|---|
|
||||
//! | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint (e.g. `https://otel.example.com`) | *(disabled)* |
|
||||
//! | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint (e.g. `http://localhost:4317`) | *(disabled)* |
|
||||
//! | `OTEL_SERVICE_NAME` | Service name for resource | `service_name` param |
|
||||
//! | `RUST_LOG` / standard `EnvFilter` | Log level filter | `info` |
|
||||
|
||||
@@ -58,7 +58,7 @@ fn build_resource(service_name: &str) -> Resource {
|
||||
/// 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.
|
||||
/// via OTLP/gRPC. 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.
|
||||
@@ -76,14 +76,11 @@ pub fn init_telemetry(service_name: &str) -> TelemetryGuard {
|
||||
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)
|
||||
.with_tonic()
|
||||
.with_endpoint(endpoint)
|
||||
.build()
|
||||
.expect("failed to create OTLP span exporter");
|
||||
|
||||
@@ -99,8 +96,8 @@ pub fn init_telemetry(service_name: &str) -> TelemetryGuard {
|
||||
// Logs
|
||||
#[allow(clippy::expect_used)]
|
||||
let log_exporter = LogExporter::builder()
|
||||
.with_http()
|
||||
.with_endpoint(&logs_endpoint)
|
||||
.with_tonic()
|
||||
.with_endpoint(endpoint)
|
||||
.build()
|
||||
.expect("failed to create OTLP log exporter");
|
||||
|
||||
@@ -114,6 +111,7 @@ pub fn init_telemetry(service_name: &str) -> TelemetryGuard {
|
||||
// Filter to prevent telemetry-induced-telemetry loops
|
||||
let otel_filter = EnvFilter::new("info")
|
||||
.add_directive("hyper=off".parse().unwrap_or_default())
|
||||
.add_directive("tonic=off".parse().unwrap_or_default())
|
||||
.add_directive("h2=off".parse().unwrap_or_default())
|
||||
.add_directive("reqwest=off".parse().unwrap_or_default());
|
||||
|
||||
@@ -127,7 +125,7 @@ pub fn init_telemetry(service_name: &str) -> TelemetryGuard {
|
||||
tracing::info!(
|
||||
endpoint = endpoint.as_str(),
|
||||
service = service_name,
|
||||
"OpenTelemetry OTLP/HTTP export enabled"
|
||||
"OpenTelemetry OTLP export enabled"
|
||||
);
|
||||
|
||||
TelemetryGuard {
|
||||
|
||||
@@ -34,8 +34,6 @@ server = [
|
||||
"dep:url",
|
||||
"dep:sha2",
|
||||
"dep:base64",
|
||||
"dep:uuid",
|
||||
"dep:bson",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
@@ -69,5 +67,3 @@ 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 }
|
||||
|
||||
@@ -323,25 +323,6 @@ code {
|
||||
|
||||
/* ── Page Header ── */
|
||||
|
||||
/* ── Back Navigation ── */
|
||||
|
||||
.back-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 20px;
|
||||
@@ -498,7 +479,7 @@ th {
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 16px;
|
||||
padding: 11px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13.5px;
|
||||
color: var(--text-primary);
|
||||
@@ -524,8 +505,7 @@ tbody tr:last-child td {
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
@@ -629,316 +609,6 @@ tbody tr:last-child td {
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.btn-scanning {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 8px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
/* ── Overview Cards Grid ── */
|
||||
|
||||
.overview-section {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.overview-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.overview-section-header h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 16px rgba(0, 200, 255, 0.06);
|
||||
}
|
||||
|
||||
.overview-card-icon {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.overview-card-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overview-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.overview-card-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mcp-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mcp-status-dot.running { background: var(--success); }
|
||||
.mcp-status-dot.stopped { background: var(--text-tertiary); }
|
||||
.mcp-status-dot.error { background: var(--danger); }
|
||||
|
||||
/* ── MCP Server Cards ── */
|
||||
|
||||
.mcp-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mcp-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.mcp-card:hover {
|
||||
border-color: var(--border-bright);
|
||||
}
|
||||
|
||||
.mcp-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mcp-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mcp-card-title h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mcp-card-status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.mcp-card-status.running {
|
||||
color: var(--success);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.mcp-card-status.stopped {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.mcp-card-status.error {
|
||||
color: var(--danger);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.mcp-card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mcp-card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.mcp-detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mcp-detail-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mcp-detail-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mcp-card-tools {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mcp-tools-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mcp-tool-chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-accent);
|
||||
}
|
||||
|
||||
.mcp-card-token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mcp-token-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mcp-token-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mcp-token-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mcp-card-footer {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── DAST Stat Cards ── */
|
||||
|
||||
.stat-card-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-card-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Button active state ── */
|
||||
|
||||
.btn-active,
|
||||
.btn.btn-active {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border-bright);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
|
||||
@@ -38,8 +38,6 @@ pub enum Route {
|
||||
DastFindingsPage {},
|
||||
#[route("/dast/findings/:id")]
|
||||
DastFindingDetailPage { id: String },
|
||||
#[route("/mcp-servers")]
|
||||
McpServersPage {},
|
||||
#[route("/settings")]
|
||||
SettingsPage {},
|
||||
}
|
||||
|
||||
@@ -24,13 +24,12 @@ pub fn AppShell() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) | Some(Err(_)) => {
|
||||
// Not authenticated — redirect to Keycloak login
|
||||
rsx! {
|
||||
document::Script {
|
||||
dangerous_inner_html: "window.location.href = '/auth';"
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
rsx! { LoginPage {} }
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
tracing::error!("Auth check failed: {e}");
|
||||
rsx! { LoginPage {} }
|
||||
}
|
||||
None => {
|
||||
rsx! {
|
||||
@@ -41,3 +40,20 @@ pub fn AppShell() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn LoginPage() -> Element {
|
||||
rsx! {
|
||||
div { class: "flex items-center justify-center h-screen bg-gray-950",
|
||||
div { class: "text-center",
|
||||
h1 { class: "text-3xl font-bold text-white mb-4", "Compliance Scanner" }
|
||||
p { class: "text-gray-400 mb-8", "Sign in to access the dashboard" }
|
||||
a {
|
||||
href: "/auth",
|
||||
class: "px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors font-medium",
|
||||
"Sign in with Keycloak"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ pub fn Sidebar() -> Element {
|
||||
route: Route::IssuesPage {},
|
||||
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 {
|
||||
label: "AI Chat",
|
||||
route: Route::ChatIndexPage {},
|
||||
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "DAST",
|
||||
route: Route::DastOverviewPage {},
|
||||
@@ -75,6 +85,10 @@ pub fn Sidebar() -> Element {
|
||||
{
|
||||
let is_active = match (¤t_route, &item.route) {
|
||||
(Route::FindingDetailPage { .. }, Route::FindingsPage {}) => true,
|
||||
(Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true,
|
||||
(Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true,
|
||||
(Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true,
|
||||
(Route::ChatPage { .. }, Route::ChatIndexPage {}) => true,
|
||||
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
|
||||
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
||||
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
||||
@@ -116,9 +130,8 @@ pub fn Sidebar() -> Element {
|
||||
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: "sidebar-user",
|
||||
div { class: "user-avatar",
|
||||
if info.avatar_url.is_empty() {
|
||||
span { class: "avatar-initials", "{initials}" }
|
||||
@@ -127,13 +140,15 @@ pub fn Sidebar() -> Element {
|
||||
}
|
||||
}
|
||||
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 }
|
||||
div { class: "user-info",
|
||||
span { class: "user-name", "{info.name}" }
|
||||
a {
|
||||
href: "/logout",
|
||||
class: "logout-link",
|
||||
Icon { icon: BsBoxArrowRight, width: 14, height: 14 }
|
||||
" Logout"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,5 @@ pub fn load_config() -> Result<DashboardConfig, DashboardError> {
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8080),
|
||||
mcp_endpoint_url: std::env::var("MCP_ENDPOINT_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,8 +42,4 @@ impl Database {
|
||||
pub fn tracker_issues(&self) -> Collection<TrackerIssue> {
|
||||
self.inner.collection("tracker_issues")
|
||||
}
|
||||
|
||||
pub fn mcp_servers(&self) -> Collection<McpServerConfig> {
|
||||
self.inner.collection("mcp_servers")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,50 +10,32 @@ pub struct FindingsListResponse {
|
||||
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]
|
||||
pub async fn fetch_findings(query: FindingsQuery) -> Result<FindingsListResponse, ServerFnError> {
|
||||
pub async fn fetch_findings(
|
||||
page: u64,
|
||||
severity: String,
|
||||
scan_type: String,
|
||||
status: String,
|
||||
repo_id: String,
|
||||
) -> Result<FindingsListResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let mut url = format!(
|
||||
"{}/api/v1/findings?page={}&limit=20",
|
||||
state.agent_api_url, query.page
|
||||
"{}/api/v1/findings?page={page}&limit=20",
|
||||
state.agent_api_url
|
||||
);
|
||||
if !query.severity.is_empty() {
|
||||
url.push_str(&format!("&severity={}", query.severity));
|
||||
if !severity.is_empty() {
|
||||
url.push_str(&format!("&severity={severity}"));
|
||||
}
|
||||
if !query.scan_type.is_empty() {
|
||||
url.push_str(&format!("&scan_type={}", query.scan_type));
|
||||
if !scan_type.is_empty() {
|
||||
url.push_str(&format!("&scan_type={scan_type}"));
|
||||
}
|
||||
if !query.status.is_empty() {
|
||||
url.push_str(&format!("&status={}", query.status));
|
||||
if !status.is_empty() {
|
||||
url.push_str(&format!("&status={status}"));
|
||||
}
|
||||
if !query.repo_id.is_empty() {
|
||||
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));
|
||||
if !repo_id.is_empty() {
|
||||
url.push_str(&format!("&repo_id={repo_id}"));
|
||||
}
|
||||
|
||||
let resp = reqwest::get(&url)
|
||||
@@ -100,40 +82,3 @@ pub async fn update_finding_status(id: String, status: String) -> Result<(), Ser
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ pub mod dast;
|
||||
pub mod findings;
|
||||
pub mod graph;
|
||||
pub mod issues;
|
||||
pub mod mcp;
|
||||
pub mod repositories;
|
||||
pub mod sbom;
|
||||
pub mod scans;
|
||||
|
||||
@@ -34,29 +34,19 @@ pub async fn add_repository(
|
||||
name: String,
|
||||
git_url: String,
|
||||
default_branch: String,
|
||||
auth_token: Option<String>,
|
||||
auth_username: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
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 resp = client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.json(&serde_json::json!({
|
||||
"name": name,
|
||||
"git_url": git_url,
|
||||
"default_branch": default_branch,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
@@ -71,32 +61,6 @@ pub async fn add_repository(
|
||||
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 =
|
||||
@@ -135,32 +99,3 @@ pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ 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::database::Database;
|
||||
use super::error::DashboardError;
|
||||
@@ -25,9 +22,6 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
KeycloakConfig::from_env().map(|kc| &*Box::leak(Box::new(kc)));
|
||||
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 {
|
||||
@@ -51,8 +45,7 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
.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 addr = dioxus_cli_config::fullstack_address_or_localhost();
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.map_err(|e| DashboardError::Other(format!("Failed to bind: {e}")))?;
|
||||
@@ -76,66 +69,3 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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::{
|
||||
@@ -181,15 +179,6 @@ pub fn ChatPage(repo_id: String) -> Element {
|
||||
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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::*;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::severity_badge::SeverityBadge;
|
||||
@@ -14,15 +12,6 @@ pub fn DastFindingDetailPage(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: "DAST Finding Detail",
|
||||
description: "Full evidence and details for a dynamic security finding",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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;
|
||||
@@ -12,15 +10,6 @@ pub fn DastFindingsPage() -> Element {
|
||||
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
|
||||
|
||||
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: "DAST Findings",
|
||||
description: "Vulnerabilities discovered through dynamic application security testing",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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;
|
||||
@@ -17,9 +15,9 @@ pub fn DastOverviewPage() -> Element {
|
||||
description: "Dynamic Application Security Testing — scan running applications for vulnerabilities",
|
||||
}
|
||||
|
||||
div { class: "stat-cards", style: "margin-bottom: 24px;",
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value",
|
||||
div { class: "grid grid-cols-3 gap-4 mb-6",
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value",
|
||||
match &*scan_runs.read() {
|
||||
Some(Some(data)) => {
|
||||
let count = data.total.unwrap_or(0);
|
||||
@@ -28,13 +26,10 @@ pub fn DastOverviewPage() -> Element {
|
||||
_ => rsx! { "—" },
|
||||
}
|
||||
}
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsPlayCircle, width: 14, height: 14 }
|
||||
" Total Scans"
|
||||
}
|
||||
div { class: "stat-label", "Total Scans" }
|
||||
}
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value",
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value",
|
||||
match &*findings.read() {
|
||||
Some(Some(data)) => {
|
||||
let count = data.total.unwrap_or(0);
|
||||
@@ -43,37 +38,29 @@ pub fn DastOverviewPage() -> Element {
|
||||
_ => rsx! { "—" },
|
||||
}
|
||||
}
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
||||
" DAST Findings"
|
||||
}
|
||||
div { class: "stat-label", "DAST Findings" }
|
||||
}
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", "—" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsBullseye, width: 14, height: 14 }
|
||||
" Active Targets"
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "—" }
|
||||
div { class: "stat-label", "Active Targets" }
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: flex; gap: 12px; margin-bottom: 24px;",
|
||||
div { class: "flex gap-4 mb-4",
|
||||
Link {
|
||||
to: Route::DastTargetsPage {},
|
||||
class: "btn btn-primary",
|
||||
Icon { icon: BsBullseye, width: 14, height: 14 }
|
||||
" Manage Targets"
|
||||
"Manage Targets"
|
||||
}
|
||||
Link {
|
||||
to: Route::DastFindingsPage {},
|
||||
class: "btn btn-secondary",
|
||||
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
||||
" View Findings"
|
||||
"View Findings"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Recent Scan Runs" }
|
||||
h3 { "Recent Scan Runs" }
|
||||
match &*scan_runs.read() {
|
||||
Some(Some(data)) => {
|
||||
let runs = &data.data;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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};
|
||||
@@ -16,15 +14,6 @@ pub fn DastTargetsPage() -> Element {
|
||||
let mut new_url = use_signal(String::new);
|
||||
|
||||
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: "DAST Targets",
|
||||
description: "Configure target applications for dynamic security testing",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::*;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::components::code_snippet::CodeSnippet;
|
||||
use crate::components::page_header::PageHeader;
|
||||
@@ -10,7 +8,7 @@ use crate::components::severity_badge::SeverityBadge;
|
||||
pub fn FindingDetailPage(id: String) -> Element {
|
||||
let finding_id = id.clone();
|
||||
|
||||
let mut finding = use_resource(move || {
|
||||
let finding = use_resource(move || {
|
||||
let fid = finding_id.clone();
|
||||
async move {
|
||||
crate::infrastructure::findings::fetch_finding_detail(fid)
|
||||
@@ -24,18 +22,7 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
match snapshot {
|
||||
Some(Some(f)) => {
|
||||
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! {
|
||||
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: f.title.clone(),
|
||||
description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status),
|
||||
@@ -52,9 +39,6 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
if let Some(score) = f.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",
|
||||
@@ -62,19 +46,6 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
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 {
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Code Evidence" }
|
||||
@@ -119,60 +90,23 @@ pub fn FindingDetailPage(id: String) -> Element {
|
||||
{
|
||||
let status_str = status.to_string();
|
||||
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! {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
title: "{label}",
|
||||
onclick: move |_| {
|
||||
let s = status_str.clone();
|
||||
let id = id_clone.clone();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::findings::update_finding_status(id, s).await;
|
||||
});
|
||||
finding.restart();
|
||||
},
|
||||
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}"
|
||||
"{status}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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! {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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;
|
||||
@@ -14,10 +12,6 @@ pub fn FindingsPage() -> Element {
|
||||
let mut type_filter = use_signal(String::new);
|
||||
let mut status_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 {
|
||||
crate::infrastructure::repositories::fetch_repositories(1)
|
||||
@@ -25,52 +19,19 @@ pub fn FindingsPage() -> Element {
|
||||
.ok()
|
||||
});
|
||||
|
||||
let mut findings = use_resource(move || {
|
||||
let query = crate::infrastructure::findings::FindingsQuery {
|
||||
page: page(),
|
||||
severity: severity_filter(),
|
||||
scan_type: type_filter(),
|
||||
status: status_filter(),
|
||||
repo_id: repo_filter(),
|
||||
q: search_query(),
|
||||
sort_by: sort_by(),
|
||||
sort_order: sort_order(),
|
||||
};
|
||||
let findings = use_resource(move || {
|
||||
let p = page();
|
||||
let sev = severity_filter();
|
||||
let typ = type_filter();
|
||||
let stat = status_filter();
|
||||
let repo = repo_filter();
|
||||
async move {
|
||||
crate::infrastructure::findings::fetch_findings(query)
|
||||
crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo)
|
||||
.await
|
||||
.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! {
|
||||
PageHeader {
|
||||
title: "Findings",
|
||||
@@ -78,12 +39,6 @@ pub fn FindingsPage() -> Element {
|
||||
}
|
||||
|
||||
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 {
|
||||
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
|
||||
option { value: "", "All Repositories" }
|
||||
@@ -121,9 +76,6 @@ pub fn FindingsPage() -> Element {
|
||||
option { value: "cve", "CVE" }
|
||||
option { value: "gdpr", "GDPR" }
|
||||
option { value: "oauth", "OAuth" }
|
||||
option { value: "secret_detection", "Secrets" }
|
||||
option { value: "lint", "Lint" }
|
||||
option { value: "code_review", "Code Review" }
|
||||
}
|
||||
select {
|
||||
onchange: move |e| { status_filter.set(e.value()); page.set(1); },
|
||||
@@ -136,132 +88,29 @@ 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() {
|
||||
Some(Some(resp)) => {
|
||||
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! {
|
||||
div { class: "card",
|
||||
div { class: "table-wrapper",
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th {
|
||||
style: "width: 40px;",
|
||||
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 { "Severity" }
|
||||
th { "Title" }
|
||||
th { "Type" }
|
||||
th { "Scanner" }
|
||||
th { "File" }
|
||||
th {
|
||||
style: "cursor: pointer; user-select: none;",
|
||||
onclick: toggle_sort("status"),
|
||||
"Status{sort_indicator(\"status\")}"
|
||||
}
|
||||
th { "Status" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for finding in &resp.data {
|
||||
{
|
||||
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! {
|
||||
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 {
|
||||
Link {
|
||||
@@ -271,29 +120,13 @@ pub fn FindingsPage() -> Element {
|
||||
}
|
||||
}
|
||||
td { "{finding.scan_type}" }
|
||||
td {
|
||||
Icon { icon: BsCpu, width: 14, height: 14 }
|
||||
" {finding.scanner}"
|
||||
}
|
||||
td { "{finding.scanner}" }
|
||||
td {
|
||||
style: "font-family: monospace; font-size: 12px;",
|
||||
Icon { icon: BsFileEarmarkCode, width: 14, height: 14 }
|
||||
" {finding.file_path.as_deref().unwrap_or(\"-\")}"
|
||||
"{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}"
|
||||
}
|
||||
span { class: "badge badge-info", "{finding.status}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::*;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::components::code_inspector::CodeInspector;
|
||||
use crate::components::file_tree::{build_file_tree, FileTree};
|
||||
@@ -10,36 +8,6 @@ use crate::infrastructure::graph::{fetch_graph, search_nodes, trigger_graph_buil
|
||||
|
||||
#[component]
|
||||
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 mut graph_data = use_resource(move || {
|
||||
let rid = repo_id_clone.clone();
|
||||
@@ -53,15 +21,22 @@ fn GraphExplorerBody(repo_id: String) -> Element {
|
||||
|
||||
let mut building = use_signal(|| false);
|
||||
let mut toasts = use_context::<Toasts>();
|
||||
|
||||
// Selected node state
|
||||
let mut selected_node = use_signal(|| Option::<serde_json::Value>::None);
|
||||
let mut inspector_open = use_signal(|| false);
|
||||
|
||||
// Search state
|
||||
let mut search_query = use_signal(String::new);
|
||||
let mut search_results = use_signal(Vec::<serde_json::Value>::new);
|
||||
let mut file_filter = 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);
|
||||
|
||||
// When resource resolves, serialize the data into signals
|
||||
let graph_data_read = graph_data.read();
|
||||
if let Some(Some(data)) = &*graph_data_read {
|
||||
if !data.data.nodes.is_empty() && !graph_ready() {
|
||||
@@ -73,6 +48,7 @@ fn GraphExplorerBody(repo_id: String) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Derive stats and file tree
|
||||
let (node_count, edge_count, community_count, languages, file_tree_data) =
|
||||
if let Some(Some(data)) = &*graph_data_read {
|
||||
let build = data.data.build.clone().unwrap_or_default();
|
||||
@@ -104,8 +80,11 @@ fn GraphExplorerBody(repo_id: String) -> Element {
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// use_effect runs AFTER DOM commit — this is when #graph-canvas exists
|
||||
use_effect(move || {
|
||||
let ready = graph_ready();
|
||||
if !ready {
|
||||
@@ -117,6 +96,7 @@ fn GraphExplorerBody(repo_id: String) -> Element {
|
||||
return;
|
||||
}
|
||||
spawn(async move {
|
||||
// Register the click callback + load graph with a small delay for DOM paint
|
||||
let js = format!(
|
||||
r#"
|
||||
window.__onNodeClick = function(nodeJson) {{
|
||||
@@ -129,6 +109,8 @@ fn GraphExplorerBody(repo_id: String) -> Element {
|
||||
setTimeout(function() {{
|
||||
if (window.__loadGraph) {{
|
||||
window.__loadGraph({nj}, {ej});
|
||||
}} else {{
|
||||
console.error('[graph-viz] __loadGraph not found — vis-network may not be loaded');
|
||||
}}
|
||||
}}, 300);
|
||||
"#
|
||||
@@ -137,6 +119,7 @@ fn GraphExplorerBody(repo_id: String) -> Element {
|
||||
});
|
||||
});
|
||||
|
||||
// Extract selected node fields
|
||||
let sel = selected_node();
|
||||
let sel_file = sel
|
||||
.as_ref()
|
||||
@@ -163,6 +146,11 @@ fn GraphExplorerBody(repo_id: String) -> Element {
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Code Knowledge Graph",
|
||||
description: "Interactive visualization of code structure and relationships",
|
||||
}
|
||||
|
||||
if repo_id.is_empty() {
|
||||
div { class: "card",
|
||||
p { "Select a repository to view its code graph." }
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::*;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::graph::fetch_impact;
|
||||
@@ -14,15 +12,6 @@ pub fn ImpactAnalysisPage(repo_id: String, finding_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: "Impact Analysis",
|
||||
description: "Blast radius and affected entry points for a security finding",
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
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..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ pub mod graph_explorer;
|
||||
pub mod graph_index;
|
||||
pub mod impact_analysis;
|
||||
pub mod issues;
|
||||
pub mod mcp_servers;
|
||||
pub mod overview;
|
||||
pub mod repositories;
|
||||
pub mod sbom;
|
||||
@@ -28,7 +27,6 @@ pub use graph_explorer::GraphExplorerPage;
|
||||
pub use graph_index::GraphIndexPage;
|
||||
pub use impact_analysis::ImpactAnalysisPage;
|
||||
pub use issues::IssuesPage;
|
||||
pub use mcp_servers::McpServersPage;
|
||||
pub use overview::OverviewPage;
|
||||
pub use repositories::RepositoriesPage;
|
||||
pub use sbom::SbomPage;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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::stat_card::StatCard;
|
||||
use crate::infrastructure::mcp::fetch_mcp_servers;
|
||||
use crate::infrastructure::repositories::fetch_repositories;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
use crate::infrastructure::stats::fetch_overview_stats;
|
||||
@@ -26,9 +21,6 @@ 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! {
|
||||
PageHeader {
|
||||
title: "Overview",
|
||||
@@ -74,125 +66,6 @@ pub fn OverviewPage() -> Element {
|
||||
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! {
|
||||
div { class: "card",
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
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::pagination::Pagination;
|
||||
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]
|
||||
pub fn RepositoriesPage() -> Element {
|
||||
@@ -25,16 +12,8 @@ pub fn RepositoriesPage() -> Element {
|
||||
let mut name = use_signal(String::new);
|
||||
let mut git_url = use_signal(String::new);
|
||||
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 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 p = page();
|
||||
@@ -75,7 +54,7 @@ pub fn RepositoriesPage() -> Element {
|
||||
label { "Git URL" }
|
||||
input {
|
||||
r#type: "text",
|
||||
placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
|
||||
placeholder: "https://github.com/org/repo.git",
|
||||
value: "{git_url}",
|
||||
oninput: move |e| git_url.set(e.value()),
|
||||
}
|
||||
@@ -89,105 +68,26 @@ pub fn RepositoriesPage() -> Element {
|
||||
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 {
|
||||
class: "btn btn-primary",
|
||||
disabled: adding(),
|
||||
onclick: move |_| {
|
||||
let n = name();
|
||||
let u = git_url();
|
||||
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 {
|
||||
match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr).await {
|
||||
match crate::infrastructure::repositories::add_repository(n, u, b).await {
|
||||
Ok(_) => {
|
||||
toasts.push(ToastType::Success, "Repository added");
|
||||
repos.restart();
|
||||
}
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
adding.set(false);
|
||||
});
|
||||
show_add_form.set(false);
|
||||
show_auth.set(false);
|
||||
name.set(String::new());
|
||||
git_url.set(String::new());
|
||||
auth_token.set(String::new());
|
||||
auth_username.set(String::new());
|
||||
},
|
||||
if adding() { "Validating..." } else { "Add" }
|
||||
"Add"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +158,6 @@ pub fn RepositoriesPage() -> Element {
|
||||
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! {
|
||||
tr {
|
||||
td { "{repo.name}" }
|
||||
@@ -287,68 +186,30 @@ pub fn RepositoriesPage() -> Element {
|
||||
}
|
||||
}
|
||||
td { style: "display: flex; gap: 4px;",
|
||||
button {
|
||||
class: if graph_repo_id().as_deref() == Some(repo_id.as_str()) { "btn btn-ghost btn-active" } else { "btn btn-ghost" },
|
||||
title: "View 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 }
|
||||
Link {
|
||||
to: Route::GraphExplorerPage { repo_id: repo_id.clone() },
|
||||
class: "btn btn-ghost",
|
||||
"Graph"
|
||||
}
|
||||
button {
|
||||
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
|
||||
title: "Trigger scan",
|
||||
disabled: is_scanning,
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| {
|
||||
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 {
|
||||
match crate::infrastructure::repositories::trigger_repo_scan(id.clone()).await {
|
||||
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();
|
||||
}
|
||||
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
||||
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
||||
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);
|
||||
});
|
||||
},
|
||||
if is_scanning {
|
||||
span { class: "spinner" }
|
||||
} else {
|
||||
Icon { icon: BsPlayCircle, width: 16, height: 16 }
|
||||
}
|
||||
"Scan"
|
||||
}
|
||||
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 }
|
||||
"Delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,22 +225,6 @@ pub fn RepositoriesPage() -> Element {
|
||||
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! {
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "compliance-mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
compliance-core = { workspace = true, features = ["mongodb"] }
|
||||
rmcp = { version = "0.16", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
mongodb = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = "0.15"
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
bson = { version = "2", features = ["chrono-0_4"] }
|
||||
schemars = "1.0"
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
@@ -1,34 +0,0 @@
|
||||
use mongodb::{Client, Collection};
|
||||
|
||||
use compliance_core::models::*;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Database {
|
||||
inner: mongodb::Database,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn connect(uri: &str, db_name: &str) -> Result<Self, mongodb::error::Error> {
|
||||
let client = Client::with_uri_str(uri).await?;
|
||||
let db = client.database(db_name);
|
||||
db.run_command(mongodb::bson::doc! { "ping": 1 }).await?;
|
||||
tracing::info!("MCP server connected to MongoDB '{db_name}'");
|
||||
Ok(Self { inner: db })
|
||||
}
|
||||
|
||||
pub fn findings(&self) -> Collection<Finding> {
|
||||
self.inner.collection("findings")
|
||||
}
|
||||
|
||||
pub fn sbom_entries(&self) -> Collection<SbomEntry> {
|
||||
self.inner.collection("sbom_entries")
|
||||
}
|
||||
|
||||
pub fn dast_findings(&self) -> Collection<DastFinding> {
|
||||
self.inner.collection("dast_findings")
|
||||
}
|
||||
|
||||
pub fn dast_scan_runs(&self) -> Collection<DastScanRun> {
|
||||
self.inner.collection("dast_scan_runs")
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
mod database;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use database::Database;
|
||||
use rmcp::transport::{
|
||||
streamable_http_server::session::local::LocalSessionManager, StreamableHttpServerConfig,
|
||||
StreamableHttpService,
|
||||
};
|
||||
use server::ComplianceMcpServer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("compliance_mcp=info".parse()?),
|
||||
)
|
||||
.init();
|
||||
|
||||
let mongo_uri =
|
||||
std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".to_string());
|
||||
let db_name =
|
||||
std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "compliance_scanner".to_string());
|
||||
|
||||
let db = Database::connect(&mongo_uri, &db_name).await?;
|
||||
|
||||
// If MCP_PORT is set, run as Streamable HTTP server; otherwise use stdio.
|
||||
if let Ok(port_str) = std::env::var("MCP_PORT") {
|
||||
let port: u16 = port_str.parse()?;
|
||||
tracing::info!("Starting MCP server on HTTP port {port}");
|
||||
|
||||
let db_clone = db.clone();
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok(ComplianceMcpServer::new(db_clone.clone())),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
|
||||
let router = axum::Router::new().nest_service("/mcp", service);
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?;
|
||||
tracing::info!("MCP HTTP server listening on 0.0.0.0:{port}");
|
||||
axum::serve(listener, router).await?;
|
||||
} else {
|
||||
tracing::info!("Starting MCP server on stdio");
|
||||
let server = ComplianceMcpServer::new(db);
|
||||
let transport = rmcp::transport::stdio();
|
||||
use rmcp::ServiceExt;
|
||||
let handle = server.serve(transport).await?;
|
||||
handle.waiting().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
use rmcp::{
|
||||
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler,
|
||||
};
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::tools::{dast, findings, sbom};
|
||||
|
||||
pub struct ComplianceMcpServer {
|
||||
db: Database,
|
||||
#[allow(dead_code)]
|
||||
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl ComplianceMcpServer {
|
||||
pub fn new(db: Database) -> Self {
|
||||
Self {
|
||||
db,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Findings ──────────────────────────────────────────
|
||||
|
||||
#[tool(
|
||||
description = "List security findings with optional filters for repo, severity, status, and scan type"
|
||||
)]
|
||||
async fn list_findings(
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::ListFindingsParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::list_findings(&self.db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a single finding by its ID")]
|
||||
async fn get_finding(
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::GetFindingParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::get_finding(&self.db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a summary of findings counts grouped by severity and status")]
|
||||
async fn findings_summary(
|
||||
&self,
|
||||
Parameters(params): Parameters<findings::FindingsSummaryParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
findings::findings_summary(&self.db, params).await
|
||||
}
|
||||
|
||||
// ── SBOM ──────────────────────────────────────────────
|
||||
|
||||
#[tool(
|
||||
description = "List SBOM packages with optional filters for repo, vulnerabilities, package manager, and license"
|
||||
)]
|
||||
async fn list_sbom_packages(
|
||||
&self,
|
||||
Parameters(params): Parameters<sbom::ListSbomPackagesParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
sbom::list_sbom_packages(&self.db, params).await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Generate a vulnerability report for a repository showing all packages with known CVEs"
|
||||
)]
|
||||
async fn sbom_vuln_report(
|
||||
&self,
|
||||
Parameters(params): Parameters<sbom::SbomVulnReportParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
sbom::sbom_vuln_report(&self.db, params).await
|
||||
}
|
||||
|
||||
// ── DAST ──────────────────────────────────────────────
|
||||
|
||||
#[tool(
|
||||
description = "List DAST findings with optional filters for target, scan run, severity, exploitability, and vulnerability type"
|
||||
)]
|
||||
async fn list_dast_findings(
|
||||
&self,
|
||||
Parameters(params): Parameters<dast::ListDastFindingsParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
dast::list_dast_findings(&self.db, params).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a summary of recent DAST scan runs and finding counts")]
|
||||
async fn dast_scan_summary(
|
||||
&self,
|
||||
Parameters(params): Parameters<dast::DastScanSummaryParams>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
dast::dast_scan_summary(&self.db, params).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for ComplianceMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
protocol_version: ProtocolVersion::V_2024_11_05,
|
||||
capabilities: ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.build(),
|
||||
server_info: Implementation::from_build_env(),
|
||||
instructions: Some(
|
||||
"Compliance Scanner MCP server. Query security findings, SBOM data, and DAST results."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
use mongodb::bson::doc;
|
||||
use rmcp::{model::*, ErrorData as McpError};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::database::Database;
|
||||
|
||||
const MAX_LIMIT: i64 = 200;
|
||||
const DEFAULT_LIMIT: i64 = 50;
|
||||
|
||||
fn cap_limit(limit: Option<i64>) -> i64 {
|
||||
limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ListDastFindingsParams {
|
||||
/// Filter by DAST target ID
|
||||
pub target_id: Option<String>,
|
||||
/// Filter by scan run ID
|
||||
pub scan_run_id: Option<String>,
|
||||
/// Filter by severity: info, low, medium, high, critical
|
||||
pub severity: Option<String>,
|
||||
/// Only show confirmed exploitable findings
|
||||
pub exploitable: Option<bool>,
|
||||
/// Filter by vulnerability type (e.g. sql_injection, xss, ssrf)
|
||||
pub vuln_type: Option<String>,
|
||||
/// Maximum number of results (default 50, max 200)
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_dast_findings(
|
||||
db: &Database,
|
||||
params: ListDastFindingsParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut filter = doc! {};
|
||||
if let Some(ref target_id) = params.target_id {
|
||||
filter.insert("target_id", target_id);
|
||||
}
|
||||
if let Some(ref scan_run_id) = params.scan_run_id {
|
||||
filter.insert("scan_run_id", scan_run_id);
|
||||
}
|
||||
if let Some(ref severity) = params.severity {
|
||||
filter.insert("severity", severity);
|
||||
}
|
||||
if let Some(exploitable) = params.exploitable {
|
||||
filter.insert("exploitable", exploitable);
|
||||
}
|
||||
if let Some(ref vuln_type) = params.vuln_type {
|
||||
filter.insert("vuln_type", vuln_type);
|
||||
}
|
||||
|
||||
let limit = cap_limit(params.limit);
|
||||
|
||||
let mut cursor = db
|
||||
.dast_findings()
|
||||
.find(filter)
|
||||
.sort(doc! { "created_at": -1 })
|
||||
.limit(limit)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while cursor
|
||||
.advance()
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
|
||||
{
|
||||
let finding = cursor
|
||||
.deserialize_current()
|
||||
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
|
||||
results.push(finding);
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&results)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct DastScanSummaryParams {
|
||||
/// Filter by DAST target ID
|
||||
pub target_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn dast_scan_summary(
|
||||
db: &Database,
|
||||
params: DastScanSummaryParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut filter = doc! {};
|
||||
if let Some(ref target_id) = params.target_id {
|
||||
filter.insert("target_id", target_id);
|
||||
}
|
||||
|
||||
// Get recent scan runs
|
||||
let mut cursor = db
|
||||
.dast_scan_runs()
|
||||
.find(filter.clone())
|
||||
.sort(doc! { "started_at": -1 })
|
||||
.limit(10)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
|
||||
let mut scan_runs = Vec::new();
|
||||
while cursor
|
||||
.advance()
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
|
||||
{
|
||||
let run = cursor
|
||||
.deserialize_current()
|
||||
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
|
||||
scan_runs.push(serde_json::json!({
|
||||
"id": run.id.map(|id| id.to_hex()),
|
||||
"target_id": run.target_id,
|
||||
"status": run.status,
|
||||
"findings_count": run.findings_count,
|
||||
"exploitable_count": run.exploitable_count,
|
||||
"endpoints_discovered": run.endpoints_discovered,
|
||||
"started_at": run.started_at.to_rfc3339(),
|
||||
"completed_at": run.completed_at.map(|t| t.to_rfc3339()),
|
||||
}));
|
||||
}
|
||||
|
||||
// Count findings by severity
|
||||
let mut findings_filter = doc! {};
|
||||
if let Some(ref target_id) = params.target_id {
|
||||
findings_filter.insert("target_id", target_id);
|
||||
}
|
||||
let total_findings = db
|
||||
.dast_findings()
|
||||
.count_documents(findings_filter.clone())
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
|
||||
let mut exploitable_filter = findings_filter.clone();
|
||||
exploitable_filter.insert("exploitable", true);
|
||||
let exploitable_count = db
|
||||
.dast_findings()
|
||||
.count_documents(exploitable_filter)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
|
||||
let summary = serde_json::json!({
|
||||
"total_findings": total_findings,
|
||||
"exploitable_findings": exploitable_count,
|
||||
"recent_scan_runs": scan_runs,
|
||||
});
|
||||
|
||||
let json = serde_json::to_string_pretty(&summary)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
use mongodb::bson::doc;
|
||||
use rmcp::{model::*, ErrorData as McpError};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::database::Database;
|
||||
|
||||
const MAX_LIMIT: i64 = 200;
|
||||
const DEFAULT_LIMIT: i64 = 50;
|
||||
|
||||
fn cap_limit(limit: Option<i64>) -> i64 {
|
||||
limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ListFindingsParams {
|
||||
/// Filter by repository ID
|
||||
pub repo_id: Option<String>,
|
||||
/// Filter by severity: info, low, medium, high, critical
|
||||
pub severity: Option<String>,
|
||||
/// Filter by status: open, triaged, false_positive, resolved, ignored
|
||||
pub status: Option<String>,
|
||||
/// Filter by scan type: sast, sbom, cve, gdpr, oauth, secret_detection, lint, code_review
|
||||
pub scan_type: Option<String>,
|
||||
/// Maximum number of results (default 50, max 200)
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_findings(
|
||||
db: &Database,
|
||||
params: ListFindingsParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut filter = doc! {};
|
||||
if let Some(ref repo_id) = params.repo_id {
|
||||
filter.insert("repo_id", repo_id);
|
||||
}
|
||||
if let Some(ref severity) = params.severity {
|
||||
filter.insert("severity", severity);
|
||||
}
|
||||
if let Some(ref status) = params.status {
|
||||
filter.insert("status", status);
|
||||
}
|
||||
if let Some(ref scan_type) = params.scan_type {
|
||||
filter.insert("scan_type", scan_type);
|
||||
}
|
||||
|
||||
let limit = cap_limit(params.limit);
|
||||
|
||||
let mut cursor = db
|
||||
.findings()
|
||||
.find(filter)
|
||||
.sort(doc! { "created_at": -1 })
|
||||
.limit(limit)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while cursor
|
||||
.advance()
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
|
||||
{
|
||||
let finding = cursor
|
||||
.deserialize_current()
|
||||
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
|
||||
results.push(finding);
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&results)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct GetFindingParams {
|
||||
/// Finding ID (MongoDB ObjectId hex string)
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub async fn get_finding(
|
||||
db: &Database,
|
||||
params: GetFindingParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let oid = bson::oid::ObjectId::parse_str(¶ms.id)
|
||||
.map_err(|e| McpError::invalid_params(format!("invalid ObjectId: {e}"), None))?;
|
||||
|
||||
let finding = db
|
||||
.findings()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?
|
||||
.ok_or_else(|| McpError::invalid_params("finding not found", None))?;
|
||||
|
||||
let json = serde_json::to_string_pretty(&finding)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct FindingsSummaryParams {
|
||||
/// Filter by repository ID
|
||||
pub repo_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SeverityCount {
|
||||
severity: String,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
pub async fn findings_summary(
|
||||
db: &Database,
|
||||
params: FindingsSummaryParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut base_filter = doc! {};
|
||||
if let Some(ref repo_id) = params.repo_id {
|
||||
base_filter.insert("repo_id", repo_id);
|
||||
}
|
||||
|
||||
let severities = ["critical", "high", "medium", "low", "info"];
|
||||
let mut counts = Vec::new();
|
||||
|
||||
for sev in &severities {
|
||||
let mut filter = base_filter.clone();
|
||||
filter.insert("severity", sev);
|
||||
let count = db
|
||||
.findings()
|
||||
.count_documents(filter)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
counts.push(SeverityCount {
|
||||
severity: sev.to_string(),
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
let total: u64 = counts.iter().map(|c| c.count).sum();
|
||||
|
||||
let mut status_counts = Vec::new();
|
||||
for status in &["open", "triaged", "false_positive", "resolved", "ignored"] {
|
||||
let mut filter = base_filter.clone();
|
||||
filter.insert("status", status);
|
||||
let count = db
|
||||
.findings()
|
||||
.count_documents(filter)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
status_counts.push(serde_json::json!({ "status": status, "count": count }));
|
||||
}
|
||||
|
||||
let summary = serde_json::json!({
|
||||
"total": total,
|
||||
"by_severity": counts,
|
||||
"by_status": status_counts,
|
||||
});
|
||||
|
||||
let json = serde_json::to_string_pretty(&summary)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod dast;
|
||||
pub mod findings;
|
||||
pub mod sbom;
|
||||
@@ -1,129 +0,0 @@
|
||||
use mongodb::bson::doc;
|
||||
use rmcp::{model::*, ErrorData as McpError};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::database::Database;
|
||||
|
||||
const MAX_LIMIT: i64 = 200;
|
||||
const DEFAULT_LIMIT: i64 = 50;
|
||||
|
||||
fn cap_limit(limit: Option<i64>) -> i64 {
|
||||
limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ListSbomPackagesParams {
|
||||
/// Filter by repository ID
|
||||
pub repo_id: Option<String>,
|
||||
/// Only show packages with known vulnerabilities
|
||||
pub has_vulns: Option<bool>,
|
||||
/// Filter by package manager (e.g. npm, cargo, pip)
|
||||
pub package_manager: Option<String>,
|
||||
/// Filter by license (e.g. MIT, Apache-2.0)
|
||||
pub license: Option<String>,
|
||||
/// Maximum number of results (default 50, max 200)
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_sbom_packages(
|
||||
db: &Database,
|
||||
params: ListSbomPackagesParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut filter = doc! {};
|
||||
if let Some(ref repo_id) = params.repo_id {
|
||||
filter.insert("repo_id", repo_id);
|
||||
}
|
||||
if let Some(ref pm) = params.package_manager {
|
||||
filter.insert("package_manager", pm);
|
||||
}
|
||||
if let Some(ref license) = params.license {
|
||||
filter.insert("license", license);
|
||||
}
|
||||
if params.has_vulns == Some(true) {
|
||||
filter.insert("known_vulnerabilities.0", doc! { "$exists": true });
|
||||
}
|
||||
|
||||
let limit = cap_limit(params.limit);
|
||||
|
||||
let mut cursor = db
|
||||
.sbom_entries()
|
||||
.find(filter)
|
||||
.sort(doc! { "name": 1 })
|
||||
.limit(limit)
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
while cursor
|
||||
.advance()
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
|
||||
{
|
||||
let entry = cursor
|
||||
.deserialize_current()
|
||||
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
|
||||
results.push(entry);
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&results)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SbomVulnReportParams {
|
||||
/// Repository ID to generate vulnerability report for
|
||||
pub repo_id: String,
|
||||
}
|
||||
|
||||
pub async fn sbom_vuln_report(
|
||||
db: &Database,
|
||||
params: SbomVulnReportParams,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let filter = doc! {
|
||||
"repo_id": ¶ms.repo_id,
|
||||
"known_vulnerabilities.0": { "$exists": true },
|
||||
};
|
||||
|
||||
let mut cursor = db
|
||||
.sbom_entries()
|
||||
.find(filter)
|
||||
.sort(doc! { "name": 1 })
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
||||
|
||||
let mut vulnerable_packages = Vec::new();
|
||||
let mut total_vulns = 0u64;
|
||||
|
||||
while cursor
|
||||
.advance()
|
||||
.await
|
||||
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
|
||||
{
|
||||
let entry = cursor
|
||||
.deserialize_current()
|
||||
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?;
|
||||
total_vulns += entry.known_vulnerabilities.len() as u64;
|
||||
vulnerable_packages.push(serde_json::json!({
|
||||
"name": entry.name,
|
||||
"version": entry.version,
|
||||
"package_manager": entry.package_manager,
|
||||
"license": entry.license,
|
||||
"vulnerabilities": entry.known_vulnerabilities,
|
||||
}));
|
||||
}
|
||||
|
||||
let report = serde_json::json!({
|
||||
"repo_id": params.repo_id,
|
||||
"vulnerable_packages_count": vulnerable_packages.len(),
|
||||
"total_vulnerabilities": total_vulns,
|
||||
"packages": vulnerable_packages,
|
||||
});
|
||||
|
||||
let json = serde_json::to_string_pretty(&report)
|
||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
||||
|
||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export default defineConfig({
|
||||
{ text: 'DAST Scanning', link: '/features/dast' },
|
||||
{ text: 'AI Chat (RAG)', link: '/features/ai-chat' },
|
||||
{ text: 'Issue Tracker Integration', link: '/features/issues' },
|
||||
{ text: 'MCP Server', link: '/features/mcp-server' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -75,15 +75,6 @@ REDIRECT_URI=http://localhost:8080/auth/callback
|
||||
APP_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
## MCP Server
|
||||
|
||||
```bash
|
||||
MONGODB_URI=mongodb://root:example@localhost:27017/compliance_scanner?authSource=admin
|
||||
MONGODB_DATABASE=compliance_scanner
|
||||
# Set to enable HTTP transport (omit for stdio)
|
||||
MCP_PORT=8090
|
||||
```
|
||||
|
||||
## Observability
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
# MCP Server
|
||||
|
||||
The Model Context Protocol (MCP) server exposes compliance data to external LLMs and AI agents. Any MCP-compatible client — such as Claude, Cursor, or a custom agent — can connect and query findings, SBOM data, and DAST results without direct database access.
|
||||
|
||||
## How It Works
|
||||
|
||||
The `compliance-mcp` crate runs as a standalone service that connects to the same MongoDB database as the agent and dashboard. It registers a set of **tools** that LLM clients can discover and call through the MCP protocol.
|
||||
|
||||
```
|
||||
LLM Client ──MCP──▶ compliance-mcp ──MongoDB──▶ compliance_scanner DB
|
||||
```
|
||||
|
||||
The server supports two transport modes:
|
||||
|
||||
| Transport | Use Case | How to Enable |
|
||||
|-----------|----------|---------------|
|
||||
| **Stdio** | Local development, piped to a CLI tool | Default (no `MCP_PORT` set) |
|
||||
| **Streamable HTTP** | Remote deployment, multiple clients | Set `MCP_PORT=8090` |
|
||||
|
||||
## Available Tools
|
||||
|
||||
The MCP server exposes seven tools:
|
||||
|
||||
### Findings
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_findings` | Query findings with optional filters for repository, severity, status, and scan type. Returns up to 200 results (default 50). |
|
||||
| `get_finding` | Retrieve a single finding by its MongoDB ObjectId. |
|
||||
| `findings_summary` | Get finding counts grouped by severity and status, optionally filtered by repository. |
|
||||
|
||||
### SBOM
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_sbom_packages` | List SBOM packages with filters for repository, vulnerabilities, package manager, and license. |
|
||||
| `sbom_vuln_report` | Generate a vulnerability report for a repository showing all packages with known CVEs. |
|
||||
|
||||
### DAST
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_dast_findings` | Query DAST findings with filters for target, scan run, severity, exploitability, and vulnerability type. |
|
||||
| `dast_scan_summary` | Get a summary of recent DAST scan runs and finding counts. |
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Stdio Mode
|
||||
|
||||
Run the MCP server directly — it reads from stdin and writes to stdout:
|
||||
|
||||
```bash
|
||||
cd compliance-mcp
|
||||
cargo run
|
||||
```
|
||||
|
||||
Configure your MCP client to launch it as a subprocess. For example, in a Claude Code `mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"compliance": {
|
||||
"command": "cargo",
|
||||
"args": ["run", "-p", "compliance-mcp"],
|
||||
"cwd": "/path/to/compliance-scanner"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Mode
|
||||
|
||||
Set `MCP_PORT` to start the Streamable HTTP server:
|
||||
|
||||
```bash
|
||||
MCP_PORT=8090 cargo run -p compliance-mcp
|
||||
```
|
||||
|
||||
The server listens on `http://0.0.0.0:8090/mcp`. Point your MCP client to this endpoint.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `MONGODB_URI` | MongoDB connection string | `mongodb://localhost:27017` |
|
||||
| `MONGODB_DATABASE` | Database name | `compliance_scanner` |
|
||||
| `MCP_PORT` | Port for HTTP transport (omit for stdio) | — |
|
||||
| `RUST_LOG` | Log level filter | `compliance_mcp=info` |
|
||||
|
||||
Create a `.env` file in the project root or set these as environment variables.
|
||||
|
||||
## Deploying with Docker
|
||||
|
||||
The `Dockerfile.mcp` builds and runs the MCP server in HTTP mode on port 8090.
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.mcp -t compliance-mcp .
|
||||
docker run -p 8090:8090 \
|
||||
-e MONGODB_URI=mongodb://mongo:27017 \
|
||||
-e MONGODB_DATABASE=compliance_scanner \
|
||||
-e MCP_PORT=8090 \
|
||||
compliance-mcp
|
||||
```
|
||||
|
||||
### Coolify Deployment
|
||||
|
||||
1. Create a new service in your Coolify project
|
||||
2. Set the **Dockerfile path** to `Dockerfile.mcp`
|
||||
3. Set the **exposed port** to `8090`
|
||||
4. Add environment variables: `MONGODB_URI`, `MONGODB_DATABASE`, `MCP_PORT=8090`
|
||||
5. The MCP endpoint will be available at your configured domain under `/mcp`
|
||||
|
||||
The CI pipeline automatically deploys on changes to `compliance-core/`, `compliance-mcp/`, `Dockerfile.mcp`, or `Cargo.toml`/`Cargo.lock`. Add the `COOLIFY_WEBHOOK_MCP` secret to your Gitea repository.
|
||||
|
||||
## Managing MCP Servers in the Dashboard
|
||||
|
||||
Navigate to **MCP Servers** in the dashboard sidebar to:
|
||||
|
||||
- **Register** MCP server instances with their endpoint URL, transport type, port, and database connection
|
||||
- **View** server configuration, enabled tools, and status
|
||||
- **Manage access tokens** — reveal, copy, or regenerate bearer tokens for authentication
|
||||
- **Delete** servers that are no longer needed
|
||||
|
||||
Each registered server is assigned a random access token on creation. Use this token in your MCP client configuration for authenticated access.
|
||||
|
||||
## Example: Querying Findings from an LLM
|
||||
|
||||
Once connected, an LLM can call any of the registered tools. For example:
|
||||
|
||||
**"Show me all critical findings"** triggers `list_findings` with `severity: "critical"`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "list_findings",
|
||||
"arguments": {
|
||||
"severity": "critical",
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**"What vulnerable packages does repo X have?"** triggers `sbom_vuln_report`:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "sbom_vuln_report",
|
||||
"arguments": {
|
||||
"repo_id": "683abc..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
::: tip
|
||||
The MCP server is read-only — it only queries data from MongoDB. It cannot modify findings, trigger scans, or change configuration. This makes it safe to expose to external LLM clients.
|
||||
:::
|
||||
@@ -97,17 +97,6 @@ NVD_API_KEY=your-nvd-api-key
|
||||
|
||||
Get a free key at [https://nvd.nist.gov/developers/request-an-api-key](https://nvd.nist.gov/developers/request-an-api-key).
|
||||
|
||||
## MCP Server
|
||||
|
||||
The MCP server exposes compliance data to external LLMs via the Model Context Protocol. See [MCP Server](/features/mcp-server) for full details.
|
||||
|
||||
```bash
|
||||
# Set MCP_PORT to enable HTTP transport (omit for stdio mode)
|
||||
MCP_PORT=8090
|
||||
```
|
||||
|
||||
The MCP server shares the `MONGODB_URI` and `MONGODB_DATABASE` variables with the rest of the platform.
|
||||
|
||||
## Clone Path
|
||||
|
||||
Where the agent stores cloned repository files:
|
||||
@@ -150,4 +139,3 @@ GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos
|
||||
| `APP_URL` | No | — | Application root URL |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | No | — | OTLP collector endpoint |
|
||||
| `OTEL_SERVICE_NAME` | No | — | OpenTelemetry service name |
|
||||
| `MCP_PORT` | No | — | MCP HTTP transport port (omit for stdio) |
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user