Compare commits
11 Commits
fca0f93033
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8af810cdd2 | ||
|
|
a509bdcb2e | ||
| c461faa2fb | |||
|
|
11e1c5f438 | ||
|
|
77f1c92c7b | ||
| 4eac1209d8 | |||
|
|
584ef2c822 | ||
| a529e9af0c | |||
| 3bb690e5bb | |||
| acc5b86aa4 | |||
| 3ec1456b0d |
@@ -2,11 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -23,10 +21,11 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Code quality checks (run in parallel)
|
||||
# Stage 1: Lint, audit, and test (single job to share cargo cache)
|
||||
# ---------------------------------------------------------------------------
|
||||
fmt:
|
||||
name: Format
|
||||
check:
|
||||
name: Check
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: rust:1.94-bookworm
|
||||
@@ -37,105 +36,58 @@ jobs:
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- run: rustup component add rustfmt
|
||||
# Format check does not compile, so sccache is not needed here.
|
||||
- run: cargo fmt --all --check
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: docker
|
||||
container:
|
||||
image: rust:1.94-bookworm
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- name: Install sccache
|
||||
- name: Install tools
|
||||
run: |
|
||||
rustup component add rustfmt clippy
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
|
||||
chmod +x /usr/local/bin/sccache
|
||||
- run: rustup component add clippy
|
||||
# Lint the agent (native only).
|
||||
cargo install cargo-audit --locked
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
# Format (no compilation needed)
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
# Clippy (compiles once, sccache reuses across feature sets)
|
||||
- name: Clippy (agent)
|
||||
run: cargo clippy -p compliance-agent -- -D warnings
|
||||
# Lint the dashboard for both feature sets independently.
|
||||
# sccache deduplicates shared crates between the two compilations.
|
||||
- name: Clippy (dashboard server)
|
||||
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()
|
||||
|
||||
audit:
|
||||
name: Security Audit
|
||||
runs-on: docker
|
||||
if: github.ref == 'refs/heads/main'
|
||||
container:
|
||||
image: rust:1.94-bookworm
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- run: cargo install cargo-audit
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
- run: cargo audit
|
||||
# Security audit
|
||||
- name: Security Audit
|
||||
run: cargo audit
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Tests (only after all quality checks pass)
|
||||
# ---------------------------------------------------------------------------
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: docker
|
||||
needs: [fmt, clippy, audit]
|
||||
container:
|
||||
image: rust:1.94-bookworm
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
- name: Install sccache
|
||||
run: |
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
|
||||
chmod +x /usr/local/bin/sccache
|
||||
- name: Run tests (core + agent)
|
||||
# Tests (reuses compilation artifacts from clippy)
|
||||
- name: Tests (core + agent)
|
||||
run: cargo test -p compliance-core -p compliance-agent
|
||||
- name: Run tests (dashboard server)
|
||||
- name: Tests (dashboard server)
|
||||
run: cargo test -p compliance-dashboard --features server --no-default-features
|
||||
- name: Run tests (dashboard web)
|
||||
- name: Tests (dashboard web)
|
||||
run: cargo test -p compliance-dashboard --features web --no-default-features
|
||||
|
||||
- name: Show sccache stats
|
||||
run: sccache --show-stats
|
||||
if: always()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Deploy (only on main, after tests pass)
|
||||
# Stage 2: Deploy (only on main, after checks 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:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
.playwright-mcp/
|
||||
report-preview-full.png
|
||||
compliance-dashboard/attack-chain-final.html
|
||||
|
||||
439
Cargo.lock
generated
439
Cargo.lock
generated
@@ -2,6 +2,47 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -45,6 +86,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.8.2"
|
||||
@@ -391,6 +441,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
@@ -566,6 +635,16 @@ dependencies = [
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -580,13 +659,16 @@ dependencies = [
|
||||
name = "compliance-agent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"compliance-core",
|
||||
"compliance-dast",
|
||||
"compliance-graph",
|
||||
"dashmap",
|
||||
"dotenvy",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"git2",
|
||||
"hex",
|
||||
@@ -603,12 +685,15 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-cron-scheduler",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -674,18 +759,23 @@ dependencies = [
|
||||
name = "compliance-dast"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bollard",
|
||||
"bson",
|
||||
"chromiumoxide",
|
||||
"chrono",
|
||||
"compliance-core",
|
||||
"futures-util",
|
||||
"mongodb",
|
||||
"native-tls",
|
||||
"reqwest",
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -834,6 +924,12 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "content_disposition"
|
||||
version = "0.4.0"
|
||||
@@ -939,6 +1035,21 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
|
||||
dependencies = [
|
||||
"crc-catalog",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc-catalog"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -1010,6 +1121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -1036,6 +1148,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.21.3"
|
||||
@@ -1125,6 +1246,12 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1157,6 +1284,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -1976,6 +2114,16 @@ version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1994,6 +2142,21 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -2193,6 +2356,16 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git2"
|
||||
version = "0.20.4"
|
||||
@@ -2551,7 +2724,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2787,6 +2960,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.22"
|
||||
@@ -2824,15 +3006,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
@@ -3072,9 +3245,30 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.5"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
||||
checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-sys"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
@@ -3272,6 +3466,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -3361,7 +3565,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"typed-builder",
|
||||
"uuid",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3399,6 +3603,23 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe 0.2.1",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3578,6 +3799,38 @@ version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
@@ -3737,6 +3990,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3856,6 +4110,18 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -3949,7 +4215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.12.1",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -4260,7 +4526,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4806,6 +5072,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
@@ -4899,7 +5171,7 @@ version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -5116,7 +5388,7 @@ dependencies = [
|
||||
"fs4",
|
||||
"htmlescape",
|
||||
"hyperloglogplus",
|
||||
"itertools 0.14.0",
|
||||
"itertools",
|
||||
"levenshtein_automata",
|
||||
"log",
|
||||
"lru 0.12.5",
|
||||
@@ -5164,7 +5436,7 @@ checksum = "8b628488ae936c83e92b5c4056833054ca56f76c0e616aee8339e24ac89119cd"
|
||||
dependencies = [
|
||||
"downcast-rs",
|
||||
"fastdivide",
|
||||
"itertools 0.14.0",
|
||||
"itertools",
|
||||
"serde",
|
||||
"tantivy-bitpacker",
|
||||
"tantivy-common",
|
||||
@@ -5214,7 +5486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8292095d1a8a2c2b36380ec455f910ab52dde516af36321af332c93f20ab7d5"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"itertools 0.14.0",
|
||||
"itertools",
|
||||
"tantivy-bitpacker",
|
||||
"tantivy-common",
|
||||
"tantivy-fst",
|
||||
@@ -5428,6 +5700,16 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -5450,6 +5732,22 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite 0.26.2",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.27.0"
|
||||
@@ -5848,6 +6146,25 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.26.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.27.0"
|
||||
@@ -5959,6 +6276,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -6236,6 +6563,15 @@ dependencies = [
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
@@ -6805,6 +7141,15 @@ version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||
|
||||
[[package]]
|
||||
name = "xz2"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
|
||||
dependencies = [
|
||||
"lzma-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
@@ -6874,6 +7219,20 @@ name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -6908,12 +7267,54 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"deflate64",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"indexmap 2.13.0",
|
||||
"lzma-rs",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"xz2",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
@@ -29,3 +29,7 @@ hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
secrecy = { version = "0.10", features = ["serde"] }
|
||||
regex = "1"
|
||||
zip = { version = "2", features = ["aes-crypto", "deflate"] }
|
||||
dashmap = "6"
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
aes-gcm = "0.10"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/Rust-1.89-orange?logo=rust&logoColor=white" alt="Rust" /></a>
|
||||
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/Rust-1.94-orange?logo=rust&logoColor=white" alt="Rust" /></a>
|
||||
<a href="https://dioxuslabs.com/"><img src="https://img.shields.io/badge/Dioxus-0.7-blue?logo=webassembly&logoColor=white" alt="Dioxus" /></a>
|
||||
<a href="https://www.mongodb.com/"><img src="https://img.shields.io/badge/MongoDB-8.0-47A248?logo=mongodb&logoColor=white" alt="MongoDB" /></a>
|
||||
<a href="https://axum.rs/"><img src="https://img.shields.io/badge/Axum-0.8-4A4A55?logo=rust&logoColor=white" alt="Axum" /></a>
|
||||
@@ -94,7 +94,7 @@ Compliance Scanner is an autonomous agent that continuously monitors git reposit
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.89+
|
||||
- Rust 1.94+
|
||||
- [Dioxus CLI](https://dioxuslabs.com/learn/0.7/getting_started) (`dx`)
|
||||
- MongoDB
|
||||
- Docker & Docker Compose (optional)
|
||||
|
||||
@@ -441,6 +441,8 @@ tr:hover {
|
||||
padding: 24px;
|
||||
max-width: 440px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-dialog h3 {
|
||||
|
||||
@@ -36,3 +36,9 @@ base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
futures-util = "0.3"
|
||||
jsonwebtoken = "9"
|
||||
zip = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-core = "0.3"
|
||||
dashmap = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use tokio::sync::{broadcast, watch, Semaphore};
|
||||
|
||||
use compliance_core::models::pentest::PentestEvent;
|
||||
use compliance_core::AgentConfig;
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::llm::LlmClient;
|
||||
use crate::pipeline::orchestrator::PipelineOrchestrator;
|
||||
|
||||
/// Default maximum concurrent pentest sessions.
|
||||
const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 5;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ComplianceAgent {
|
||||
pub config: AgentConfig,
|
||||
pub db: Database,
|
||||
pub llm: Arc<LlmClient>,
|
||||
pub http: reqwest::Client,
|
||||
/// Per-session broadcast senders for SSE streaming.
|
||||
pub session_streams: Arc<DashMap<String, broadcast::Sender<PentestEvent>>>,
|
||||
/// Per-session pause controls (true = paused).
|
||||
pub session_pause: Arc<DashMap<String, watch::Sender<bool>>>,
|
||||
/// Semaphore limiting concurrent pentest sessions.
|
||||
pub session_semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl ComplianceAgent {
|
||||
@@ -27,6 +40,9 @@ impl ComplianceAgent {
|
||||
db,
|
||||
llm,
|
||||
http: reqwest::Client::new(),
|
||||
session_streams: Arc::new(DashMap::new()),
|
||||
session_pause: Arc::new(DashMap::new()),
|
||||
session_semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_SESSIONS)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,4 +90,54 @@ impl ComplianceAgent {
|
||||
.run_pr_review(&repo, repo_id, pr_number, base_sha, head_sha)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Session stream management ──────────────────────────────────
|
||||
|
||||
/// Register a broadcast sender for a session. Returns the sender.
|
||||
pub fn register_session_stream(&self, session_id: &str) -> broadcast::Sender<PentestEvent> {
|
||||
let (tx, _) = broadcast::channel(256);
|
||||
self.session_streams
|
||||
.insert(session_id.to_string(), tx.clone());
|
||||
tx
|
||||
}
|
||||
|
||||
/// Subscribe to a session's broadcast stream.
|
||||
pub fn subscribe_session(&self, session_id: &str) -> Option<broadcast::Receiver<PentestEvent>> {
|
||||
self.session_streams
|
||||
.get(session_id)
|
||||
.map(|tx| tx.subscribe())
|
||||
}
|
||||
|
||||
// ── Session pause/resume management ────────────────────────────
|
||||
|
||||
/// Register a pause control for a session. Returns the watch receiver.
|
||||
pub fn register_pause_control(&self, session_id: &str) -> watch::Receiver<bool> {
|
||||
let (tx, rx) = watch::channel(false);
|
||||
self.session_pause.insert(session_id.to_string(), tx);
|
||||
rx
|
||||
}
|
||||
|
||||
/// Pause a session.
|
||||
pub fn pause_session(&self, session_id: &str) -> bool {
|
||||
if let Some(tx) = self.session_pause.get(session_id) {
|
||||
tx.send(true).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume a session.
|
||||
pub fn resume_session(&self, session_id: &str) -> bool {
|
||||
if let Some(tx) = self.session_pause.get(session_id) {
|
||||
tx.send(false).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up all per-session resources.
|
||||
pub fn cleanup_session(&self, session_id: &str) {
|
||||
self.session_streams.remove(session_id);
|
||||
self.session_pause.remove(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
481
compliance-agent/src/api/handlers/dto.rs
Normal file
481
compliance-agent/src/api/handlers/dto.rs
Normal file
@@ -0,0 +1,481 @@
|
||||
use compliance_core::models::TrackerType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use compliance_core::models::ScanRun;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PaginationParams {
|
||||
#[serde(default = "default_page")]
|
||||
pub page: u64,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
pub(crate) fn default_page() -> u64 {
|
||||
1
|
||||
}
|
||||
pub(crate) fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FindingsFilter {
|
||||
#[serde(default)]
|
||||
pub repo_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub severity: Option<String>,
|
||||
#[serde(default)]
|
||||
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")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApiResponse<T: Serialize> {
|
||||
pub data: T,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OverviewStats {
|
||||
pub total_repositories: u64,
|
||||
pub total_findings: u64,
|
||||
pub critical_findings: u64,
|
||||
pub high_findings: u64,
|
||||
pub medium_findings: u64,
|
||||
pub low_findings: u64,
|
||||
pub total_sbom_entries: u64,
|
||||
pub total_cve_alerts: u64,
|
||||
pub total_issues: u64,
|
||||
pub recent_scans: Vec<ScanRun>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddRepositoryRequest {
|
||||
pub name: String,
|
||||
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>,
|
||||
pub tracker_token: Option<String>,
|
||||
pub scan_schedule: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRepositoryRequest {
|
||||
pub name: Option<String>,
|
||||
pub default_branch: Option<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>,
|
||||
pub tracker_token: Option<String>,
|
||||
pub scan_schedule: Option<String>,
|
||||
}
|
||||
|
||||
fn default_branch() -> String {
|
||||
"main".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
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)]
|
||||
pub repo_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub package_manager: Option<String>,
|
||||
#[serde(default)]
|
||||
pub q: Option<String>,
|
||||
#[serde(default)]
|
||||
pub has_vulns: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub license: Option<String>,
|
||||
#[serde(default = "default_page")]
|
||||
pub page: u64,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SbomExportParams {
|
||||
pub repo_id: String,
|
||||
#[serde(default = "default_export_format")]
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
fn default_export_format() -> String {
|
||||
"cyclonedx".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SbomDiffParams {
|
||||
pub repo_a: String,
|
||||
pub repo_b: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LicenseSummary {
|
||||
pub license: String,
|
||||
pub count: u64,
|
||||
pub is_copyleft: bool,
|
||||
pub packages: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SbomDiffResult {
|
||||
pub only_in_a: Vec<SbomDiffEntry>,
|
||||
pub only_in_b: Vec<SbomDiffEntry>,
|
||||
pub version_changed: Vec<SbomVersionDiff>,
|
||||
pub common_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SbomDiffEntry {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub package_manager: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SbomVersionDiff {
|
||||
pub name: String,
|
||||
pub package_manager: String,
|
||||
pub version_a: String,
|
||||
pub version_b: String,
|
||||
}
|
||||
|
||||
pub(crate) type AgentExt = axum::extract::Extension<std::sync::Arc<crate::agent::ComplianceAgent>>;
|
||||
pub(crate) type ApiResult<T> = Result<axum::Json<ApiResponse<T>>, axum::http::StatusCode>;
|
||||
|
||||
pub(crate) async fn collect_cursor_async<T: serde::de::DeserializeOwned + Unpin + Send>(
|
||||
mut cursor: mongodb::Cursor<T>,
|
||||
) -> Vec<T> {
|
||||
use futures_util::StreamExt;
|
||||
let mut items = Vec::new();
|
||||
while let Some(result) = cursor.next().await {
|
||||
match result {
|
||||
Ok(item) => items.push(item),
|
||||
Err(e) => tracing::warn!("Failed to deserialize document: {e}"),
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── PaginationParams ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pagination_params_defaults() {
|
||||
let p: PaginationParams = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(p.page, 1);
|
||||
assert_eq!(p.limit, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_params_custom_values() {
|
||||
let p: PaginationParams = serde_json::from_str(r#"{"page":3,"limit":10}"#).unwrap();
|
||||
assert_eq!(p.page, 3);
|
||||
assert_eq!(p.limit, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_params_partial_override() {
|
||||
let p: PaginationParams = serde_json::from_str(r#"{"page":5}"#).unwrap();
|
||||
assert_eq!(p.page, 5);
|
||||
assert_eq!(p.limit, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagination_params_zero_page() {
|
||||
let p: PaginationParams = serde_json::from_str(r#"{"page":0}"#).unwrap();
|
||||
assert_eq!(p.page, 0);
|
||||
}
|
||||
|
||||
// ── FindingsFilter ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn findings_filter_all_defaults() {
|
||||
let f: FindingsFilter = serde_json::from_str("{}").unwrap();
|
||||
assert!(f.repo_id.is_none());
|
||||
assert!(f.severity.is_none());
|
||||
assert!(f.scan_type.is_none());
|
||||
assert!(f.status.is_none());
|
||||
assert!(f.q.is_none());
|
||||
assert!(f.sort_by.is_none());
|
||||
assert!(f.sort_order.is_none());
|
||||
assert_eq!(f.page, 1);
|
||||
assert_eq!(f.limit, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn findings_filter_with_all_fields() {
|
||||
let f: FindingsFilter = serde_json::from_str(
|
||||
r#"{
|
||||
"repo_id": "abc",
|
||||
"severity": "high",
|
||||
"scan_type": "sast",
|
||||
"status": "open",
|
||||
"q": "sql injection",
|
||||
"sort_by": "severity",
|
||||
"sort_order": "desc",
|
||||
"page": 2,
|
||||
"limit": 25
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(f.repo_id.as_deref(), Some("abc"));
|
||||
assert_eq!(f.severity.as_deref(), Some("high"));
|
||||
assert_eq!(f.scan_type.as_deref(), Some("sast"));
|
||||
assert_eq!(f.status.as_deref(), Some("open"));
|
||||
assert_eq!(f.q.as_deref(), Some("sql injection"));
|
||||
assert_eq!(f.sort_by.as_deref(), Some("severity"));
|
||||
assert_eq!(f.sort_order.as_deref(), Some("desc"));
|
||||
assert_eq!(f.page, 2);
|
||||
assert_eq!(f.limit, 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn findings_filter_empty_string_fields() {
|
||||
let f: FindingsFilter = serde_json::from_str(r#"{"repo_id":"","severity":""}"#).unwrap();
|
||||
assert_eq!(f.repo_id.as_deref(), Some(""));
|
||||
assert_eq!(f.severity.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
// ── ApiResponse ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn api_response_serializes_with_all_fields() {
|
||||
let resp = ApiResponse {
|
||||
data: vec!["a", "b"],
|
||||
total: Some(100),
|
||||
page: Some(1),
|
||||
};
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(v["data"], json!(["a", "b"]));
|
||||
assert_eq!(v["total"], 100);
|
||||
assert_eq!(v["page"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_response_skips_none_fields() {
|
||||
let resp = ApiResponse {
|
||||
data: "hello",
|
||||
total: None,
|
||||
page: None,
|
||||
};
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(v["data"], "hello");
|
||||
assert!(v.get("total").is_none());
|
||||
assert!(v.get("page").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_response_with_nested_struct() {
|
||||
#[derive(Serialize)]
|
||||
struct Item {
|
||||
id: u32,
|
||||
}
|
||||
let resp = ApiResponse {
|
||||
data: Item { id: 42 },
|
||||
total: Some(1),
|
||||
page: None,
|
||||
};
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
assert_eq!(v["data"]["id"], 42);
|
||||
assert_eq!(v["total"], 1);
|
||||
assert!(v.get("page").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_response_empty_vec() {
|
||||
let resp: ApiResponse<Vec<String>> = ApiResponse {
|
||||
data: vec![],
|
||||
total: Some(0),
|
||||
page: Some(1),
|
||||
};
|
||||
let v = serde_json::to_value(&resp).unwrap();
|
||||
assert!(v["data"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ── SbomFilter ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sbom_filter_defaults() {
|
||||
let f: SbomFilter = serde_json::from_str("{}").unwrap();
|
||||
assert!(f.repo_id.is_none());
|
||||
assert!(f.package_manager.is_none());
|
||||
assert!(f.q.is_none());
|
||||
assert!(f.has_vulns.is_none());
|
||||
assert!(f.license.is_none());
|
||||
assert_eq!(f.page, 1);
|
||||
assert_eq!(f.limit, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbom_filter_has_vulns_bool() {
|
||||
let f: SbomFilter = serde_json::from_str(r#"{"has_vulns": true}"#).unwrap();
|
||||
assert_eq!(f.has_vulns, Some(true));
|
||||
}
|
||||
|
||||
// ── SbomExportParams ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sbom_export_params_default_format() {
|
||||
let p: SbomExportParams = serde_json::from_str(r#"{"repo_id":"r1"}"#).unwrap();
|
||||
assert_eq!(p.repo_id, "r1");
|
||||
assert_eq!(p.format, "cyclonedx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbom_export_params_custom_format() {
|
||||
let p: SbomExportParams =
|
||||
serde_json::from_str(r#"{"repo_id":"r1","format":"spdx"}"#).unwrap();
|
||||
assert_eq!(p.format, "spdx");
|
||||
}
|
||||
|
||||
// ── AddRepositoryRequest ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn add_repository_request_defaults() {
|
||||
let r: AddRepositoryRequest = serde_json::from_str(
|
||||
r#"{
|
||||
"name": "my-repo",
|
||||
"git_url": "https://github.com/x/y.git"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.name, "my-repo");
|
||||
assert_eq!(r.git_url, "https://github.com/x/y.git");
|
||||
assert_eq!(r.default_branch, "main");
|
||||
assert!(r.auth_token.is_none());
|
||||
assert!(r.tracker_type.is_none());
|
||||
assert!(r.scan_schedule.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_repository_request_custom_branch() {
|
||||
let r: AddRepositoryRequest = serde_json::from_str(
|
||||
r#"{
|
||||
"name": "repo",
|
||||
"git_url": "url",
|
||||
"default_branch": "develop"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.default_branch, "develop");
|
||||
}
|
||||
|
||||
// ── UpdateStatusRequest / BulkUpdateStatusRequest ────────────
|
||||
|
||||
#[test]
|
||||
fn update_status_request() {
|
||||
let r: UpdateStatusRequest = serde_json::from_str(r#"{"status":"resolved"}"#).unwrap();
|
||||
assert_eq!(r.status, "resolved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_update_status_request() {
|
||||
let r: BulkUpdateStatusRequest =
|
||||
serde_json::from_str(r#"{"ids":["a","b"],"status":"dismissed"}"#).unwrap();
|
||||
assert_eq!(r.ids, vec!["a", "b"]);
|
||||
assert_eq!(r.status, "dismissed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_update_status_empty_ids() {
|
||||
let r: BulkUpdateStatusRequest =
|
||||
serde_json::from_str(r#"{"ids":[],"status":"x"}"#).unwrap();
|
||||
assert!(r.ids.is_empty());
|
||||
}
|
||||
|
||||
// ── SbomDiffResult serialization ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sbom_diff_result_serializes() {
|
||||
let r = SbomDiffResult {
|
||||
only_in_a: vec![SbomDiffEntry {
|
||||
name: "pkg-a".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
package_manager: "npm".to_string(),
|
||||
}],
|
||||
only_in_b: vec![],
|
||||
version_changed: vec![SbomVersionDiff {
|
||||
name: "shared".to_string(),
|
||||
package_manager: "cargo".to_string(),
|
||||
version_a: "0.1".to_string(),
|
||||
version_b: "0.2".to_string(),
|
||||
}],
|
||||
common_count: 10,
|
||||
};
|
||||
let v = serde_json::to_value(&r).unwrap();
|
||||
assert_eq!(v["only_in_a"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["only_in_b"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(v["version_changed"][0]["version_a"], "0.1");
|
||||
assert_eq!(v["common_count"], 10);
|
||||
}
|
||||
|
||||
// ── LicenseSummary ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn license_summary_serializes() {
|
||||
let ls = LicenseSummary {
|
||||
license: "MIT".to_string(),
|
||||
count: 42,
|
||||
is_copyleft: false,
|
||||
packages: vec!["serde".to_string()],
|
||||
};
|
||||
let v = serde_json::to_value(&ls).unwrap();
|
||||
assert_eq!(v["license"], "MIT");
|
||||
assert_eq!(v["is_copyleft"], false);
|
||||
assert_eq!(v["count"], 42);
|
||||
}
|
||||
|
||||
// ── Default helper functions ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_page_returns_1() {
|
||||
assert_eq!(default_page(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_limit_returns_50() {
|
||||
assert_eq!(default_limit(), 50);
|
||||
}
|
||||
}
|
||||
172
compliance-agent/src/api/handlers/findings.rs
Normal file
172
compliance-agent/src/api/handlers/findings.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::Finding;
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = ?filter.repo_id, severity = ?filter.severity, scan_type = ?filter.scan_type))]
|
||||
pub async fn list_findings(
|
||||
Extension(agent): AgentExt,
|
||||
Query(filter): Query<FindingsFilter>,
|
||||
) -> ApiResult<Vec<Finding>> {
|
||||
let db = &agent.db;
|
||||
let mut query = doc! {};
|
||||
if let Some(repo_id) = &filter.repo_id {
|
||||
query.insert("repo_id", repo_id);
|
||||
}
|
||||
if let Some(severity) = &filter.severity {
|
||||
query.insert("severity", severity);
|
||||
}
|
||||
if let Some(scan_type) = &filter.scan_type {
|
||||
query.insert("scan_type", scan_type);
|
||||
}
|
||||
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
|
||||
.findings()
|
||||
.count_documents(query.clone())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let findings = match db
|
||||
.findings()
|
||||
.find(query)
|
||||
.sort(sort_doc)
|
||||
.skip(skip)
|
||||
.limit(filter.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch findings: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: findings,
|
||||
total: Some(total),
|
||||
page: Some(filter.page),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(finding_id = %id))]
|
||||
pub async fn get_finding(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Finding>>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let finding = agent
|
||||
.db
|
||||
.findings()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: finding,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(finding_id = %id))]
|
||||
pub async fn update_finding_status(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateStatusRequest>,
|
||||
) -> 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": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } },
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "updated" })))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
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 }),
|
||||
))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
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" })))
|
||||
}
|
||||
84
compliance-agent/src/api/handlers/health.rs
Normal file
84
compliance-agent/src/api/handlers/health.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::ScanRun;
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn stats_overview(axum::extract::Extension(agent): AgentExt) -> ApiResult<OverviewStats> {
|
||||
let db = &agent.db;
|
||||
|
||||
let total_repositories = db
|
||||
.repositories()
|
||||
.count_documents(doc! {})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let total_findings = db.findings().count_documents(doc! {}).await.unwrap_or(0);
|
||||
let critical_findings = db
|
||||
.findings()
|
||||
.count_documents(doc! { "severity": "critical" })
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let high_findings = db
|
||||
.findings()
|
||||
.count_documents(doc! { "severity": "high" })
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let medium_findings = db
|
||||
.findings()
|
||||
.count_documents(doc! { "severity": "medium" })
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let low_findings = db
|
||||
.findings()
|
||||
.count_documents(doc! { "severity": "low" })
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let total_sbom_entries = db
|
||||
.sbom_entries()
|
||||
.count_documents(doc! {})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let total_cve_alerts = db.cve_alerts().count_documents(doc! {}).await.unwrap_or(0);
|
||||
let total_issues = db
|
||||
.tracker_issues()
|
||||
.count_documents(doc! {})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let recent_scans: Vec<ScanRun> = match db
|
||||
.scan_runs()
|
||||
.find(doc! {})
|
||||
.sort(doc! { "started_at": -1 })
|
||||
.limit(10)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch recent scans: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: OverviewStats {
|
||||
total_repositories,
|
||||
total_findings,
|
||||
critical_findings,
|
||||
high_findings,
|
||||
medium_findings,
|
||||
low_findings,
|
||||
total_sbom_entries,
|
||||
total_cve_alerts,
|
||||
total_issues,
|
||||
recent_scans,
|
||||
},
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
41
compliance-agent/src/api/handlers/issues.rs
Normal file
41
compliance-agent/src/api/handlers/issues.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::TrackerIssue;
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_issues(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> ApiResult<Vec<TrackerIssue>> {
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.tracker_issues()
|
||||
.count_documents(doc! {})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let issues = match db
|
||||
.tracker_issues()
|
||||
.find(doc! {})
|
||||
.sort(doc! { "created_at": -1 })
|
||||
.skip(skip)
|
||||
.limit(params.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch tracker issues: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: issues,
|
||||
total: Some(total),
|
||||
page: Some(params.page),
|
||||
}))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
230
compliance-agent/src/api/handlers/pentest_handlers/export.rs
Normal file
230
compliance-agent/src/api/handlers/pentest_handlers/export.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
use serde::Deserialize;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::*;
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
use super::super::dto::collect_cursor_async;
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExportBody {
|
||||
pub password: String,
|
||||
/// Requester display name (from auth)
|
||||
#[serde(default)]
|
||||
pub requester_name: String,
|
||||
/// Requester email (from auth)
|
||||
#[serde(default)]
|
||||
pub requester_email: String,
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions/:id/export — Export an encrypted pentest report archive
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn export_session_report(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<ExportBody>,
|
||||
) -> Result<axum::response::Response, (StatusCode, String)> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
|
||||
|
||||
if body.password.len() < 8 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Password must be at least 8 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch session
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?;
|
||||
|
||||
// Resolve target name
|
||||
let target = if let Ok(tid) = mongodb::bson::oid::ObjectId::parse_str(&session.target_id) {
|
||||
agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": tid })
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let target_name = target
|
||||
.as_ref()
|
||||
.map(|t| t.name.clone())
|
||||
.unwrap_or_else(|| "Unknown Target".to_string());
|
||||
let target_url = target
|
||||
.as_ref()
|
||||
.map(|t| t.base_url.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Fetch attack chain nodes
|
||||
let nodes: Vec<AttackChainNode> = match agent
|
||||
.db
|
||||
.attack_chain_nodes()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "started_at": 1 })
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// Fetch DAST findings for this session
|
||||
let findings: Vec<DastFinding> = match agent
|
||||
.db
|
||||
.dast_findings()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "severity": -1, "created_at": -1 })
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// Fetch SAST findings, SBOM, and code context for the linked repository
|
||||
let repo_id = session
|
||||
.repo_id
|
||||
.clone()
|
||||
.or_else(|| target.as_ref().and_then(|t| t.repo_id.clone()));
|
||||
|
||||
let (sast_findings, sbom_entries, code_context) = if let Some(ref rid) = repo_id {
|
||||
let sast: Vec<Finding> = match agent
|
||||
.db
|
||||
.findings()
|
||||
.find(doc! {
|
||||
"repo_id": rid,
|
||||
"status": { "$in": ["open", "triaged"] },
|
||||
})
|
||||
.sort(doc! { "severity": -1 })
|
||||
.limit(100)
|
||||
.await
|
||||
{
|
||||
Ok(mut cursor) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(Ok(f)) = cursor.next().await {
|
||||
results.push(f);
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let sbom: Vec<SbomEntry> = match agent
|
||||
.db
|
||||
.sbom_entries()
|
||||
.find(doc! {
|
||||
"repo_id": rid,
|
||||
"known_vulnerabilities": { "$exists": true, "$ne": [] },
|
||||
})
|
||||
.limit(50)
|
||||
.await
|
||||
{
|
||||
Ok(mut cursor) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(Ok(e)) = cursor.next().await {
|
||||
results.push(e);
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// Build code context from graph nodes
|
||||
let code_ctx: Vec<CodeContextHint> = match agent
|
||||
.db
|
||||
.graph_nodes()
|
||||
.find(doc! { "repo_id": rid, "is_entry_point": true })
|
||||
.limit(50)
|
||||
.await
|
||||
{
|
||||
Ok(mut cursor) => {
|
||||
let mut nodes_vec = Vec::new();
|
||||
while let Some(Ok(n)) = cursor.next().await {
|
||||
let linked_vulns: Vec<String> = sast
|
||||
.iter()
|
||||
.filter(|f| f.file_path.as_deref() == Some(&n.file_path))
|
||||
.map(|f| {
|
||||
format!(
|
||||
"[{}] {}: {} (line {})",
|
||||
f.severity,
|
||||
f.scanner,
|
||||
f.title,
|
||||
f.line_number.unwrap_or(0)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
nodes_vec.push(CodeContextHint {
|
||||
endpoint_pattern: n.qualified_name.clone(),
|
||||
handler_function: n.name.clone(),
|
||||
file_path: n.file_path.clone(),
|
||||
code_snippet: String::new(),
|
||||
known_vulnerabilities: linked_vulns,
|
||||
});
|
||||
}
|
||||
nodes_vec
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
(sast, sbom, code_ctx)
|
||||
} else {
|
||||
(Vec::new(), Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
let config = session.config.clone();
|
||||
let ctx = crate::pentest::report::ReportContext {
|
||||
session,
|
||||
target_name,
|
||||
target_url,
|
||||
findings,
|
||||
attack_chain: nodes,
|
||||
requester_name: if body.requester_name.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
body.requester_name
|
||||
},
|
||||
requester_email: body.requester_email,
|
||||
config,
|
||||
sast_findings,
|
||||
sbom_entries,
|
||||
code_context,
|
||||
};
|
||||
|
||||
let report = crate::pentest::generate_encrypted_report(&ctx, &body.password)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
let response = serde_json::json!({
|
||||
"archive_base64": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &report.archive),
|
||||
"sha256": report.sha256,
|
||||
"filename": format!("pentest-report-{id}.zip"),
|
||||
});
|
||||
|
||||
Ok(Json(response).into_response())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod export;
|
||||
mod session;
|
||||
mod stats;
|
||||
mod stream;
|
||||
|
||||
pub use export::*;
|
||||
pub use session::*;
|
||||
pub use stats::*;
|
||||
pub use stream::*;
|
||||
834
compliance-agent/src/api/handlers/pentest_handlers/session.rs
Normal file
834
compliance-agent/src/api/handlers/pentest_handlers/session.rs
Normal file
@@ -0,0 +1,834 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
use serde::Deserialize;
|
||||
|
||||
use compliance_core::models::pentest::*;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
use crate::pentest::PentestOrchestrator;
|
||||
|
||||
use super::super::dto::{collect_cursor_async, ApiResponse, PaginationParams};
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSessionRequest {
|
||||
pub target_id: Option<String>,
|
||||
#[serde(default = "default_strategy")]
|
||||
pub strategy: String,
|
||||
pub message: Option<String>,
|
||||
/// Wizard configuration — if present, takes precedence over legacy fields
|
||||
pub config: Option<PentestConfig>,
|
||||
}
|
||||
|
||||
fn default_strategy() -> String {
|
||||
"comprehensive".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SendMessageRequest {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LookupRepoQuery {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions — Create a new pentest session and start the orchestrator
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn create_session(
|
||||
Extension(agent): AgentExt,
|
||||
Json(req): Json<CreateSessionRequest>,
|
||||
) -> Result<Json<ApiResponse<PentestSession>>, (StatusCode, String)> {
|
||||
// Try to acquire a concurrency permit
|
||||
let permit = agent
|
||||
.session_semaphore
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Maximum concurrent pentest sessions reached. Try again later.".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(ref config) = req.config {
|
||||
// ── Wizard path ──────────────────────────────────────────────
|
||||
if !config.disclaimer_accepted {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Disclaimer must be accepted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Look up or auto-create DastTarget by app_url
|
||||
let target = match agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "base_url": &config.app_url })
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||
{
|
||||
Some(t) => t,
|
||||
None => {
|
||||
use compliance_core::models::dast::{DastTarget, DastTargetType};
|
||||
let mut t = DastTarget::new(
|
||||
config.app_url.clone(),
|
||||
config.app_url.clone(),
|
||||
DastTargetType::WebApp,
|
||||
);
|
||||
if let Some(rl) = config.rate_limit {
|
||||
t.rate_limit = rl;
|
||||
}
|
||||
t.allow_destructive = config.allow_destructive;
|
||||
t.excluded_paths = config.scope_exclusions.clone();
|
||||
let res = agent.db.dast_targets().insert_one(&t).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create target: {e}"),
|
||||
)
|
||||
})?;
|
||||
t.id = res.inserted_id.as_object_id();
|
||||
t
|
||||
}
|
||||
};
|
||||
|
||||
let target_id = target.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
|
||||
// Parse strategy from config or request
|
||||
let strat_str = config.strategy.as_deref().unwrap_or(req.strategy.as_str());
|
||||
let strategy = parse_strategy(strat_str);
|
||||
|
||||
let mut session = PentestSession::new(target_id, strategy);
|
||||
session.config = Some(config.clone());
|
||||
session.repo_id = target.repo_id.clone();
|
||||
|
||||
// Resolve repo_id from git_repo_url if provided
|
||||
if let Some(ref git_url) = config.git_repo_url {
|
||||
if let Ok(Some(repo)) = agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "git_url": git_url })
|
||||
.await
|
||||
{
|
||||
session.repo_id = repo.id.map(|oid| oid.to_hex());
|
||||
}
|
||||
}
|
||||
|
||||
let insert_result = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create session: {e}"),
|
||||
)
|
||||
})?;
|
||||
session.id = insert_result.inserted_id.as_object_id();
|
||||
|
||||
let session_id_str = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
|
||||
// Register broadcast stream and pause control
|
||||
let event_tx = agent.register_session_stream(&session_id_str);
|
||||
let pause_rx = agent.register_pause_control(&session_id_str);
|
||||
|
||||
// Merge server-default IMAP/email settings where wizard left blanks
|
||||
if let Some(ref mut cfg) = session.config {
|
||||
if cfg.auth.mode == AuthMode::AutoRegister {
|
||||
if cfg.auth.verification_email.is_none() {
|
||||
cfg.auth.verification_email = agent.config.pentest_verification_email.clone();
|
||||
}
|
||||
if cfg.auth.imap_host.is_none() {
|
||||
cfg.auth.imap_host = agent.config.pentest_imap_host.clone();
|
||||
}
|
||||
if cfg.auth.imap_port.is_none() {
|
||||
cfg.auth.imap_port = agent.config.pentest_imap_port;
|
||||
}
|
||||
if cfg.auth.imap_username.is_none() {
|
||||
cfg.auth.imap_username = agent.config.pentest_imap_username.clone();
|
||||
}
|
||||
if cfg.auth.imap_password.is_none() {
|
||||
cfg.auth.imap_password = agent.config.pentest_imap_password.as_ref().map(|s| {
|
||||
use secrecy::ExposeSecret;
|
||||
s.expose_secret().to_string()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-populate test user record for auto-register sessions
|
||||
if let Some(ref cfg) = session.config {
|
||||
if cfg.auth.mode == AuthMode::AutoRegister {
|
||||
let verification_email = cfg.auth.verification_email.clone();
|
||||
// Build plus-addressed email for this session
|
||||
let test_email = verification_email.as_deref().map(|email| {
|
||||
let parts: Vec<&str> = email.splitn(2, '@').collect();
|
||||
if parts.len() == 2 {
|
||||
format!("{}+{}@{}", parts[0], session_id_str, parts[1])
|
||||
} else {
|
||||
email.to_string()
|
||||
}
|
||||
});
|
||||
|
||||
// Detect identity provider from keycloak config
|
||||
let provider = if agent.config.keycloak_url.is_some() {
|
||||
Some(compliance_core::models::pentest::IdentityProvider::Keycloak)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
session.test_user = Some(compliance_core::models::pentest::TestUserRecord {
|
||||
username: None, // LLM will choose; updated after registration
|
||||
email: test_email,
|
||||
provider_user_id: None,
|
||||
provider,
|
||||
cleaned_up: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt credentials before they linger in memory
|
||||
let mut session_for_task = session.clone();
|
||||
if let Some(ref mut cfg) = session_for_task.config {
|
||||
cfg.auth.username = cfg
|
||||
.auth
|
||||
.username
|
||||
.as_ref()
|
||||
.map(|u| crate::pentest::crypto::encrypt(u));
|
||||
cfg.auth.password = cfg
|
||||
.auth
|
||||
.password
|
||||
.as_ref()
|
||||
.map(|p| crate::pentest::crypto::encrypt(p));
|
||||
}
|
||||
|
||||
// Persist encrypted credentials to DB
|
||||
if session_for_task.config.is_some() {
|
||||
if let Some(sid) = session.id {
|
||||
let _ = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": sid },
|
||||
doc! { "$set": {
|
||||
"config.auth.username": session_for_task.config.as_ref()
|
||||
.and_then(|c| c.auth.username.as_deref())
|
||||
.map(|s| mongodb::bson::Bson::String(s.to_string()))
|
||||
.unwrap_or(mongodb::bson::Bson::Null),
|
||||
"config.auth.password": session_for_task.config.as_ref()
|
||||
.and_then(|c| c.auth.password.as_deref())
|
||||
.map(|s| mongodb::bson::Bson::String(s.to_string()))
|
||||
.unwrap_or(mongodb::bson::Bson::Null),
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let initial_message = config
|
||||
.initial_instructions
|
||||
.clone()
|
||||
.or(req.message.clone())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Begin a {} penetration test against {} ({}). \
|
||||
Identify vulnerabilities and provide evidence for each finding.",
|
||||
session.strategy, target.name, target.base_url,
|
||||
)
|
||||
});
|
||||
|
||||
let llm = agent.llm.clone();
|
||||
let db = agent.db.clone();
|
||||
let session_clone = session.clone();
|
||||
let target_clone = target.clone();
|
||||
let agent_ref = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, Some(pause_rx));
|
||||
orchestrator
|
||||
.run_session_guarded(&session_clone, &target_clone, &initial_message)
|
||||
.await;
|
||||
// Clean up session resources
|
||||
agent_ref.cleanup_session(&session_id_str);
|
||||
// Release concurrency permit
|
||||
drop(permit);
|
||||
});
|
||||
|
||||
// Redact credentials in response
|
||||
let mut response_session = session;
|
||||
if let Some(ref mut cfg) = response_session.config {
|
||||
if cfg.auth.username.is_some() {
|
||||
cfg.auth.username = Some("********".to_string());
|
||||
}
|
||||
if cfg.auth.password.is_some() {
|
||||
cfg.auth.password = Some("********".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: response_session,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
} else {
|
||||
// ── Legacy path ──────────────────────────────────────────────
|
||||
let target_id = req.target_id.clone().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"target_id is required for legacy creation".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&target_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid target_id format".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let target = agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Target not found".to_string()))?;
|
||||
|
||||
let strategy = parse_strategy(&req.strategy);
|
||||
|
||||
let mut session = PentestSession::new(target_id, strategy);
|
||||
session.repo_id = target.repo_id.clone();
|
||||
|
||||
let insert_result = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create session: {e}"),
|
||||
)
|
||||
})?;
|
||||
session.id = insert_result.inserted_id.as_object_id();
|
||||
|
||||
let session_id_str = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
|
||||
// Register broadcast stream and pause control
|
||||
let event_tx = agent.register_session_stream(&session_id_str);
|
||||
let pause_rx = agent.register_pause_control(&session_id_str);
|
||||
|
||||
let initial_message = req.message.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Begin a {} penetration test against {} ({}). \
|
||||
Identify vulnerabilities and provide evidence for each finding.",
|
||||
session.strategy, target.name, target.base_url,
|
||||
)
|
||||
});
|
||||
|
||||
let llm = agent.llm.clone();
|
||||
let db = agent.db.clone();
|
||||
let session_clone = session.clone();
|
||||
let target_clone = target.clone();
|
||||
let agent_ref = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, Some(pause_rx));
|
||||
orchestrator
|
||||
.run_session_guarded(&session_clone, &target_clone, &initial_message)
|
||||
.await;
|
||||
agent_ref.cleanup_session(&session_id_str);
|
||||
drop(permit);
|
||||
});
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: session,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_strategy(s: &str) -> PentestStrategy {
|
||||
match s {
|
||||
"quick" => PentestStrategy::Quick,
|
||||
"targeted" => PentestStrategy::Targeted,
|
||||
"aggressive" => PentestStrategy::Aggressive,
|
||||
"stealth" => PentestStrategy::Stealth,
|
||||
_ => PentestStrategy::Comprehensive,
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/pentest/lookup-repo — Look up a tracked repository by git URL
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn lookup_repo(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<LookupRepoQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, StatusCode> {
|
||||
let repo = agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "git_url": ¶ms.url })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let data = match repo {
|
||||
Some(r) => serde_json::json!({
|
||||
"name": r.name,
|
||||
"default_branch": r.default_branch,
|
||||
"last_scanned_commit": r.last_scanned_commit,
|
||||
}),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/v1/pentest/sessions — List pentest sessions
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_sessions(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<PentestSession>>>, StatusCode> {
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.pentest_sessions()
|
||||
.count_documents(doc! {})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let sessions = match db
|
||||
.pentest_sessions()
|
||||
.find(doc! {})
|
||||
.sort(doc! { "started_at": -1 })
|
||||
.skip(skip)
|
||||
.limit(params.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch pentest sessions: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: sessions,
|
||||
total: Some(total),
|
||||
page: Some(params.page),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/v1/pentest/sessions/:id — Get a single pentest session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn get_session(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<PentestSession>>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Redact credentials in response
|
||||
if let Some(ref mut cfg) = session.config {
|
||||
if cfg.auth.username.is_some() {
|
||||
cfg.auth.username = Some("********".to_string());
|
||||
}
|
||||
if cfg.auth.password.is_some() {
|
||||
cfg.auth.password = Some("********".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: session,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions/:id/chat — Send a user message and trigger next orchestrator iteration
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn send_message(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<SendMessageRequest>,
|
||||
) -> Result<Json<ApiResponse<PentestMessage>>, (StatusCode, String)> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
|
||||
|
||||
// Verify session exists and is running
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?;
|
||||
|
||||
if session.status != PentestStatus::Running && session.status != PentestStatus::Paused {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Session is {}, cannot send messages", session.status),
|
||||
));
|
||||
}
|
||||
|
||||
// Look up the target
|
||||
let target_oid = mongodb::bson::oid::ObjectId::parse_str(&session.target_id).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid target_id in session".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let target = agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": target_oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
"Target for session not found".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Store user message
|
||||
let session_id = id.clone();
|
||||
let user_msg = PentestMessage::user(session_id.clone(), req.message.clone());
|
||||
let _ = agent.db.pentest_messages().insert_one(&user_msg).await;
|
||||
|
||||
let response_msg = user_msg.clone();
|
||||
|
||||
// Spawn orchestrator to continue the session
|
||||
let llm = agent.llm.clone();
|
||||
let db = agent.db.clone();
|
||||
let message = req.message.clone();
|
||||
|
||||
// Use existing broadcast sender if available, otherwise create a new one
|
||||
let event_tx = agent
|
||||
.subscribe_session(&session_id)
|
||||
.and_then(|_| {
|
||||
agent
|
||||
.session_streams
|
||||
.get(&session_id)
|
||||
.map(|entry| entry.value().clone())
|
||||
})
|
||||
.unwrap_or_else(|| agent.register_session_stream(&session_id));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, None);
|
||||
orchestrator
|
||||
.run_session_guarded(&session, &target, &message)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: response_msg,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions/:id/stop — Stop a running pentest session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn stop_session(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<PentestSession>>, (StatusCode, String)> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
|
||||
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?;
|
||||
|
||||
if session.status != PentestStatus::Running && session.status != PentestStatus::Paused {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Session is {}, not running or paused", session.status),
|
||||
));
|
||||
}
|
||||
|
||||
agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$set": {
|
||||
"status": "failed",
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
"error_message": "Stopped by user",
|
||||
}},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Clean up session resources
|
||||
agent.cleanup_session(&id);
|
||||
|
||||
let updated = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
"Session not found after update".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: updated,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions/:id/pause — Pause a running pentest session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn pause_session(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, String)> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
|
||||
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?;
|
||||
|
||||
if session.status != PentestStatus::Running {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Session is {}, not running", session.status),
|
||||
));
|
||||
}
|
||||
|
||||
if !agent.pause_session(&id) {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to send pause signal".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: serde_json::json!({ "status": "paused" }),
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/pentest/sessions/:id/resume — Resume a paused pentest session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn resume_session(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, String)> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
|
||||
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Database error: {e}"),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| (StatusCode::NOT_FOUND, "Session not found".to_string()))?;
|
||||
|
||||
if session.status != PentestStatus::Paused {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Session is {}, not paused", session.status),
|
||||
));
|
||||
}
|
||||
|
||||
if !agent.resume_session(&id) {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to send resume signal".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: serde_json::json!({ "status": "running" }),
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/v1/pentest/sessions/:id/attack-chain — Get attack chain nodes for a session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn get_attack_chain(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Vec<AttackChainNode>>>, StatusCode> {
|
||||
let _oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let nodes = match agent
|
||||
.db
|
||||
.attack_chain_nodes()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "started_at": 1 })
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch attack chain nodes: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let total = nodes.len() as u64;
|
||||
Ok(Json(ApiResponse {
|
||||
data: nodes,
|
||||
total: Some(total),
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/v1/pentest/sessions/:id/messages — Get messages for a session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn get_messages(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<PentestMessage>>>, StatusCode> {
|
||||
let _oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = agent
|
||||
.db
|
||||
.pentest_messages()
|
||||
.count_documents(doc! { "session_id": &id })
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let messages = match agent
|
||||
.db
|
||||
.pentest_messages()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "created_at": 1 })
|
||||
.skip(skip)
|
||||
.limit(params.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch pentest messages: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: messages,
|
||||
total: Some(total),
|
||||
page: Some(params.page),
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/v1/pentest/sessions/:id/findings — Get DAST findings for a pentest session
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn get_session_findings(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<compliance_core::models::dast::DastFinding>>>, StatusCode> {
|
||||
let _oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = agent
|
||||
.db
|
||||
.dast_findings()
|
||||
.count_documents(doc! { "session_id": &id })
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let findings = match agent
|
||||
.db
|
||||
.dast_findings()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "created_at": -1 })
|
||||
.skip(skip)
|
||||
.limit(params.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch pentest session findings: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: findings,
|
||||
total: Some(total),
|
||||
page: Some(params.page),
|
||||
}))
|
||||
}
|
||||
102
compliance-agent/src/api/handlers/pentest_handlers/stats.rs
Normal file
102
compliance-agent/src/api/handlers/pentest_handlers/stats.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use compliance_core::models::pentest::*;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
use super::super::dto::{collect_cursor_async, ApiResponse};
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
/// GET /api/v1/pentest/stats — Aggregated pentest statistics
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn pentest_stats(
|
||||
Extension(agent): AgentExt,
|
||||
) -> Result<Json<ApiResponse<PentestStats>>, StatusCode> {
|
||||
let db = &agent.db;
|
||||
|
||||
let running_sessions = db
|
||||
.pentest_sessions()
|
||||
.count_documents(doc! { "status": "running" })
|
||||
.await
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// Count DAST findings from pentest sessions
|
||||
let total_vulnerabilities = db
|
||||
.dast_findings()
|
||||
.count_documents(doc! { "session_id": { "$exists": true, "$ne": null } })
|
||||
.await
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// Aggregate tool invocations from all sessions
|
||||
let sessions: Vec<PentestSession> = match db.pentest_sessions().find(doc! {}).await {
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let total_tool_invocations: u32 = sessions.iter().map(|s| s.tool_invocations).sum();
|
||||
let total_successes: u32 = sessions.iter().map(|s| s.tool_successes).sum();
|
||||
let tool_success_rate = if total_tool_invocations == 0 {
|
||||
100.0
|
||||
} else {
|
||||
(total_successes as f64 / total_tool_invocations as f64) * 100.0
|
||||
};
|
||||
|
||||
// Severity distribution from pentest-related DAST findings
|
||||
let critical = db
|
||||
.dast_findings()
|
||||
.count_documents(
|
||||
doc! { "session_id": { "$exists": true, "$ne": null }, "severity": "critical" },
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0) as u32;
|
||||
let high = db
|
||||
.dast_findings()
|
||||
.count_documents(
|
||||
doc! { "session_id": { "$exists": true, "$ne": null }, "severity": "high" },
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0) as u32;
|
||||
let medium = db
|
||||
.dast_findings()
|
||||
.count_documents(
|
||||
doc! { "session_id": { "$exists": true, "$ne": null }, "severity": "medium" },
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0) as u32;
|
||||
let low = db
|
||||
.dast_findings()
|
||||
.count_documents(doc! { "session_id": { "$exists": true, "$ne": null }, "severity": "low" })
|
||||
.await
|
||||
.unwrap_or(0) as u32;
|
||||
let info = db
|
||||
.dast_findings()
|
||||
.count_documents(
|
||||
doc! { "session_id": { "$exists": true, "$ne": null }, "severity": "info" },
|
||||
)
|
||||
.await
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: PentestStats {
|
||||
running_sessions,
|
||||
total_vulnerabilities,
|
||||
total_tool_invocations,
|
||||
tool_success_rate,
|
||||
severity_distribution: SeverityDistribution {
|
||||
critical,
|
||||
high,
|
||||
medium,
|
||||
low,
|
||||
info,
|
||||
},
|
||||
},
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
158
compliance-agent/src/api/handlers/pentest_handlers/stream.rs
Normal file
158
compliance-agent/src/api/handlers/pentest_handlers/stream.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures_util::stream;
|
||||
use mongodb::bson::doc;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use compliance_core::models::pentest::*;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
use super::super::dto::collect_cursor_async;
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
/// GET /api/v1/pentest/sessions/:id/stream — SSE endpoint for real-time events
|
||||
///
|
||||
/// Replays stored messages/nodes as initial burst, then subscribes to the
|
||||
/// broadcast channel for live updates. Sends keepalive comments every 15s.
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn session_stream(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
// Verify session exists
|
||||
let _session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// ── Initial burst: replay stored data ──────────────────────────
|
||||
|
||||
let mut initial_events: Vec<Result<Event, Infallible>> = Vec::new();
|
||||
|
||||
// Fetch recent messages for this session
|
||||
let messages: Vec<PentestMessage> = match agent
|
||||
.db
|
||||
.pentest_messages()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "created_at": 1 })
|
||||
.limit(100)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
// Fetch recent attack chain nodes
|
||||
let nodes: Vec<AttackChainNode> = match agent
|
||||
.db
|
||||
.attack_chain_nodes()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "started_at": 1 })
|
||||
.limit(100)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
for msg in &messages {
|
||||
let event_data = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"created_at": msg.created_at.to_rfc3339(),
|
||||
});
|
||||
if let Ok(data) = serde_json::to_string(&event_data) {
|
||||
initial_events.push(Ok(Event::default().event("message").data(data)));
|
||||
}
|
||||
}
|
||||
|
||||
for node in &nodes {
|
||||
let event_data = serde_json::json!({
|
||||
"type": "tool_execution",
|
||||
"node_id": node.node_id,
|
||||
"tool_name": node.tool_name,
|
||||
"status": node.status,
|
||||
"findings_produced": node.findings_produced,
|
||||
});
|
||||
if let Ok(data) = serde_json::to_string(&event_data) {
|
||||
initial_events.push(Ok(Event::default().event("tool").data(data)));
|
||||
}
|
||||
}
|
||||
|
||||
// Add current session status event
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some(s) = session {
|
||||
let status_data = serde_json::json!({
|
||||
"type": "status",
|
||||
"status": s.status,
|
||||
"findings_count": s.findings_count,
|
||||
"tool_invocations": s.tool_invocations,
|
||||
});
|
||||
if let Ok(data) = serde_json::to_string(&status_data) {
|
||||
initial_events.push(Ok(Event::default().event("status").data(data)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Live stream: subscribe to broadcast ────────────────────────
|
||||
|
||||
let live_stream = if let Some(rx) = agent.subscribe_session(&id) {
|
||||
let broadcast = BroadcastStream::new(rx).filter_map(|result| match result {
|
||||
Ok(event) => {
|
||||
if let Ok(data) = serde_json::to_string(&event) {
|
||||
let event_type = match &event {
|
||||
PentestEvent::ToolStart { .. } => "tool_start",
|
||||
PentestEvent::ToolComplete { .. } => "tool_complete",
|
||||
PentestEvent::Finding { .. } => "finding",
|
||||
PentestEvent::Message { .. } => "message",
|
||||
PentestEvent::Complete { .. } => "complete",
|
||||
PentestEvent::Error { .. } => "error",
|
||||
PentestEvent::Thinking { .. } => "thinking",
|
||||
PentestEvent::Paused => "paused",
|
||||
PentestEvent::Resumed => "resumed",
|
||||
};
|
||||
Some(Ok(Event::default().event(event_type).data(data)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
});
|
||||
// Box to unify types
|
||||
Box::pin(broadcast)
|
||||
as std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<Event, Infallible>> + Send>>
|
||||
} else {
|
||||
// No active broadcast — return empty stream
|
||||
Box::pin(stream::empty())
|
||||
as std::pin::Pin<Box<dyn futures_util::Stream<Item = Result<Event, Infallible>> + Send>>
|
||||
};
|
||||
|
||||
// Chain initial burst + live stream
|
||||
let combined = stream::iter(initial_events).chain(live_stream);
|
||||
|
||||
Ok(Sse::new(combined).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("keepalive"),
|
||||
))
|
||||
}
|
||||
241
compliance-agent/src/api/handlers/repos.rs
Normal file
241
compliance-agent/src/api/handlers/repos.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use axum::extract::{Extension, Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::*;
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_repositories(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> ApiResult<Vec<TrackedRepository>> {
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.repositories()
|
||||
.count_documents(doc! {})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let repos = match db
|
||||
.repositories()
|
||||
.find(doc! {})
|
||||
.skip(skip)
|
||||
.limit(params.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch repositories: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: repos,
|
||||
total: Some(total),
|
||||
page: Some(params.page),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
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}"),
|
||||
));
|
||||
}
|
||||
|
||||
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;
|
||||
repo.tracker_token = req.tracker_token;
|
||||
repo.scan_schedule = req.scan_schedule;
|
||||
|
||||
agent
|
||||
.db
|
||||
.repositories()
|
||||
.insert_one(&repo)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
"Repository already exists".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: repo,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %id))]
|
||||
pub async fn update_repository(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateRepositoryRequest>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut set_doc = doc! { "updated_at": mongodb::bson::DateTime::now() };
|
||||
|
||||
if let Some(name) = &req.name {
|
||||
set_doc.insert("name", name);
|
||||
}
|
||||
if let Some(branch) = &req.default_branch {
|
||||
set_doc.insert("default_branch", branch);
|
||||
}
|
||||
if let Some(token) = &req.auth_token {
|
||||
set_doc.insert("auth_token", token);
|
||||
}
|
||||
if let Some(username) = &req.auth_username {
|
||||
set_doc.insert("auth_username", username);
|
||||
}
|
||||
if let Some(tracker_type) = &req.tracker_type {
|
||||
set_doc.insert("tracker_type", tracker_type.to_string());
|
||||
}
|
||||
if let Some(owner) = &req.tracker_owner {
|
||||
set_doc.insert("tracker_owner", owner);
|
||||
}
|
||||
if let Some(repo) = &req.tracker_repo {
|
||||
set_doc.insert("tracker_repo", repo);
|
||||
}
|
||||
if let Some(token) = &req.tracker_token {
|
||||
set_doc.insert("tracker_token", token);
|
||||
}
|
||||
if let Some(schedule) = &req.scan_schedule {
|
||||
set_doc.insert("scan_schedule", schedule);
|
||||
}
|
||||
|
||||
let result = agent
|
||||
.db
|
||||
.repositories()
|
||||
.update_one(doc! { "_id": oid }, doc! { "$set": set_doc })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("Failed to update repository: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
if result.matched_count == 0 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "updated" })))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
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() })))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %id))]
|
||||
pub async fn trigger_scan(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let agent_clone = (*agent).clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = agent_clone.run_scan(&id, ScanTrigger::Manual).await {
|
||||
tracing::error!("Manual scan failed for {id}: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
||||
}
|
||||
|
||||
/// Return the webhook secret for a repository (used by dashboard to display it)
|
||||
pub async fn get_webhook_config(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let repo = agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let tracker_type = repo
|
||||
.tracker_type
|
||||
.as_ref()
|
||||
.map(|t| t.to_string())
|
||||
.unwrap_or_else(|| "gitea".to_string());
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"webhook_secret": repo.webhook_secret,
|
||||
"tracker_type": tracker_type,
|
||||
})))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %id))]
|
||||
pub async fn delete_repository(
|
||||
Extension(agent): AgentExt,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let db = &agent.db;
|
||||
|
||||
// Delete the repository
|
||||
let result = db
|
||||
.repositories()
|
||||
.delete_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
if result.deleted_count == 0 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// Cascade delete all related data
|
||||
let _ = db.findings().delete_many(doc! { "repo_id": &id }).await;
|
||||
let _ = db.sbom_entries().delete_many(doc! { "repo_id": &id }).await;
|
||||
let _ = db.scan_runs().delete_many(doc! { "repo_id": &id }).await;
|
||||
let _ = db.cve_alerts().delete_many(doc! { "repo_id": &id }).await;
|
||||
let _ = db
|
||||
.tracker_issues()
|
||||
.delete_many(doc! { "repo_id": &id })
|
||||
.await;
|
||||
let _ = db.graph_nodes().delete_many(doc! { "repo_id": &id }).await;
|
||||
let _ = db.graph_edges().delete_many(doc! { "repo_id": &id }).await;
|
||||
let _ = db.graph_builds().delete_many(doc! { "repo_id": &id }).await;
|
||||
let _ = db
|
||||
.impact_analyses()
|
||||
.delete_many(doc! { "repo_id": &id })
|
||||
.await;
|
||||
let _ = db
|
||||
.code_embeddings()
|
||||
.delete_many(doc! { "repo_id": &id })
|
||||
.await;
|
||||
let _ = db
|
||||
.embedding_builds()
|
||||
.delete_many(doc! { "repo_id": &id })
|
||||
.await;
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "deleted" })))
|
||||
}
|
||||
379
compliance-agent/src/api/handlers/sbom.rs
Normal file
379
compliance-agent/src/api/handlers/sbom.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::SbomEntry;
|
||||
|
||||
const COPYLEFT_LICENSES: &[&str] = &[
|
||||
"GPL-2.0",
|
||||
"GPL-2.0-only",
|
||||
"GPL-2.0-or-later",
|
||||
"GPL-3.0",
|
||||
"GPL-3.0-only",
|
||||
"GPL-3.0-or-later",
|
||||
"AGPL-3.0",
|
||||
"AGPL-3.0-only",
|
||||
"AGPL-3.0-or-later",
|
||||
"LGPL-2.1",
|
||||
"LGPL-2.1-only",
|
||||
"LGPL-2.1-or-later",
|
||||
"LGPL-3.0",
|
||||
"LGPL-3.0-only",
|
||||
"LGPL-3.0-or-later",
|
||||
"MPL-2.0",
|
||||
];
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn sbom_filters(
|
||||
Extension(agent): AgentExt,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let db = &agent.db;
|
||||
|
||||
let managers: Vec<String> = db
|
||||
.sbom_entries()
|
||||
.distinct("package_manager", doc! {})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.filter(|s| !s.is_empty() && s != "unknown" && s != "file")
|
||||
.collect();
|
||||
|
||||
let licenses: Vec<String> = db
|
||||
.sbom_entries()
|
||||
.distinct("license", doc! {})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"package_managers": managers,
|
||||
"licenses": licenses,
|
||||
})))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = ?filter.repo_id, package_manager = ?filter.package_manager))]
|
||||
pub async fn list_sbom(
|
||||
Extension(agent): AgentExt,
|
||||
Query(filter): Query<SbomFilter>,
|
||||
) -> ApiResult<Vec<SbomEntry>> {
|
||||
let db = &agent.db;
|
||||
let mut query = doc! {};
|
||||
|
||||
if let Some(repo_id) = &filter.repo_id {
|
||||
query.insert("repo_id", repo_id);
|
||||
}
|
||||
if let Some(pm) = &filter.package_manager {
|
||||
query.insert("package_manager", pm);
|
||||
}
|
||||
if let Some(q) = &filter.q {
|
||||
if !q.is_empty() {
|
||||
query.insert("name", doc! { "$regex": q, "$options": "i" });
|
||||
}
|
||||
}
|
||||
if let Some(has_vulns) = filter.has_vulns {
|
||||
if has_vulns {
|
||||
query.insert("known_vulnerabilities", doc! { "$exists": true, "$ne": [] });
|
||||
} else {
|
||||
query.insert("known_vulnerabilities", doc! { "$size": 0 });
|
||||
}
|
||||
}
|
||||
if let Some(license) = &filter.license {
|
||||
query.insert("license", license);
|
||||
}
|
||||
|
||||
let skip = (filter.page.saturating_sub(1)) * filter.limit as u64;
|
||||
let total = db
|
||||
.sbom_entries()
|
||||
.count_documents(query.clone())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let entries = match db
|
||||
.sbom_entries()
|
||||
.find(query)
|
||||
.sort(doc! { "name": 1 })
|
||||
.skip(skip)
|
||||
.limit(filter.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch SBOM entries: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: entries,
|
||||
total: Some(total),
|
||||
page: Some(filter.page),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn export_sbom(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<SbomExportParams>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let db = &agent.db;
|
||||
let entries: Vec<SbomEntry> = match db
|
||||
.sbom_entries()
|
||||
.find(doc! { "repo_id": ¶ms.repo_id })
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch SBOM entries for export: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let body = if params.format == "spdx" {
|
||||
// SPDX 2.3 format
|
||||
let packages: Vec<serde_json::Value> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
serde_json::json!({
|
||||
"SPDXID": format!("SPDXRef-Package-{i}"),
|
||||
"name": e.name,
|
||||
"versionInfo": e.version,
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"licenseConcluded": e.license.as_deref().unwrap_or("NOASSERTION"),
|
||||
"externalRefs": e.purl.as_ref().map(|p| vec![serde_json::json!({
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": p,
|
||||
})]).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": format!("sbom-{}", params.repo_id),
|
||||
"documentNamespace": format!("https://compliance-scanner/sbom/{}", params.repo_id),
|
||||
"packages": packages,
|
||||
})
|
||||
} else {
|
||||
// CycloneDX 1.5 format
|
||||
let components: Vec<serde_json::Value> = entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut comp = serde_json::json!({
|
||||
"type": "library",
|
||||
"name": e.name,
|
||||
"version": e.version,
|
||||
"group": e.package_manager,
|
||||
});
|
||||
if let Some(purl) = &e.purl {
|
||||
comp["purl"] = serde_json::Value::String(purl.clone());
|
||||
}
|
||||
if let Some(license) = &e.license {
|
||||
comp["licenses"] = serde_json::json!([{ "license": { "id": license } }]);
|
||||
}
|
||||
if !e.known_vulnerabilities.is_empty() {
|
||||
comp["vulnerabilities"] = serde_json::json!(
|
||||
e.known_vulnerabilities.iter().map(|v| serde_json::json!({
|
||||
"id": v.id,
|
||||
"source": { "name": v.source },
|
||||
"ratings": v.severity.as_ref().map(|s| vec![serde_json::json!({"severity": s})]).unwrap_or_default(),
|
||||
})).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
comp
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": format!("repo-{}", params.repo_id),
|
||||
}
|
||||
},
|
||||
"components": components,
|
||||
})
|
||||
};
|
||||
|
||||
let json_str =
|
||||
serde_json::to_string_pretty(&body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let filename = if params.format == "spdx" {
|
||||
format!("sbom-{}-spdx.json", params.repo_id)
|
||||
} else {
|
||||
format!("sbom-{}-cyclonedx.json", params.repo_id)
|
||||
};
|
||||
|
||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||
Ok((
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
header::HeaderValue::from_str(&disposition)
|
||||
.unwrap_or_else(|_| header::HeaderValue::from_static("attachment")),
|
||||
),
|
||||
],
|
||||
json_str,
|
||||
))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn license_summary(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<SbomFilter>,
|
||||
) -> ApiResult<Vec<LicenseSummary>> {
|
||||
let db = &agent.db;
|
||||
let mut query = doc! {};
|
||||
if let Some(repo_id) = ¶ms.repo_id {
|
||||
query.insert("repo_id", repo_id);
|
||||
}
|
||||
|
||||
let entries: Vec<SbomEntry> = match db.sbom_entries().find(query).await {
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch SBOM entries for license summary: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut license_map: std::collections::HashMap<String, Vec<String>> =
|
||||
std::collections::HashMap::new();
|
||||
for entry in &entries {
|
||||
let lic = entry.license.as_deref().unwrap_or("Unknown").to_string();
|
||||
license_map.entry(lic).or_default().push(entry.name.clone());
|
||||
}
|
||||
|
||||
let mut summaries: Vec<LicenseSummary> = license_map
|
||||
.into_iter()
|
||||
.map(|(license, packages)| {
|
||||
let is_copyleft = COPYLEFT_LICENSES
|
||||
.iter()
|
||||
.any(|c| license.to_uppercase().contains(&c.to_uppercase()));
|
||||
LicenseSummary {
|
||||
license,
|
||||
count: packages.len() as u64,
|
||||
is_copyleft,
|
||||
packages,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
summaries.sort_by(|a, b| b.count.cmp(&a.count));
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: summaries,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn sbom_diff(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<SbomDiffParams>,
|
||||
) -> ApiResult<SbomDiffResult> {
|
||||
let db = &agent.db;
|
||||
|
||||
let entries_a: Vec<SbomEntry> = match db
|
||||
.sbom_entries()
|
||||
.find(doc! { "repo_id": ¶ms.repo_a })
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch SBOM entries for repo_a: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let entries_b: Vec<SbomEntry> = match db
|
||||
.sbom_entries()
|
||||
.find(doc! { "repo_id": ¶ms.repo_b })
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch SBOM entries for repo_b: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Build maps by (name, package_manager) -> version
|
||||
let map_a: std::collections::HashMap<(String, String), String> = entries_a
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
(e.name.clone(), e.package_manager.clone()),
|
||||
e.version.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let map_b: std::collections::HashMap<(String, String), String> = entries_b
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
(e.name.clone(), e.package_manager.clone()),
|
||||
e.version.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut only_in_a = Vec::new();
|
||||
let mut version_changed = Vec::new();
|
||||
let mut common_count: u64 = 0;
|
||||
|
||||
for (key, ver_a) in &map_a {
|
||||
match map_b.get(key) {
|
||||
None => only_in_a.push(SbomDiffEntry {
|
||||
name: key.0.clone(),
|
||||
version: ver_a.clone(),
|
||||
package_manager: key.1.clone(),
|
||||
}),
|
||||
Some(ver_b) if ver_a != ver_b => {
|
||||
version_changed.push(SbomVersionDiff {
|
||||
name: key.0.clone(),
|
||||
package_manager: key.1.clone(),
|
||||
version_a: ver_a.clone(),
|
||||
version_b: ver_b.clone(),
|
||||
});
|
||||
}
|
||||
Some(_) => common_count += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let only_in_b: Vec<SbomDiffEntry> = map_b
|
||||
.iter()
|
||||
.filter(|(key, _)| !map_a.contains_key(key))
|
||||
.map(|(key, ver)| SbomDiffEntry {
|
||||
name: key.0.clone(),
|
||||
version: ver.clone(),
|
||||
package_manager: key.1.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: SbomDiffResult {
|
||||
only_in_a,
|
||||
only_in_b,
|
||||
version_changed,
|
||||
common_count,
|
||||
},
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
37
compliance-agent/src/api/handlers/scans.rs
Normal file
37
compliance-agent/src/api/handlers/scans.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::ScanRun;
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_scan_runs(
|
||||
Extension(agent): AgentExt,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> ApiResult<Vec<ScanRun>> {
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db.scan_runs().count_documents(doc! {}).await.unwrap_or(0);
|
||||
|
||||
let scans = match db
|
||||
.scan_runs()
|
||||
.find(doc! {})
|
||||
.sort(doc! { "started_at": -1 })
|
||||
.skip(skip)
|
||||
.limit(params.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch scan runs: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: scans,
|
||||
total: Some(total),
|
||||
page: Some(params.page),
|
||||
}))
|
||||
}
|
||||
@@ -99,6 +99,59 @@ pub fn build_router() -> Router {
|
||||
"/api/v1/chat/{repo_id}/status",
|
||||
get(handlers::chat::embedding_status),
|
||||
)
|
||||
// Pentest API endpoints
|
||||
.route(
|
||||
"/api/v1/pentest/lookup-repo",
|
||||
get(handlers::pentest::lookup_repo),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions",
|
||||
get(handlers::pentest::list_sessions).post(handlers::pentest::create_session),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}",
|
||||
get(handlers::pentest::get_session),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/chat",
|
||||
post(handlers::pentest::send_message),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/stop",
|
||||
post(handlers::pentest::stop_session),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/pause",
|
||||
post(handlers::pentest::pause_session),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/resume",
|
||||
post(handlers::pentest::resume_session),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/stream",
|
||||
get(handlers::pentest::session_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/attack-chain",
|
||||
get(handlers::pentest::get_attack_chain),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/messages",
|
||||
get(handlers::pentest::get_messages),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/findings",
|
||||
get(handlers::pentest::get_session_findings),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/sessions/{id}/export",
|
||||
post(handlers::pentest::export_session_report),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/pentest/stats",
|
||||
get(handlers::pentest::pentest_stats),
|
||||
)
|
||||
// Webhook endpoints (proxied through dashboard)
|
||||
.route(
|
||||
"/webhook/github/{repo_id}",
|
||||
|
||||
@@ -49,5 +49,15 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
||||
.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"),
|
||||
keycloak_admin_username: env_var_opt("KEYCLOAK_ADMIN_USERNAME"),
|
||||
keycloak_admin_password: env_secret_opt("KEYCLOAK_ADMIN_PASSWORD"),
|
||||
pentest_verification_email: env_var_opt("PENTEST_VERIFICATION_EMAIL"),
|
||||
pentest_imap_host: env_var_opt("PENTEST_IMAP_HOST"),
|
||||
pentest_imap_port: env_var_opt("PENTEST_IMAP_PORT").and_then(|p| p.parse().ok()),
|
||||
pentest_imap_tls: env_var_opt("PENTEST_IMAP_TLS")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(true),
|
||||
pentest_imap_username: env_var_opt("PENTEST_IMAP_USERNAME"),
|
||||
pentest_imap_password: env_secret_opt("PENTEST_IMAP_PASSWORD"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -166,6 +166,38 @@ impl Database {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// pentest_sessions: compound (target_id, started_at DESC)
|
||||
self.pentest_sessions()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "target_id": 1, "started_at": -1 })
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// pentest_sessions: status index
|
||||
self.pentest_sessions()
|
||||
.create_index(IndexModel::builder().keys(doc! { "status": 1 }).build())
|
||||
.await?;
|
||||
|
||||
// attack_chain_nodes: compound (session_id, node_id)
|
||||
self.attack_chain_nodes()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "session_id": 1, "node_id": 1 })
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// pentest_messages: compound (session_id, created_at)
|
||||
self.pentest_messages()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "session_id": 1, "created_at": 1 })
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Database indexes ensured");
|
||||
Ok(())
|
||||
}
|
||||
@@ -235,6 +267,19 @@ impl Database {
|
||||
self.inner.collection("embedding_builds")
|
||||
}
|
||||
|
||||
// Pentest collections
|
||||
pub fn pentest_sessions(&self) -> Collection<PentestSession> {
|
||||
self.inner.collection("pentest_sessions")
|
||||
}
|
||||
|
||||
pub fn attack_chain_nodes(&self) -> Collection<AttackChainNode> {
|
||||
self.inner.collection("attack_chain_nodes")
|
||||
}
|
||||
|
||||
pub fn pentest_messages(&self) -> Collection<PentestMessage> {
|
||||
self.inner.collection("pentest_messages")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
|
||||
self.inner.collection(name)
|
||||
|
||||
@@ -1,66 +1,15 @@
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::*;
|
||||
use crate::error::AgentError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LlmClient {
|
||||
base_url: String,
|
||||
api_key: SecretString,
|
||||
model: String,
|
||||
embed_model: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChatCompletionRequest {
|
||||
model: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatCompletionResponse {
|
||||
choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatChoice {
|
||||
message: ChatResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
/// Request body for the embeddings API
|
||||
#[derive(Serialize)]
|
||||
struct EmbeddingRequest {
|
||||
model: String,
|
||||
input: Vec<String>,
|
||||
}
|
||||
|
||||
/// Response from the embeddings API
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingResponse {
|
||||
data: Vec<EmbeddingData>,
|
||||
}
|
||||
|
||||
/// A single embedding result
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingData {
|
||||
embedding: Vec<f64>,
|
||||
index: usize,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) api_key: SecretString,
|
||||
pub(crate) model: String,
|
||||
pub(crate) embed_model: String,
|
||||
pub(crate) http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl LlmClient {
|
||||
@@ -79,102 +28,142 @@ impl LlmClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn embed_model(&self) -> &str {
|
||||
&self.embed_model
|
||||
pub(crate) fn chat_url(&self) -> String {
|
||||
format!(
|
||||
"{}/v1/chat/completions",
|
||||
self.base_url.trim_end_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn auth_header(&self) -> Option<String> {
|
||||
let key = self.api_key.expose_secret();
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("Bearer {key}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple chat: system + user prompt → text response
|
||||
pub async fn chat(
|
||||
&self,
|
||||
system_prompt: &str,
|
||||
user_prompt: &str,
|
||||
temperature: Option<f64>,
|
||||
) -> Result<String, AgentError> {
|
||||
let url = format!(
|
||||
"{}/v1/chat/completions",
|
||||
self.base_url.trim_end_matches('/')
|
||||
);
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(system_prompt.to_string()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(user_prompt.to_string()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
let request_body = ChatCompletionRequest {
|
||||
model: self.model.clone(),
|
||||
messages: vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: system_prompt.to_string(),
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: user_prompt.to_string(),
|
||||
},
|
||||
],
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: Some(4096),
|
||||
tools: None,
|
||||
};
|
||||
|
||||
let mut req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body);
|
||||
|
||||
let key = self.api_key.expose_secret();
|
||||
if !key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {key}"));
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("LiteLLM request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AgentError::Other(format!(
|
||||
"LiteLLM returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: ChatCompletionResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Failed to parse LiteLLM response: {e}")))?;
|
||||
|
||||
body.choices
|
||||
.first()
|
||||
.map(|c| c.message.content.clone())
|
||||
.ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string()))
|
||||
self.send_chat_request(&request_body).await.map(|resp| {
|
||||
match resp {
|
||||
LlmResponse::Content(c) => c,
|
||||
LlmResponse::ToolCalls { .. } => String::new(), // shouldn't happen without tools
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Chat with a list of (role, content) messages → text response
|
||||
#[allow(dead_code)]
|
||||
pub async fn chat_with_messages(
|
||||
&self,
|
||||
messages: Vec<(String, String)>,
|
||||
temperature: Option<f64>,
|
||||
) -> Result<String, AgentError> {
|
||||
let url = format!(
|
||||
"{}/v1/chat/completions",
|
||||
self.base_url.trim_end_matches('/')
|
||||
);
|
||||
let messages = messages
|
||||
.into_iter()
|
||||
.map(|(role, content)| ChatMessage {
|
||||
role,
|
||||
content: Some(content),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let request_body = ChatCompletionRequest {
|
||||
model: self.model.clone(),
|
||||
messages: messages
|
||||
.into_iter()
|
||||
.map(|(role, content)| ChatMessage { role, content })
|
||||
.collect(),
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: Some(4096),
|
||||
tools: None,
|
||||
};
|
||||
|
||||
self.send_chat_request(&request_body)
|
||||
.await
|
||||
.map(|resp| match resp {
|
||||
LlmResponse::Content(c) => c,
|
||||
LlmResponse::ToolCalls { .. } => String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Chat with tool definitions — returns either content or tool calls.
|
||||
/// Use this for the AI pentest orchestrator loop.
|
||||
pub async fn chat_with_tools(
|
||||
&self,
|
||||
messages: Vec<ChatMessage>,
|
||||
tools: &[ToolDefinition],
|
||||
temperature: Option<f64>,
|
||||
max_tokens: Option<u32>,
|
||||
) -> Result<LlmResponse, AgentError> {
|
||||
let tool_payloads: Vec<ToolDefinitionPayload> = tools
|
||||
.iter()
|
||||
.map(|t| ToolDefinitionPayload {
|
||||
r#type: "function".to_string(),
|
||||
function: ToolFunctionPayload {
|
||||
name: t.name.clone(),
|
||||
description: t.description.clone(),
|
||||
parameters: t.parameters.clone(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let request_body = ChatCompletionRequest {
|
||||
model: self.model.clone(),
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: Some(max_tokens.unwrap_or(8192)),
|
||||
tools: if tool_payloads.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_payloads)
|
||||
},
|
||||
};
|
||||
|
||||
self.send_chat_request(&request_body).await
|
||||
}
|
||||
|
||||
/// Internal method to send a chat completion request and parse the response
|
||||
async fn send_chat_request(
|
||||
&self,
|
||||
request_body: &ChatCompletionRequest,
|
||||
) -> Result<LlmResponse, AgentError> {
|
||||
let mut req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.post(self.chat_url())
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body);
|
||||
.json(request_body);
|
||||
|
||||
let key = self.api_key.expose_secret();
|
||||
if !key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {key}"));
|
||||
if let Some(auth) = self.auth_header() {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
@@ -195,54 +184,34 @@ impl LlmClient {
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Failed to parse LiteLLM response: {e}")))?;
|
||||
|
||||
body.choices
|
||||
let choice = body
|
||||
.choices
|
||||
.first()
|
||||
.map(|c| c.message.content.clone())
|
||||
.ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string()))
|
||||
}
|
||||
.ok_or_else(|| AgentError::Other("Empty response from LiteLLM".to_string()))?;
|
||||
|
||||
/// Generate embeddings for a batch of texts
|
||||
pub async fn embed(&self, texts: Vec<String>) -> Result<Vec<Vec<f64>>, AgentError> {
|
||||
let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/'));
|
||||
|
||||
let request_body = EmbeddingRequest {
|
||||
model: self.embed_model.clone(),
|
||||
input: texts,
|
||||
};
|
||||
|
||||
let mut req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body);
|
||||
|
||||
let key = self.api_key.expose_secret();
|
||||
if !key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {key}"));
|
||||
// Check for tool calls first
|
||||
if let Some(tool_calls) = &choice.message.tool_calls {
|
||||
if !tool_calls.is_empty() {
|
||||
let calls: Vec<LlmToolCall> = tool_calls
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
let arguments = serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
LlmToolCall {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
arguments,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Capture any reasoning text the LLM included alongside tool calls
|
||||
let reasoning = choice.message.content.clone().unwrap_or_default();
|
||||
return Ok(LlmResponse::ToolCalls { calls, reasoning });
|
||||
}
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Embedding request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AgentError::Other(format!(
|
||||
"Embedding API returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: EmbeddingResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Failed to parse embedding response: {e}")))?;
|
||||
|
||||
// Sort by index to maintain input order
|
||||
let mut data = body.data;
|
||||
data.sort_by_key(|d| d.index);
|
||||
|
||||
Ok(data.into_iter().map(|d| d.embedding).collect())
|
||||
// Otherwise return content
|
||||
let content = choice.message.content.clone().unwrap_or_default();
|
||||
Ok(LlmResponse::Content(content))
|
||||
}
|
||||
}
|
||||
|
||||
74
compliance-agent/src/llm/embedding.rs
Normal file
74
compliance-agent/src/llm/embedding.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::client::LlmClient;
|
||||
use crate::error::AgentError;
|
||||
|
||||
// ── Embedding types ────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EmbeddingRequest {
|
||||
model: String,
|
||||
input: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingResponse {
|
||||
data: Vec<EmbeddingData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingData {
|
||||
embedding: Vec<f64>,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
// ── Embedding implementation ───────────────────────────────────
|
||||
|
||||
impl LlmClient {
|
||||
pub fn embed_model(&self) -> &str {
|
||||
&self.embed_model
|
||||
}
|
||||
|
||||
/// Generate embeddings for a batch of texts
|
||||
pub async fn embed(&self, texts: Vec<String>) -> Result<Vec<Vec<f64>>, AgentError> {
|
||||
let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/'));
|
||||
|
||||
let request_body = EmbeddingRequest {
|
||||
model: self.embed_model.clone(),
|
||||
input: texts,
|
||||
};
|
||||
|
||||
let mut req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request_body);
|
||||
|
||||
if let Some(auth) = self.auth_header() {
|
||||
req = req.header("Authorization", auth);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Embedding request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(AgentError::Other(format!(
|
||||
"Embedding API returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: EmbeddingResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Failed to parse embedding response: {e}")))?;
|
||||
|
||||
let mut data = body.data;
|
||||
data.sort_by_key(|d| d.index);
|
||||
|
||||
Ok(data.into_iter().map(|d| d.embedding).collect())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
pub mod client;
|
||||
#[allow(dead_code)]
|
||||
pub mod descriptions;
|
||||
pub mod embedding;
|
||||
#[allow(dead_code)]
|
||||
pub mod fixes;
|
||||
#[allow(dead_code)]
|
||||
pub mod pr_review;
|
||||
pub mod review_prompts;
|
||||
pub mod triage;
|
||||
pub mod types;
|
||||
|
||||
pub use client::LlmClient;
|
||||
pub use types::{
|
||||
ChatMessage, LlmResponse, ToolCallRequest, ToolCallRequestFunction, ToolDefinition,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,10 @@ 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.
|
||||
/// Maximum number of findings to include in a single LLM triage call.
|
||||
const TRIAGE_CHUNK_SIZE: usize = 30;
|
||||
|
||||
const TRIAGE_SYSTEM_PROMPT: &str = r#"You are a security finding triage expert. Analyze each of the following security findings with its code context and determine the appropriate action.
|
||||
|
||||
Actions:
|
||||
- "confirm": The finding is a true positive at the reported severity. Keep as-is.
|
||||
@@ -19,8 +22,8 @@ Consider:
|
||||
- Is the finding actionable by a developer?
|
||||
- Would a real attacker be able to exploit this?
|
||||
|
||||
Respond in JSON format:
|
||||
{"action": "confirm|downgrade|upgrade|dismiss", "confidence": 0-10, "rationale": "brief explanation", "remediation": "optional fix suggestion"}"#;
|
||||
Respond with a JSON array, one entry per finding in the same order they were presented:
|
||||
[{"id": "<fingerprint>", "action": "confirm|downgrade|upgrade|dismiss", "confidence": 0-10, "rationale": "brief explanation", "remediation": "optional fix suggestion"}, ...]"#;
|
||||
|
||||
pub async fn triage_findings(
|
||||
llm: &Arc<LlmClient>,
|
||||
@@ -29,60 +32,76 @@ pub async fn triage_findings(
|
||||
) -> usize {
|
||||
let mut passed = 0;
|
||||
|
||||
for finding in findings.iter_mut() {
|
||||
let file_classification = classify_file_path(finding.file_path.as_deref());
|
||||
// Process findings in chunks to avoid overflowing the LLM context window.
|
||||
for chunk_start in (0..findings.len()).step_by(TRIAGE_CHUNK_SIZE) {
|
||||
let chunk_end = (chunk_start + TRIAGE_CHUNK_SIZE).min(findings.len());
|
||||
let chunk = &mut findings[chunk_start..chunk_end];
|
||||
|
||||
let mut user_prompt = format!(
|
||||
"Scanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}\nFile classification: {}",
|
||||
finding.scanner,
|
||||
finding.rule_id.as_deref().unwrap_or("N/A"),
|
||||
finding.severity,
|
||||
finding.title,
|
||||
finding.description,
|
||||
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,
|
||||
);
|
||||
// Build a combined prompt for the entire chunk.
|
||||
let mut user_prompt = String::new();
|
||||
let mut file_classifications: Vec<String> = Vec::new();
|
||||
|
||||
for (i, finding) in chunk.iter().enumerate() {
|
||||
let file_classification = classify_file_path(finding.file_path.as_deref());
|
||||
|
||||
// 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}"
|
||||
"\n--- Finding {} (id: {}) ---\nScanner: {}\nRule: {}\nSeverity: {}\nTitle: {}\nDescription: {}\nFile: {}\nLine: {}\nCode: {}\nFile classification: {}",
|
||||
i + 1,
|
||||
finding.fingerprint,
|
||||
finding.scanner,
|
||||
finding.rule_id.as_deref().unwrap_or("N/A"),
|
||||
finding.severity,
|
||||
finding.title,
|
||||
finding.description,
|
||||
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 graph context if available
|
||||
if let Some(ctx) = graph_context {
|
||||
if let Some(impact) = ctx
|
||||
.impacts
|
||||
.iter()
|
||||
.find(|i| i.finding_id == finding.fingerprint)
|
||||
{
|
||||
// Enrich with surrounding code context if possible
|
||||
if let Some(context) = read_surrounding_context(finding) {
|
||||
user_prompt.push_str(&format!(
|
||||
"\n\n--- Code Graph Context ---\n\
|
||||
Blast radius: {} nodes affected\n\
|
||||
Entry points affected: {}\n\
|
||||
Direct callers: {}\n\
|
||||
Communities affected: {}\n\
|
||||
Call chains: {}",
|
||||
impact.blast_radius,
|
||||
if impact.affected_entry_points.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
impact.affected_entry_points.join(", ")
|
||||
},
|
||||
if impact.direct_callers.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
impact.direct_callers.join(", ")
|
||||
},
|
||||
impact.affected_communities.len(),
|
||||
impact.call_chains.len(),
|
||||
"\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
|
||||
.impacts
|
||||
.iter()
|
||||
.find(|im| im.finding_id == finding.fingerprint)
|
||||
{
|
||||
user_prompt.push_str(&format!(
|
||||
"\n\n--- Code Graph Context ---\n\
|
||||
Blast radius: {} nodes affected\n\
|
||||
Entry points affected: {}\n\
|
||||
Direct callers: {}\n\
|
||||
Communities affected: {}\n\
|
||||
Call chains: {}",
|
||||
impact.blast_radius,
|
||||
if impact.affected_entry_points.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
impact.affected_entry_points.join(", ")
|
||||
},
|
||||
if impact.direct_callers.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
impact.direct_callers.join(", ")
|
||||
},
|
||||
impact.affected_communities.len(),
|
||||
impact.call_chains.len(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
user_prompt.push('\n');
|
||||
file_classifications.push(file_classification);
|
||||
}
|
||||
|
||||
// Send the batch to the LLM.
|
||||
match llm
|
||||
.chat(TRIAGE_SYSTEM_PROMPT, &user_prompt, Some(0.1))
|
||||
.await
|
||||
@@ -98,58 +117,77 @@ pub async fn triage_findings(
|
||||
} 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);
|
||||
|
||||
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 {
|
||||
match serde_json::from_str::<Vec<TriageResult>>(cleaned) {
|
||||
Ok(results) => {
|
||||
for (idx, finding) in chunk.iter_mut().enumerate() {
|
||||
// Match result by position; fall back to keeping the finding.
|
||||
let Some(result) = results.get(idx) else {
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
} else {
|
||||
finding.status = FindingStatus::FalsePositive;
|
||||
continue;
|
||||
};
|
||||
|
||||
let file_classification = file_classifications
|
||||
.get(idx)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
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.clone());
|
||||
|
||||
if let Some(ref remediation) = result.remediation {
|
||||
finding.remediation = Some(remediation.clone());
|
||||
}
|
||||
|
||||
match result.action.as_str() {
|
||||
"dismiss" => {
|
||||
finding.status = FindingStatus::FalsePositive;
|
||||
}
|
||||
"downgrade" => {
|
||||
finding.severity = downgrade_severity(&finding.severity);
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
}
|
||||
"upgrade" => {
|
||||
finding.severity = upgrade_severity(&finding.severity);
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
}
|
||||
_ => {
|
||||
// "confirm" or unknown — keep as-is
|
||||
if adjusted_confidence >= 3.0 {
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
} else {
|
||||
finding.status = FindingStatus::FalsePositive;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Parse failure — keep the finding
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
tracing::warn!(
|
||||
"Failed to parse triage response for {}: {response}",
|
||||
finding.fingerprint
|
||||
);
|
||||
Err(_) => {
|
||||
// Batch parse failure — keep all findings in the chunk.
|
||||
tracing::warn!(
|
||||
"Failed to parse batch triage response for chunk starting at {chunk_start}: {cleaned}"
|
||||
);
|
||||
for finding in chunk.iter_mut() {
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// On LLM error, keep the finding
|
||||
tracing::warn!("LLM triage failed for {}: {e}", finding.fingerprint);
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
// On LLM error, keep all findings in the chunk.
|
||||
tracing::warn!("LLM batch triage failed for chunk starting at {chunk_start}: {e}");
|
||||
for finding in chunk.iter_mut() {
|
||||
finding.status = FindingStatus::Triaged;
|
||||
passed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +304,10 @@ fn upgrade_severity(
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TriageResult {
|
||||
/// Finding fingerprint echoed back by the LLM (optional).
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
#[serde(default = "default_action")]
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
@@ -278,3 +320,220 @@ struct TriageResult {
|
||||
fn default_action() -> String {
|
||||
"confirm".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::Severity;
|
||||
|
||||
// ── classify_file_path ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn classify_none_path() {
|
||||
assert_eq!(classify_file_path(None), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_production_path() {
|
||||
assert_eq!(classify_file_path(Some("src/main.rs")), "production");
|
||||
assert_eq!(classify_file_path(Some("lib/core/engine.py")), "production");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_test_paths() {
|
||||
assert_eq!(classify_file_path(Some("src/test/helper.rs")), "test");
|
||||
assert_eq!(classify_file_path(Some("src/tests/unit.rs")), "test");
|
||||
assert_eq!(classify_file_path(Some("foo_test.go")), "test");
|
||||
assert_eq!(classify_file_path(Some("bar.test.js")), "test");
|
||||
assert_eq!(classify_file_path(Some("baz.spec.ts")), "test");
|
||||
assert_eq!(
|
||||
classify_file_path(Some("data/fixtures/sample.json")),
|
||||
"test"
|
||||
);
|
||||
assert_eq!(classify_file_path(Some("src/testdata/input.txt")), "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_example_paths() {
|
||||
assert_eq!(
|
||||
classify_file_path(Some("docs/examples/basic.rs")),
|
||||
"example"
|
||||
);
|
||||
// /example matches because contains("/example")
|
||||
assert_eq!(classify_file_path(Some("src/example/main.py")), "example");
|
||||
assert_eq!(classify_file_path(Some("src/demo/run.sh")), "example");
|
||||
assert_eq!(classify_file_path(Some("src/sample/lib.rs")), "example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_generated_paths() {
|
||||
assert_eq!(
|
||||
classify_file_path(Some("src/generated/api.rs")),
|
||||
"generated"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_file_path(Some("proto/gen/service.go")),
|
||||
"generated"
|
||||
);
|
||||
assert_eq!(classify_file_path(Some("api.generated.ts")), "generated");
|
||||
assert_eq!(classify_file_path(Some("service.pb.go")), "generated");
|
||||
assert_eq!(classify_file_path(Some("model_generated.rs")), "generated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_vendored_paths() {
|
||||
// Implementation checks for /vendor/, /node_modules/, /third_party/ (with slashes)
|
||||
assert_eq!(
|
||||
classify_file_path(Some("src/vendor/lib/foo.go")),
|
||||
"vendored"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_file_path(Some("src/node_modules/pkg/index.js")),
|
||||
"vendored"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_file_path(Some("src/third_party/lib.c")),
|
||||
"vendored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_is_case_insensitive() {
|
||||
assert_eq!(classify_file_path(Some("src/TEST/Helper.rs")), "test");
|
||||
assert_eq!(classify_file_path(Some("src/VENDOR/lib.go")), "vendored");
|
||||
assert_eq!(
|
||||
classify_file_path(Some("src/GENERATED/foo.ts")),
|
||||
"generated"
|
||||
);
|
||||
}
|
||||
|
||||
// ── adjust_confidence ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn adjust_confidence_production() {
|
||||
assert_eq!(adjust_confidence(8.0, "production"), 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_confidence_test() {
|
||||
assert_eq!(adjust_confidence(10.0, "test"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_confidence_example() {
|
||||
assert_eq!(adjust_confidence(10.0, "example"), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_confidence_generated() {
|
||||
assert_eq!(adjust_confidence(10.0, "generated"), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_confidence_vendored() {
|
||||
assert_eq!(adjust_confidence(10.0, "vendored"), 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_confidence_unknown_classification() {
|
||||
assert_eq!(adjust_confidence(7.0, "unknown"), 7.0);
|
||||
assert_eq!(adjust_confidence(7.0, "something_else"), 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_confidence_zero() {
|
||||
assert_eq!(adjust_confidence(0.0, "test"), 0.0);
|
||||
assert_eq!(adjust_confidence(0.0, "production"), 0.0);
|
||||
}
|
||||
|
||||
// ── downgrade_severity ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn downgrade_severity_all_levels() {
|
||||
assert_eq!(downgrade_severity(&Severity::Critical), Severity::High);
|
||||
assert_eq!(downgrade_severity(&Severity::High), Severity::Medium);
|
||||
assert_eq!(downgrade_severity(&Severity::Medium), Severity::Low);
|
||||
assert_eq!(downgrade_severity(&Severity::Low), Severity::Info);
|
||||
assert_eq!(downgrade_severity(&Severity::Info), Severity::Info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downgrade_severity_info_is_floor() {
|
||||
// Downgrading Info twice should still be Info
|
||||
let s = downgrade_severity(&Severity::Info);
|
||||
assert_eq!(downgrade_severity(&s), Severity::Info);
|
||||
}
|
||||
|
||||
// ── upgrade_severity ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn upgrade_severity_all_levels() {
|
||||
assert_eq!(upgrade_severity(&Severity::Info), Severity::Low);
|
||||
assert_eq!(upgrade_severity(&Severity::Low), Severity::Medium);
|
||||
assert_eq!(upgrade_severity(&Severity::Medium), Severity::High);
|
||||
assert_eq!(upgrade_severity(&Severity::High), Severity::Critical);
|
||||
assert_eq!(upgrade_severity(&Severity::Critical), Severity::Critical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_severity_critical_is_ceiling() {
|
||||
let s = upgrade_severity(&Severity::Critical);
|
||||
assert_eq!(upgrade_severity(&s), Severity::Critical);
|
||||
}
|
||||
|
||||
// ── upgrade/downgrade roundtrip ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn upgrade_then_downgrade_is_identity_for_middle_values() {
|
||||
for sev in [Severity::Low, Severity::Medium, Severity::High] {
|
||||
assert_eq!(downgrade_severity(&upgrade_severity(&sev)), sev);
|
||||
}
|
||||
}
|
||||
|
||||
// ── TriageResult deserialization ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn triage_result_full() {
|
||||
let json = r#"{"action":"dismiss","confidence":8.5,"rationale":"false positive","remediation":"remove code"}"#;
|
||||
let r: TriageResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(r.action, "dismiss");
|
||||
assert_eq!(r.confidence, 8.5);
|
||||
assert_eq!(r.rationale, "false positive");
|
||||
assert_eq!(r.remediation.as_deref(), Some("remove code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triage_result_defaults() {
|
||||
let json = r#"{}"#;
|
||||
let r: TriageResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(r.action, "confirm");
|
||||
assert_eq!(r.confidence, 0.0);
|
||||
assert_eq!(r.rationale, "");
|
||||
assert!(r.remediation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triage_result_partial() {
|
||||
let json = r#"{"action":"downgrade","confidence":6.0}"#;
|
||||
let r: TriageResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(r.action, "downgrade");
|
||||
assert_eq!(r.confidence, 6.0);
|
||||
assert_eq!(r.rationale, "");
|
||||
assert!(r.remediation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triage_result_with_markdown_fences() {
|
||||
// Simulate LLM wrapping response in markdown code fences
|
||||
let raw = "```json\n{\"action\":\"upgrade\",\"confidence\":9,\"rationale\":\"critical\",\"remediation\":null}\n```";
|
||||
let cleaned = raw
|
||||
.trim()
|
||||
.trim_start_matches("```json")
|
||||
.trim_start_matches("```")
|
||||
.trim_end_matches("```")
|
||||
.trim();
|
||||
let r: TriageResult = serde_json::from_str(cleaned).unwrap();
|
||||
assert_eq!(r.action, "upgrade");
|
||||
assert_eq!(r.confidence, 9.0);
|
||||
}
|
||||
}
|
||||
|
||||
369
compliance-agent/src/llm/types.rs
Normal file
369
compliance-agent/src/llm/types.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Request types ──────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallRequest>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ChatCompletionRequest {
|
||||
pub(crate) model: String,
|
||||
pub(crate) messages: Vec<ChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) temperature: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) tools: Option<Vec<ToolDefinitionPayload>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ToolDefinitionPayload {
|
||||
pub(crate) r#type: String,
|
||||
pub(crate) function: ToolFunctionPayload,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct ToolFunctionPayload {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
pub(crate) parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
// ── Response types ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ChatCompletionResponse {
|
||||
pub(crate) choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ChatChoice {
|
||||
pub(crate) message: ChatResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ChatResponseMessage {
|
||||
#[serde(default)]
|
||||
pub(crate) content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) tool_calls: Option<Vec<ToolCallResponse>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ToolCallResponse {
|
||||
pub(crate) id: String,
|
||||
pub(crate) function: ToolCallFunction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ToolCallFunction {
|
||||
pub(crate) name: String,
|
||||
pub(crate) arguments: String,
|
||||
}
|
||||
|
||||
// ── Public types for tool calling ──────────────────────────────
|
||||
|
||||
/// Definition of a tool that the LLM can invoke
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A tool call request from the LLM
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A tool call in the request message format (for sending back tool_calls in assistant messages)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallRequest {
|
||||
pub id: String,
|
||||
pub r#type: String,
|
||||
pub function: ToolCallRequestFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallRequestFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// Response from the LLM — either content or tool calls
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LlmResponse {
|
||||
Content(String),
|
||||
/// Tool calls with optional reasoning text from the LLM
|
||||
ToolCalls {
|
||||
calls: Vec<LlmToolCall>,
|
||||
reasoning: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── ChatMessage ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn chat_message_serializes_minimal() {
|
||||
let msg = ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some("hello".to_string()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
};
|
||||
let v = serde_json::to_value(&msg).unwrap();
|
||||
assert_eq!(v["role"], "user");
|
||||
assert_eq!(v["content"], "hello");
|
||||
// None fields with skip_serializing_if should be absent
|
||||
assert!(v.get("tool_calls").is_none());
|
||||
assert!(v.get("tool_call_id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_serializes_with_tool_calls() {
|
||||
let msg = ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: None,
|
||||
tool_calls: Some(vec![ToolCallRequest {
|
||||
id: "call_1".to_string(),
|
||||
r#type: "function".to_string(),
|
||||
function: ToolCallRequestFunction {
|
||||
name: "get_weather".to_string(),
|
||||
arguments: r#"{"city":"NYC"}"#.to_string(),
|
||||
},
|
||||
}]),
|
||||
tool_call_id: None,
|
||||
};
|
||||
let v = serde_json::to_value(&msg).unwrap();
|
||||
assert!(v["tool_calls"].is_array());
|
||||
assert_eq!(v["tool_calls"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_content_null_when_none() {
|
||||
let msg = ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
};
|
||||
let v = serde_json::to_value(&msg).unwrap();
|
||||
assert!(v["content"].is_null());
|
||||
}
|
||||
|
||||
// ── ToolDefinition ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_definition_serializes() {
|
||||
let td = ToolDefinition {
|
||||
name: "search".to_string(),
|
||||
description: "Search the web".to_string(),
|
||||
parameters: json!({"type": "object", "properties": {"q": {"type": "string"}}}),
|
||||
};
|
||||
let v = serde_json::to_value(&td).unwrap();
|
||||
assert_eq!(v["name"], "search");
|
||||
assert_eq!(v["parameters"]["type"], "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_definition_empty_parameters() {
|
||||
let td = ToolDefinition {
|
||||
name: "noop".to_string(),
|
||||
description: "".to_string(),
|
||||
parameters: json!({}),
|
||||
};
|
||||
let v = serde_json::to_value(&td).unwrap();
|
||||
assert_eq!(v["parameters"], json!({}));
|
||||
}
|
||||
|
||||
// ── LlmToolCall ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn llm_tool_call_roundtrip() {
|
||||
let call = LlmToolCall {
|
||||
id: "tc_42".to_string(),
|
||||
name: "run_scan".to_string(),
|
||||
arguments: json!({"path": "/tmp", "verbose": true}),
|
||||
};
|
||||
let serialized = serde_json::to_string(&call).unwrap();
|
||||
let deserialized: LlmToolCall = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(deserialized.id, "tc_42");
|
||||
assert_eq!(deserialized.name, "run_scan");
|
||||
assert_eq!(deserialized.arguments["path"], "/tmp");
|
||||
assert_eq!(deserialized.arguments["verbose"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_tool_call_empty_arguments() {
|
||||
let call = LlmToolCall {
|
||||
id: "tc_0".to_string(),
|
||||
name: "noop".to_string(),
|
||||
arguments: json!({}),
|
||||
};
|
||||
let rt: LlmToolCall = serde_json::from_str(&serde_json::to_string(&call).unwrap()).unwrap();
|
||||
assert!(rt.arguments.as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ── ToolCallRequest / ToolCallRequestFunction ────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_call_request_roundtrip() {
|
||||
let req = ToolCallRequest {
|
||||
id: "call_abc".to_string(),
|
||||
r#type: "function".to_string(),
|
||||
function: ToolCallRequestFunction {
|
||||
name: "my_func".to_string(),
|
||||
arguments: r#"{"x":1}"#.to_string(),
|
||||
},
|
||||
};
|
||||
let json_str = serde_json::to_string(&req).unwrap();
|
||||
let back: ToolCallRequest = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(back.id, "call_abc");
|
||||
assert_eq!(back.r#type, "function");
|
||||
assert_eq!(back.function.name, "my_func");
|
||||
assert_eq!(back.function.arguments, r#"{"x":1}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_request_type_field_serializes_as_type() {
|
||||
let req = ToolCallRequest {
|
||||
id: "id".to_string(),
|
||||
r#type: "function".to_string(),
|
||||
function: ToolCallRequestFunction {
|
||||
name: "f".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
// The field should be "type" in JSON, not "r#type"
|
||||
assert!(v.get("type").is_some());
|
||||
assert!(v.get("r#type").is_none());
|
||||
}
|
||||
|
||||
// ── ChatCompletionRequest ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn chat_completion_request_skips_none_fields() {
|
||||
let req = ChatCompletionRequest {
|
||||
model: "gpt-4".to_string(),
|
||||
messages: vec![],
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
tools: None,
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["model"], "gpt-4");
|
||||
assert!(v.get("temperature").is_none());
|
||||
assert!(v.get("max_tokens").is_none());
|
||||
assert!(v.get("tools").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completion_request_includes_set_fields() {
|
||||
let req = ChatCompletionRequest {
|
||||
model: "gpt-4".to_string(),
|
||||
messages: vec![],
|
||||
temperature: Some(0.7),
|
||||
max_tokens: Some(1024),
|
||||
tools: Some(vec![]),
|
||||
};
|
||||
let v = serde_json::to_value(&req).unwrap();
|
||||
assert_eq!(v["temperature"], 0.7);
|
||||
assert_eq!(v["max_tokens"], 1024);
|
||||
assert!(v["tools"].is_array());
|
||||
}
|
||||
|
||||
// ── ChatCompletionResponse deserialization ───────────────────
|
||||
|
||||
#[test]
|
||||
fn chat_completion_response_deserializes_content() {
|
||||
let json_str = r#"{"choices":[{"message":{"content":"Hello!"}}]}"#;
|
||||
let resp: ChatCompletionResponse = serde_json::from_str(json_str).unwrap();
|
||||
assert_eq!(resp.choices.len(), 1);
|
||||
assert_eq!(resp.choices[0].message.content.as_deref(), Some("Hello!"));
|
||||
assert!(resp.choices[0].message.tool_calls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completion_response_deserializes_tool_calls() {
|
||||
let json_str = r#"{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"function": {"name": "search", "arguments": "{\"q\":\"rust\"}"}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}"#;
|
||||
let resp: ChatCompletionResponse = serde_json::from_str(json_str).unwrap();
|
||||
let tc = resp.choices[0].message.tool_calls.as_ref().unwrap();
|
||||
assert_eq!(tc.len(), 1);
|
||||
assert_eq!(tc[0].id, "call_1");
|
||||
assert_eq!(tc[0].function.name, "search");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completion_response_defaults_missing_fields() {
|
||||
// content and tool_calls are both missing — should default to None
|
||||
let json_str = r#"{"choices":[{"message":{}}]}"#;
|
||||
let resp: ChatCompletionResponse = serde_json::from_str(json_str).unwrap();
|
||||
assert!(resp.choices[0].message.content.is_none());
|
||||
assert!(resp.choices[0].message.tool_calls.is_none());
|
||||
}
|
||||
|
||||
// ── LlmResponse ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn llm_response_content_variant() {
|
||||
let resp = LlmResponse::Content("answer".to_string());
|
||||
match resp {
|
||||
LlmResponse::Content(s) => assert_eq!(s, "answer"),
|
||||
_ => panic!("expected Content variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_response_tool_calls_variant() {
|
||||
let resp = LlmResponse::ToolCalls {
|
||||
calls: vec![LlmToolCall {
|
||||
id: "1".to_string(),
|
||||
name: "f".to_string(),
|
||||
arguments: json!({}),
|
||||
}],
|
||||
reasoning: "because".to_string(),
|
||||
};
|
||||
match resp {
|
||||
LlmResponse::ToolCalls { calls, reasoning } => {
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(reasoning, "because");
|
||||
}
|
||||
_ => panic!("expected ToolCalls variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_response_empty_content() {
|
||||
let resp = LlmResponse::Content(String::new());
|
||||
match resp {
|
||||
LlmResponse::Content(s) => assert!(s.is_empty()),
|
||||
_ => panic!("expected Content variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
mod agent;
|
||||
mod api;
|
||||
mod config;
|
||||
pub(crate) mod config;
|
||||
mod database;
|
||||
mod error;
|
||||
mod llm;
|
||||
mod pentest;
|
||||
mod pipeline;
|
||||
mod rag;
|
||||
mod scheduler;
|
||||
@@ -14,11 +15,20 @@ mod webhooks;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
match dotenvy::dotenv() {
|
||||
Ok(path) => eprintln!("[dotenv] Loaded from: {}", path.display()),
|
||||
Err(e) => eprintln!("[dotenv] FAILED: {e}"),
|
||||
}
|
||||
|
||||
let _telemetry_guard = compliance_core::telemetry::init_telemetry("compliance-agent");
|
||||
|
||||
tracing::info!("Loading configuration...");
|
||||
// Log critical env vars at startup
|
||||
tracing::info!(
|
||||
chrome_ws_url = std::env::var("CHROME_WS_URL").ok().as_deref(),
|
||||
pentest_email = std::env::var("PENTEST_VERIFICATION_EMAIL").ok().as_deref(),
|
||||
encryption_key_set = std::env::var("PENTEST_ENCRYPTION_KEY").is_ok(),
|
||||
"Loading configuration..."
|
||||
);
|
||||
let config = config::load_config()?;
|
||||
|
||||
// Ensure SSH key pair exists for cloning private repos
|
||||
|
||||
484
compliance-agent/src/pentest/cleanup.rs
Normal file
484
compliance-agent/src/pentest/cleanup.rs
Normal file
@@ -0,0 +1,484 @@
|
||||
use compliance_core::models::pentest::{IdentityProvider, TestUserRecord};
|
||||
use compliance_core::AgentConfig;
|
||||
use secrecy::ExposeSecret;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Attempt to delete a test user created during a pentest session.
|
||||
///
|
||||
/// Routes to the appropriate identity provider based on `TestUserRecord.provider`.
|
||||
/// Falls back to browser-based cleanup if no API credentials are available.
|
||||
///
|
||||
/// Returns `Ok(true)` if the user was deleted, `Ok(false)` if skipped, `Err` on failure.
|
||||
pub async fn cleanup_test_user(
|
||||
user: &TestUserRecord,
|
||||
config: &AgentConfig,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<bool, String> {
|
||||
if user.cleaned_up {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let provider = user.provider.as_ref();
|
||||
|
||||
match provider {
|
||||
Some(IdentityProvider::Keycloak) => cleanup_keycloak(user, config, http).await,
|
||||
Some(IdentityProvider::Auth0) => cleanup_auth0(user, config, http).await,
|
||||
Some(IdentityProvider::Okta) => cleanup_okta(user, config, http).await,
|
||||
Some(IdentityProvider::Firebase) => {
|
||||
warn!("Firebase user cleanup not yet implemented");
|
||||
Ok(false)
|
||||
}
|
||||
Some(IdentityProvider::Custom) | None => {
|
||||
// For custom/unknown providers, try Keycloak if configured, else skip
|
||||
if config.keycloak_url.is_some() && config.keycloak_admin_username.is_some() {
|
||||
cleanup_keycloak(user, config, http).await
|
||||
} else {
|
||||
warn!(
|
||||
username = user.username.as_deref(),
|
||||
"No identity provider configured for cleanup — skipping"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a user from Keycloak via the Admin REST API.
|
||||
///
|
||||
/// Flow: get admin token → search user by username → delete by ID.
|
||||
async fn cleanup_keycloak(
|
||||
user: &TestUserRecord,
|
||||
config: &AgentConfig,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<bool, String> {
|
||||
let base_url = config
|
||||
.keycloak_url
|
||||
.as_deref()
|
||||
.ok_or("KEYCLOAK_URL not configured")?;
|
||||
let realm = config
|
||||
.keycloak_realm
|
||||
.as_deref()
|
||||
.ok_or("KEYCLOAK_REALM not configured")?;
|
||||
let admin_user = config
|
||||
.keycloak_admin_username
|
||||
.as_deref()
|
||||
.ok_or("KEYCLOAK_ADMIN_USERNAME not configured")?;
|
||||
let admin_pass = config
|
||||
.keycloak_admin_password
|
||||
.as_ref()
|
||||
.ok_or("KEYCLOAK_ADMIN_PASSWORD not configured")?;
|
||||
|
||||
let username = user
|
||||
.username
|
||||
.as_deref()
|
||||
.ok_or("No username in test user record")?;
|
||||
|
||||
info!(username, realm, "Cleaning up Keycloak test user");
|
||||
|
||||
// Step 1: Get admin access token
|
||||
let token_url = format!("{base_url}/realms/master/protocol/openid-connect/token");
|
||||
let token_resp = http
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "password"),
|
||||
("client_id", "admin-cli"),
|
||||
("username", admin_user),
|
||||
("password", admin_pass.expose_secret()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Keycloak token request failed: {e}"))?;
|
||||
|
||||
if !token_resp.status().is_success() {
|
||||
let status = token_resp.status();
|
||||
let body = token_resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Keycloak admin auth failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let token_body: serde_json::Value = token_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Keycloak token: {e}"))?;
|
||||
let access_token = token_body
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No access_token in Keycloak response")?;
|
||||
|
||||
// Step 2: Search for user by username
|
||||
let search_url =
|
||||
format!("{base_url}/admin/realms/{realm}/users?username={username}&exact=true");
|
||||
let search_resp = http
|
||||
.get(&search_url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Keycloak user search failed: {e}"))?;
|
||||
|
||||
if !search_resp.status().is_success() {
|
||||
let status = search_resp.status();
|
||||
let body = search_resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Keycloak user search failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let users: Vec<serde_json::Value> = search_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Keycloak users: {e}"))?;
|
||||
|
||||
let user_id = users
|
||||
.first()
|
||||
.and_then(|u| u.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| format!("User '{username}' not found in Keycloak realm '{realm}'"))?;
|
||||
|
||||
// Step 3: Delete the user
|
||||
let delete_url = format!("{base_url}/admin/realms/{realm}/users/{user_id}");
|
||||
let delete_resp = http
|
||||
.delete(&delete_url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Keycloak user delete failed: {e}"))?;
|
||||
|
||||
if delete_resp.status().is_success() || delete_resp.status().as_u16() == 204 {
|
||||
info!(username, user_id, "Keycloak test user deleted");
|
||||
Ok(true)
|
||||
} else {
|
||||
let status = delete_resp.status();
|
||||
let body = delete_resp.text().await.unwrap_or_default();
|
||||
Err(format!("Keycloak delete failed ({status}): {body}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a user from Auth0 via the Management API.
|
||||
///
|
||||
/// Requires `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET` env vars.
|
||||
async fn cleanup_auth0(
|
||||
user: &TestUserRecord,
|
||||
_config: &AgentConfig,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<bool, String> {
|
||||
let domain = std::env::var("AUTH0_DOMAIN").map_err(|_| "AUTH0_DOMAIN not set")?;
|
||||
let client_id = std::env::var("AUTH0_CLIENT_ID").map_err(|_| "AUTH0_CLIENT_ID not set")?;
|
||||
let client_secret =
|
||||
std::env::var("AUTH0_CLIENT_SECRET").map_err(|_| "AUTH0_CLIENT_SECRET not set")?;
|
||||
|
||||
let email = user
|
||||
.email
|
||||
.as_deref()
|
||||
.ok_or("No email in test user record for Auth0 lookup")?;
|
||||
|
||||
info!(email, "Cleaning up Auth0 test user");
|
||||
|
||||
// Get management API token
|
||||
let token_resp = http
|
||||
.post(format!("https://{domain}/oauth/token"))
|
||||
.json(&serde_json::json!({
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"audience": format!("https://{domain}/api/v2/"),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Auth0 token request failed: {e}"))?;
|
||||
|
||||
let token_body: serde_json::Value = token_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Auth0 token: {e}"))?;
|
||||
let access_token = token_body
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("No access_token in Auth0 response")?;
|
||||
|
||||
// Search for user by email
|
||||
let encoded_email = urlencoding::encode(email);
|
||||
let search_url = format!("https://{domain}/api/v2/users-by-email?email={encoded_email}");
|
||||
let search_resp = http
|
||||
.get(&search_url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Auth0 user search failed: {e}"))?;
|
||||
|
||||
let users: Vec<serde_json::Value> = search_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Auth0 users: {e}"))?;
|
||||
|
||||
let user_id = users
|
||||
.first()
|
||||
.and_then(|u| u.get("user_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| format!("User with email '{email}' not found in Auth0"))?;
|
||||
|
||||
// Delete
|
||||
let encoded_id = urlencoding::encode(user_id);
|
||||
let delete_resp = http
|
||||
.delete(format!("https://{domain}/api/v2/users/{encoded_id}"))
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Auth0 user delete failed: {e}"))?;
|
||||
|
||||
if delete_resp.status().is_success() || delete_resp.status().as_u16() == 204 {
|
||||
info!(email, user_id, "Auth0 test user deleted");
|
||||
Ok(true)
|
||||
} else {
|
||||
let status = delete_resp.status();
|
||||
let body = delete_resp.text().await.unwrap_or_default();
|
||||
Err(format!("Auth0 delete failed ({status}): {body}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a user from Okta via the Users API.
|
||||
///
|
||||
/// Requires `OKTA_DOMAIN`, `OKTA_API_TOKEN` env vars.
|
||||
async fn cleanup_okta(
|
||||
user: &TestUserRecord,
|
||||
_config: &AgentConfig,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<bool, String> {
|
||||
let domain = std::env::var("OKTA_DOMAIN").map_err(|_| "OKTA_DOMAIN not set")?;
|
||||
let api_token = std::env::var("OKTA_API_TOKEN").map_err(|_| "OKTA_API_TOKEN not set")?;
|
||||
|
||||
let username = user
|
||||
.username
|
||||
.as_deref()
|
||||
.or(user.email.as_deref())
|
||||
.ok_or("No username/email in test user record for Okta lookup")?;
|
||||
|
||||
info!(username, "Cleaning up Okta test user");
|
||||
|
||||
// Search user
|
||||
let encoded = urlencoding::encode(username);
|
||||
let search_url = format!("https://{domain}/api/v1/users?search=profile.login+eq+\"{encoded}\"");
|
||||
let search_resp = http
|
||||
.get(&search_url)
|
||||
.header("Authorization", format!("SSWS {api_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Okta user search failed: {e}"))?;
|
||||
|
||||
let users: Vec<serde_json::Value> = search_resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Okta users: {e}"))?;
|
||||
|
||||
let user_id = users
|
||||
.first()
|
||||
.and_then(|u| u.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| format!("User '{username}' not found in Okta"))?;
|
||||
|
||||
// Deactivate first (required by Okta before delete)
|
||||
let _ = http
|
||||
.post(format!(
|
||||
"https://{domain}/api/v1/users/{user_id}/lifecycle/deactivate"
|
||||
))
|
||||
.header("Authorization", format!("SSWS {api_token}"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
// Delete
|
||||
let delete_resp = http
|
||||
.delete(format!("https://{domain}/api/v1/users/{user_id}"))
|
||||
.header("Authorization", format!("SSWS {api_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Okta user delete failed: {e}"))?;
|
||||
|
||||
if delete_resp.status().is_success() || delete_resp.status().as_u16() == 204 {
|
||||
info!(username, user_id, "Okta test user deleted");
|
||||
Ok(true)
|
||||
} else {
|
||||
let status = delete_resp.status();
|
||||
let body = delete_resp.text().await.unwrap_or_default();
|
||||
Err(format!("Okta delete failed ({status}): {body}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::pentest::{IdentityProvider, TestUserRecord};
|
||||
use secrecy::SecretString;
|
||||
|
||||
fn make_config_no_keycloak() -> AgentConfig {
|
||||
AgentConfig {
|
||||
mongodb_uri: String::new(),
|
||||
mongodb_database: String::new(),
|
||||
litellm_url: String::new(),
|
||||
litellm_api_key: SecretString::from(String::new()),
|
||||
litellm_model: String::new(),
|
||||
litellm_embed_model: String::new(),
|
||||
github_token: None,
|
||||
github_webhook_secret: None,
|
||||
gitlab_url: None,
|
||||
gitlab_token: None,
|
||||
gitlab_webhook_secret: None,
|
||||
jira_url: None,
|
||||
jira_email: None,
|
||||
jira_api_token: None,
|
||||
jira_project_key: None,
|
||||
searxng_url: None,
|
||||
nvd_api_key: None,
|
||||
agent_port: 3001,
|
||||
scan_schedule: String::new(),
|
||||
cve_monitor_schedule: String::new(),
|
||||
git_clone_base_path: String::new(),
|
||||
ssh_key_path: String::new(),
|
||||
keycloak_url: None,
|
||||
keycloak_realm: None,
|
||||
keycloak_admin_username: None,
|
||||
keycloak_admin_password: None,
|
||||
pentest_verification_email: None,
|
||||
pentest_imap_host: None,
|
||||
pentest_imap_port: None,
|
||||
pentest_imap_tls: true,
|
||||
pentest_imap_username: None,
|
||||
pentest_imap_password: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn already_cleaned_up_returns_false() {
|
||||
let user = TestUserRecord {
|
||||
username: Some("test".into()),
|
||||
email: None,
|
||||
provider_user_id: None,
|
||||
provider: Some(IdentityProvider::Keycloak),
|
||||
cleaned_up: true,
|
||||
};
|
||||
let config = make_config_no_keycloak();
|
||||
let http = reqwest::Client::new();
|
||||
let result = cleanup_test_user(&user, &config, &http).await;
|
||||
assert_eq!(result, Ok(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn firebase_returns_false_not_implemented() {
|
||||
let user = TestUserRecord {
|
||||
username: Some("test".into()),
|
||||
email: None,
|
||||
provider_user_id: None,
|
||||
provider: Some(IdentityProvider::Firebase),
|
||||
cleaned_up: false,
|
||||
};
|
||||
let config = make_config_no_keycloak();
|
||||
let http = reqwest::Client::new();
|
||||
let result = cleanup_test_user(&user, &config, &http).await;
|
||||
assert_eq!(result, Ok(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_provider_no_keycloak_skips() {
|
||||
let user = TestUserRecord {
|
||||
username: Some("test".into()),
|
||||
email: None,
|
||||
provider_user_id: None,
|
||||
provider: None,
|
||||
cleaned_up: false,
|
||||
};
|
||||
let config = make_config_no_keycloak();
|
||||
let http = reqwest::Client::new();
|
||||
let result = cleanup_test_user(&user, &config, &http).await;
|
||||
assert_eq!(result, Ok(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_provider_no_keycloak_skips() {
|
||||
let user = TestUserRecord {
|
||||
username: Some("test".into()),
|
||||
email: None,
|
||||
provider_user_id: None,
|
||||
provider: Some(IdentityProvider::Custom),
|
||||
cleaned_up: false,
|
||||
};
|
||||
let config = make_config_no_keycloak();
|
||||
let http = reqwest::Client::new();
|
||||
let result = cleanup_test_user(&user, &config, &http).await;
|
||||
assert_eq!(result, Ok(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keycloak_missing_config_returns_error() {
|
||||
let user = TestUserRecord {
|
||||
username: Some("test".into()),
|
||||
email: None,
|
||||
provider_user_id: None,
|
||||
provider: Some(IdentityProvider::Keycloak),
|
||||
cleaned_up: false,
|
||||
};
|
||||
let config = make_config_no_keycloak();
|
||||
let http = reqwest::Client::new();
|
||||
let result = cleanup_test_user(&user, &config, &http).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|e| e.contains("KEYCLOAK_URL")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn keycloak_missing_username_returns_error() {
|
||||
let user = TestUserRecord {
|
||||
username: None,
|
||||
email: Some("test@example.com".into()),
|
||||
provider_user_id: None,
|
||||
provider: Some(IdentityProvider::Keycloak),
|
||||
cleaned_up: false,
|
||||
};
|
||||
let mut config = make_config_no_keycloak();
|
||||
config.keycloak_url = Some("http://localhost:8080".into());
|
||||
config.keycloak_realm = Some("test".into());
|
||||
config.keycloak_admin_username = Some("admin".into());
|
||||
config.keycloak_admin_password = Some(SecretString::from("pass".to_string()));
|
||||
let http = reqwest::Client::new();
|
||||
let result = cleanup_test_user(&user, &config, &http).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|e| e.contains("username")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth0_missing_env_returns_error() {
|
||||
let user = TestUserRecord {
|
||||
username: None,
|
||||
email: Some("test@example.com".into()),
|
||||
provider_user_id: None,
|
||||
provider: Some(IdentityProvider::Auth0),
|
||||
cleaned_up: false,
|
||||
};
|
||||
let config = make_config_no_keycloak();
|
||||
let http = reqwest::Client::new();
|
||||
let result = cleanup_test_user(&user, &config, &http).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|e| e.contains("AUTH0_DOMAIN")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn okta_missing_env_returns_error() {
|
||||
let user = TestUserRecord {
|
||||
username: Some("test".into()),
|
||||
email: None,
|
||||
provider_user_id: None,
|
||||
provider: Some(IdentityProvider::Okta),
|
||||
cleaned_up: false,
|
||||
};
|
||||
let config = make_config_no_keycloak();
|
||||
let http = reqwest::Client::new();
|
||||
let result = cleanup_test_user(&user, &config, &http).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.as_ref()
|
||||
.err()
|
||||
.is_some_and(|e| e.contains("OKTA_DOMAIN")));
|
||||
}
|
||||
}
|
||||
150
compliance-agent/src/pentest/context.rs
Normal file
150
compliance-agent/src/pentest/context.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use futures_util::StreamExt;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use compliance_core::models::dast::DastTarget;
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::CodeContextHint;
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
|
||||
use super::orchestrator::PentestOrchestrator;
|
||||
|
||||
impl PentestOrchestrator {
|
||||
/// Fetch SAST findings, SBOM entries (with CVEs), and code graph entry points
|
||||
/// for the repo linked to this DAST target.
|
||||
pub(crate) async fn gather_repo_context(
|
||||
&self,
|
||||
target: &DastTarget,
|
||||
) -> (Vec<Finding>, Vec<SbomEntry>, Vec<CodeContextHint>) {
|
||||
let Some(repo_id) = &target.repo_id else {
|
||||
return (Vec::new(), Vec::new(), Vec::new());
|
||||
};
|
||||
|
||||
let sast_findings = self.fetch_sast_findings(repo_id).await;
|
||||
let sbom_entries = self.fetch_vulnerable_sbom(repo_id).await;
|
||||
let code_context = self.fetch_code_context(repo_id, &sast_findings).await;
|
||||
|
||||
tracing::info!(
|
||||
repo_id,
|
||||
sast_findings = sast_findings.len(),
|
||||
vulnerable_deps = sbom_entries.len(),
|
||||
code_hints = code_context.len(),
|
||||
"Gathered code-awareness context for pentest"
|
||||
);
|
||||
|
||||
(sast_findings, sbom_entries, code_context)
|
||||
}
|
||||
|
||||
/// Fetch open/triaged SAST findings for the repo (not false positives or resolved)
|
||||
async fn fetch_sast_findings(&self, repo_id: &str) -> Vec<Finding> {
|
||||
let cursor = self
|
||||
.db
|
||||
.findings()
|
||||
.find(doc! {
|
||||
"repo_id": repo_id,
|
||||
"status": { "$in": ["open", "triaged"] },
|
||||
})
|
||||
.sort(doc! { "severity": -1 })
|
||||
.limit(100)
|
||||
.await;
|
||||
|
||||
match cursor {
|
||||
Ok(mut c) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(Ok(f)) = c.next().await {
|
||||
results.push(f);
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch SAST findings for pentest: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch SBOM entries that have known vulnerabilities
|
||||
async fn fetch_vulnerable_sbom(&self, repo_id: &str) -> Vec<SbomEntry> {
|
||||
let cursor = self
|
||||
.db
|
||||
.sbom_entries()
|
||||
.find(doc! {
|
||||
"repo_id": repo_id,
|
||||
"known_vulnerabilities": { "$exists": true, "$ne": [] },
|
||||
})
|
||||
.limit(50)
|
||||
.await;
|
||||
|
||||
match cursor {
|
||||
Ok(mut c) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(Ok(e)) = c.next().await {
|
||||
results.push(e);
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch vulnerable SBOM entries: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build CodeContextHint objects from the code knowledge graph.
|
||||
/// Maps entry points to their source files and links SAST findings.
|
||||
async fn fetch_code_context(
|
||||
&self,
|
||||
repo_id: &str,
|
||||
sast_findings: &[Finding],
|
||||
) -> Vec<CodeContextHint> {
|
||||
// Get entry point nodes from the code graph
|
||||
let cursor = self
|
||||
.db
|
||||
.graph_nodes()
|
||||
.find(doc! {
|
||||
"repo_id": repo_id,
|
||||
"is_entry_point": true,
|
||||
})
|
||||
.limit(50)
|
||||
.await;
|
||||
|
||||
let nodes = match cursor {
|
||||
Ok(mut c) => {
|
||||
let mut results = Vec::new();
|
||||
while let Some(Ok(n)) = c.next().await {
|
||||
results.push(n);
|
||||
}
|
||||
results
|
||||
}
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// Build hints by matching graph nodes to SAST findings by file path
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| {
|
||||
// Find SAST findings in the same file
|
||||
let linked_vulns: Vec<String> = sast_findings
|
||||
.iter()
|
||||
.filter(|f| f.file_path.as_deref() == Some(&node.file_path))
|
||||
.map(|f| {
|
||||
format!(
|
||||
"[{}] {}: {} (line {})",
|
||||
f.severity,
|
||||
f.scanner,
|
||||
f.title,
|
||||
f.line_number.unwrap_or(0)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
CodeContextHint {
|
||||
endpoint_pattern: node.qualified_name.clone(),
|
||||
handler_function: node.name.clone(),
|
||||
file_path: node.file_path.clone(),
|
||||
code_snippet: String::new(), // Could fetch from embeddings
|
||||
known_vulnerabilities: linked_vulns,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
117
compliance-agent/src/pentest/crypto.rs
Normal file
117
compliance-agent/src/pentest/crypto.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use aes_gcm::aead::AeadCore;
|
||||
use aes_gcm::{
|
||||
aead::{Aead, KeyInit, OsRng},
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use base64::Engine;
|
||||
|
||||
/// Load the 32-byte encryption key from PENTEST_ENCRYPTION_KEY env var.
|
||||
/// Returns None if not set or invalid length.
|
||||
pub fn load_encryption_key() -> Option<[u8; 32]> {
|
||||
let hex_key = std::env::var("PENTEST_ENCRYPTION_KEY").ok()?;
|
||||
let bytes = hex::decode(hex_key).ok()?;
|
||||
if bytes.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&bytes);
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext string. Returns base64-encoded nonce+ciphertext.
|
||||
/// Returns the original string if no encryption key is available.
|
||||
pub fn encrypt(plaintext: &str) -> String {
|
||||
let Some(key_bytes) = load_encryption_key() else {
|
||||
return plaintext.to_string();
|
||||
};
|
||||
let Ok(cipher) = Aes256Gcm::new_from_slice(&key_bytes) else {
|
||||
return plaintext.to_string();
|
||||
};
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let Ok(ciphertext) = cipher.encrypt(&nonce, plaintext.as_bytes()) else {
|
||||
return plaintext.to_string();
|
||||
};
|
||||
let mut combined = nonce.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
base64::engine::general_purpose::STANDARD.encode(&combined)
|
||||
}
|
||||
|
||||
/// Decrypt a base64-encoded nonce+ciphertext string.
|
||||
/// Returns None if decryption fails.
|
||||
pub fn decrypt(encrypted: &str) -> Option<String> {
|
||||
let key_bytes = load_encryption_key()?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&key_bytes).ok()?;
|
||||
let combined = base64::engine::general_purpose::STANDARD
|
||||
.decode(encrypted)
|
||||
.ok()?;
|
||||
if combined.len() < 12 {
|
||||
return None;
|
||||
}
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
let plaintext = cipher.decrypt(nonce, ciphertext).ok()?;
|
||||
String::from_utf8(plaintext).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Guard to serialize tests that touch env vars
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn with_key<F: FnOnce()>(hex_key: &str, f: F) {
|
||||
let _guard = ENV_LOCK.lock();
|
||||
unsafe { std::env::set_var("PENTEST_ENCRYPTION_KEY", hex_key) };
|
||||
f();
|
||||
unsafe { std::env::remove_var("PENTEST_ENCRYPTION_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
with_key(key, || {
|
||||
let plaintext = "my_secret_password";
|
||||
let encrypted = encrypt(plaintext);
|
||||
assert_ne!(encrypted, plaintext);
|
||||
let decrypted = decrypt(&encrypted);
|
||||
assert_eq!(decrypted, Some(plaintext.to_string()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails() {
|
||||
let _guard = ENV_LOCK.lock();
|
||||
let key1 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let key2 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
|
||||
let encrypted = {
|
||||
unsafe { std::env::set_var("PENTEST_ENCRYPTION_KEY", key1) };
|
||||
let e = encrypt("secret");
|
||||
unsafe { std::env::remove_var("PENTEST_ENCRYPTION_KEY") };
|
||||
e
|
||||
};
|
||||
unsafe { std::env::set_var("PENTEST_ENCRYPTION_KEY", key2) };
|
||||
assert!(decrypt(&encrypted).is_none());
|
||||
unsafe { std::env::remove_var("PENTEST_ENCRYPTION_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_key_passthrough() {
|
||||
let _guard = ENV_LOCK.lock();
|
||||
unsafe { std::env::remove_var("PENTEST_ENCRYPTION_KEY") };
|
||||
let result = encrypt("plain");
|
||||
assert_eq!(result, "plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_ciphertext() {
|
||||
let key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
with_key(key, || {
|
||||
assert!(decrypt("not-valid-base64!!!").is_none());
|
||||
// Valid base64 but wrong content
|
||||
let garbage = base64::engine::general_purpose::STANDARD.encode(b"tooshort");
|
||||
assert!(decrypt(&garbage).is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
9
compliance-agent/src/pentest/mod.rs
Normal file
9
compliance-agent/src/pentest/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod cleanup;
|
||||
mod context;
|
||||
pub mod crypto;
|
||||
pub mod orchestrator;
|
||||
mod prompt_builder;
|
||||
pub mod report;
|
||||
|
||||
pub use orchestrator::PentestOrchestrator;
|
||||
pub use report::generate_encrypted_report;
|
||||
706
compliance-agent/src/pentest/orchestrator.rs
Normal file
706
compliance-agent/src/pentest/orchestrator.rs
Normal file
@@ -0,0 +1,706 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use mongodb::bson::doc;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
|
||||
use compliance_core::models::dast::DastTarget;
|
||||
use compliance_core::models::pentest::*;
|
||||
use compliance_core::traits::pentest_tool::PentestToolContext;
|
||||
use compliance_dast::ToolRegistry;
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::llm::{
|
||||
ChatMessage, LlmClient, LlmResponse, ToolCallRequest, ToolCallRequestFunction, ToolDefinition,
|
||||
};
|
||||
|
||||
/// Maximum duration for a single pentest session before timeout
|
||||
const SESSION_TIMEOUT: Duration = Duration::from_secs(30 * 60); // 30 minutes
|
||||
|
||||
pub struct PentestOrchestrator {
|
||||
pub(crate) tool_registry: ToolRegistry,
|
||||
pub(crate) llm: Arc<LlmClient>,
|
||||
pub(crate) db: Database,
|
||||
pub(crate) event_tx: broadcast::Sender<PentestEvent>,
|
||||
pub(crate) pause_rx: Option<watch::Receiver<bool>>,
|
||||
}
|
||||
|
||||
impl PentestOrchestrator {
|
||||
/// Create a new orchestrator with an externally-provided broadcast sender
|
||||
/// and an optional pause receiver.
|
||||
pub fn new(
|
||||
llm: Arc<LlmClient>,
|
||||
db: Database,
|
||||
event_tx: broadcast::Sender<PentestEvent>,
|
||||
pause_rx: Option<watch::Receiver<bool>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_registry: ToolRegistry::new(),
|
||||
llm,
|
||||
db,
|
||||
event_tx,
|
||||
pause_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a pentest session with timeout and automatic failure marking on errors.
|
||||
pub async fn run_session_guarded(
|
||||
&self,
|
||||
session: &PentestSession,
|
||||
target: &DastTarget,
|
||||
initial_message: &str,
|
||||
) {
|
||||
let session_id = session.id;
|
||||
|
||||
// Use config-specified timeout or default
|
||||
let timeout_duration = session
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.max_duration_minutes)
|
||||
.map(|m| Duration::from_secs(m as u64 * 60))
|
||||
.unwrap_or(SESSION_TIMEOUT);
|
||||
|
||||
let timeout_minutes = timeout_duration.as_secs() / 60;
|
||||
|
||||
match tokio::time::timeout(
|
||||
timeout_duration,
|
||||
self.run_session(session, target, initial_message),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {
|
||||
tracing::info!(?session_id, "Pentest session completed successfully");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(?session_id, error = %e, "Pentest session failed");
|
||||
self.mark_session_failed(session_id, &format!("Error: {e}"))
|
||||
.await;
|
||||
let _ = self.event_tx.send(PentestEvent::Error {
|
||||
message: format!("Session failed: {e}"),
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
let msg = format!("Session timed out after {timeout_minutes} minutes");
|
||||
tracing::warn!(?session_id, "{msg}");
|
||||
self.mark_session_failed(session_id, &msg).await;
|
||||
let _ = self.event_tx.send(PentestEvent::Error { message: msg });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_session_failed(
|
||||
&self,
|
||||
session_id: Option<mongodb::bson::oid::ObjectId>,
|
||||
reason: &str,
|
||||
) {
|
||||
if let Some(sid) = session_id {
|
||||
let _ = self
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": sid },
|
||||
doc! { "$set": {
|
||||
"status": "failed",
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
"error_message": reason,
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the session is paused; if so, update DB status and wait until resumed.
|
||||
async fn wait_if_paused(&self, session: &PentestSession) {
|
||||
let Some(ref pause_rx) = self.pause_rx else {
|
||||
return;
|
||||
};
|
||||
let mut rx = pause_rx.clone();
|
||||
|
||||
if !*rx.borrow() {
|
||||
return;
|
||||
}
|
||||
|
||||
// We are paused — update DB status
|
||||
if let Some(sid) = session.id {
|
||||
let _ = self
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(doc! { "_id": sid }, doc! { "$set": { "status": "paused" }})
|
||||
.await;
|
||||
}
|
||||
let _ = self.event_tx.send(PentestEvent::Paused);
|
||||
|
||||
// Wait until unpaused
|
||||
while *rx.borrow() {
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Resumed — update DB status back to running
|
||||
if let Some(sid) = session.id {
|
||||
let _ = self
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(doc! { "_id": sid }, doc! { "$set": { "status": "running" }})
|
||||
.await;
|
||||
}
|
||||
let _ = self.event_tx.send(PentestEvent::Resumed);
|
||||
}
|
||||
|
||||
async fn run_session(
|
||||
&self,
|
||||
session: &PentestSession,
|
||||
target: &DastTarget,
|
||||
initial_message: &str,
|
||||
) -> Result<(), crate::error::AgentError> {
|
||||
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
|
||||
// Gather code-awareness context from linked repo
|
||||
let (sast_findings, sbom_entries, code_context) = self.gather_repo_context(target).await;
|
||||
|
||||
// Build system prompt with code context
|
||||
let system_prompt = self
|
||||
.build_system_prompt(
|
||||
session,
|
||||
target,
|
||||
&sast_findings,
|
||||
&sbom_entries,
|
||||
&code_context,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Build tool definitions for LLM
|
||||
let tool_defs: Vec<ToolDefinition> = self
|
||||
.tool_registry
|
||||
.all_definitions()
|
||||
.into_iter()
|
||||
.map(|td| ToolDefinition {
|
||||
name: td.name,
|
||||
description: td.description,
|
||||
parameters: td.input_schema,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Initialize messages
|
||||
let mut messages = vec![
|
||||
ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(system_prompt),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(initial_message.to_string()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
// Store user message
|
||||
let user_msg = PentestMessage::user(session_id.clone(), initial_message.to_string());
|
||||
let _ = self.db.pentest_messages().insert_one(&user_msg).await;
|
||||
|
||||
// Build tool context with real data
|
||||
let tool_context = PentestToolContext {
|
||||
target: target.clone(),
|
||||
session_id: session_id.clone(),
|
||||
sast_findings,
|
||||
sbom_entries,
|
||||
code_context,
|
||||
rate_limit: target.rate_limit,
|
||||
allow_destructive: target.allow_destructive,
|
||||
};
|
||||
|
||||
let max_iterations = 50;
|
||||
let mut total_findings = 0u32;
|
||||
let mut total_tool_calls = 0u32;
|
||||
let mut total_successes = 0u32;
|
||||
let mut prev_node_ids: Vec<String> = Vec::new();
|
||||
|
||||
for _iteration in 0..max_iterations {
|
||||
// Check pause state at top of each iteration
|
||||
self.wait_if_paused(session).await;
|
||||
|
||||
let response = self
|
||||
.llm
|
||||
.chat_with_tools(messages.clone(), &tool_defs, Some(0.2), Some(8192))
|
||||
.await?;
|
||||
|
||||
match response {
|
||||
LlmResponse::Content(content) => {
|
||||
let msg = PentestMessage::assistant(session_id.clone(), content.clone());
|
||||
let _ = self.db.pentest_messages().insert_one(&msg).await;
|
||||
let _ = self.event_tx.send(PentestEvent::Message {
|
||||
content: content.clone(),
|
||||
});
|
||||
|
||||
messages.push(ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(content.clone()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
let done_indicators = [
|
||||
"pentest complete",
|
||||
"testing complete",
|
||||
"scan complete",
|
||||
"analysis complete",
|
||||
"finished",
|
||||
"that concludes",
|
||||
];
|
||||
let content_lower = content.to_lowercase();
|
||||
if done_indicators
|
||||
.iter()
|
||||
.any(|ind| content_lower.contains(ind))
|
||||
{
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
LlmResponse::ToolCalls {
|
||||
calls: tool_calls,
|
||||
reasoning,
|
||||
} => {
|
||||
let tc_requests: Vec<ToolCallRequest> = tool_calls
|
||||
.iter()
|
||||
.map(|tc| ToolCallRequest {
|
||||
id: tc.id.clone(),
|
||||
r#type: "function".to_string(),
|
||||
function: ToolCallRequestFunction {
|
||||
name: tc.name.clone(),
|
||||
arguments: serde_json::to_string(&tc.arguments).unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
messages.push(ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if reasoning.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(reasoning.clone())
|
||||
},
|
||||
tool_calls: Some(tc_requests),
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
let mut current_batch_node_ids: Vec<String> = Vec::new();
|
||||
|
||||
for tc in &tool_calls {
|
||||
total_tool_calls += 1;
|
||||
let node_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let mut node = AttackChainNode::new(
|
||||
session_id.clone(),
|
||||
node_id.clone(),
|
||||
tc.name.clone(),
|
||||
tc.arguments.clone(),
|
||||
reasoning.clone(),
|
||||
);
|
||||
// Link to previous iteration's nodes
|
||||
node.parent_node_ids = prev_node_ids.clone();
|
||||
node.status = AttackNodeStatus::Running;
|
||||
node.started_at = Some(chrono::Utc::now());
|
||||
let _ = self.db.attack_chain_nodes().insert_one(&node).await;
|
||||
current_batch_node_ids.push(node_id.clone());
|
||||
|
||||
let _ = self.event_tx.send(PentestEvent::ToolStart {
|
||||
node_id: node_id.clone(),
|
||||
tool_name: tc.name.clone(),
|
||||
input: tc.arguments.clone(),
|
||||
});
|
||||
|
||||
let result = if let Some(tool) = self.tool_registry.get(&tc.name) {
|
||||
match tool.execute(tc.arguments.clone(), &tool_context).await {
|
||||
Ok(result) => {
|
||||
total_successes += 1;
|
||||
let findings_count = result.findings.len() as u32;
|
||||
total_findings += findings_count;
|
||||
|
||||
let mut finding_ids: Vec<String> = Vec::new();
|
||||
for mut finding in result.findings {
|
||||
finding.scan_run_id = session_id.clone();
|
||||
finding.session_id = Some(session_id.clone());
|
||||
let insert_result =
|
||||
self.db.dast_findings().insert_one(&finding).await;
|
||||
if let Ok(res) = &insert_result {
|
||||
finding_ids.push(
|
||||
res.inserted_id
|
||||
.as_object_id()
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
let _ = self.event_tx.send(PentestEvent::Finding {
|
||||
finding_id: finding
|
||||
.id
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_default(),
|
||||
title: finding.title.clone(),
|
||||
severity: finding.severity.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Compute risk score based on findings severity
|
||||
let risk_score: Option<u8> = if findings_count > 0 {
|
||||
Some(std::cmp::min(
|
||||
100,
|
||||
(findings_count as u8)
|
||||
.saturating_mul(15)
|
||||
.saturating_add(20),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let _ = self.event_tx.send(PentestEvent::ToolComplete {
|
||||
node_id: node_id.clone(),
|
||||
summary: result.summary.clone(),
|
||||
findings_count,
|
||||
});
|
||||
|
||||
let finding_ids_bson: Vec<mongodb::bson::Bson> = finding_ids
|
||||
.iter()
|
||||
.map(|id| mongodb::bson::Bson::String(id.clone()))
|
||||
.collect();
|
||||
|
||||
let mut update_doc = doc! {
|
||||
"status": "completed",
|
||||
"tool_output": mongodb::bson::to_bson(&result.data)
|
||||
.unwrap_or(mongodb::bson::Bson::Null),
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
"findings_produced": finding_ids_bson,
|
||||
};
|
||||
if let Some(rs) = risk_score {
|
||||
update_doc.insert("risk_score", rs as i32);
|
||||
}
|
||||
|
||||
let _ = self
|
||||
.db
|
||||
.attack_chain_nodes()
|
||||
.update_one(
|
||||
doc! {
|
||||
"session_id": &session_id,
|
||||
"node_id": &node_id,
|
||||
},
|
||||
doc! { "$set": update_doc },
|
||||
)
|
||||
.await;
|
||||
|
||||
// Build LLM-facing summary: strip large fields
|
||||
// (screenshots, raw HTML) to save context window
|
||||
let llm_data = summarize_tool_output(&result.data);
|
||||
serde_json::json!({
|
||||
"summary": result.summary,
|
||||
"findings_count": findings_count,
|
||||
"data": llm_data,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = self
|
||||
.db
|
||||
.attack_chain_nodes()
|
||||
.update_one(
|
||||
doc! {
|
||||
"session_id": &session_id,
|
||||
"node_id": &node_id,
|
||||
},
|
||||
doc! { "$set": {
|
||||
"status": "failed",
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
format!("Tool execution failed: {e}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
format!("Unknown tool: {}", tc.name)
|
||||
};
|
||||
|
||||
messages.push(ChatMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(result),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tc.id.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// Advance parent links so next iteration's nodes connect to this batch
|
||||
prev_node_ids = current_batch_node_ids;
|
||||
|
||||
if let Some(sid) = session.id {
|
||||
let _ = self
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": sid },
|
||||
doc! { "$set": {
|
||||
"tool_invocations": total_tool_calls as i64,
|
||||
"tool_successes": total_successes as i64,
|
||||
"findings_count": total_findings as i64,
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sid) = session.id {
|
||||
let _ = self
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": sid },
|
||||
doc! { "$set": {
|
||||
"status": "completed",
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
"tool_invocations": total_tool_calls as i64,
|
||||
"tool_successes": total_successes as i64,
|
||||
"findings_count": total_findings as i64,
|
||||
}},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Clean up test user via identity provider API if requested
|
||||
if session
|
||||
.config
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.auth.cleanup_test_user)
|
||||
{
|
||||
if let Some(ref test_user) = session.test_user {
|
||||
let http = reqwest::Client::new();
|
||||
// We need the AgentConfig — read from env since orchestrator doesn't hold it
|
||||
let config = crate::config::load_config();
|
||||
match config {
|
||||
Ok(cfg) => {
|
||||
match crate::pentest::cleanup::cleanup_test_user(test_user, &cfg, &http)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
username = test_user.username.as_deref(),
|
||||
"Test user cleaned up via provider API"
|
||||
);
|
||||
// Mark as cleaned up in DB
|
||||
if let Some(sid) = session.id {
|
||||
let _ = self
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": sid },
|
||||
doc! { "$set": { "test_user.cleaned_up": true } },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::info!(
|
||||
"Test user cleanup skipped (no provider configured)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Test user cleanup failed");
|
||||
let _ = self.event_tx.send(PentestEvent::Error {
|
||||
message: format!("Test user cleanup failed: {e}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Could not load config for cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the persistent browser session for this pentest
|
||||
compliance_dast::tools::browser::cleanup_browser_session(&session_id).await;
|
||||
|
||||
let _ = self.event_tx.send(PentestEvent::Complete {
|
||||
summary: format!(
|
||||
"Pentest complete. {} findings from {} tool invocations.",
|
||||
total_findings, total_tool_calls
|
||||
),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip large fields from tool output before sending to the LLM.
|
||||
/// Screenshots, raw HTML, and other bulky data are replaced with short summaries.
|
||||
/// The full data is still stored in the DB for the report.
|
||||
fn summarize_tool_output(data: &serde_json::Value) -> serde_json::Value {
|
||||
let Some(obj) = data.as_object() else {
|
||||
return data.clone();
|
||||
};
|
||||
|
||||
let mut summarized = serde_json::Map::new();
|
||||
for (key, value) in obj {
|
||||
match key.as_str() {
|
||||
// Replace screenshot base64 with a placeholder
|
||||
"screenshot_base64" => {
|
||||
if let Some(s) = value.as_str() {
|
||||
if !s.is_empty() {
|
||||
summarized.insert(
|
||||
key.clone(),
|
||||
serde_json::Value::String(
|
||||
"[screenshot captured and saved to report]".to_string(),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
summarized.insert(key.clone(), value.clone());
|
||||
}
|
||||
// Truncate raw HTML content
|
||||
"html" => {
|
||||
if let Some(s) = value.as_str() {
|
||||
if s.len() > 2000 {
|
||||
summarized.insert(
|
||||
key.clone(),
|
||||
serde_json::Value::String(format!(
|
||||
"{}... [truncated, {} chars total]",
|
||||
&s[..2000],
|
||||
s.len()
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
summarized.insert(key.clone(), value.clone());
|
||||
}
|
||||
// Truncate page text
|
||||
"text" if value.as_str().is_some_and(|s| s.len() > 1500) => {
|
||||
let s = value.as_str().unwrap_or_default();
|
||||
summarized.insert(
|
||||
key.clone(),
|
||||
serde_json::Value::String(format!("{}... [truncated]", &s[..1500])),
|
||||
);
|
||||
}
|
||||
// Trim large arrays (e.g., "elements", "links", "inputs")
|
||||
"elements" | "links" | "inputs" => {
|
||||
if let Some(arr) = value.as_array() {
|
||||
if arr.len() > 15 {
|
||||
let mut trimmed: Vec<serde_json::Value> = arr[..15].to_vec();
|
||||
trimmed.push(serde_json::json!(format!(
|
||||
"... and {} more",
|
||||
arr.len() - 15
|
||||
)));
|
||||
summarized.insert(key.clone(), serde_json::Value::Array(trimmed));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
summarized.insert(key.clone(), value.clone());
|
||||
}
|
||||
// Recursively summarize nested objects (e.g., "page" in get_content)
|
||||
_ if value.is_object() => {
|
||||
summarized.insert(key.clone(), summarize_tool_output(value));
|
||||
}
|
||||
// Keep everything else as-is
|
||||
_ => {
|
||||
summarized.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(summarized)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_summarize_strips_screenshot() {
|
||||
let input = json!({
|
||||
"screenshot_base64": "iVBOR...",
|
||||
"url": "https://example.com"
|
||||
});
|
||||
let result = summarize_tool_output(&input);
|
||||
assert_eq!(
|
||||
result["screenshot_base64"],
|
||||
"[screenshot captured and saved to report]"
|
||||
);
|
||||
assert_eq!(result["url"], "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_truncates_html() {
|
||||
let long_html = "x".repeat(3000);
|
||||
let input = json!({ "html": long_html });
|
||||
let result = summarize_tool_output(&input);
|
||||
let s = result["html"].as_str().unwrap_or_default();
|
||||
assert!(s.contains("[truncated, 3000 chars total]"));
|
||||
assert!(s.starts_with(&"x".repeat(2000)));
|
||||
assert!(s.len() < 3000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_truncates_text() {
|
||||
let long_text = "a".repeat(2000);
|
||||
let input = json!({ "text": long_text });
|
||||
let result = summarize_tool_output(&input);
|
||||
let s = result["text"].as_str().unwrap_or_default();
|
||||
assert!(s.contains("[truncated]"));
|
||||
assert!(s.starts_with(&"a".repeat(1500)));
|
||||
assert!(s.len() < 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_trims_large_arrays() {
|
||||
let elements: Vec<serde_json::Value> = (0..20).map(|i| json!(format!("el-{i}"))).collect();
|
||||
let input = json!({ "elements": elements });
|
||||
let result = summarize_tool_output(&input);
|
||||
let arr = result["elements"].as_array();
|
||||
assert!(arr.is_some());
|
||||
if let Some(arr) = arr {
|
||||
// 15 kept + 1 summary entry
|
||||
assert_eq!(arr.len(), 16);
|
||||
assert_eq!(arr[15], json!("... and 5 more"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_preserves_small_data() {
|
||||
let input = json!({
|
||||
"url": "https://example.com",
|
||||
"status": 200,
|
||||
"title": "Example"
|
||||
});
|
||||
let result = summarize_tool_output(&input);
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_recursive() {
|
||||
let input = json!({
|
||||
"page": {
|
||||
"screenshot_base64": "iVBORw0KGgoAAAA...",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
});
|
||||
let result = summarize_tool_output(&input);
|
||||
assert_eq!(
|
||||
result["page"]["screenshot_base64"],
|
||||
"[screenshot captured and saved to report]"
|
||||
);
|
||||
assert_eq!(result["page"]["url"], "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_non_object() {
|
||||
let string_val = json!("just a string");
|
||||
assert_eq!(summarize_tool_output(&string_val), string_val);
|
||||
|
||||
let num_val = json!(42);
|
||||
assert_eq!(summarize_tool_output(&num_val), num_val);
|
||||
}
|
||||
}
|
||||
622
compliance-agent/src/pentest/prompt_builder.rs
Normal file
622
compliance-agent/src/pentest/prompt_builder.rs
Normal file
@@ -0,0 +1,622 @@
|
||||
use compliance_core::models::dast::DastTarget;
|
||||
use compliance_core::models::finding::{Finding, FindingStatus, Severity};
|
||||
use compliance_core::models::pentest::*;
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
|
||||
use super::orchestrator::PentestOrchestrator;
|
||||
|
||||
/// Attempt to decrypt a field; if decryption fails, return the original value
|
||||
/// (which may be plaintext from before encryption was enabled).
|
||||
fn decrypt_field(value: &str) -> String {
|
||||
super::crypto::decrypt(value).unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
/// Build additional prompt sections from PentestConfig when present.
|
||||
fn build_config_sections(config: &PentestConfig) -> String {
|
||||
let mut sections = String::new();
|
||||
|
||||
// Authentication section
|
||||
match config.auth.mode {
|
||||
AuthMode::Manual => {
|
||||
sections.push_str("\n## Authentication\n");
|
||||
sections.push_str("- **Mode**: Manual credentials\n");
|
||||
if let Some(ref u) = config.auth.username {
|
||||
let decrypted = decrypt_field(u);
|
||||
sections.push_str(&format!("- **Username**: {decrypted}\n"));
|
||||
}
|
||||
if let Some(ref p) = config.auth.password {
|
||||
let decrypted = decrypt_field(p);
|
||||
sections.push_str(&format!("- **Password**: {decrypted}\n"));
|
||||
}
|
||||
sections.push_str(
|
||||
"Use these credentials to log in before testing authenticated endpoints.\n",
|
||||
);
|
||||
}
|
||||
AuthMode::AutoRegister => {
|
||||
sections.push_str("\n## Authentication\n");
|
||||
sections.push_str("- **Mode**: Auto-register\n");
|
||||
if let Some(ref url) = config.auth.registration_url {
|
||||
sections.push_str(&format!("- **Registration URL**: {url}\n"));
|
||||
} else {
|
||||
sections.push_str(
|
||||
"- **Registration URL**: Not provided — use Playwright to discover the registration page.\n",
|
||||
);
|
||||
}
|
||||
if let Some(ref email) = config.auth.verification_email {
|
||||
sections.push_str(&format!(
|
||||
"- **Verification Email**: Use plus-addressing from `{email}` \
|
||||
(e.g. `{base}+{{session_id}}@{domain}`) for email verification. \
|
||||
The system will poll the IMAP mailbox for verification links.\n",
|
||||
base = email.split('@').next().unwrap_or(email),
|
||||
domain = email.split('@').nth(1).unwrap_or("example.com"),
|
||||
));
|
||||
}
|
||||
sections.push_str(
|
||||
"Register a new test account using the registration page, then use it for testing.\n",
|
||||
);
|
||||
}
|
||||
AuthMode::None => {}
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
if !config.custom_headers.is_empty() {
|
||||
sections.push_str("\n## Custom HTTP Headers\n");
|
||||
sections.push_str("Include these headers in all HTTP requests:\n");
|
||||
for (k, v) in &config.custom_headers {
|
||||
sections.push_str(&format!("- `{k}: {v}`\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Scope exclusions
|
||||
if !config.scope_exclusions.is_empty() {
|
||||
sections.push_str("\n## Scope Exclusions\n");
|
||||
sections.push_str("Do NOT test the following paths:\n");
|
||||
for path in &config.scope_exclusions {
|
||||
sections.push_str(&format!("- `{path}`\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Git context
|
||||
if config.git_repo_url.is_some() || config.branch.is_some() || config.commit_hash.is_some() {
|
||||
sections.push_str("\n## Git Context\n");
|
||||
if let Some(ref url) = config.git_repo_url {
|
||||
sections.push_str(&format!("- **Repository**: {url}\n"));
|
||||
}
|
||||
if let Some(ref branch) = config.branch {
|
||||
sections.push_str(&format!("- **Branch**: {branch}\n"));
|
||||
}
|
||||
if let Some(ref commit) = config.commit_hash {
|
||||
sections.push_str(&format!("- **Commit**: {commit}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
// Environment
|
||||
sections.push_str(&format!(
|
||||
"\n## Environment\n- **Target environment**: {}\n",
|
||||
config.environment
|
||||
));
|
||||
|
||||
sections
|
||||
}
|
||||
|
||||
/// Return strategy guidance text for the given strategy.
|
||||
fn strategy_guidance(strategy: &PentestStrategy) -> &'static str {
|
||||
match strategy {
|
||||
PentestStrategy::Quick => {
|
||||
"Focus on the most common and impactful vulnerabilities. Run a quick recon, then target the highest-risk areas."
|
||||
}
|
||||
PentestStrategy::Comprehensive => {
|
||||
"Perform a thorough assessment covering all vulnerability types. Start with recon, then systematically test each attack surface."
|
||||
}
|
||||
PentestStrategy::Targeted => {
|
||||
"Focus specifically on areas highlighted by SAST findings and known CVEs. Prioritize exploiting known weaknesses."
|
||||
}
|
||||
PentestStrategy::Aggressive => {
|
||||
"Use all available tools aggressively. Test with maximum payloads and attempt full exploitation."
|
||||
}
|
||||
PentestStrategy::Stealth => {
|
||||
"Minimize noise. Use fewer requests, avoid aggressive payloads. Focus on passive analysis and targeted probes."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the SAST findings section for the system prompt.
|
||||
fn build_sast_section(sast_findings: &[Finding]) -> String {
|
||||
if sast_findings.is_empty() {
|
||||
return String::from("No SAST findings available for this target.");
|
||||
}
|
||||
|
||||
let critical = sast_findings
|
||||
.iter()
|
||||
.filter(|f| f.severity == Severity::Critical)
|
||||
.count();
|
||||
let high = sast_findings
|
||||
.iter()
|
||||
.filter(|f| f.severity == Severity::High)
|
||||
.count();
|
||||
|
||||
let mut section = format!(
|
||||
"{} open findings ({} critical, {} high):\n",
|
||||
sast_findings.len(),
|
||||
critical,
|
||||
high
|
||||
);
|
||||
|
||||
// List the most important findings (critical/high first, up to 20)
|
||||
for f in sast_findings.iter().take(20) {
|
||||
let file_info = f
|
||||
.file_path
|
||||
.as_ref()
|
||||
.map(|p| format!(" in {}:{}", p, f.line_number.unwrap_or(0)))
|
||||
.unwrap_or_default();
|
||||
let status_note = match f.status {
|
||||
FindingStatus::Triaged => " [TRIAGED]",
|
||||
_ => "",
|
||||
};
|
||||
section.push_str(&format!(
|
||||
"- [{sev}] {title}{file}{status}\n",
|
||||
sev = f.severity,
|
||||
title = f.title,
|
||||
file = file_info,
|
||||
status = status_note,
|
||||
));
|
||||
if let Some(cwe) = &f.cwe {
|
||||
section.push_str(&format!(" CWE: {cwe}\n"));
|
||||
}
|
||||
}
|
||||
if sast_findings.len() > 20 {
|
||||
section.push_str(&format!(
|
||||
"... and {} more findings\n",
|
||||
sast_findings.len() - 20
|
||||
));
|
||||
}
|
||||
section
|
||||
}
|
||||
|
||||
/// Build the SBOM/CVE section for the system prompt.
|
||||
fn build_sbom_section(sbom_entries: &[SbomEntry]) -> String {
|
||||
if sbom_entries.is_empty() {
|
||||
return String::from("No vulnerable dependencies identified.");
|
||||
}
|
||||
|
||||
let mut section = format!(
|
||||
"{} dependencies with known vulnerabilities:\n",
|
||||
sbom_entries.len()
|
||||
);
|
||||
for entry in sbom_entries.iter().take(15) {
|
||||
let cve_ids: Vec<&str> = entry
|
||||
.known_vulnerabilities
|
||||
.iter()
|
||||
.map(|v| v.id.as_str())
|
||||
.collect();
|
||||
section.push_str(&format!(
|
||||
"- {} {} ({}): {}\n",
|
||||
entry.name,
|
||||
entry.version,
|
||||
entry.package_manager,
|
||||
cve_ids.join(", ")
|
||||
));
|
||||
}
|
||||
if sbom_entries.len() > 15 {
|
||||
section.push_str(&format!(
|
||||
"... and {} more vulnerable dependencies\n",
|
||||
sbom_entries.len() - 15
|
||||
));
|
||||
}
|
||||
section
|
||||
}
|
||||
|
||||
/// Build the code context section for the system prompt.
|
||||
fn build_code_section(code_context: &[CodeContextHint]) -> String {
|
||||
if code_context.is_empty() {
|
||||
return String::from("No code knowledge graph available for this target.");
|
||||
}
|
||||
|
||||
let with_vulns = code_context
|
||||
.iter()
|
||||
.filter(|c| !c.known_vulnerabilities.is_empty())
|
||||
.count();
|
||||
|
||||
let mut section = format!(
|
||||
"{} entry points identified ({} with linked SAST findings):\n",
|
||||
code_context.len(),
|
||||
with_vulns
|
||||
);
|
||||
|
||||
for hint in code_context.iter().take(20) {
|
||||
section.push_str(&format!(
|
||||
"- {} ({})\n",
|
||||
hint.endpoint_pattern, hint.file_path
|
||||
));
|
||||
for vuln in &hint.known_vulnerabilities {
|
||||
section.push_str(&format!(" SAST: {vuln}\n"));
|
||||
}
|
||||
}
|
||||
section
|
||||
}
|
||||
|
||||
impl PentestOrchestrator {
|
||||
pub(crate) async fn build_system_prompt(
|
||||
&self,
|
||||
session: &PentestSession,
|
||||
target: &DastTarget,
|
||||
sast_findings: &[Finding],
|
||||
sbom_entries: &[SbomEntry],
|
||||
code_context: &[CodeContextHint],
|
||||
) -> String {
|
||||
let tool_names = self.tool_registry.list_names().join(", ");
|
||||
let guidance = strategy_guidance(&session.strategy);
|
||||
let sast_section = build_sast_section(sast_findings);
|
||||
let sbom_section = build_sbom_section(sbom_entries);
|
||||
let code_section = build_code_section(code_context);
|
||||
let config_sections = session
|
||||
.config
|
||||
.as_ref()
|
||||
.map(build_config_sections)
|
||||
.unwrap_or_default();
|
||||
|
||||
format!(
|
||||
r#"You are an expert penetration tester conducting an authorized security assessment.
|
||||
|
||||
## Target
|
||||
- **Name**: {target_name}
|
||||
- **URL**: {base_url}
|
||||
- **Type**: {target_type}
|
||||
- **Rate Limit**: {rate_limit} req/s
|
||||
- **Destructive Tests Allowed**: {allow_destructive}
|
||||
- **Linked Repository**: {repo_linked}
|
||||
|
||||
## Strategy
|
||||
{strategy_guidance}
|
||||
|
||||
## SAST Findings (Static Analysis)
|
||||
{sast_section}
|
||||
|
||||
## Vulnerable Dependencies (SBOM)
|
||||
{sbom_section}
|
||||
|
||||
## Code Entry Points (Knowledge Graph)
|
||||
{code_section}
|
||||
{config_sections}
|
||||
## Available Tools
|
||||
{tool_names}
|
||||
|
||||
## Instructions
|
||||
1. Start by running reconnaissance (recon tool) to fingerprint the target and discover technologies.
|
||||
2. Run the OpenAPI parser to discover API endpoints from specs.
|
||||
3. Check infrastructure: DNS, DMARC, TLS, security headers, cookies, CSP, CORS.
|
||||
4. If the target requires authentication (auto-register mode), use the browser tool to:
|
||||
a. Navigate to the target — it will redirect to the login page.
|
||||
b. Click the "Register" link to reach the registration form.
|
||||
c. Fill all form fields (username, email with plus-addressing, password, name) one by one.
|
||||
d. Click submit. If a Terms & Conditions page appears, accept it.
|
||||
e. After registration, use the browser to navigate through the application pages.
|
||||
f. **Take a screenshot after each major page** for evidence in the report.
|
||||
5. Use the browser tool to explore the authenticated application — navigate to each section,
|
||||
use get_content to understand the page structure, and take screenshots.
|
||||
6. Based on SAST findings, prioritize testing endpoints where vulnerabilities were found in code.
|
||||
7. For each vulnerability type found in SAST, use the corresponding DAST tool to verify exploitability.
|
||||
8. If vulnerable dependencies are listed, try to trigger known CVE conditions against the running application.
|
||||
9. Test rate limiting on critical endpoints (login, API).
|
||||
10. Check for console.log leakage in frontend JavaScript.
|
||||
11. Analyze tool results and chain findings — if one vulnerability enables others, explore the chain.
|
||||
12. When testing is complete, provide a structured summary with severity and remediation.
|
||||
13. Always explain your reasoning before invoking each tool.
|
||||
14. When done, say "Testing complete" followed by a final summary.
|
||||
|
||||
## Browser Tool Usage
|
||||
- The browser tab **persists** between calls — cookies and login state are preserved.
|
||||
- After navigate, the response includes `elements` (links, inputs, buttons on the page).
|
||||
- Use `get_content` to see forms, links, buttons, headings, and page text.
|
||||
- Use `click` with CSS selectors to interact (e.g., `a:text('Register')`, `input[type='submit']`).
|
||||
- Use `fill` with selector + value to fill form fields (e.g., `input[name='email']`).
|
||||
- **Take screenshots** (`action: screenshot`) after important actions for evidence.
|
||||
- For SPA apps: a 200 HTTP status does NOT mean the page is accessible — check the actual
|
||||
page content with the browser tool to verify if it shows real data or a login redirect.
|
||||
|
||||
## Important
|
||||
- This is an authorized penetration test. All testing is permitted within the target scope.
|
||||
- Respect the rate limit of {rate_limit} requests per second.
|
||||
- Only use destructive tests if explicitly allowed ({allow_destructive}).
|
||||
- Use SAST findings to guide your testing — they tell you WHERE in the code vulnerabilities exist.
|
||||
- Use SBOM data to understand what technologies and versions the target runs.
|
||||
"#,
|
||||
target_name = target.name,
|
||||
base_url = target.base_url,
|
||||
target_type = target.target_type,
|
||||
rate_limit = target.rate_limit,
|
||||
allow_destructive = target.allow_destructive,
|
||||
repo_linked = target.repo_id.as_deref().unwrap_or("None"),
|
||||
strategy_guidance = guidance,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::finding::Severity;
|
||||
use compliance_core::models::sbom::VulnRef;
|
||||
use compliance_core::models::scan::ScanType;
|
||||
|
||||
fn make_finding(
|
||||
severity: Severity,
|
||||
title: &str,
|
||||
file_path: Option<&str>,
|
||||
line: Option<u32>,
|
||||
status: FindingStatus,
|
||||
cwe: Option<&str>,
|
||||
) -> Finding {
|
||||
let mut f = Finding::new(
|
||||
"repo-1".into(),
|
||||
format!("fp-{title}"),
|
||||
"semgrep".into(),
|
||||
ScanType::Sast,
|
||||
title.into(),
|
||||
"desc".into(),
|
||||
severity,
|
||||
);
|
||||
f.file_path = file_path.map(|s| s.to_string());
|
||||
f.line_number = line;
|
||||
f.status = status;
|
||||
f.cwe = cwe.map(|s| s.to_string());
|
||||
f
|
||||
}
|
||||
|
||||
fn make_sbom_entry(name: &str, version: &str, cves: &[&str]) -> SbomEntry {
|
||||
let mut entry = SbomEntry::new("repo-1".into(), name.into(), version.into(), "npm".into());
|
||||
entry.known_vulnerabilities = cves
|
||||
.iter()
|
||||
.map(|id| VulnRef {
|
||||
id: id.to_string(),
|
||||
source: "nvd".into(),
|
||||
severity: None,
|
||||
url: None,
|
||||
})
|
||||
.collect();
|
||||
entry
|
||||
}
|
||||
|
||||
fn make_code_hint(endpoint: &str, file: &str, vulns: Vec<String>) -> CodeContextHint {
|
||||
CodeContextHint {
|
||||
endpoint_pattern: endpoint.into(),
|
||||
handler_function: "handler".into(),
|
||||
file_path: file.into(),
|
||||
code_snippet: String::new(),
|
||||
known_vulnerabilities: vulns,
|
||||
}
|
||||
}
|
||||
|
||||
// ── strategy_guidance ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn strategy_guidance_quick() {
|
||||
let g = strategy_guidance(&PentestStrategy::Quick);
|
||||
assert!(g.contains("most common"));
|
||||
assert!(g.contains("quick recon"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_guidance_comprehensive() {
|
||||
let g = strategy_guidance(&PentestStrategy::Comprehensive);
|
||||
assert!(g.contains("thorough assessment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_guidance_targeted() {
|
||||
let g = strategy_guidance(&PentestStrategy::Targeted);
|
||||
assert!(g.contains("SAST findings"));
|
||||
assert!(g.contains("known CVEs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_guidance_aggressive() {
|
||||
let g = strategy_guidance(&PentestStrategy::Aggressive);
|
||||
assert!(g.contains("aggressively"));
|
||||
assert!(g.contains("full exploitation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_guidance_stealth() {
|
||||
let g = strategy_guidance(&PentestStrategy::Stealth);
|
||||
assert!(g.contains("Minimize noise"));
|
||||
assert!(g.contains("passive analysis"));
|
||||
}
|
||||
|
||||
// ── build_sast_section ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sast_section_empty() {
|
||||
let section = build_sast_section(&[]);
|
||||
assert_eq!(section, "No SAST findings available for this target.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sast_section_single_critical() {
|
||||
let findings = vec![make_finding(
|
||||
Severity::Critical,
|
||||
"SQL Injection",
|
||||
Some("src/db.rs"),
|
||||
Some(42),
|
||||
FindingStatus::Open,
|
||||
Some("CWE-89"),
|
||||
)];
|
||||
let section = build_sast_section(&findings);
|
||||
assert!(section.contains("1 open findings (1 critical, 0 high)"));
|
||||
assert!(section.contains("[critical] SQL Injection in src/db.rs:42"));
|
||||
assert!(section.contains("CWE: CWE-89"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sast_section_triaged_finding_shows_marker() {
|
||||
let findings = vec![make_finding(
|
||||
Severity::High,
|
||||
"XSS",
|
||||
None,
|
||||
None,
|
||||
FindingStatus::Triaged,
|
||||
None,
|
||||
)];
|
||||
let section = build_sast_section(&findings);
|
||||
assert!(section.contains("[TRIAGED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sast_section_no_file_path_omits_location() {
|
||||
let findings = vec![make_finding(
|
||||
Severity::Medium,
|
||||
"Open Redirect",
|
||||
None,
|
||||
None,
|
||||
FindingStatus::Open,
|
||||
None,
|
||||
)];
|
||||
let section = build_sast_section(&findings);
|
||||
assert!(section.contains("- [medium] Open Redirect\n"));
|
||||
assert!(!section.contains(" in "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sast_section_counts_critical_and_high() {
|
||||
let findings = vec![
|
||||
make_finding(
|
||||
Severity::Critical,
|
||||
"F1",
|
||||
None,
|
||||
None,
|
||||
FindingStatus::Open,
|
||||
None,
|
||||
),
|
||||
make_finding(
|
||||
Severity::Critical,
|
||||
"F2",
|
||||
None,
|
||||
None,
|
||||
FindingStatus::Open,
|
||||
None,
|
||||
),
|
||||
make_finding(Severity::High, "F3", None, None, FindingStatus::Open, None),
|
||||
make_finding(
|
||||
Severity::Medium,
|
||||
"F4",
|
||||
None,
|
||||
None,
|
||||
FindingStatus::Open,
|
||||
None,
|
||||
),
|
||||
];
|
||||
let section = build_sast_section(&findings);
|
||||
assert!(section.contains("4 open findings (2 critical, 1 high)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sast_section_truncates_at_20() {
|
||||
let findings: Vec<Finding> = (0..25)
|
||||
.map(|i| {
|
||||
make_finding(
|
||||
Severity::Low,
|
||||
&format!("Finding {i}"),
|
||||
None,
|
||||
None,
|
||||
FindingStatus::Open,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let section = build_sast_section(&findings);
|
||||
assert!(section.contains("... and 5 more findings"));
|
||||
// Should contain Finding 19 (the 20th) but not Finding 20 (the 21st)
|
||||
assert!(section.contains("Finding 19"));
|
||||
assert!(!section.contains("Finding 20"));
|
||||
}
|
||||
|
||||
// ── build_sbom_section ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sbom_section_empty() {
|
||||
let section = build_sbom_section(&[]);
|
||||
assert_eq!(section, "No vulnerable dependencies identified.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbom_section_single_entry() {
|
||||
let entries = vec![make_sbom_entry("lodash", "4.17.20", &["CVE-2021-23337"])];
|
||||
let section = build_sbom_section(&entries);
|
||||
assert!(section.contains("1 dependencies with known vulnerabilities"));
|
||||
assert!(section.contains("- lodash 4.17.20 (npm): CVE-2021-23337"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbom_section_multiple_cves() {
|
||||
let entries = vec![make_sbom_entry(
|
||||
"openssl",
|
||||
"1.1.1",
|
||||
&["CVE-2022-0001", "CVE-2022-0002"],
|
||||
)];
|
||||
let section = build_sbom_section(&entries);
|
||||
assert!(section.contains("CVE-2022-0001, CVE-2022-0002"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbom_section_truncates_at_15() {
|
||||
let entries: Vec<SbomEntry> = (0..18)
|
||||
.map(|i| make_sbom_entry(&format!("pkg-{i}"), "1.0.0", &["CVE-2024-0001"]))
|
||||
.collect();
|
||||
let section = build_sbom_section(&entries);
|
||||
assert!(section.contains("... and 3 more vulnerable dependencies"));
|
||||
assert!(section.contains("pkg-14"));
|
||||
assert!(!section.contains("pkg-15"));
|
||||
}
|
||||
|
||||
// ── build_code_section ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn code_section_empty() {
|
||||
let section = build_code_section(&[]);
|
||||
assert_eq!(
|
||||
section,
|
||||
"No code knowledge graph available for this target."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_section_single_entry_no_vulns() {
|
||||
let hints = vec![make_code_hint("GET /api/users", "src/routes.rs", vec![])];
|
||||
let section = build_code_section(&hints);
|
||||
assert!(section.contains("1 entry points identified (0 with linked SAST findings)"));
|
||||
assert!(section.contains("- GET /api/users (src/routes.rs)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_section_with_linked_vulns() {
|
||||
let hints = vec![make_code_hint(
|
||||
"POST /login",
|
||||
"src/auth.rs",
|
||||
vec!["[critical] semgrep: SQL Injection (line 15)".into()],
|
||||
)];
|
||||
let section = build_code_section(&hints);
|
||||
assert!(section.contains("1 entry points identified (1 with linked SAST findings)"));
|
||||
assert!(section.contains("SAST: [critical] semgrep: SQL Injection (line 15)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_section_counts_entries_with_vulns() {
|
||||
let hints = vec![
|
||||
make_code_hint("GET /a", "a.rs", vec!["vuln1".into()]),
|
||||
make_code_hint("GET /b", "b.rs", vec![]),
|
||||
make_code_hint("GET /c", "c.rs", vec!["vuln2".into(), "vuln3".into()]),
|
||||
];
|
||||
let section = build_code_section(&hints);
|
||||
assert!(section.contains("3 entry points identified (2 with linked SAST findings)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_section_truncates_at_20() {
|
||||
let hints: Vec<CodeContextHint> = (0..25)
|
||||
.map(|i| make_code_hint(&format!("GET /ep{i}"), &format!("f{i}.rs"), vec![]))
|
||||
.collect();
|
||||
let section = build_code_section(&hints);
|
||||
assert!(section.contains("GET /ep19"));
|
||||
assert!(!section.contains("GET /ep20"));
|
||||
}
|
||||
}
|
||||
43
compliance-agent/src/pentest/report/archive.rs
Normal file
43
compliance-agent/src/pentest/report/archive.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
use zip::write::SimpleFileOptions;
|
||||
use zip::AesMode;
|
||||
|
||||
use super::ReportContext;
|
||||
|
||||
pub(super) fn build_zip(
|
||||
ctx: &ReportContext,
|
||||
password: &str,
|
||||
html: &str,
|
||||
pdf: &[u8],
|
||||
) -> Result<Vec<u8>, zip::result::ZipError> {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let mut zip = zip::ZipWriter::new(buf);
|
||||
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.with_aes_encryption(AesMode::Aes256, password);
|
||||
|
||||
// report.pdf (primary)
|
||||
zip.start_file("report.pdf", options)?;
|
||||
zip.write_all(pdf)?;
|
||||
|
||||
// report.html (fallback)
|
||||
zip.start_file("report.html", options)?;
|
||||
zip.write_all(html.as_bytes())?;
|
||||
|
||||
// findings.json
|
||||
let findings_json =
|
||||
serde_json::to_string_pretty(&ctx.findings).unwrap_or_else(|_| "[]".to_string());
|
||||
zip.start_file("findings.json", options)?;
|
||||
zip.write_all(findings_json.as_bytes())?;
|
||||
|
||||
// attack-chain.json
|
||||
let chain_json =
|
||||
serde_json::to_string_pretty(&ctx.attack_chain).unwrap_or_else(|_| "[]".to_string());
|
||||
zip.start_file("attack-chain.json", options)?;
|
||||
zip.write_all(chain_json.as_bytes())?;
|
||||
|
||||
let cursor = zip.finish()?;
|
||||
Ok(cursor.into_inner())
|
||||
}
|
||||
40
compliance-agent/src/pentest/report/html/appendix.rs
Normal file
40
compliance-agent/src/pentest/report/html/appendix.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use super::html_escape;
|
||||
|
||||
pub(super) fn appendix(session_id: &str) -> String {
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 5. APPENDIX ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">5.</span> Appendix</h2>
|
||||
|
||||
<h3>Severity Definitions</h3>
|
||||
<table class="info">
|
||||
<tr><td style="color: var(--sev-critical); font-weight: 700;">Critical</td><td>Vulnerabilities that can be exploited remotely without authentication to execute arbitrary code, exfiltrate sensitive data, or fully compromise the system.</td></tr>
|
||||
<tr><td style="color: var(--sev-high); font-weight: 700;">High</td><td>Vulnerabilities that allow significant unauthorized access or data exposure, typically requiring minimal user interaction or privileges.</td></tr>
|
||||
<tr><td style="color: var(--sev-medium); font-weight: 700;">Medium</td><td>Vulnerabilities that may lead to limited data exposure or require specific conditions to exploit, but still represent meaningful risk.</td></tr>
|
||||
<tr><td style="color: var(--sev-low); font-weight: 700;">Low</td><td>Minor issues with limited direct impact. May contribute to broader attack chains or indicate defense-in-depth weaknesses.</td></tr>
|
||||
<tr><td style="color: var(--sev-info); font-weight: 700;">Info</td><td>Observations and best-practice recommendations that do not represent direct security vulnerabilities.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Disclaimer</h3>
|
||||
<p style="font-size: 9pt; color: var(--text-secondary);">
|
||||
This report was generated by an automated AI-powered penetration testing engine. While the system
|
||||
employs advanced techniques to identify vulnerabilities, no automated assessment can guarantee
|
||||
complete coverage. The results should be reviewed by qualified security professionals and validated
|
||||
in the context of the target application's threat model. Findings are point-in-time observations
|
||||
and may change as the application evolves.
|
||||
</p>
|
||||
|
||||
<!-- ═══════════════ FOOTER ═══════════════ -->
|
||||
<div class="report-footer">
|
||||
<div class="footer-company">Compliance Scanner</div>
|
||||
<div>AI-Powered Security Assessment Platform</div>
|
||||
<div style="margin-top: 6px;">This document is confidential and intended solely for the named recipient.</div>
|
||||
<div>Report ID: {session_id}</div>
|
||||
</div>
|
||||
|
||||
</div><!-- .report-body -->
|
||||
</body>
|
||||
</html>"##,
|
||||
session_id = html_escape(session_id),
|
||||
)
|
||||
}
|
||||
193
compliance-agent/src/pentest/report/html/attack_chain.rs
Normal file
193
compliance-agent/src/pentest/report/html/attack_chain.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::pentest::AttackChainNode;
|
||||
|
||||
pub(super) fn attack_chain(chain: &[AttackChainNode]) -> String {
|
||||
let chain_section = if chain.is_empty() {
|
||||
r#"<p style="color: var(--text-muted);">No attack chain steps recorded.</p>"#.to_string()
|
||||
} else {
|
||||
build_chain_html(chain)
|
||||
};
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 4. ATTACK CHAIN ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">4.</span> Attack Chain Timeline</h2>
|
||||
|
||||
<p>
|
||||
The following sequence shows each tool invocation made by the AI orchestrator during the assessment,
|
||||
grouped by phase. Each step includes the tool's name, execution status, and the AI's reasoning
|
||||
for choosing that action.
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 16px;">
|
||||
{chain_section}
|
||||
</div>"##
|
||||
)
|
||||
}
|
||||
|
||||
fn build_chain_html(chain: &[AttackChainNode]) -> String {
|
||||
let mut chain_html = String::new();
|
||||
|
||||
// Compute phases via BFS from root nodes
|
||||
let mut phase_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
|
||||
|
||||
for node in chain {
|
||||
if node.parent_node_ids.is_empty() {
|
||||
let nid = node.node_id.clone();
|
||||
if !nid.is_empty() {
|
||||
phase_map.insert(nid.clone(), 0);
|
||||
queue.push_back(nid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(nid) = queue.pop_front() {
|
||||
let parent_phase = phase_map.get(&nid).copied().unwrap_or(0);
|
||||
for node in chain {
|
||||
if node.parent_node_ids.contains(&nid) {
|
||||
let child_id = node.node_id.clone();
|
||||
if !child_id.is_empty() && !phase_map.contains_key(&child_id) {
|
||||
phase_map.insert(child_id.clone(), parent_phase + 1);
|
||||
queue.push_back(child_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign phase 0 to any unassigned nodes
|
||||
for node in chain {
|
||||
let nid = node.node_id.clone();
|
||||
if !nid.is_empty() && !phase_map.contains_key(&nid) {
|
||||
phase_map.insert(nid, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Group nodes by phase
|
||||
let max_phase = phase_map.values().copied().max().unwrap_or(0);
|
||||
let phase_labels = [
|
||||
"Reconnaissance",
|
||||
"Enumeration",
|
||||
"Exploitation",
|
||||
"Validation",
|
||||
"Post-Exploitation",
|
||||
];
|
||||
|
||||
for phase_idx in 0..=max_phase {
|
||||
let phase_nodes: Vec<&AttackChainNode> = chain
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
let nid = n.node_id.clone();
|
||||
phase_map.get(&nid).copied().unwrap_or(0) == phase_idx
|
||||
})
|
||||
.collect();
|
||||
|
||||
if phase_nodes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let label = if phase_idx < phase_labels.len() {
|
||||
phase_labels[phase_idx]
|
||||
} else {
|
||||
"Additional Testing"
|
||||
};
|
||||
|
||||
chain_html.push_str(&format!(
|
||||
r#"<div class="phase-block">
|
||||
<div class="phase-header">
|
||||
<span class="phase-num">Phase {}</span>
|
||||
<span class="phase-label">{}</span>
|
||||
<span class="phase-count">{} step{}</span>
|
||||
</div>
|
||||
<div class="phase-steps">"#,
|
||||
phase_idx + 1,
|
||||
label,
|
||||
phase_nodes.len(),
|
||||
if phase_nodes.len() == 1 { "" } else { "s" },
|
||||
));
|
||||
|
||||
for (i, node) in phase_nodes.iter().enumerate() {
|
||||
let status_label = format!("{:?}", node.status);
|
||||
let status_class = match status_label.to_lowercase().as_str() {
|
||||
"completed" => "step-completed",
|
||||
"failed" => "step-failed",
|
||||
_ => "step-running",
|
||||
};
|
||||
let findings_badge = if !node.findings_produced.is_empty() {
|
||||
format!(
|
||||
r#"<span class="step-findings">{} finding{}</span>"#,
|
||||
node.findings_produced.len(),
|
||||
if node.findings_produced.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
},
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let risk_badge = node
|
||||
.risk_score
|
||||
.map(|r| {
|
||||
let risk_class = if r >= 70 {
|
||||
"risk-high"
|
||||
} else if r >= 40 {
|
||||
"risk-med"
|
||||
} else {
|
||||
"risk-low"
|
||||
};
|
||||
format!(r#"<span class="step-risk {risk_class}">Risk: {r}</span>"#)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let reasoning_html = if node.llm_reasoning.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
r#"<div class="step-reasoning">{}</div>"#,
|
||||
html_escape(&node.llm_reasoning)
|
||||
)
|
||||
};
|
||||
|
||||
// Render inline screenshot if this is a browser screenshot action
|
||||
let screenshot_html = if node.tool_name == "browser" {
|
||||
node.tool_output
|
||||
.as_ref()
|
||||
.and_then(|out| out.get("screenshot_base64"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|b64| {
|
||||
format!(
|
||||
r#"<div class="step-screenshot"><img src="data:image/png;base64,{b64}" alt="Browser screenshot" style="max-width:100%;border:1px solid #e2e8f0;border-radius:6px;margin-top:8px;"/></div>"#
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
chain_html.push_str(&format!(
|
||||
r#"<div class="step-row">
|
||||
<div class="step-num">{num}</div>
|
||||
<div class="step-connector"></div>
|
||||
<div class="step-content">
|
||||
<div class="step-header">
|
||||
<span class="step-tool">{tool_name}</span>
|
||||
<span class="step-status {status_class}">{status_label}</span>
|
||||
{findings_badge}
|
||||
{risk_badge}
|
||||
</div>
|
||||
{reasoning_html}
|
||||
{screenshot_html}
|
||||
</div>
|
||||
</div>"#,
|
||||
num = i + 1,
|
||||
tool_name = html_escape(&node.tool_name),
|
||||
));
|
||||
}
|
||||
|
||||
chain_html.push_str("</div></div>");
|
||||
}
|
||||
|
||||
chain_html
|
||||
}
|
||||
69
compliance-agent/src/pentest/report/html/cover.rs
Normal file
69
compliance-agent/src/pentest/report/html/cover.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use super::html_escape;
|
||||
|
||||
pub(super) fn cover(
|
||||
target_name: &str,
|
||||
session_id: &str,
|
||||
date_short: &str,
|
||||
target_url: &str,
|
||||
requester_name: &str,
|
||||
requester_email: &str,
|
||||
app_screenshot_b64: Option<&str>,
|
||||
) -> String {
|
||||
let screenshot_html = app_screenshot_b64
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|b64| {
|
||||
format!(
|
||||
r#"<div style="margin: 20px auto; max-width: 560px; border: 1px solid #cbd5e1; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.08);">
|
||||
<img src="data:image/png;base64,{b64}" alt="Application screenshot" style="width:100%;display:block;"/>
|
||||
</div>"#
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r##"<!-- ═══════════════ COVER PAGE ═══════════════ -->
|
||||
<div class="cover">
|
||||
<svg class="cover-shield" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
||||
<defs>
|
||||
<linearGradient id="sg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0d2137"/>
|
||||
<stop offset="100%" stop-color="#1a56db"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M48 6 L22 22 L22 48 C22 66 34 80 48 86 C62 80 74 66 74 48 L74 22 Z"
|
||||
fill="none" stroke="url(#sg)" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M48 12 L26 26 L26 47 C26 63 36 76 48 82 C60 76 70 63 70 47 L70 26 Z"
|
||||
fill="url(#sg)" opacity="0.07"/>
|
||||
<circle cx="44" cy="44" r="11" fill="none" stroke="#0d2137" stroke-width="2.5"/>
|
||||
<line x1="52" y1="52" x2="62" y2="62" stroke="#0d2137" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M39 44 L42.5 47.5 L49 41" fill="none" stroke="#166534" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
<div class="cover-tag">CONFIDENTIAL</div>
|
||||
|
||||
<div class="cover-title">Penetration Test Report</div>
|
||||
<div class="cover-subtitle">{target_name}</div>
|
||||
|
||||
<div class="cover-divider"></div>
|
||||
|
||||
<div class="cover-meta">
|
||||
<strong>Report ID:</strong> {session_id}<br>
|
||||
<strong>Date:</strong> {date_short}<br>
|
||||
<strong>Target:</strong> {target_url}<br>
|
||||
<strong>Prepared for:</strong> {requester_name} ({requester_email})
|
||||
</div>
|
||||
|
||||
{screenshot_html}
|
||||
|
||||
<div class="cover-footer">
|
||||
Compliance Scanner — AI-Powered Security Assessment Platform
|
||||
</div>
|
||||
</div>"##,
|
||||
target_name = html_escape(target_name),
|
||||
session_id = html_escape(session_id),
|
||||
date_short = date_short,
|
||||
target_url = html_escape(target_url),
|
||||
requester_name = html_escape(requester_name),
|
||||
requester_email = html_escape(requester_email),
|
||||
)
|
||||
}
|
||||
238
compliance-agent/src/pentest/report/html/executive_summary.rs
Normal file
238
compliance-agent/src/pentest/report/html/executive_summary.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
|
||||
pub(super) fn executive_summary(
|
||||
findings: &[DastFinding],
|
||||
target_name: &str,
|
||||
target_url: &str,
|
||||
tool_count: usize,
|
||||
tool_invocations: u32,
|
||||
success_rate: f64,
|
||||
) -> String {
|
||||
let critical = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "critical")
|
||||
.count();
|
||||
let high = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "high")
|
||||
.count();
|
||||
let medium = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "medium")
|
||||
.count();
|
||||
let low = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "low")
|
||||
.count();
|
||||
let info = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == "info")
|
||||
.count();
|
||||
let exploitable = findings.iter().filter(|f| f.exploitable).count();
|
||||
let total = findings.len();
|
||||
|
||||
let overall_risk = if critical > 0 {
|
||||
"CRITICAL"
|
||||
} else if high > 0 {
|
||||
"HIGH"
|
||||
} else if medium > 0 {
|
||||
"MEDIUM"
|
||||
} else if low > 0 {
|
||||
"LOW"
|
||||
} else {
|
||||
"INFORMATIONAL"
|
||||
};
|
||||
|
||||
let risk_color = match overall_risk {
|
||||
"CRITICAL" => "#991b1b",
|
||||
"HIGH" => "#c2410c",
|
||||
"MEDIUM" => "#a16207",
|
||||
"LOW" => "#1d4ed8",
|
||||
_ => "#4b5563",
|
||||
};
|
||||
|
||||
let risk_score: usize =
|
||||
std::cmp::min(100, critical * 25 + high * 15 + medium * 8 + low * 3 + info);
|
||||
|
||||
let severity_bar = build_severity_bar(critical, high, medium, low, info, total);
|
||||
|
||||
// Table of contents finding sub-entries
|
||||
let severity_order = ["critical", "high", "medium", "low", "info"];
|
||||
let toc_findings_sub = if !findings.is_empty() {
|
||||
let mut sub = String::new();
|
||||
let mut fnum = 0usize;
|
||||
for &sev_key in severity_order.iter() {
|
||||
let count = findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == sev_key)
|
||||
.count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
for f in findings
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == sev_key)
|
||||
{
|
||||
fnum += 1;
|
||||
sub.push_str(&format!(
|
||||
r#"<div class="toc-sub">F-{:03} — {}</div>"#,
|
||||
fnum,
|
||||
html_escape(&f.title),
|
||||
));
|
||||
}
|
||||
}
|
||||
sub
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let critical_high_str = format!("{} / {}", critical, high);
|
||||
let escaped_target_name = html_escape(target_name);
|
||||
let escaped_target_url = html_escape(target_url);
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ TABLE OF CONTENTS ═══════════════ -->
|
||||
<div class="report-body">
|
||||
|
||||
<div class="toc">
|
||||
<h2>Table of Contents</h2>
|
||||
<div class="toc-entry"><span class="toc-num">1</span><span class="toc-label">Executive Summary</span></div>
|
||||
<div class="toc-entry"><span class="toc-num">2</span><span class="toc-label">Scope & Methodology</span></div>
|
||||
<div class="toc-entry"><span class="toc-num">3</span><span class="toc-label">Findings ({total_findings})</span></div>
|
||||
{toc_findings_sub}
|
||||
<div class="toc-entry"><span class="toc-num">4</span><span class="toc-label">Attack Chain Timeline</span></div>
|
||||
<div class="toc-entry"><span class="toc-num">5</span><span class="toc-label">Appendix</span></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════ 1. EXECUTIVE SUMMARY ═══════════════ -->
|
||||
<h2><span class="section-num">1.</span> Executive Summary</h2>
|
||||
|
||||
<div class="risk-gauge">
|
||||
<div class="risk-gauge-meter">
|
||||
<div class="risk-gauge-track">
|
||||
<div class="risk-gauge-fill" style="width: {risk_score}%; background: {risk_color};"></div>
|
||||
</div>
|
||||
<div class="risk-gauge-score" style="color: {risk_color};">{risk_score} / 100</div>
|
||||
</div>
|
||||
<div class="risk-gauge-text">
|
||||
<div class="risk-gauge-label" style="color: {risk_color};">Overall Risk: {overall_risk}</div>
|
||||
<div class="risk-gauge-desc">
|
||||
Based on {total_findings} finding{findings_plural} identified across the target application.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="exec-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value">{total_findings}</div>
|
||||
<div class="kpi-label">Total Findings</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value" style="color: var(--sev-critical);">{critical_high}</div>
|
||||
<div class="kpi-label">Critical / High</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value" style="color: var(--sev-critical);">{exploitable_count}</div>
|
||||
<div class="kpi-label">Exploitable</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value">{tool_count}</div>
|
||||
<div class="kpi-label">Tools Used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Severity Distribution</h3>
|
||||
{severity_bar}
|
||||
|
||||
<p>
|
||||
This report presents the results of an automated penetration test conducted against
|
||||
<strong>{target_name}</strong> (<code>{target_url}</code>) using the Compliance Scanner
|
||||
AI-powered testing engine. A total of <strong>{total_findings} vulnerabilities</strong> were
|
||||
identified, of which <strong>{exploitable_count}</strong> were confirmed exploitable with
|
||||
working proof-of-concept payloads. The assessment employed <strong>{tool_count} security tools</strong>
|
||||
across <strong>{tool_invocations} invocations</strong> ({success_rate:.0}% success rate).
|
||||
</p>"##,
|
||||
total_findings = total,
|
||||
findings_plural = if total == 1 { "" } else { "s" },
|
||||
critical_high = critical_high_str,
|
||||
exploitable_count = exploitable,
|
||||
target_name = escaped_target_name,
|
||||
target_url = escaped_target_url,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_severity_bar(
|
||||
critical: usize,
|
||||
high: usize,
|
||||
medium: usize,
|
||||
low: usize,
|
||||
info: usize,
|
||||
total: usize,
|
||||
) -> String {
|
||||
if total == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let crit_pct = (critical as f64 / total as f64 * 100.0) as usize;
|
||||
let high_pct = (high as f64 / total as f64 * 100.0) as usize;
|
||||
let med_pct = (medium as f64 / total as f64 * 100.0) as usize;
|
||||
let low_pct = (low as f64 / total as f64 * 100.0) as usize;
|
||||
let info_pct = 100_usize.saturating_sub(crit_pct + high_pct + med_pct + low_pct);
|
||||
|
||||
let mut bar = String::from(r#"<div class="sev-bar">"#);
|
||||
if critical > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-critical" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(crit_pct, 4),
|
||||
critical
|
||||
));
|
||||
}
|
||||
if high > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-high" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(high_pct, 4),
|
||||
high
|
||||
));
|
||||
}
|
||||
if medium > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-medium" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(med_pct, 4),
|
||||
medium
|
||||
));
|
||||
}
|
||||
if low > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-low" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(low_pct, 4),
|
||||
low
|
||||
));
|
||||
}
|
||||
if info > 0 {
|
||||
bar.push_str(&format!(
|
||||
r#"<div class="sev-bar-seg sev-bar-info" style="width:{}%"><span>{}</span></div>"#,
|
||||
std::cmp::max(info_pct, 4),
|
||||
info
|
||||
));
|
||||
}
|
||||
bar.push_str("</div>");
|
||||
bar.push_str(r#"<div class="sev-bar-legend">"#);
|
||||
if critical > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#991b1b"></i> Critical</span>"#);
|
||||
}
|
||||
if high > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#c2410c"></i> High</span>"#);
|
||||
}
|
||||
if medium > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#a16207"></i> Medium</span>"#);
|
||||
}
|
||||
if low > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#1d4ed8"></i> Low</span>"#);
|
||||
}
|
||||
if info > 0 {
|
||||
bar.push_str(r#"<span><i class="sev-dot" style="background:#4b5563"></i> Info</span>"#);
|
||||
}
|
||||
bar.push_str("</div>");
|
||||
bar
|
||||
}
|
||||
522
compliance-agent/src/pentest/report/html/findings.rs
Normal file
522
compliance-agent/src/pentest/report/html/findings.rs
Normal file
@@ -0,0 +1,522 @@
|
||||
use super::html_escape;
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::CodeContextHint;
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
|
||||
/// Render the findings section with code-level correlation.
|
||||
///
|
||||
/// For each DAST finding, if a linked SAST finding exists (via `linked_sast_finding_id`)
|
||||
/// or if we can match the endpoint to a code entry point, we render a "Code-Level
|
||||
/// Remediation" block showing the exact file, line, code snippet, and suggested fix.
|
||||
pub(super) fn findings(
|
||||
findings_list: &[DastFinding],
|
||||
sast_findings: &[Finding],
|
||||
code_context: &[CodeContextHint],
|
||||
sbom_entries: &[SbomEntry],
|
||||
) -> String {
|
||||
if findings_list.is_empty() {
|
||||
return r#"<!-- ═══════════════ 3. FINDINGS ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">3.</span> Findings</h2>
|
||||
|
||||
<p style="color: var(--text-muted);">No vulnerabilities were identified during this assessment.</p>"#.to_string();
|
||||
}
|
||||
|
||||
let severity_order = ["critical", "high", "medium", "low", "info"];
|
||||
let severity_labels = ["Critical", "High", "Medium", "Low", "Informational"];
|
||||
let severity_colors = ["#991b1b", "#c2410c", "#a16207", "#1d4ed8", "#4b5563"];
|
||||
|
||||
// Build SAST lookup by ObjectId hex string
|
||||
let sast_by_id: std::collections::HashMap<String, &Finding> = sast_findings
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let id = f.id.as_ref()?.to_hex();
|
||||
Some((id, f))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut findings_html = String::new();
|
||||
let mut finding_num = 0usize;
|
||||
|
||||
for (si, &sev_key) in severity_order.iter().enumerate() {
|
||||
let sev_findings: Vec<&DastFinding> = findings_list
|
||||
.iter()
|
||||
.filter(|f| f.severity.to_string() == sev_key)
|
||||
.collect();
|
||||
if sev_findings.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
findings_html.push_str(&format!(
|
||||
r#"<h4 class="sev-group-title" style="border-color: {color}">{label} ({count})</h4>"#,
|
||||
color = severity_colors[si],
|
||||
label = severity_labels[si],
|
||||
count = sev_findings.len(),
|
||||
));
|
||||
|
||||
for f in sev_findings {
|
||||
finding_num += 1;
|
||||
let sev_color = severity_colors[si];
|
||||
let exploitable_badge = if f.exploitable {
|
||||
r#"<span class="badge badge-exploit">EXPLOITABLE</span>"#
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let cwe_cell = f
|
||||
.cwe
|
||||
.as_deref()
|
||||
.map(|c| format!("<tr><td>CWE</td><td>{}</td></tr>", html_escape(c)))
|
||||
.unwrap_or_default();
|
||||
let param_row = f
|
||||
.parameter
|
||||
.as_deref()
|
||||
.map(|p| {
|
||||
format!(
|
||||
"<tr><td>Parameter</td><td><code>{}</code></td></tr>",
|
||||
html_escape(p)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let remediation = f
|
||||
.remediation
|
||||
.as_deref()
|
||||
.unwrap_or("Refer to industry best practices for this vulnerability class.");
|
||||
|
||||
let evidence_html = build_evidence_html(f);
|
||||
|
||||
// ── Code-level correlation ──────────────────────────────
|
||||
let code_correlation =
|
||||
build_code_correlation(f, &sast_by_id, code_context, sbom_entries);
|
||||
|
||||
findings_html.push_str(&format!(
|
||||
r#"
|
||||
<div class="finding" style="border-left-color: {sev_color}">
|
||||
<div class="finding-header">
|
||||
<span class="finding-id">F-{num:03}</span>
|
||||
<span class="finding-title">{title}</span>
|
||||
{exploitable_badge}
|
||||
</div>
|
||||
<table class="finding-meta">
|
||||
<tr><td>Type</td><td>{vuln_type}</td></tr>
|
||||
<tr><td>Endpoint</td><td><code>{method} {endpoint}</code></td></tr>
|
||||
{param_row}
|
||||
{cwe_cell}
|
||||
</table>
|
||||
<div class="finding-desc">{description}</div>
|
||||
{evidence_html}
|
||||
{code_correlation}
|
||||
<div class="remediation">
|
||||
<div class="remediation-label">Recommendation</div>
|
||||
{remediation}
|
||||
</div>
|
||||
</div>
|
||||
"#,
|
||||
num = finding_num,
|
||||
title = html_escape(&f.title),
|
||||
vuln_type = f.vuln_type,
|
||||
method = f.method,
|
||||
endpoint = html_escape(&f.endpoint),
|
||||
description = html_escape(&f.description),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
format!(
|
||||
r##"<!-- ═══════════════ 3. FINDINGS ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">3.</span> Findings</h2>
|
||||
|
||||
{findings_html}"##
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the evidence table HTML for a finding.
|
||||
fn build_evidence_html(f: &DastFinding) -> String {
|
||||
if f.evidence.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut eh = String::from(
|
||||
r#"<div class="evidence-block"><div class="evidence-title">Evidence</div><table class="evidence-table"><thead><tr><th>Request</th><th>Status</th><th>Details</th></tr></thead><tbody>"#,
|
||||
);
|
||||
for ev in &f.evidence {
|
||||
let payload_info = ev
|
||||
.payload
|
||||
.as_deref()
|
||||
.map(|p| {
|
||||
format!(
|
||||
"<br><span class=\"evidence-payload\">Payload: <code>{}</code></span>",
|
||||
html_escape(p)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
eh.push_str(&format!(
|
||||
"<tr><td><code>{} {}</code></td><td>{}</td><td>{}{}</td></tr>",
|
||||
html_escape(&ev.request_method),
|
||||
html_escape(&ev.request_url),
|
||||
ev.response_status,
|
||||
ev.response_snippet
|
||||
.as_deref()
|
||||
.map(html_escape)
|
||||
.unwrap_or_default(),
|
||||
payload_info,
|
||||
));
|
||||
}
|
||||
eh.push_str("</tbody></table></div>");
|
||||
eh
|
||||
}
|
||||
|
||||
/// Build the code-level correlation block for a DAST finding.
|
||||
///
|
||||
/// Attempts correlation in priority order:
|
||||
/// 1. Direct link via `linked_sast_finding_id` → shows exact file, line, snippet, suggested fix
|
||||
/// 2. Endpoint match via code context → shows handler function, file, known SAST vulns
|
||||
/// 3. CWE/CVE match to SBOM → shows vulnerable dependency + version to upgrade
|
||||
fn build_code_correlation(
|
||||
dast_finding: &DastFinding,
|
||||
sast_by_id: &std::collections::HashMap<String, &Finding>,
|
||||
code_context: &[CodeContextHint],
|
||||
sbom_entries: &[SbomEntry],
|
||||
) -> String {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
|
||||
// 1. Direct SAST link
|
||||
if let Some(ref sast_id) = dast_finding.linked_sast_finding_id {
|
||||
if let Some(sast) = sast_by_id.get(sast_id) {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">SAST Correlation</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
|
||||
if let Some(ref fp) = sast.file_path {
|
||||
let line_info = sast
|
||||
.line_number
|
||||
.map(|l| format!(":{l}"))
|
||||
.unwrap_or_default();
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Location</td><td><code>{}{}</code></td></tr>",
|
||||
html_escape(fp),
|
||||
line_info,
|
||||
));
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Scanner</td><td>{} — {}</td></tr>",
|
||||
html_escape(&sast.scanner),
|
||||
html_escape(&sast.title),
|
||||
));
|
||||
if let Some(ref cwe) = sast.cwe {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>CWE</td><td>{}</td></tr>",
|
||||
html_escape(cwe)
|
||||
));
|
||||
}
|
||||
if let Some(ref rule) = sast.rule_id {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Rule</td><td><code>{}</code></td></tr>",
|
||||
html_escape(rule)
|
||||
));
|
||||
}
|
||||
s.push_str("</table>");
|
||||
|
||||
// Code snippet
|
||||
if let Some(ref snippet) = sast.code_snippet {
|
||||
if !snippet.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-snippet-block\"><div class=\"code-snippet-label\">Vulnerable Code</div><pre class=\"code-snippet\">{}</pre></div>",
|
||||
html_escape(snippet)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Suggested fix
|
||||
if let Some(ref fix) = sast.suggested_fix {
|
||||
if !fix.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-fix-block\"><div class=\"code-fix-label\">Suggested Fix</div><pre class=\"code-fix\">{}</pre></div>",
|
||||
html_escape(fix)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Remediation from SAST
|
||||
if let Some(ref rem) = sast.remediation {
|
||||
if !rem.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-remediation\">{}</div>",
|
||||
html_escape(rem)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Endpoint match via code context
|
||||
let endpoint_lower = dast_finding.endpoint.to_lowercase();
|
||||
let matching_hints: Vec<&CodeContextHint> = code_context
|
||||
.iter()
|
||||
.filter(|hint| {
|
||||
// Match by endpoint pattern overlap
|
||||
let pattern_lower = hint.endpoint_pattern.to_lowercase();
|
||||
endpoint_lower.contains(&pattern_lower)
|
||||
|| pattern_lower.contains(&endpoint_lower)
|
||||
|| hint.file_path.to_lowercase().contains(
|
||||
&endpoint_lower
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("")
|
||||
.replace(".html", "")
|
||||
.replace(".php", ""),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for hint in &matching_hints {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">Code Entry Point</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Handler</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.handler_function),
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"<tr><td>File</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.file_path),
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Route</td><td><code>{}</code></td></tr>",
|
||||
html_escape(&hint.endpoint_pattern),
|
||||
));
|
||||
s.push_str("</table>");
|
||||
|
||||
if !hint.known_vulnerabilities.is_empty() {
|
||||
s.push_str("<div class=\"code-linked-vulns\"><strong>Known SAST issues in this file:</strong><ul>");
|
||||
for vuln in &hint.known_vulnerabilities {
|
||||
s.push_str(&format!("<li>{}</li>", html_escape(vuln)));
|
||||
}
|
||||
s.push_str("</ul></div>");
|
||||
}
|
||||
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
|
||||
// 3. SBOM match — if a linked SAST finding has a CVE, or we can match by CWE
|
||||
let linked_cve = dast_finding
|
||||
.linked_sast_finding_id
|
||||
.as_deref()
|
||||
.and_then(|id| sast_by_id.get(id))
|
||||
.and_then(|f| f.cve.as_deref());
|
||||
|
||||
if let Some(cve_id) = linked_cve {
|
||||
let matching_deps: Vec<&SbomEntry> = sbom_entries
|
||||
.iter()
|
||||
.filter(|e| e.known_vulnerabilities.iter().any(|v| v.id == cve_id))
|
||||
.collect();
|
||||
|
||||
for dep in &matching_deps {
|
||||
let mut s = String::new();
|
||||
s.push_str(r#"<div class="code-correlation-item">"#);
|
||||
s.push_str(r#"<div class="code-correlation-badge">Vulnerable Dependency</div>"#);
|
||||
s.push_str("<table class=\"code-meta\">");
|
||||
s.push_str(&format!(
|
||||
"<tr><td>Package</td><td><code>{} {}</code> ({})</td></tr>",
|
||||
html_escape(&dep.name),
|
||||
html_escape(&dep.version),
|
||||
html_escape(&dep.package_manager),
|
||||
));
|
||||
let cve_ids: Vec<&str> = dep
|
||||
.known_vulnerabilities
|
||||
.iter()
|
||||
.map(|v| v.id.as_str())
|
||||
.collect();
|
||||
s.push_str(&format!(
|
||||
"<tr><td>CVEs</td><td>{}</td></tr>",
|
||||
cve_ids.join(", "),
|
||||
));
|
||||
if let Some(ref purl) = dep.purl {
|
||||
s.push_str(&format!(
|
||||
"<tr><td>PURL</td><td><code>{}</code></td></tr>",
|
||||
html_escape(purl),
|
||||
));
|
||||
}
|
||||
s.push_str("</table>");
|
||||
s.push_str(&format!(
|
||||
"<div class=\"code-remediation\">Upgrade <code>{}</code> to the latest patched version to resolve {}.</div>",
|
||||
html_escape(&dep.name),
|
||||
html_escape(cve_id),
|
||||
));
|
||||
s.push_str("</div>");
|
||||
sections.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
if sections.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
format!(
|
||||
r#"<div class="code-correlation">
|
||||
<div class="code-correlation-title">Code-Level Remediation</div>
|
||||
{}
|
||||
</div>"#,
|
||||
sections.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::dast::{DastEvidence, DastVulnType};
|
||||
use compliance_core::models::finding::Severity;
|
||||
use compliance_core::models::scan::ScanType;
|
||||
|
||||
/// Helper: create a minimal `DastFinding`.
|
||||
fn make_dast(title: &str, severity: Severity, endpoint: &str) -> DastFinding {
|
||||
DastFinding::new(
|
||||
"run1".into(),
|
||||
"target1".into(),
|
||||
DastVulnType::Xss,
|
||||
title.into(),
|
||||
"desc".into(),
|
||||
severity,
|
||||
endpoint.into(),
|
||||
"GET".into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Helper: create a minimal SAST `Finding` with an ObjectId.
|
||||
fn make_sast(title: &str) -> Finding {
|
||||
let mut f = Finding::new(
|
||||
"repo1".into(),
|
||||
"fp1".into(),
|
||||
"semgrep".into(),
|
||||
ScanType::Sast,
|
||||
title.into(),
|
||||
"sast desc".into(),
|
||||
Severity::High,
|
||||
);
|
||||
f.id = Some(mongodb::bson::oid::ObjectId::new());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_findings_empty() {
|
||||
let result = findings(&[], &[], &[], &[]);
|
||||
assert!(
|
||||
result.contains("No vulnerabilities were identified"),
|
||||
"Empty findings should contain the no-vulns message"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_findings_grouped_by_severity() {
|
||||
let f_high = make_dast("High vuln", Severity::High, "/a");
|
||||
let f_low = make_dast("Low vuln", Severity::Low, "/b");
|
||||
let f_critical = make_dast("Crit vuln", Severity::Critical, "/c");
|
||||
|
||||
let result = findings(&[f_high, f_low, f_critical], &[], &[], &[]);
|
||||
|
||||
// All severity group headers should appear
|
||||
assert!(
|
||||
result.contains("Critical (1)"),
|
||||
"should have Critical header"
|
||||
);
|
||||
assert!(result.contains("High (1)"), "should have High header");
|
||||
assert!(result.contains("Low (1)"), "should have Low header");
|
||||
|
||||
// Critical should appear before High, High before Low
|
||||
let crit_pos = result.find("Critical (1)");
|
||||
let high_pos = result.find("High (1)");
|
||||
let low_pos = result.find("Low (1)");
|
||||
assert!(crit_pos < high_pos, "Critical should come before High");
|
||||
assert!(high_pos < low_pos, "High should come before Low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_code_correlation_sast_link() {
|
||||
let mut sast = make_sast("SQL Injection in query");
|
||||
sast.file_path = Some("src/db/query.rs".into());
|
||||
sast.line_number = Some(42);
|
||||
sast.code_snippet =
|
||||
Some("let q = format!(\"SELECT * FROM {} WHERE id={}\", table, id);".into());
|
||||
|
||||
let sast_id = sast.id.as_ref().map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
|
||||
let mut dast = make_dast("SQLi on /api/users", Severity::High, "/api/users");
|
||||
dast.linked_sast_finding_id = Some(sast_id);
|
||||
|
||||
let result = findings(&[dast], &[sast], &[], &[]);
|
||||
|
||||
assert!(
|
||||
result.contains("SAST Correlation"),
|
||||
"should render SAST Correlation badge"
|
||||
);
|
||||
assert!(
|
||||
result.contains("src/db/query.rs"),
|
||||
"should contain the file path"
|
||||
);
|
||||
assert!(result.contains(":42"), "should contain the line number");
|
||||
assert!(
|
||||
result.contains("Vulnerable Code"),
|
||||
"should render code snippet block"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_code_correlation_no_match() {
|
||||
let dast = make_dast("XSS in search", Severity::Medium, "/search");
|
||||
// No linked_sast_finding_id, no code context, no sbom
|
||||
let result = findings(&[dast], &[], &[], &[]);
|
||||
|
||||
assert!(
|
||||
!result.contains("code-correlation"),
|
||||
"should not contain any code-correlation div"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evidence_html_empty() {
|
||||
let f = make_dast("No evidence", Severity::Low, "/x");
|
||||
let result = build_evidence_html(&f);
|
||||
assert!(result.is_empty(), "no evidence should yield empty string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evidence_html_with_entries() {
|
||||
let mut f = make_dast("Has evidence", Severity::High, "/y");
|
||||
f.evidence.push(DastEvidence {
|
||||
request_method: "POST".into(),
|
||||
request_url: "https://example.com/login".into(),
|
||||
request_headers: None,
|
||||
request_body: None,
|
||||
response_status: 200,
|
||||
response_headers: None,
|
||||
response_snippet: Some("OK".into()),
|
||||
screenshot_path: None,
|
||||
payload: Some("<script>alert(1)</script>".into()),
|
||||
response_time_ms: None,
|
||||
});
|
||||
|
||||
let result = build_evidence_html(&f);
|
||||
|
||||
assert!(
|
||||
result.contains("evidence-table"),
|
||||
"should render the evidence table"
|
||||
);
|
||||
assert!(result.contains("POST"), "should contain request method");
|
||||
assert!(
|
||||
result.contains("https://example.com/login"),
|
||||
"should contain request URL"
|
||||
);
|
||||
assert!(result.contains("200"), "should contain response status");
|
||||
assert!(
|
||||
result.contains("<script>alert(1)</script>"),
|
||||
"payload should be HTML-escaped"
|
||||
);
|
||||
}
|
||||
}
|
||||
518
compliance-agent/src/pentest/report/html/mod.rs
Normal file
518
compliance-agent/src/pentest/report/html/mod.rs
Normal file
@@ -0,0 +1,518 @@
|
||||
mod appendix;
|
||||
mod attack_chain;
|
||||
mod cover;
|
||||
mod executive_summary;
|
||||
mod findings;
|
||||
mod scope;
|
||||
mod styles;
|
||||
|
||||
use super::ReportContext;
|
||||
|
||||
#[allow(clippy::format_in_format_args)]
|
||||
pub(super) fn build_html_report(ctx: &ReportContext) -> String {
|
||||
let session = &ctx.session;
|
||||
let session_id = session
|
||||
.id
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let date_str = session
|
||||
.started_at
|
||||
.format("%B %d, %Y at %H:%M UTC")
|
||||
.to_string();
|
||||
let date_short = session.started_at.format("%B %d, %Y").to_string();
|
||||
let completed_str = session
|
||||
.completed_at
|
||||
.map(|d| d.format("%B %d, %Y at %H:%M UTC").to_string())
|
||||
.unwrap_or_else(|| "In Progress".to_string());
|
||||
|
||||
// Collect unique tool names used
|
||||
let tool_names: Vec<String> = {
|
||||
let mut names: Vec<String> = ctx
|
||||
.attack_chain
|
||||
.iter()
|
||||
.map(|n| n.tool_name.clone())
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
};
|
||||
|
||||
// Find the best app screenshot for the cover page:
|
||||
// prefer the first navigate to the target URL that has a screenshot,
|
||||
// falling back to any navigate with a screenshot
|
||||
let app_screenshot: Option<String> = ctx
|
||||
.attack_chain
|
||||
.iter()
|
||||
.filter(|n| n.tool_name == "browser")
|
||||
.filter_map(|n| {
|
||||
n.tool_output
|
||||
.as_ref()?
|
||||
.get("screenshot_base64")?
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
// Skip the Keycloak login page screenshots — prefer one that shows the actual app
|
||||
.find(|_| {
|
||||
ctx.attack_chain
|
||||
.iter()
|
||||
.filter(|n| n.tool_name == "browser")
|
||||
.any(|n| {
|
||||
n.tool_output
|
||||
.as_ref()
|
||||
.and_then(|o| o.get("title"))
|
||||
.and_then(|t| t.as_str())
|
||||
.is_some_and(|t| t.contains("Compliance") || t.contains("Dashboard"))
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback: any screenshot
|
||||
ctx.attack_chain
|
||||
.iter()
|
||||
.filter(|n| n.tool_name == "browser")
|
||||
.filter_map(|n| {
|
||||
n.tool_output
|
||||
.as_ref()?
|
||||
.get("screenshot_base64")?
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.next()
|
||||
});
|
||||
|
||||
let styles_html = styles::styles();
|
||||
let cover_html = cover::cover(
|
||||
&ctx.target_name,
|
||||
&session_id,
|
||||
&date_short,
|
||||
&ctx.target_url,
|
||||
&ctx.requester_name,
|
||||
&ctx.requester_email,
|
||||
app_screenshot.as_deref(),
|
||||
);
|
||||
let exec_html = executive_summary::executive_summary(
|
||||
&ctx.findings,
|
||||
&ctx.target_name,
|
||||
&ctx.target_url,
|
||||
tool_names.len(),
|
||||
session.tool_invocations,
|
||||
session.success_rate(),
|
||||
);
|
||||
let scope_html = scope::scope(
|
||||
session,
|
||||
&ctx.target_name,
|
||||
&ctx.target_url,
|
||||
&date_str,
|
||||
&completed_str,
|
||||
&tool_names,
|
||||
ctx.config.as_ref(),
|
||||
);
|
||||
let findings_html = findings::findings(
|
||||
&ctx.findings,
|
||||
&ctx.sast_findings,
|
||||
&ctx.code_context,
|
||||
&ctx.sbom_entries,
|
||||
);
|
||||
let chain_html = attack_chain::attack_chain(&ctx.attack_chain);
|
||||
let appendix_html = appendix::appendix(&session_id);
|
||||
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Penetration Test Report — {target_name}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:ital,wght@0,300;0,400;0,600;0,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
{styles_html}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{cover_html}
|
||||
|
||||
{exec_html}
|
||||
|
||||
{scope_html}
|
||||
|
||||
{findings_html}
|
||||
|
||||
{chain_html}
|
||||
|
||||
{appendix_html}
|
||||
"#,
|
||||
target_name = html_escape(&ctx.target_name),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_category(tool_name: &str) -> &'static str {
|
||||
let name = tool_name.to_lowercase();
|
||||
if name.contains("nmap") || name.contains("port") {
|
||||
return "Network Reconnaissance";
|
||||
}
|
||||
if name.contains("nikto") || name.contains("header") {
|
||||
return "Web Server Analysis";
|
||||
}
|
||||
if name.contains("zap") || name.contains("spider") || name.contains("crawl") {
|
||||
return "Web Application Scanning";
|
||||
}
|
||||
if name.contains("sqlmap") || name.contains("sqli") || name.contains("sql") {
|
||||
return "SQL Injection Testing";
|
||||
}
|
||||
if name.contains("xss") || name.contains("cross-site") {
|
||||
return "Cross-Site Scripting Testing";
|
||||
}
|
||||
if name.contains("dir")
|
||||
|| name.contains("brute")
|
||||
|| name.contains("fuzz")
|
||||
|| name.contains("gobuster")
|
||||
{
|
||||
return "Directory Enumeration";
|
||||
}
|
||||
if name.contains("ssl") || name.contains("tls") || name.contains("cert") {
|
||||
return "SSL/TLS Analysis";
|
||||
}
|
||||
if name.contains("api") || name.contains("endpoint") {
|
||||
return "API Security Testing";
|
||||
}
|
||||
if name.contains("auth") || name.contains("login") || name.contains("credential") {
|
||||
return "Authentication Testing";
|
||||
}
|
||||
if name.contains("cors") {
|
||||
return "CORS Testing";
|
||||
}
|
||||
if name.contains("csrf") {
|
||||
return "CSRF Testing";
|
||||
}
|
||||
if name.contains("nuclei") || name.contains("template") {
|
||||
return "Vulnerability Scanning";
|
||||
}
|
||||
if name.contains("whatweb") || name.contains("tech") || name.contains("wappalyzer") {
|
||||
return "Technology Fingerprinting";
|
||||
}
|
||||
"Security Testing"
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::dast::{DastFinding, DastVulnType};
|
||||
use compliance_core::models::finding::Severity;
|
||||
use compliance_core::models::pentest::{
|
||||
AttackChainNode, AttackNodeStatus, PentestSession, PentestStrategy,
|
||||
};
|
||||
|
||||
// ── html_escape ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn html_escape_handles_ampersand() {
|
||||
assert_eq!(html_escape("a & b"), "a & b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_handles_angle_brackets() {
|
||||
assert_eq!(html_escape("<script>"), "<script>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_handles_quotes() {
|
||||
assert_eq!(html_escape(r#"key="val""#), "key="val"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_handles_all_special_chars() {
|
||||
assert_eq!(
|
||||
html_escape(r#"<a href="x">&y</a>"#),
|
||||
"<a href="x">&y</a>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_no_change_for_plain_text() {
|
||||
assert_eq!(html_escape("hello world"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_escape_empty_string() {
|
||||
assert_eq!(html_escape(""), "");
|
||||
}
|
||||
|
||||
// ── tool_category ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_category_nmap() {
|
||||
assert_eq!(tool_category("nmap_scan"), "Network Reconnaissance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_port_scanner() {
|
||||
assert_eq!(tool_category("port_scanner"), "Network Reconnaissance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_nikto() {
|
||||
assert_eq!(tool_category("nikto"), "Web Server Analysis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_header_check() {
|
||||
assert_eq!(
|
||||
tool_category("security_header_check"),
|
||||
"Web Server Analysis"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_zap_spider() {
|
||||
assert_eq!(tool_category("zap_spider"), "Web Application Scanning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_sqlmap() {
|
||||
assert_eq!(tool_category("sqlmap"), "SQL Injection Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_xss_scanner() {
|
||||
assert_eq!(tool_category("xss_scanner"), "Cross-Site Scripting Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_dir_bruteforce() {
|
||||
assert_eq!(tool_category("dir_bruteforce"), "Directory Enumeration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_gobuster() {
|
||||
assert_eq!(tool_category("gobuster"), "Directory Enumeration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_ssl_check() {
|
||||
assert_eq!(tool_category("ssl_check"), "SSL/TLS Analysis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_tls_scan() {
|
||||
assert_eq!(tool_category("tls_scan"), "SSL/TLS Analysis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_api_test() {
|
||||
assert_eq!(tool_category("api_endpoint_test"), "API Security Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_auth_bypass() {
|
||||
assert_eq!(tool_category("auth_bypass_check"), "Authentication Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_cors() {
|
||||
assert_eq!(tool_category("cors_check"), "CORS Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_csrf() {
|
||||
assert_eq!(tool_category("csrf_scanner"), "CSRF Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_nuclei() {
|
||||
assert_eq!(tool_category("nuclei"), "Vulnerability Scanning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_whatweb() {
|
||||
assert_eq!(tool_category("whatweb"), "Technology Fingerprinting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_unknown_defaults_to_security_testing() {
|
||||
assert_eq!(tool_category("custom_tool"), "Security Testing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_category_is_case_insensitive() {
|
||||
assert_eq!(tool_category("NMAP_Scanner"), "Network Reconnaissance");
|
||||
assert_eq!(tool_category("SQLMap"), "SQL Injection Testing");
|
||||
}
|
||||
|
||||
// ── build_html_report ────────────────────────────────────────────
|
||||
|
||||
fn make_session(strategy: PentestStrategy) -> PentestSession {
|
||||
let mut s = PentestSession::new("target-1".into(), strategy);
|
||||
s.tool_invocations = 5;
|
||||
s.tool_successes = 4;
|
||||
s.findings_count = 2;
|
||||
s.exploitable_count = 1;
|
||||
s
|
||||
}
|
||||
|
||||
fn make_finding(severity: Severity, title: &str, exploitable: bool) -> DastFinding {
|
||||
let mut f = DastFinding::new(
|
||||
"run-1".into(),
|
||||
"target-1".into(),
|
||||
DastVulnType::Xss,
|
||||
title.into(),
|
||||
"description".into(),
|
||||
severity,
|
||||
"https://example.com/test".into(),
|
||||
"GET".into(),
|
||||
);
|
||||
f.exploitable = exploitable;
|
||||
f
|
||||
}
|
||||
|
||||
fn make_attack_node(tool_name: &str) -> AttackChainNode {
|
||||
let mut node = AttackChainNode::new(
|
||||
"session-1".into(),
|
||||
"node-1".into(),
|
||||
tool_name.into(),
|
||||
serde_json::json!({}),
|
||||
"Testing this tool".into(),
|
||||
);
|
||||
node.status = AttackNodeStatus::Completed;
|
||||
node
|
||||
}
|
||||
|
||||
fn make_report_context(
|
||||
findings: Vec<DastFinding>,
|
||||
chain: Vec<AttackChainNode>,
|
||||
) -> ReportContext {
|
||||
ReportContext {
|
||||
session: make_session(PentestStrategy::Comprehensive),
|
||||
target_name: "Test App".into(),
|
||||
target_url: "https://example.com".into(),
|
||||
findings,
|
||||
attack_chain: chain,
|
||||
requester_name: "Alice".into(),
|
||||
requester_email: "alice@example.com".into(),
|
||||
config: None,
|
||||
sast_findings: Vec::new(),
|
||||
sbom_entries: Vec::new(),
|
||||
code_context: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_contains_target_info() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("Test App"));
|
||||
assert!(html.contains("https://example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_contains_requester_info() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("Alice"));
|
||||
assert!(html.contains("alice@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_informational_risk_when_no_findings() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("INFORMATIONAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_critical_risk_with_critical_finding() {
|
||||
let findings = vec![make_finding(Severity::Critical, "Critical XSS", true)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("CRITICAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_high_risk_without_critical() {
|
||||
let findings = vec![make_finding(Severity::High, "High SQLi", false)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
// Should show HIGH, not CRITICAL
|
||||
assert!(html.contains("HIGH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_medium_risk_level() {
|
||||
let findings = vec![make_finding(Severity::Medium, "Medium Issue", false)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("MEDIUM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_includes_finding_title() {
|
||||
let findings = vec![make_finding(
|
||||
Severity::High,
|
||||
"Reflected XSS in /search",
|
||||
true,
|
||||
)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("Reflected XSS in /search"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shows_exploitable_badge() {
|
||||
let findings = vec![make_finding(Severity::Critical, "SQLi", true)];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
// The report should mark exploitable findings
|
||||
assert!(html.contains("EXPLOITABLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_includes_attack_chain_tool_names() {
|
||||
let chain = vec![make_attack_node("nmap_scan"), make_attack_node("sqlmap")];
|
||||
let ctx = make_report_context(vec![], chain);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("nmap_scan"));
|
||||
assert!(html.contains("sqlmap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_is_valid_html_structure() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
assert!(html.contains("<!DOCTYPE html>") || html.contains("<html"));
|
||||
assert!(html.contains("</html>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_strategy_appears() {
|
||||
let ctx = make_report_context(vec![], vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
// PentestStrategy::Comprehensive => "comprehensive"
|
||||
assert!(html.contains("comprehensive") || html.contains("Comprehensive"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_finding_count_is_correct() {
|
||||
let findings = vec![
|
||||
make_finding(Severity::Critical, "F1", true),
|
||||
make_finding(Severity::High, "F2", false),
|
||||
make_finding(Severity::Low, "F3", false),
|
||||
];
|
||||
let ctx = make_report_context(findings, vec![]);
|
||||
let html = build_html_report(&ctx);
|
||||
// The total count "3" should appear somewhere
|
||||
assert!(
|
||||
html.contains(">3<")
|
||||
|| html.contains(">3 ")
|
||||
|| html.contains("3 findings")
|
||||
|| html.contains("3 Total")
|
||||
);
|
||||
}
|
||||
}
|
||||
127
compliance-agent/src/pentest/report/html/scope.rs
Normal file
127
compliance-agent/src/pentest/report/html/scope.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use super::{html_escape, tool_category};
|
||||
use compliance_core::models::pentest::{AuthMode, PentestConfig, PentestSession};
|
||||
|
||||
pub(super) fn scope(
|
||||
session: &PentestSession,
|
||||
target_name: &str,
|
||||
target_url: &str,
|
||||
date_str: &str,
|
||||
completed_str: &str,
|
||||
tool_names: &[String],
|
||||
config: Option<&PentestConfig>,
|
||||
) -> String {
|
||||
let tools_table: String = tool_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let category = tool_category(t);
|
||||
format!(
|
||||
"<tr><td>{}</td><td><code>{}</code></td><td>{}</td></tr>",
|
||||
i + 1,
|
||||
html_escape(t),
|
||||
category,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let engagement_config_section = if let Some(cfg) = config {
|
||||
let mut rows = String::new();
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Environment</td><td>{}</td></tr>",
|
||||
html_escape(&cfg.environment.to_string())
|
||||
));
|
||||
if let Some(ref app_type) = cfg.app_type {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Application Type</td><td>{}</td></tr>",
|
||||
html_escape(app_type)
|
||||
));
|
||||
}
|
||||
let auth_mode = match cfg.auth.mode {
|
||||
AuthMode::None => "No authentication",
|
||||
AuthMode::Manual => "Manual credentials",
|
||||
AuthMode::AutoRegister => "Auto-register",
|
||||
};
|
||||
rows.push_str(&format!("<tr><td>Auth Mode</td><td>{auth_mode}</td></tr>"));
|
||||
if !cfg.scope_exclusions.is_empty() {
|
||||
let excl = cfg
|
||||
.scope_exclusions
|
||||
.iter()
|
||||
.map(|s| html_escape(s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Scope Exclusions</td><td><code>{excl}</code></td></tr>"
|
||||
));
|
||||
}
|
||||
if !cfg.tester.name.is_empty() {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Tester</td><td>{} ({})</td></tr>",
|
||||
html_escape(&cfg.tester.name),
|
||||
html_escape(&cfg.tester.email)
|
||||
));
|
||||
}
|
||||
if let Some(ref ts) = cfg.disclaimer_accepted_at {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Disclaimer Accepted</td><td>{}</td></tr>",
|
||||
ts.format("%B %d, %Y at %H:%M UTC")
|
||||
));
|
||||
}
|
||||
if let Some(ref branch) = cfg.branch {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Git Branch</td><td>{}</td></tr>",
|
||||
html_escape(branch)
|
||||
));
|
||||
}
|
||||
if let Some(ref commit) = cfg.commit_hash {
|
||||
rows.push_str(&format!(
|
||||
"<tr><td>Git Commit</td><td><code>{}</code></td></tr>",
|
||||
html_escape(commit)
|
||||
));
|
||||
}
|
||||
format!("<h3>Engagement Configuration</h3>\n<table class=\"info\">\n{rows}\n</table>")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
r##"
|
||||
<!-- ═══════════════ 2. SCOPE & METHODOLOGY ═══════════════ -->
|
||||
<div class="page-break"></div>
|
||||
<h2><span class="section-num">2.</span> Scope & Methodology</h2>
|
||||
|
||||
<p>
|
||||
The assessment was performed using an AI-driven orchestrator that autonomously selects and
|
||||
executes security testing tools based on the target's attack surface, technology stack, and
|
||||
any available static analysis (SAST) findings and SBOM data.
|
||||
</p>
|
||||
|
||||
<h3>Engagement Details</h3>
|
||||
<table class="info">
|
||||
<tr><td>Target</td><td><strong>{target_name}</strong></td></tr>
|
||||
<tr><td>URL</td><td><code>{target_url}</code></td></tr>
|
||||
<tr><td>Strategy</td><td>{strategy}</td></tr>
|
||||
<tr><td>Status</td><td>{status}</td></tr>
|
||||
<tr><td>Started</td><td>{date_str}</td></tr>
|
||||
<tr><td>Completed</td><td>{completed_str}</td></tr>
|
||||
<tr><td>Tool Invocations</td><td>{tool_invocations} ({tool_successes} successful, {success_rate:.1}% success rate)</td></tr>
|
||||
</table>
|
||||
|
||||
{engagement_config_section}
|
||||
|
||||
<h3>Tools Employed</h3>
|
||||
<table class="tools-table">
|
||||
<thead><tr><th>#</th><th>Tool</th><th>Category</th></tr></thead>
|
||||
<tbody>{tools_table}</tbody>
|
||||
</table>"##,
|
||||
target_name = html_escape(target_name),
|
||||
target_url = html_escape(target_url),
|
||||
strategy = session.strategy,
|
||||
status = session.status,
|
||||
date_str = date_str,
|
||||
completed_str = completed_str,
|
||||
tool_invocations = session.tool_invocations,
|
||||
tool_successes = session.tool_successes,
|
||||
success_rate = session.success_rate(),
|
||||
)
|
||||
}
|
||||
889
compliance-agent/src/pentest/report/html/styles.rs
Normal file
889
compliance-agent/src/pentest/report/html/styles.rs
Normal file
@@ -0,0 +1,889 @@
|
||||
pub(super) fn styles() -> String {
|
||||
r##"<style>
|
||||
/* ──────────────── Base / Print-first ──────────────── */
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 20mm 18mm 25mm 18mm;
|
||||
}
|
||||
@page :first {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--text: #1a1a2e;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #64748b;
|
||||
--heading: #0d2137;
|
||||
--accent: #1a56db;
|
||||
--accent-light: #dbeafe;
|
||||
--border: #d1d5db;
|
||||
--border-light: #e5e7eb;
|
||||
--bg-subtle: #f8fafc;
|
||||
--bg-section: #f1f5f9;
|
||||
--sev-critical: #991b1b;
|
||||
--sev-high: #c2410c;
|
||||
--sev-medium: #a16207;
|
||||
--sev-low: #1d4ed8;
|
||||
--sev-info: #4b5563;
|
||||
--font-serif: 'Libre Baskerville', 'Georgia', serif;
|
||||
--font-sans: 'Source Sans 3', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
line-height: 1.65;
|
||||
font-size: 10.5pt;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.report-body {
|
||||
max-width: 190mm;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* ──────────────── Cover Page ──────────────── */
|
||||
.cover {
|
||||
height: 100vh;
|
||||
min-height: 297mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 40mm 30mm;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cover-shield {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.cover-tag {
|
||||
display: inline-block;
|
||||
background: var(--sev-critical);
|
||||
color: #fff;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 16px;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 28pt;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cover-subtitle {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14pt;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.cover-meta {
|
||||
font-size: 10pt;
|
||||
color: var(--text-secondary);
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.cover-meta strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cover-divider {
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
margin: 24px auto;
|
||||
}
|
||||
|
||||
.cover-footer {
|
||||
position: absolute;
|
||||
bottom: 30mm;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ──────────────── Typography ──────────────── */
|
||||
h2 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 16pt;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
margin: 36px 0 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid var(--heading);
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
margin: 24px 0 10px;
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
font-size: 10.5pt;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9pt;
|
||||
background: var(--bg-section);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-light);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ──────────────── Section Numbers ──────────────── */
|
||||
.section-num {
|
||||
color: var(--accent);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* ──────────────── Table of Contents ──────────────── */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.toc h2 {
|
||||
border-bottom-color: var(--accent);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted var(--border);
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.toc-entry .toc-num {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
min-width: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.toc-entry .toc-label {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.toc-sub {
|
||||
padding: 3px 0 3px 34px;
|
||||
font-size: 9.5pt;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ──────────────── Executive Summary ──────────────── */
|
||||
.exec-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin: 16px 0 20px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 14px 12px;
|
||||
text-align: center;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 22pt;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Risk gauge */
|
||||
.risk-gauge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-subtle);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.risk-gauge-meter {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.risk-gauge-track {
|
||||
height: 10px;
|
||||
background: var(--border-light);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.risk-gauge-fill {
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.risk-gauge-score {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.risk-gauge-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.risk-gauge-label {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 14pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.risk-gauge-desc {
|
||||
font-size: 9.5pt;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Severity bar */
|
||||
.sev-bar {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 12px 0 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sev-bar-seg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 8.5pt;
|
||||
font-weight: 700;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.sev-bar-critical { background: var(--sev-critical); }
|
||||
.sev-bar-high { background: var(--sev-high); }
|
||||
.sev-bar-medium { background: var(--sev-medium); }
|
||||
.sev-bar-low { background: var(--sev-low); }
|
||||
.sev-bar-info { background: var(--sev-info); }
|
||||
|
||||
.sev-bar-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 8.5pt;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sev-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ──────────────── Info Tables ──────────────── */
|
||||
table.info {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
table.info td,
|
||||
table.info th {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table.info td:first-child,
|
||||
table.info th:first-child {
|
||||
width: 160px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
/* Methodology tools table */
|
||||
table.tools-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
table.tools-table th {
|
||||
background: var(--heading);
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
table.tools-table td {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
table.tools-table tr:nth-child(even) td {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
table.tools-table td:first-child {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ──────────────── Badges ──────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-exploit {
|
||||
background: var(--sev-critical);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ──────────────── Findings ──────────────── */
|
||||
.sev-group-title {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
padding: 8px 0 6px 12px;
|
||||
margin: 20px 0 8px;
|
||||
border-left: 4px solid;
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
.finding {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid;
|
||||
border-radius: 0 4px 4px 0;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.finding-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.finding-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9pt;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-section);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.finding-title {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 700;
|
||||
font-size: 11pt;
|
||||
flex: 1;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.finding-meta {
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0;
|
||||
font-size: 9.5pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.finding-meta td {
|
||||
padding: 3px 10px 3px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.finding-meta td:first-child {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
width: 90px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finding-desc {
|
||||
margin: 8px 0;
|
||||
font-size: 10pt;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.remediation {
|
||||
margin-top: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--accent-light);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 4px 4px 0;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.remediation-label {
|
||||
font-weight: 700;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.evidence-block {
|
||||
margin: 10px 0;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.evidence-title {
|
||||
font-weight: 700;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.evidence-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.evidence-table th {
|
||||
background: var(--bg-section);
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.evidence-table td {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.evidence-payload {
|
||||
font-size: 8.5pt;
|
||||
color: var(--sev-critical);
|
||||
}
|
||||
|
||||
.linked-sast {
|
||||
font-size: 9pt;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ──────────────── Code-Level Correlation ──────────────── */
|
||||
.code-correlation {
|
||||
margin: 12px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-correlation-title {
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
padding: 6px 12px;
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.code-correlation-item {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
.code-correlation-item:last-child { border-bottom: none; }
|
||||
.code-correlation-badge {
|
||||
display: inline-block;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
font-size: 7pt;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.code-meta {
|
||||
width: 100%;
|
||||
font-size: 8.5pt;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.code-meta td:first-child {
|
||||
width: 80px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px 2px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.code-meta td:last-child {
|
||||
padding: 2px 0;
|
||||
}
|
||||
.code-snippet-block, .code-fix-block {
|
||||
margin: 6px 0;
|
||||
}
|
||||
.code-snippet-label, .code-fix-label {
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.code-snippet-label { color: #dc2626; }
|
||||
.code-fix-label { color: #16a34a; }
|
||||
.code-snippet {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: 8px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 8pt;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.code-fix {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-left: 3px solid #16a34a;
|
||||
padding: 8px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 8pt;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
.code-remediation {
|
||||
font-size: 8.5pt;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.code-linked-vulns {
|
||||
font-size: 8.5pt;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.code-linked-vulns ul {
|
||||
margin: 2px 0 0 16px;
|
||||
padding: 0;
|
||||
}
|
||||
.code-linked-vulns li {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ──────────────── Attack Chain ──────────────── */
|
||||
.phase-block {
|
||||
margin-bottom: 20px;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.phase-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: var(--heading);
|
||||
color: #fff;
|
||||
border-radius: 4px 4px 0 0;
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
|
||||
.phase-num {
|
||||
font-weight: 700;
|
||||
font-size: 8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
background: rgba(255,255,255,0.15);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.phase-label {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.phase-count {
|
||||
font-size: 8.5pt;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.phase-steps {
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-section);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.step-connector {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.step-tool {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9.5pt;
|
||||
font-weight: 500;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.step-status {
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.step-completed { background: #dcfce7; color: #166534; }
|
||||
.step-failed { background: #fef2f2; color: #991b1b; }
|
||||
.step-running { background: #fef9c3; color: #854d0e; }
|
||||
|
||||
.step-findings {
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: var(--sev-high);
|
||||
background: #fff7ed;
|
||||
padding: 1px 7px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #fed7aa;
|
||||
}
|
||||
|
||||
.step-risk {
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.risk-high { background: #fef2f2; color: var(--sev-critical); border: 1px solid #fecaca; }
|
||||
.risk-med { background: #fffbeb; color: var(--sev-medium); border: 1px solid #fde68a; }
|
||||
.risk-low { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||
|
||||
.step-reasoning {
|
||||
font-size: 9pt;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ──────────────── Footer ──────────────── */
|
||||
.report-footer {
|
||||
margin-top: 48px;
|
||||
padding-top: 14px;
|
||||
border-top: 2px solid var(--heading);
|
||||
font-size: 8pt;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.report-footer .footer-company {
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ──────────────── Page Break Utilities ──────────────── */
|
||||
.page-break {
|
||||
page-break-before: always;
|
||||
break-before: page;
|
||||
}
|
||||
|
||||
.avoid-break {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* ──────────────── Print Overrides ──────────────── */
|
||||
@media print {
|
||||
body {
|
||||
font-size: 10pt;
|
||||
}
|
||||
.cover {
|
||||
height: auto;
|
||||
min-height: 250mm;
|
||||
padding: 50mm 20mm;
|
||||
}
|
||||
.report-body {
|
||||
padding: 0;
|
||||
}
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────────── Screen Enhancements ──────────────── */
|
||||
@media screen {
|
||||
body {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.cover {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
.report-body {
|
||||
background: #fff;
|
||||
padding: 20px 32px 40px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
}
|
||||
</style>"##
|
||||
.to_string()
|
||||
}
|
||||
69
compliance-agent/src/pentest/report/mod.rs
Normal file
69
compliance-agent/src/pentest/report/mod.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
mod archive;
|
||||
mod html;
|
||||
mod pdf;
|
||||
|
||||
use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::{
|
||||
AttackChainNode, CodeContextHint, PentestConfig, PentestSession,
|
||||
};
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Report archive with metadata
|
||||
pub struct ReportArchive {
|
||||
/// The password-protected ZIP bytes
|
||||
pub archive: Vec<u8>,
|
||||
/// SHA-256 hex digest of the archive
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Report context gathered from the database
|
||||
pub struct ReportContext {
|
||||
pub session: PentestSession,
|
||||
pub target_name: String,
|
||||
pub target_url: String,
|
||||
pub findings: Vec<DastFinding>,
|
||||
pub attack_chain: Vec<AttackChainNode>,
|
||||
pub requester_name: String,
|
||||
pub requester_email: String,
|
||||
pub config: Option<PentestConfig>,
|
||||
/// SAST findings for the linked repository (for code-level correlation)
|
||||
pub sast_findings: Vec<Finding>,
|
||||
/// Vulnerable dependencies from SBOM
|
||||
pub sbom_entries: Vec<SbomEntry>,
|
||||
/// Code knowledge graph entry points linked to SAST findings
|
||||
pub code_context: Vec<CodeContextHint>,
|
||||
}
|
||||
|
||||
/// Generate a password-protected ZIP archive containing the pentest report.
|
||||
///
|
||||
/// The archive contains:
|
||||
/// - `report.pdf` — Professional pentest report (PDF)
|
||||
/// - `report.html` — HTML source (fallback)
|
||||
/// - `findings.json` — Raw findings data
|
||||
/// - `attack-chain.json` — Attack chain timeline
|
||||
///
|
||||
/// Files are encrypted with AES-256 inside the ZIP (standard WinZip AES format,
|
||||
/// supported by 7-Zip, WinRAR, macOS Archive Utility, etc.).
|
||||
pub async fn generate_encrypted_report(
|
||||
ctx: &ReportContext,
|
||||
password: &str,
|
||||
) -> Result<ReportArchive, String> {
|
||||
let html = html::build_html_report(ctx);
|
||||
|
||||
// Convert HTML to PDF via headless Chrome
|
||||
let pdf_bytes = pdf::html_to_pdf(&html).await?;
|
||||
|
||||
let zip_bytes = archive::build_zip(ctx, password, &html, &pdf_bytes)
|
||||
.map_err(|e| format!("Failed to create archive: {e}"))?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&zip_bytes);
|
||||
let sha256 = hex::encode(hasher.finalize());
|
||||
|
||||
Ok(ReportArchive {
|
||||
archive: zip_bytes,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
289
compliance-agent/src/pentest/report/pdf.rs
Normal file
289
compliance-agent/src/pentest/report/pdf.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use futures_util::SinkExt;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
type WsStream =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
/// Convert HTML string to PDF bytes.
|
||||
///
|
||||
/// If `CHROME_WS_URL` is set (e.g. `ws://host:3000`), connects to a remote
|
||||
/// headless Chrome via the Chrome DevTools Protocol over WebSocket.
|
||||
/// Otherwise falls back to a local Chrome/Chromium binary.
|
||||
pub(super) async fn html_to_pdf(html: &str) -> Result<Vec<u8>, String> {
|
||||
if let Ok(ws_url) = std::env::var("CHROME_WS_URL") {
|
||||
tracing::info!(url = %ws_url, "Generating PDF via remote Chrome (CDP)");
|
||||
cdp_print_to_pdf(&ws_url, html).await
|
||||
} else {
|
||||
tracing::info!("Generating PDF via local Chrome binary");
|
||||
local_chrome_pdf(html).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a CDP command (no session) and return the response.
|
||||
async fn cdp_send(
|
||||
ws: &mut WsStream,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = serde_json::json!({ "id": id, "method": method, "params": params });
|
||||
ws.send(Message::Text(msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("WS send failed: {e}"))?;
|
||||
read_until_result(ws, id).await
|
||||
}
|
||||
|
||||
/// Send a CDP command on a session and return the response.
|
||||
async fn cdp_send_session(
|
||||
ws: &mut WsStream,
|
||||
id: u64,
|
||||
session_id: &str,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = serde_json::json!({
|
||||
"id": id,
|
||||
"sessionId": session_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
ws.send(Message::Text(msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("WS send failed: {e}"))?;
|
||||
read_until_result(ws, id).await
|
||||
}
|
||||
|
||||
/// Generate PDF by connecting to a remote Chrome instance over CDP WebSocket.
|
||||
async fn cdp_print_to_pdf(base_ws_url: &str, html: &str) -> Result<Vec<u8>, String> {
|
||||
use base64::Engine;
|
||||
|
||||
// Step 1: Discover browser WS endpoint via /json/version
|
||||
let http_url = base_ws_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://");
|
||||
let version_url = format!("{http_url}/json/version");
|
||||
|
||||
let version: serde_json::Value = reqwest::get(&version_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to reach Chrome at {version_url}: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid /json/version response: {e}"))?;
|
||||
|
||||
let browser_ws = version["webSocketDebuggerUrl"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "No webSocketDebuggerUrl in /json/version".to_string())?;
|
||||
|
||||
// Step 2: Connect to browser WS endpoint
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(browser_ws)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connect failed: {e}"))?;
|
||||
|
||||
let mut id: u64 = 1;
|
||||
|
||||
// Step 3: Create a new target (tab)
|
||||
let resp = cdp_send(
|
||||
&mut ws,
|
||||
id,
|
||||
"Target.createTarget",
|
||||
serde_json::json!({ "url": "about:blank" }),
|
||||
)
|
||||
.await?;
|
||||
id += 1;
|
||||
|
||||
let target_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("targetId"))
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or("No targetId in createTarget response")?
|
||||
.to_string();
|
||||
|
||||
// Step 4: Attach to target
|
||||
let resp = cdp_send(
|
||||
&mut ws,
|
||||
id,
|
||||
"Target.attachToTarget",
|
||||
serde_json::json!({ "targetId": target_id, "flatten": true }),
|
||||
)
|
||||
.await?;
|
||||
id += 1;
|
||||
|
||||
let session_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or("No sessionId in attachToTarget response")?
|
||||
.to_string();
|
||||
|
||||
// Step 5: Enable Page domain
|
||||
cdp_send_session(
|
||||
&mut ws,
|
||||
id,
|
||||
&session_id,
|
||||
"Page.enable",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await?;
|
||||
id += 1;
|
||||
|
||||
// Step 6: Set page content with the HTML
|
||||
cdp_send_session(
|
||||
&mut ws,
|
||||
id,
|
||||
&session_id,
|
||||
"Page.setDocumentContent",
|
||||
serde_json::json!({ "frameId": target_id, "html": html }),
|
||||
)
|
||||
.await?;
|
||||
id += 1;
|
||||
|
||||
// Brief pause for rendering
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Step 7: Print to PDF
|
||||
let pdf_response = cdp_send_session(
|
||||
&mut ws,
|
||||
id,
|
||||
&session_id,
|
||||
"Page.printToPDF",
|
||||
serde_json::json!({
|
||||
"printBackground": true,
|
||||
"preferCSSPageSize": true,
|
||||
"displayHeaderFooter": false,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
id += 1;
|
||||
|
||||
let pdf_b64 = pdf_response
|
||||
.get("result")
|
||||
.and_then(|r| r.get("data"))
|
||||
.and_then(|d| d.as_str())
|
||||
.ok_or("No PDF data in printToPDF response")?;
|
||||
|
||||
let pdf_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(pdf_b64)
|
||||
.map_err(|e| format!("Failed to decode PDF base64: {e}"))?;
|
||||
|
||||
// Step 8: Close the target
|
||||
let _ = cdp_send(
|
||||
&mut ws,
|
||||
id,
|
||||
"Target.closeTarget",
|
||||
serde_json::json!({ "targetId": target_id }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let _ = ws.close(None).await;
|
||||
|
||||
if pdf_bytes.is_empty() {
|
||||
return Err("Chrome produced an empty PDF".to_string());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
size_kb = pdf_bytes.len() / 1024,
|
||||
"PDF report generated via CDP"
|
||||
);
|
||||
Ok(pdf_bytes)
|
||||
}
|
||||
|
||||
/// Read WebSocket messages until we get a response matching the given id.
|
||||
async fn read_until_result(ws: &mut WsStream, id: u64) -> Result<serde_json::Value, String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
let msg = tokio::time::timeout_at(deadline, ws.next())
|
||||
.await
|
||||
.map_err(|_| format!("Timeout waiting for CDP response id={id}"))?
|
||||
.ok_or_else(|| "WebSocket closed unexpectedly".to_string())?
|
||||
.map_err(|e| format!("WebSocket read error: {e}"))?;
|
||||
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if val.get("id").and_then(|i| i.as_u64()) == Some(id) {
|
||||
if let Some(err) = val.get("error") {
|
||||
return Err(format!("CDP error: {err}"));
|
||||
}
|
||||
return Ok(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback: generate PDF using a local Chrome/Chromium binary.
|
||||
async fn local_chrome_pdf(html: &str) -> Result<Vec<u8>, String> {
|
||||
let tmp_dir = std::env::temp_dir();
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
let html_path = tmp_dir.join(format!("pentest-report-{run_id}.html"));
|
||||
let pdf_path = tmp_dir.join(format!("pentest-report-{run_id}.pdf"));
|
||||
|
||||
std::fs::write(&html_path, html).map_err(|e| format!("Failed to write temp HTML: {e}"))?;
|
||||
|
||||
let chrome_bin = find_chrome_binary().ok_or_else(|| {
|
||||
"Chrome/Chromium not found. Set CHROME_WS_URL for remote Chrome or install chromium locally."
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
tracing::info!(chrome = %chrome_bin, "Generating PDF report via headless Chrome");
|
||||
|
||||
let html_url = format!("file://{}", html_path.display());
|
||||
|
||||
let output = tokio::process::Command::new(&chrome_bin)
|
||||
.args([
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--disable-software-rasterizer",
|
||||
"--run-all-compositor-stages-before-draw",
|
||||
"--disable-dev-shm-usage",
|
||||
&format!("--print-to-pdf={}", pdf_path.display()),
|
||||
"--no-pdf-header-footer",
|
||||
&html_url,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run Chrome: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _ = std::fs::remove_file(&html_path);
|
||||
let _ = std::fs::remove_file(&pdf_path);
|
||||
return Err(format!("Chrome PDF generation failed: {stderr}"));
|
||||
}
|
||||
|
||||
let pdf_bytes =
|
||||
std::fs::read(&pdf_path).map_err(|e| format!("Failed to read generated PDF: {e}"))?;
|
||||
|
||||
let _ = std::fs::remove_file(&html_path);
|
||||
let _ = std::fs::remove_file(&pdf_path);
|
||||
|
||||
if pdf_bytes.is_empty() {
|
||||
return Err("Chrome produced an empty PDF".to_string());
|
||||
}
|
||||
|
||||
tracing::info!(size_kb = pdf_bytes.len() / 1024, "PDF report generated");
|
||||
Ok(pdf_bytes)
|
||||
}
|
||||
|
||||
/// Search for Chrome/Chromium binary on the system.
|
||||
fn find_chrome_binary() -> Option<String> {
|
||||
let candidates = [
|
||||
"google-chrome-stable",
|
||||
"google-chrome",
|
||||
"chromium-browser",
|
||||
"chromium",
|
||||
];
|
||||
for name in &candidates {
|
||||
if let Ok(output) = std::process::Command::new("which").arg(name).output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -8,3 +8,51 @@ pub fn compute_fingerprint(parts: &[&str]) -> String {
|
||||
}
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_deterministic() {
|
||||
let a = compute_fingerprint(&["repo1", "rule-x", "src/main.rs", "42"]);
|
||||
let b = compute_fingerprint(&["repo1", "rule-x", "src/main.rs", "42"]);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_changes_with_different_input() {
|
||||
let a = compute_fingerprint(&["repo1", "rule-x", "src/main.rs", "42"]);
|
||||
let b = compute_fingerprint(&["repo1", "rule-x", "src/main.rs", "43"]);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_valid_hex_sha256() {
|
||||
let fp = compute_fingerprint(&["hello"]);
|
||||
assert_eq!(fp.len(), 64, "SHA-256 hex should be 64 chars");
|
||||
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_empty_parts() {
|
||||
let fp = compute_fingerprint(&[]);
|
||||
// Should still produce a valid hash (of empty input)
|
||||
assert_eq!(fp.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_order_matters() {
|
||||
let a = compute_fingerprint(&["a", "b"]);
|
||||
let b = compute_fingerprint(&["b", "a"]);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_separator_prevents_collision() {
|
||||
// "ab" + "c" vs "a" + "bc" should differ because of the "|" separator
|
||||
let a = compute_fingerprint(&["ab", "c"]);
|
||||
let b = compute_fingerprint(&["a", "bc"]);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,3 +129,110 @@ struct GitleaksResult {
|
||||
#[serde(rename = "Match")]
|
||||
r#match: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- is_allowlisted tests ---
|
||||
|
||||
#[test]
|
||||
fn allowlisted_env_example_files() {
|
||||
assert!(is_allowlisted(".env.example"));
|
||||
assert!(is_allowlisted("config/.env.sample"));
|
||||
assert!(is_allowlisted("deploy/.ENV.TEMPLATE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlisted_test_directories() {
|
||||
assert!(is_allowlisted("src/test/config.json"));
|
||||
assert!(is_allowlisted("src/tests/fixtures.rs"));
|
||||
assert!(is_allowlisted("data/fixtures/secret.txt"));
|
||||
assert!(is_allowlisted("pkg/testdata/key.pem"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlisted_mock_files() {
|
||||
assert!(is_allowlisted("src/mock_service.py"));
|
||||
assert!(is_allowlisted("lib/MockAuth.java"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlisted_test_suffixes() {
|
||||
assert!(is_allowlisted("auth_test.go"));
|
||||
assert!(is_allowlisted("auth.test.ts"));
|
||||
assert!(is_allowlisted("auth.test.js"));
|
||||
assert!(is_allowlisted("auth.spec.ts"));
|
||||
assert!(is_allowlisted("auth.spec.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_allowlisted_regular_files() {
|
||||
assert!(!is_allowlisted("src/main.rs"));
|
||||
assert!(!is_allowlisted("config/.env"));
|
||||
assert!(!is_allowlisted("lib/auth.ts"));
|
||||
assert!(!is_allowlisted("deploy/secrets.yaml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_allowlisted_partial_matches() {
|
||||
// "test" as substring in a non-directory context should not match
|
||||
assert!(!is_allowlisted("src/attestation.rs"));
|
||||
assert!(!is_allowlisted("src/contest/data.json"));
|
||||
}
|
||||
|
||||
// --- GitleaksResult deserialization tests ---
|
||||
|
||||
#[test]
|
||||
fn deserialize_gitleaks_result() {
|
||||
let json = r#"{
|
||||
"Description": "AWS Access Key",
|
||||
"RuleID": "aws-access-key",
|
||||
"File": "src/config.rs",
|
||||
"StartLine": 10,
|
||||
"Match": "AKIAIOSFODNN7EXAMPLE"
|
||||
}"#;
|
||||
let result: GitleaksResult = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(result.description, "AWS Access Key");
|
||||
assert_eq!(result.rule_id, "aws-access-key");
|
||||
assert_eq!(result.file, "src/config.rs");
|
||||
assert_eq!(result.start_line, 10);
|
||||
assert_eq!(result.r#match, "AKIAIOSFODNN7EXAMPLE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_gitleaks_result_array() {
|
||||
let json = r#"[
|
||||
{
|
||||
"Description": "Generic Secret",
|
||||
"RuleID": "generic-secret",
|
||||
"File": "app.py",
|
||||
"StartLine": 5,
|
||||
"Match": "password=hunter2"
|
||||
}
|
||||
]"#;
|
||||
let results: Vec<GitleaksResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].rule_id, "generic-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_mapping_private_key() {
|
||||
// Verify the severity logic from the scan method
|
||||
let rule_id = "some-private-key-rule";
|
||||
assert!(rule_id.contains("private-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_mapping_token_password_secret() {
|
||||
for keyword in &["token", "password", "secret"] {
|
||||
let rule_id = format!("some-{}-rule", keyword);
|
||||
assert!(
|
||||
rule_id.contains("token")
|
||||
|| rule_id.contains("password")
|
||||
|| rule_id.contains("secret"),
|
||||
"Expected '{rule_id}' to match token/password/secret"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
compliance-agent/src/pipeline/graph_build.rs
Normal file
106
compliance-agent/src/pipeline/graph_build.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use compliance_core::models::Finding;
|
||||
|
||||
use super::orchestrator::{GraphContext, PipelineOrchestrator};
|
||||
use crate::error::AgentError;
|
||||
|
||||
impl PipelineOrchestrator {
|
||||
/// Build the code knowledge graph for a repo and compute impact analyses
|
||||
pub(super) async fn build_code_graph(
|
||||
&self,
|
||||
repo_path: &std::path::Path,
|
||||
repo_id: &str,
|
||||
findings: &[Finding],
|
||||
) -> Result<GraphContext, AgentError> {
|
||||
let graph_build_id = uuid::Uuid::new_v4().to_string();
|
||||
let engine = compliance_graph::GraphEngine::new(50_000);
|
||||
|
||||
let (mut code_graph, build_run) =
|
||||
engine
|
||||
.build_graph(repo_path, repo_id, &graph_build_id)
|
||||
.map_err(|e| AgentError::Other(format!("Graph build error: {e}")))?;
|
||||
|
||||
// Apply community detection
|
||||
compliance_graph::graph::community::apply_communities(&mut code_graph);
|
||||
|
||||
// Store graph in MongoDB
|
||||
let store = compliance_graph::graph::persistence::GraphStore::new(self.db.inner());
|
||||
store
|
||||
.delete_repo_graph(repo_id)
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Graph cleanup error: {e}")))?;
|
||||
store
|
||||
.store_graph(&build_run, &code_graph.nodes, &code_graph.edges)
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Graph store error: {e}")))?;
|
||||
|
||||
// Compute impact analysis for each finding
|
||||
let analyzer = compliance_graph::GraphEngine::impact_analyzer(&code_graph);
|
||||
let mut impacts = Vec::new();
|
||||
|
||||
for finding in findings {
|
||||
if let Some(file_path) = &finding.file_path {
|
||||
let impact = analyzer.analyze(
|
||||
repo_id,
|
||||
&finding.fingerprint,
|
||||
&graph_build_id,
|
||||
file_path,
|
||||
finding.line_number,
|
||||
);
|
||||
store
|
||||
.store_impact(&impact)
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Impact store error: {e}")))?;
|
||||
impacts.push(impact);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(GraphContext {
|
||||
node_count: build_run.node_count,
|
||||
edge_count: build_run.edge_count,
|
||||
community_count: build_run.community_count,
|
||||
impacts,
|
||||
})
|
||||
}
|
||||
|
||||
/// Trigger DAST scan if a target is configured for this repo
|
||||
pub(super) async fn maybe_trigger_dast(&self, repo_id: &str, scan_run_id: &str) {
|
||||
use futures_util::TryStreamExt;
|
||||
|
||||
let filter = mongodb::bson::doc! { "repo_id": repo_id };
|
||||
let targets: Vec<compliance_core::models::DastTarget> =
|
||||
match self.db.dast_targets().find(filter).await {
|
||||
Ok(cursor) => cursor.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
tracing::info!("[{repo_id}] No DAST targets configured, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
for target in targets {
|
||||
let db = self.db.clone();
|
||||
let scan_run_id = scan_run_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = compliance_dast::DastOrchestrator::new(100);
|
||||
match orchestrator.run_scan(&target, Vec::new()).await {
|
||||
Ok((mut scan_run, findings)) => {
|
||||
scan_run.sast_scan_run_id = Some(scan_run_id);
|
||||
if let Err(e) = db.dast_scan_runs().insert_one(&scan_run).await {
|
||||
tracing::error!("Failed to store DAST scan run: {e}");
|
||||
}
|
||||
for finding in &findings {
|
||||
if let Err(e) = db.dast_findings().insert_one(finding).await {
|
||||
tracing::error!("Failed to store DAST finding: {e}");
|
||||
}
|
||||
}
|
||||
tracing::info!("DAST scan complete: {} findings", findings.len());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("DAST scan failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
259
compliance-agent/src/pipeline/issue_creation.rs
Normal file
259
compliance-agent/src/pipeline/issue_creation.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use compliance_core::models::*;
|
||||
|
||||
use super::orchestrator::{extract_base_url, PipelineOrchestrator};
|
||||
use super::tracker_dispatch::TrackerDispatch;
|
||||
use crate::error::AgentError;
|
||||
use crate::trackers;
|
||||
|
||||
impl PipelineOrchestrator {
|
||||
/// Build an issue tracker client from a repository's tracker configuration.
|
||||
/// Returns `None` if the repo has no tracker configured.
|
||||
pub(super) fn build_tracker(&self, repo: &TrackedRepository) -> Option<TrackerDispatch> {
|
||||
let tracker_type = repo.tracker_type.as_ref()?;
|
||||
// Per-repo token takes precedence, fall back to global config
|
||||
match tracker_type {
|
||||
TrackerType::GitHub => {
|
||||
let token = repo.tracker_token.clone().or_else(|| {
|
||||
self.config.github_token.as_ref().map(|t| {
|
||||
use secrecy::ExposeSecret;
|
||||
t.expose_secret().to_string()
|
||||
})
|
||||
})?;
|
||||
let secret = secrecy::SecretString::from(token);
|
||||
match trackers::github::GitHubTracker::new(&secret) {
|
||||
Ok(t) => Some(TrackerDispatch::GitHub(t)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to build GitHub tracker: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackerType::GitLab => {
|
||||
let base_url = self
|
||||
.config
|
||||
.gitlab_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "https://gitlab.com".to_string());
|
||||
let token = repo.tracker_token.clone().or_else(|| {
|
||||
self.config.gitlab_token.as_ref().map(|t| {
|
||||
use secrecy::ExposeSecret;
|
||||
t.expose_secret().to_string()
|
||||
})
|
||||
})?;
|
||||
let secret = secrecy::SecretString::from(token);
|
||||
Some(TrackerDispatch::GitLab(
|
||||
trackers::gitlab::GitLabTracker::new(base_url, secret),
|
||||
))
|
||||
}
|
||||
TrackerType::Gitea => {
|
||||
let token = repo.tracker_token.clone()?;
|
||||
let base_url = extract_base_url(&repo.git_url)?;
|
||||
let secret = secrecy::SecretString::from(token);
|
||||
Some(TrackerDispatch::Gitea(trackers::gitea::GiteaTracker::new(
|
||||
base_url, secret,
|
||||
)))
|
||||
}
|
||||
TrackerType::Jira => {
|
||||
let base_url = self.config.jira_url.clone()?;
|
||||
let email = self.config.jira_email.clone()?;
|
||||
let project_key = self.config.jira_project_key.clone()?;
|
||||
let token = repo.tracker_token.clone().or_else(|| {
|
||||
self.config.jira_api_token.as_ref().map(|t| {
|
||||
use secrecy::ExposeSecret;
|
||||
t.expose_secret().to_string()
|
||||
})
|
||||
})?;
|
||||
let secret = secrecy::SecretString::from(token);
|
||||
Some(TrackerDispatch::Jira(trackers::jira::JiraTracker::new(
|
||||
base_url,
|
||||
email,
|
||||
secret,
|
||||
project_key,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create tracker issues for new findings (severity >= Medium).
|
||||
/// Checks for duplicates via fingerprint search before creating.
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub(super) async fn create_tracker_issues(
|
||||
&self,
|
||||
repo: &TrackedRepository,
|
||||
repo_id: &str,
|
||||
new_findings: &[Finding],
|
||||
) -> Result<(), AgentError> {
|
||||
let tracker = match self.build_tracker(repo) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::info!("[{repo_id}] No issue tracker configured, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let owner = match repo.tracker_owner.as_deref() {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
tracing::warn!("[{repo_id}] tracker_owner not set, skipping issue creation");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let tracker_repo_name = match repo.tracker_repo.as_deref() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tracing::warn!("[{repo_id}] tracker_repo not set, skipping issue creation");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Only create issues for medium+ severity findings
|
||||
let actionable: Vec<&Finding> = new_findings
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
matches!(
|
||||
f.severity,
|
||||
Severity::Medium | Severity::High | Severity::Critical
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if actionable.is_empty() {
|
||||
tracing::info!("[{repo_id}] No medium+ findings, skipping issue creation");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[{repo_id}] Creating issues for {} findings via {}",
|
||||
actionable.len(),
|
||||
tracker.name()
|
||||
);
|
||||
|
||||
let mut created = 0u32;
|
||||
for finding in actionable {
|
||||
let title = format!(
|
||||
"[{}] {}: {}",
|
||||
finding.severity, finding.scanner, finding.title
|
||||
);
|
||||
|
||||
// Check if an issue already exists by fingerprint first, then by title
|
||||
let mut found_existing = false;
|
||||
for search_term in [&finding.fingerprint, &title] {
|
||||
match tracker
|
||||
.find_existing_issue(owner, tracker_repo_name, search_term)
|
||||
.await
|
||||
{
|
||||
Ok(Some(existing)) => {
|
||||
tracing::debug!(
|
||||
"[{repo_id}] Issue already exists for '{}': {}",
|
||||
search_term,
|
||||
existing.external_url
|
||||
);
|
||||
found_existing = true;
|
||||
break;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("[{repo_id}] Failed to search for existing issue: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if found_existing {
|
||||
continue;
|
||||
}
|
||||
let body = format_issue_body(finding);
|
||||
let labels = vec![
|
||||
format!("severity:{}", finding.severity),
|
||||
format!("scanner:{}", finding.scanner),
|
||||
"compliance-scanner".to_string(),
|
||||
];
|
||||
|
||||
match tracker
|
||||
.create_issue(owner, tracker_repo_name, &title, &body, &labels)
|
||||
.await
|
||||
{
|
||||
Ok(mut issue) => {
|
||||
issue.finding_id = finding
|
||||
.id
|
||||
.as_ref()
|
||||
.map(|id| id.to_hex())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Update the finding with the issue URL
|
||||
if let Some(finding_id) = &finding.id {
|
||||
let _ = self
|
||||
.db
|
||||
.findings()
|
||||
.update_one(
|
||||
doc! { "_id": finding_id },
|
||||
doc! { "$set": { "tracker_issue_url": &issue.external_url } },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Store the tracker issue record
|
||||
if let Err(e) = self.db.tracker_issues().insert_one(&issue).await {
|
||||
tracing::warn!("[{repo_id}] Failed to store tracker issue: {e}");
|
||||
}
|
||||
|
||||
created += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[{repo_id}] Failed to create issue for {}: {e}",
|
||||
finding.fingerprint
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[{repo_id}] Created {created} tracker issues");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a finding into a markdown issue body for the tracker.
|
||||
pub(super) fn format_issue_body(finding: &Finding) -> String {
|
||||
let mut body = String::new();
|
||||
|
||||
body.push_str(&format!("## {} Finding\n\n", finding.severity));
|
||||
body.push_str(&format!("**Scanner:** {}\n", finding.scanner));
|
||||
body.push_str(&format!("**Severity:** {}\n", finding.severity));
|
||||
|
||||
if let Some(rule) = &finding.rule_id {
|
||||
body.push_str(&format!("**Rule:** {}\n", rule));
|
||||
}
|
||||
if let Some(cwe) = &finding.cwe {
|
||||
body.push_str(&format!("**CWE:** {}\n", cwe));
|
||||
}
|
||||
|
||||
body.push_str(&format!("\n### Description\n\n{}\n", finding.description));
|
||||
|
||||
if let Some(file_path) = &finding.file_path {
|
||||
body.push_str(&format!("\n### Location\n\n**File:** `{}`", file_path));
|
||||
if let Some(line) = finding.line_number {
|
||||
body.push_str(&format!(" (line {})", line));
|
||||
}
|
||||
body.push('\n');
|
||||
}
|
||||
|
||||
if let Some(snippet) = &finding.code_snippet {
|
||||
body.push_str(&format!("\n### Code\n\n```\n{}\n```\n", snippet));
|
||||
}
|
||||
|
||||
if let Some(remediation) = &finding.remediation {
|
||||
body.push_str(&format!("\n### Remediation\n\n{}\n", remediation));
|
||||
}
|
||||
|
||||
if let Some(fix) = &finding.suggested_fix {
|
||||
body.push_str(&format!("\n### Suggested Fix\n\n```\n{}\n```\n", fix));
|
||||
}
|
||||
|
||||
body.push_str(&format!(
|
||||
"\n---\n*Fingerprint:* `{}`\n*Generated by compliance-scanner*",
|
||||
finding.fingerprint
|
||||
));
|
||||
|
||||
body
|
||||
}
|
||||
@@ -1,365 +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
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
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,
|
||||
}
|
||||
251
compliance-agent/src/pipeline/lint/clippy.rs
Normal file
251
compliance-agent/src/pipeline/lint/clippy.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::models::{Finding, ScanType, Severity};
|
||||
use compliance_core::CoreError;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::pipeline::dedup;
|
||||
|
||||
use super::run_with_timeout;
|
||||
|
||||
pub(super) 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)
|
||||
.env("RUSTC_WRAPPER", "")
|
||||
.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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_primary_span_with_primary() {
|
||||
let msg = serde_json::json!({
|
||||
"spans": [
|
||||
{
|
||||
"file_name": "src/lib.rs",
|
||||
"line_start": 42,
|
||||
"is_primary": true
|
||||
}
|
||||
]
|
||||
});
|
||||
let (file, line) = extract_primary_span(&msg);
|
||||
assert_eq!(file, "src/lib.rs");
|
||||
assert_eq!(line, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_primary_span_no_primary() {
|
||||
let msg = serde_json::json!({
|
||||
"spans": [
|
||||
{
|
||||
"file_name": "src/lib.rs",
|
||||
"line_start": 42,
|
||||
"is_primary": false
|
||||
}
|
||||
]
|
||||
});
|
||||
let (file, line) = extract_primary_span(&msg);
|
||||
assert_eq!(file, "");
|
||||
assert_eq!(line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_primary_span_multiple_spans() {
|
||||
let msg = serde_json::json!({
|
||||
"spans": [
|
||||
{
|
||||
"file_name": "src/other.rs",
|
||||
"line_start": 10,
|
||||
"is_primary": false
|
||||
},
|
||||
{
|
||||
"file_name": "src/main.rs",
|
||||
"line_start": 99,
|
||||
"is_primary": true
|
||||
}
|
||||
]
|
||||
});
|
||||
let (file, line) = extract_primary_span(&msg);
|
||||
assert_eq!(file, "src/main.rs");
|
||||
assert_eq!(line, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_primary_span_no_spans() {
|
||||
let msg = serde_json::json!({});
|
||||
let (file, line) = extract_primary_span(&msg);
|
||||
assert_eq!(file, "");
|
||||
assert_eq!(line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_primary_span_empty_spans() {
|
||||
let msg = serde_json::json!({ "spans": [] });
|
||||
let (file, line) = extract_primary_span(&msg);
|
||||
assert_eq!(file, "");
|
||||
assert_eq!(line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_clippy_compiler_message_line() {
|
||||
let line = r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused variable","code":{"code":"unused_variables"},"spans":[{"file_name":"src/main.rs","line_start":5,"is_primary":true}]}}"#;
|
||||
let msg: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
msg.get("reason").and_then(|v| v.as_str()),
|
||||
Some("compiler-message")
|
||||
);
|
||||
let message = msg.get("message").unwrap();
|
||||
assert_eq!(
|
||||
message.get("level").and_then(|v| v.as_str()),
|
||||
Some("warning")
|
||||
);
|
||||
assert_eq!(
|
||||
message.get("message").and_then(|v| v.as_str()),
|
||||
Some("unused variable")
|
||||
);
|
||||
assert_eq!(
|
||||
message
|
||||
.get("code")
|
||||
.and_then(|v| v.get("code"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("unused_variables")
|
||||
);
|
||||
|
||||
let (file, line_num) = extract_primary_span(message);
|
||||
assert_eq!(file, "src/main.rs");
|
||||
assert_eq!(line_num, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_non_compiler_message() {
|
||||
let line = r#"{"reason":"build-script-executed","package_id":"foo 0.1.0"}"#;
|
||||
let msg: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
assert_ne!(
|
||||
msg.get("reason").and_then(|v| v.as_str()),
|
||||
Some("compiler-message")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_aborting_message() {
|
||||
let text = "aborting due to 3 previous errors";
|
||||
assert!(text.starts_with("aborting due to"));
|
||||
}
|
||||
}
|
||||
183
compliance-agent/src/pipeline/lint/eslint.rs
Normal file
183
compliance-agent/src/pipeline/lint/eslint.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::models::{Finding, ScanType, Severity};
|
||||
use compliance_core::CoreError;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::pipeline::dedup;
|
||||
|
||||
use super::run_with_timeout;
|
||||
|
||||
pub(super) 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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_eslint_output() {
|
||||
let json = r#"[
|
||||
{
|
||||
"filePath": "/home/user/project/src/app.js",
|
||||
"messages": [
|
||||
{
|
||||
"ruleId": "no-unused-vars",
|
||||
"severity": 2,
|
||||
"message": "'x' is defined but never used.",
|
||||
"line": 10
|
||||
},
|
||||
{
|
||||
"ruleId": "semi",
|
||||
"severity": 1,
|
||||
"message": "Missing semicolon.",
|
||||
"line": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
let results: Vec<EslintFileResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].file_path, "/home/user/project/src/app.js");
|
||||
assert_eq!(results[0].messages.len(), 2);
|
||||
|
||||
assert_eq!(
|
||||
results[0].messages[0].rule_id,
|
||||
Some("no-unused-vars".to_string())
|
||||
);
|
||||
assert_eq!(results[0].messages[0].severity, 2);
|
||||
assert_eq!(results[0].messages[0].line, 10);
|
||||
|
||||
assert_eq!(results[0].messages[1].severity, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_eslint_null_rule_id() {
|
||||
let json = r#"[
|
||||
{
|
||||
"filePath": "src/index.js",
|
||||
"messages": [
|
||||
{
|
||||
"ruleId": null,
|
||||
"severity": 2,
|
||||
"message": "Parsing error: Unexpected token",
|
||||
"line": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
let results: Vec<EslintFileResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results[0].messages[0].rule_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_eslint_empty_messages() {
|
||||
let json = r#"[{"filePath": "src/clean.js", "messages": []}]"#;
|
||||
let results: Vec<EslintFileResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results[0].messages.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_eslint_empty_array() {
|
||||
let json = "[]";
|
||||
let results: Vec<EslintFileResult> = serde_json::from_str(json).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eslint_severity_mapping() {
|
||||
// severity 2 = error -> Medium, anything else -> Low
|
||||
assert_eq!(
|
||||
match 2u8 {
|
||||
2 => "Medium",
|
||||
_ => "Low",
|
||||
},
|
||||
"Medium"
|
||||
);
|
||||
assert_eq!(
|
||||
match 1u8 {
|
||||
2 => "Medium",
|
||||
_ => "Low",
|
||||
},
|
||||
"Low"
|
||||
);
|
||||
assert_eq!(
|
||||
match 0u8 {
|
||||
2 => "Medium",
|
||||
_ => "Low",
|
||||
},
|
||||
"Low"
|
||||
);
|
||||
}
|
||||
}
|
||||
97
compliance-agent/src/pipeline/lint/mod.rs
Normal file
97
compliance-agent/src/pipeline/lint/mod.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
mod clippy;
|
||||
mod eslint;
|
||||
mod ruff;
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use compliance_core::models::ScanType;
|
||||
use compliance_core::traits::{ScanOutput, Scanner};
|
||||
use compliance_core::CoreError;
|
||||
|
||||
/// Timeout for each individual lint command
|
||||
pub(crate) 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
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
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 clippy::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 eslint::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 ruff::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
|
||||
pub(crate) 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()),
|
||||
)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
150
compliance-agent/src/pipeline/lint/ruff.rs
Normal file
150
compliance-agent/src/pipeline/lint/ruff.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::models::{Finding, ScanType, Severity};
|
||||
use compliance_core::CoreError;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::pipeline::dedup;
|
||||
|
||||
use super::run_with_timeout;
|
||||
|
||||
pub(super) 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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_ruff_output() {
|
||||
let json = r#"[
|
||||
{
|
||||
"code": "E501",
|
||||
"message": "Line too long (120 > 79 characters)",
|
||||
"filename": "src/main.py",
|
||||
"location": {"row": 42}
|
||||
},
|
||||
{
|
||||
"code": "F401",
|
||||
"message": "`os` imported but unused",
|
||||
"filename": "src/utils.py",
|
||||
"location": {"row": 1}
|
||||
}
|
||||
]"#;
|
||||
let results: Vec<RuffResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
assert_eq!(results[0].code, "E501");
|
||||
assert_eq!(results[0].filename, "src/main.py");
|
||||
assert_eq!(results[0].location.row, 42);
|
||||
|
||||
assert_eq!(results[1].code, "F401");
|
||||
assert_eq!(results[1].location.row, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ruff_empty() {
|
||||
let json = "[]";
|
||||
let results: Vec<RuffResult> = serde_json::from_str(json).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_severity_e_and_f_are_medium() {
|
||||
for code in &["E501", "E302", "F401", "F811"] {
|
||||
let is_medium = code.starts_with('E') || code.starts_with('F');
|
||||
assert!(is_medium, "Expected {code} to be Medium severity");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ruff_severity_others_are_low() {
|
||||
for code in &["W291", "I001", "D100", "C901", "N801"] {
|
||||
let is_medium = code.starts_with('E') || code.starts_with('F');
|
||||
assert!(!is_medium, "Expected {code} to be Low severity");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ruff_with_extra_fields() {
|
||||
// Ruff output may contain additional fields we don't use
|
||||
let json = r#"[{
|
||||
"code": "W291",
|
||||
"message": "Trailing whitespace",
|
||||
"filename": "app.py",
|
||||
"location": {"row": 3, "column": 10},
|
||||
"end_location": {"row": 3, "column": 11},
|
||||
"fix": null,
|
||||
"noqa_row": 3
|
||||
}]"#;
|
||||
let results: Vec<RuffResult> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].code, "W291");
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,12 @@ pub mod cve;
|
||||
pub mod dedup;
|
||||
pub mod git;
|
||||
pub mod gitleaks;
|
||||
mod graph_build;
|
||||
mod issue_creation;
|
||||
pub mod lint;
|
||||
pub mod orchestrator;
|
||||
pub mod patterns;
|
||||
mod pr_review;
|
||||
pub mod sbom;
|
||||
pub mod semgrep;
|
||||
mod tracker_dispatch;
|
||||
|
||||
@@ -4,14 +4,12 @@ use mongodb::bson::doc;
|
||||
use tracing::Instrument;
|
||||
|
||||
use compliance_core::models::*;
|
||||
use compliance_core::traits::issue_tracker::IssueTracker;
|
||||
use compliance_core::traits::Scanner;
|
||||
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;
|
||||
use crate::pipeline::gitleaks::GitleaksScanner;
|
||||
@@ -19,84 +17,6 @@ use crate::pipeline::lint::LintScanner;
|
||||
use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner};
|
||||
use crate::pipeline::sbom::SbomScanner;
|
||||
use crate::pipeline::semgrep::SemgrepScanner;
|
||||
use crate::trackers;
|
||||
|
||||
/// Enum dispatch for issue trackers (async traits aren't dyn-compatible).
|
||||
enum TrackerDispatch {
|
||||
GitHub(trackers::github::GitHubTracker),
|
||||
GitLab(trackers::gitlab::GitLabTracker),
|
||||
Gitea(trackers::gitea::GiteaTracker),
|
||||
Jira(trackers::jira::JiraTracker),
|
||||
}
|
||||
|
||||
impl TrackerDispatch {
|
||||
fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::GitHub(t) => t.name(),
|
||||
Self::GitLab(t) => t.name(),
|
||||
Self::Gitea(t) => t.name(),
|
||||
Self::Jira(t) => t.name(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_issue(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
labels: &[String],
|
||||
) -> Result<TrackerIssue, compliance_core::error::CoreError> {
|
||||
match self {
|
||||
Self::GitHub(t) => t.create_issue(owner, repo, title, body, labels).await,
|
||||
Self::GitLab(t) => t.create_issue(owner, repo, title, body, labels).await,
|
||||
Self::Gitea(t) => t.create_issue(owner, repo, title, body, labels).await,
|
||||
Self::Jira(t) => t.create_issue(owner, repo, title, body, labels).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_existing_issue(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<Option<TrackerIssue>, compliance_core::error::CoreError> {
|
||||
match self {
|
||||
Self::GitHub(t) => t.find_existing_issue(owner, repo, fingerprint).await,
|
||||
Self::GitLab(t) => t.find_existing_issue(owner, repo, fingerprint).await,
|
||||
Self::Gitea(t) => t.find_existing_issue(owner, repo, fingerprint).await,
|
||||
Self::Jira(t) => t.find_existing_issue(owner, repo, fingerprint).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_pr_review(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: u64,
|
||||
body: &str,
|
||||
comments: Vec<compliance_core::traits::issue_tracker::ReviewComment>,
|
||||
) -> Result<(), compliance_core::error::CoreError> {
|
||||
match self {
|
||||
Self::GitHub(t) => {
|
||||
t.create_pr_review(owner, repo, pr_number, body, comments)
|
||||
.await
|
||||
}
|
||||
Self::GitLab(t) => {
|
||||
t.create_pr_review(owner, repo, pr_number, body, comments)
|
||||
.await
|
||||
}
|
||||
Self::Gitea(t) => {
|
||||
t.create_pr_review(owner, repo, pr_number, body, comments)
|
||||
.await
|
||||
}
|
||||
Self::Jira(t) => {
|
||||
t.create_pr_review(owner, repo, pr_number, body, comments)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context from graph analysis passed to LLM triage for enhanced filtering
|
||||
#[derive(Debug)]
|
||||
@@ -109,10 +29,10 @@ pub struct GraphContext {
|
||||
}
|
||||
|
||||
pub struct PipelineOrchestrator {
|
||||
config: AgentConfig,
|
||||
db: Database,
|
||||
llm: Arc<LlmClient>,
|
||||
http: reqwest::Client,
|
||||
pub(super) config: AgentConfig,
|
||||
pub(super) db: Database,
|
||||
pub(super) llm: Arc<LlmClient>,
|
||||
pub(super) http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl PipelineOrchestrator {
|
||||
@@ -320,21 +240,6 @@ impl PipelineOrchestrator {
|
||||
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 review_output = async {
|
||||
let reviewer = CodeReviewScanner::new(self.llm.clone());
|
||||
reviewer
|
||||
.review_diff(&repo_path, &repo_id, old_sha, ¤t_sha)
|
||||
.await
|
||||
}
|
||||
.instrument(tracing::info_span!("stage_code_review"))
|
||||
.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;
|
||||
@@ -460,440 +365,7 @@ impl PipelineOrchestrator {
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Build the code knowledge graph for a repo and compute impact analyses
|
||||
async fn build_code_graph(
|
||||
&self,
|
||||
repo_path: &std::path::Path,
|
||||
repo_id: &str,
|
||||
findings: &[Finding],
|
||||
) -> Result<GraphContext, AgentError> {
|
||||
let graph_build_id = uuid::Uuid::new_v4().to_string();
|
||||
let engine = compliance_graph::GraphEngine::new(50_000);
|
||||
|
||||
let (mut code_graph, build_run) =
|
||||
engine
|
||||
.build_graph(repo_path, repo_id, &graph_build_id)
|
||||
.map_err(|e| AgentError::Other(format!("Graph build error: {e}")))?;
|
||||
|
||||
// Apply community detection
|
||||
compliance_graph::graph::community::apply_communities(&mut code_graph);
|
||||
|
||||
// Store graph in MongoDB
|
||||
let store = compliance_graph::graph::persistence::GraphStore::new(self.db.inner());
|
||||
store
|
||||
.delete_repo_graph(repo_id)
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Graph cleanup error: {e}")))?;
|
||||
store
|
||||
.store_graph(&build_run, &code_graph.nodes, &code_graph.edges)
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Graph store error: {e}")))?;
|
||||
|
||||
// Compute impact analysis for each finding
|
||||
let analyzer = compliance_graph::GraphEngine::impact_analyzer(&code_graph);
|
||||
let mut impacts = Vec::new();
|
||||
|
||||
for finding in findings {
|
||||
if let Some(file_path) = &finding.file_path {
|
||||
let impact = analyzer.analyze(
|
||||
repo_id,
|
||||
&finding.fingerprint,
|
||||
&graph_build_id,
|
||||
file_path,
|
||||
finding.line_number,
|
||||
);
|
||||
store
|
||||
.store_impact(&impact)
|
||||
.await
|
||||
.map_err(|e| AgentError::Other(format!("Impact store error: {e}")))?;
|
||||
impacts.push(impact);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(GraphContext {
|
||||
node_count: build_run.node_count,
|
||||
edge_count: build_run.edge_count,
|
||||
community_count: build_run.community_count,
|
||||
impacts,
|
||||
})
|
||||
}
|
||||
|
||||
/// Trigger DAST scan if a target is configured for this repo
|
||||
async fn maybe_trigger_dast(&self, repo_id: &str, scan_run_id: &str) {
|
||||
use futures_util::TryStreamExt;
|
||||
|
||||
let filter = mongodb::bson::doc! { "repo_id": repo_id };
|
||||
let targets: Vec<compliance_core::models::DastTarget> =
|
||||
match self.db.dast_targets().find(filter).await {
|
||||
Ok(cursor) => cursor.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
tracing::info!("[{repo_id}] No DAST targets configured, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
for target in targets {
|
||||
let db = self.db.clone();
|
||||
let scan_run_id = scan_run_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = compliance_dast::DastOrchestrator::new(100);
|
||||
match orchestrator.run_scan(&target, Vec::new()).await {
|
||||
Ok((mut scan_run, findings)) => {
|
||||
scan_run.sast_scan_run_id = Some(scan_run_id);
|
||||
if let Err(e) = db.dast_scan_runs().insert_one(&scan_run).await {
|
||||
tracing::error!("Failed to store DAST scan run: {e}");
|
||||
}
|
||||
for finding in &findings {
|
||||
if let Err(e) = db.dast_findings().insert_one(finding).await {
|
||||
tracing::error!("Failed to store DAST finding: {e}");
|
||||
}
|
||||
}
|
||||
tracing::info!("DAST scan complete: {} findings", findings.len());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("DAST scan failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an issue tracker client from a repository's tracker configuration.
|
||||
/// Returns `None` if the repo has no tracker configured.
|
||||
fn build_tracker(&self, repo: &TrackedRepository) -> Option<TrackerDispatch> {
|
||||
let tracker_type = repo.tracker_type.as_ref()?;
|
||||
// Per-repo token takes precedence, fall back to global config
|
||||
match tracker_type {
|
||||
TrackerType::GitHub => {
|
||||
let token = repo.tracker_token.clone().or_else(|| {
|
||||
self.config.github_token.as_ref().map(|t| {
|
||||
use secrecy::ExposeSecret;
|
||||
t.expose_secret().to_string()
|
||||
})
|
||||
})?;
|
||||
let secret = secrecy::SecretString::from(token);
|
||||
match trackers::github::GitHubTracker::new(&secret) {
|
||||
Ok(t) => Some(TrackerDispatch::GitHub(t)),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to build GitHub tracker: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackerType::GitLab => {
|
||||
let base_url = self
|
||||
.config
|
||||
.gitlab_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "https://gitlab.com".to_string());
|
||||
let token = repo.tracker_token.clone().or_else(|| {
|
||||
self.config.gitlab_token.as_ref().map(|t| {
|
||||
use secrecy::ExposeSecret;
|
||||
t.expose_secret().to_string()
|
||||
})
|
||||
})?;
|
||||
let secret = secrecy::SecretString::from(token);
|
||||
Some(TrackerDispatch::GitLab(
|
||||
trackers::gitlab::GitLabTracker::new(base_url, secret),
|
||||
))
|
||||
}
|
||||
TrackerType::Gitea => {
|
||||
let token = repo.tracker_token.clone()?;
|
||||
let base_url = extract_base_url(&repo.git_url)?;
|
||||
let secret = secrecy::SecretString::from(token);
|
||||
Some(TrackerDispatch::Gitea(trackers::gitea::GiteaTracker::new(
|
||||
base_url, secret,
|
||||
)))
|
||||
}
|
||||
TrackerType::Jira => {
|
||||
let base_url = self.config.jira_url.clone()?;
|
||||
let email = self.config.jira_email.clone()?;
|
||||
let project_key = self.config.jira_project_key.clone()?;
|
||||
let token = repo.tracker_token.clone().or_else(|| {
|
||||
self.config.jira_api_token.as_ref().map(|t| {
|
||||
use secrecy::ExposeSecret;
|
||||
t.expose_secret().to_string()
|
||||
})
|
||||
})?;
|
||||
let secret = secrecy::SecretString::from(token);
|
||||
Some(TrackerDispatch::Jira(trackers::jira::JiraTracker::new(
|
||||
base_url,
|
||||
email,
|
||||
secret,
|
||||
project_key,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create tracker issues for new findings (severity >= Medium).
|
||||
/// Checks for duplicates via fingerprint search before creating.
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
async fn create_tracker_issues(
|
||||
&self,
|
||||
repo: &TrackedRepository,
|
||||
repo_id: &str,
|
||||
new_findings: &[Finding],
|
||||
) -> Result<(), AgentError> {
|
||||
let tracker = match self.build_tracker(repo) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::info!("[{repo_id}] No issue tracker configured, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let owner = match repo.tracker_owner.as_deref() {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
tracing::warn!("[{repo_id}] tracker_owner not set, skipping issue creation");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let tracker_repo_name = match repo.tracker_repo.as_deref() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tracing::warn!("[{repo_id}] tracker_repo not set, skipping issue creation");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Only create issues for medium+ severity findings
|
||||
let actionable: Vec<&Finding> = new_findings
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
matches!(
|
||||
f.severity,
|
||||
Severity::Medium | Severity::High | Severity::Critical
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if actionable.is_empty() {
|
||||
tracing::info!("[{repo_id}] No medium+ findings, skipping issue creation");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[{repo_id}] Creating issues for {} findings via {}",
|
||||
actionable.len(),
|
||||
tracker.name()
|
||||
);
|
||||
|
||||
let mut created = 0u32;
|
||||
for finding in actionable {
|
||||
// Check if an issue already exists for this fingerprint
|
||||
match tracker
|
||||
.find_existing_issue(owner, tracker_repo_name, &finding.fingerprint)
|
||||
.await
|
||||
{
|
||||
Ok(Some(existing)) => {
|
||||
tracing::debug!(
|
||||
"[{repo_id}] Issue already exists for {}: {}",
|
||||
finding.fingerprint,
|
||||
existing.external_url
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!("[{repo_id}] Failed to search for existing issue: {e}");
|
||||
// Continue and try to create anyway
|
||||
}
|
||||
}
|
||||
|
||||
let title = format!(
|
||||
"[{}] {}: {}",
|
||||
finding.severity, finding.scanner, finding.title
|
||||
);
|
||||
let body = format_issue_body(finding);
|
||||
let labels = vec![
|
||||
format!("severity:{}", finding.severity),
|
||||
format!("scanner:{}", finding.scanner),
|
||||
"compliance-scanner".to_string(),
|
||||
];
|
||||
|
||||
match tracker
|
||||
.create_issue(owner, tracker_repo_name, &title, &body, &labels)
|
||||
.await
|
||||
{
|
||||
Ok(mut issue) => {
|
||||
issue.finding_id = finding
|
||||
.id
|
||||
.as_ref()
|
||||
.map(|id| id.to_hex())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Update the finding with the issue URL
|
||||
if let Some(finding_id) = &finding.id {
|
||||
let _ = self
|
||||
.db
|
||||
.findings()
|
||||
.update_one(
|
||||
doc! { "_id": finding_id },
|
||||
doc! { "$set": { "tracker_issue_url": &issue.external_url } },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Store the tracker issue record
|
||||
if let Err(e) = self.db.tracker_issues().insert_one(&issue).await {
|
||||
tracing::warn!("[{repo_id}] Failed to store tracker issue: {e}");
|
||||
}
|
||||
|
||||
created += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[{repo_id}] Failed to create issue for {}: {e}",
|
||||
finding.fingerprint
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[{repo_id}] Created {created} tracker issues");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run an incremental scan on a PR diff and post review comments.
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, pr_number))]
|
||||
pub async fn run_pr_review(
|
||||
&self,
|
||||
repo: &TrackedRepository,
|
||||
repo_id: &str,
|
||||
pr_number: u64,
|
||||
base_sha: &str,
|
||||
head_sha: &str,
|
||||
) -> Result<(), AgentError> {
|
||||
let tracker = match self.build_tracker(repo) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::warn!("[{repo_id}] No tracker configured, cannot post PR review");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let owner = repo.tracker_owner.as_deref().unwrap_or("");
|
||||
let tracker_repo_name = repo.tracker_repo.as_deref().unwrap_or("");
|
||||
if owner.is_empty() || tracker_repo_name.is_empty() {
|
||||
tracing::warn!("[{repo_id}] tracker_owner or tracker_repo not set");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Clone/fetch the repo
|
||||
let creds = GitOps::make_repo_credentials(&self.config, repo);
|
||||
let git_ops = GitOps::new(&self.config.git_clone_base_path, creds);
|
||||
let repo_path = git_ops.clone_or_fetch(&repo.git_url, &repo.name)?;
|
||||
|
||||
// Get diff between base and head
|
||||
let diff_files = GitOps::get_diff_content(&repo_path, base_sha, head_sha)?;
|
||||
if diff_files.is_empty() {
|
||||
tracing::info!("[{repo_id}] PR #{pr_number}: no diff files, skipping review");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Run semgrep on the full repo but we'll filter findings to changed files
|
||||
let changed_paths: std::collections::HashSet<String> =
|
||||
diff_files.iter().map(|f| f.path.clone()).collect();
|
||||
|
||||
let mut pr_findings: Vec<Finding> = Vec::new();
|
||||
|
||||
// SAST scan (semgrep)
|
||||
match SemgrepScanner.scan(&repo_path, repo_id).await {
|
||||
Ok(output) => {
|
||||
for f in output.findings {
|
||||
if let Some(fp) = &f.file_path {
|
||||
if changed_paths.contains(fp.as_str()) {
|
||||
pr_findings.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("[{repo_id}] PR semgrep failed: {e}"),
|
||||
}
|
||||
|
||||
// LLM code review on the diff
|
||||
let reviewer = CodeReviewScanner::new(self.llm.clone());
|
||||
let review_output = reviewer
|
||||
.review_diff(&repo_path, repo_id, base_sha, head_sha)
|
||||
.await;
|
||||
pr_findings.extend(review_output.findings);
|
||||
|
||||
if pr_findings.is_empty() {
|
||||
// Post a clean review
|
||||
if let Err(e) = tracker
|
||||
.create_pr_review(
|
||||
owner,
|
||||
tracker_repo_name,
|
||||
pr_number,
|
||||
"Compliance scan: no issues found in this PR.",
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[{repo_id}] Failed to post clean PR review: {e}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build review comments from findings
|
||||
let mut review_comments = Vec::new();
|
||||
for finding in &pr_findings {
|
||||
if let (Some(path), Some(line)) = (&finding.file_path, finding.line_number) {
|
||||
let comment_body = format!(
|
||||
"**[{}] {}**\n\n{}\n\n*Scanner: {} | {}*",
|
||||
finding.severity,
|
||||
finding.title,
|
||||
finding.description,
|
||||
finding.scanner,
|
||||
finding
|
||||
.cwe
|
||||
.as_deref()
|
||||
.map(|c| format!("CWE: {c}"))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
review_comments.push(compliance_core::traits::issue_tracker::ReviewComment {
|
||||
path: path.clone(),
|
||||
line,
|
||||
body: comment_body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let summary = format!(
|
||||
"Compliance scan found **{}** issue(s) in this PR:\n\n{}",
|
||||
pr_findings.len(),
|
||||
pr_findings
|
||||
.iter()
|
||||
.map(|f| format!("- **[{}]** {}: {}", f.severity, f.scanner, f.title))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
if let Err(e) = tracker
|
||||
.create_pr_review(
|
||||
owner,
|
||||
tracker_repo_name,
|
||||
pr_number,
|
||||
&summary,
|
||||
review_comments,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[{repo_id}] Failed to post PR review: {e}");
|
||||
} else {
|
||||
tracing::info!(
|
||||
"[{repo_id}] Posted PR review on #{pr_number} with {} findings",
|
||||
pr_findings.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_phase(&self, scan_run_id: &str, phase: &str) {
|
||||
pub(super) async fn update_phase(&self, scan_run_id: &str, phase: &str) {
|
||||
if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(scan_run_id) {
|
||||
let _ = self
|
||||
.db
|
||||
@@ -911,9 +383,9 @@ impl PipelineOrchestrator {
|
||||
}
|
||||
|
||||
/// Extract the scheme + host from a git URL.
|
||||
/// e.g. "https://gitea.example.com/owner/repo.git" → "https://gitea.example.com"
|
||||
/// e.g. "ssh://git@gitea.example.com:22/owner/repo.git" → "https://gitea.example.com"
|
||||
fn extract_base_url(git_url: &str) -> Option<String> {
|
||||
/// e.g. "https://gitea.example.com/owner/repo.git" -> "https://gitea.example.com"
|
||||
/// e.g. "ssh://git@gitea.example.com:22/owner/repo.git" -> "https://gitea.example.com"
|
||||
pub(super) fn extract_base_url(git_url: &str) -> Option<String> {
|
||||
if let Some(rest) = git_url.strip_prefix("https://") {
|
||||
let host = rest.split('/').next()?;
|
||||
Some(format!("https://{host}"))
|
||||
@@ -921,7 +393,7 @@ fn extract_base_url(git_url: &str) -> Option<String> {
|
||||
let host = rest.split('/').next()?;
|
||||
Some(format!("http://{host}"))
|
||||
} else if let Some(rest) = git_url.strip_prefix("ssh://") {
|
||||
// ssh://git@host:port/path → extract host
|
||||
// ssh://git@host:port/path -> extract host
|
||||
let after_at = rest.find('@').map(|i| &rest[i + 1..]).unwrap_or(rest);
|
||||
let host = after_at.split(&[':', '/'][..]).next()?;
|
||||
Some(format!("https://{host}"))
|
||||
@@ -934,48 +406,3 @@ fn extract_base_url(git_url: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a finding into a markdown issue body for the tracker.
|
||||
fn format_issue_body(finding: &Finding) -> String {
|
||||
let mut body = String::new();
|
||||
|
||||
body.push_str(&format!("## {} Finding\n\n", finding.severity));
|
||||
body.push_str(&format!("**Scanner:** {}\n", finding.scanner));
|
||||
body.push_str(&format!("**Severity:** {}\n", finding.severity));
|
||||
|
||||
if let Some(rule) = &finding.rule_id {
|
||||
body.push_str(&format!("**Rule:** {}\n", rule));
|
||||
}
|
||||
if let Some(cwe) = &finding.cwe {
|
||||
body.push_str(&format!("**CWE:** {}\n", cwe));
|
||||
}
|
||||
|
||||
body.push_str(&format!("\n### Description\n\n{}\n", finding.description));
|
||||
|
||||
if let Some(file_path) = &finding.file_path {
|
||||
body.push_str(&format!("\n### Location\n\n**File:** `{}`", file_path));
|
||||
if let Some(line) = finding.line_number {
|
||||
body.push_str(&format!(" (line {})", line));
|
||||
}
|
||||
body.push('\n');
|
||||
}
|
||||
|
||||
if let Some(snippet) = &finding.code_snippet {
|
||||
body.push_str(&format!("\n### Code\n\n```\n{}\n```\n", snippet));
|
||||
}
|
||||
|
||||
if let Some(remediation) = &finding.remediation {
|
||||
body.push_str(&format!("\n### Remediation\n\n{}\n", remediation));
|
||||
}
|
||||
|
||||
if let Some(fix) = &finding.suggested_fix {
|
||||
body.push_str(&format!("\n### Suggested Fix\n\n```\n{}\n```\n", fix));
|
||||
}
|
||||
|
||||
body.push_str(&format!(
|
||||
"\n---\n*Fingerprint:* `{}`\n*Generated by compliance-scanner*",
|
||||
finding.fingerprint
|
||||
));
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
@@ -256,3 +256,159 @@ fn walkdir(path: &Path) -> Result<Vec<walkdir::DirEntry>, CoreError> {
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- compile_regex tests ---
|
||||
|
||||
#[test]
|
||||
fn compile_regex_valid_pattern() {
|
||||
let re = compile_regex(r"\bfoo\b");
|
||||
assert!(re.is_match("hello foo bar"));
|
||||
assert!(!re.is_match("foobar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_regex_invalid_pattern_returns_fallback() {
|
||||
// An invalid regex should return the fallback "^$" that only matches empty strings
|
||||
let re = compile_regex(r"[invalid");
|
||||
assert!(re.is_match(""));
|
||||
assert!(!re.is_match("anything"));
|
||||
}
|
||||
|
||||
// --- GDPR pattern tests ---
|
||||
|
||||
#[test]
|
||||
fn gdpr_pii_logging_matches() {
|
||||
let scanner = GdprPatternScanner::new();
|
||||
let pattern = &scanner.patterns[0]; // gdpr-pii-logging
|
||||
// Regex: (log|print|console\.|logger\.|tracing::)\s*[\.(].*\b(pii_keyword)\b
|
||||
assert!(pattern.pattern.is_match("console.log(email)"));
|
||||
assert!(pattern.pattern.is_match("console.log(user.ssn)"));
|
||||
assert!(pattern.pattern.is_match("print(phone_number)"));
|
||||
assert!(pattern.pattern.is_match("tracing::(ip_addr)"));
|
||||
assert!(pattern.pattern.is_match("log.debug(credit_card)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gdpr_pii_logging_no_false_positive() {
|
||||
let scanner = GdprPatternScanner::new();
|
||||
let pattern = &scanner.patterns[0];
|
||||
// Regular logging without PII fields should not match
|
||||
assert!(!pattern
|
||||
.pattern
|
||||
.is_match("logger.info(\"request completed\")"));
|
||||
assert!(!pattern.pattern.is_match("let email = user.email;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gdpr_no_consent_matches() {
|
||||
let scanner = GdprPatternScanner::new();
|
||||
let pattern = &scanner.patterns[1]; // gdpr-no-consent
|
||||
assert!(pattern.pattern.is_match("collect personal data"));
|
||||
assert!(pattern.pattern.is_match("store user_data in db"));
|
||||
assert!(pattern.pattern.is_match("save pii to disk"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gdpr_user_model_matches() {
|
||||
let scanner = GdprPatternScanner::new();
|
||||
let pattern = &scanner.patterns[2]; // gdpr-no-delete-endpoint
|
||||
assert!(pattern.pattern.is_match("struct User {"));
|
||||
assert!(pattern.pattern.is_match("class User(Model):"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gdpr_hardcoded_retention_matches() {
|
||||
let scanner = GdprPatternScanner::new();
|
||||
let pattern = &scanner.patterns[3]; // gdpr-hardcoded-retention
|
||||
assert!(pattern.pattern.is_match("retention = 30"));
|
||||
assert!(pattern.pattern.is_match("ttl: 3600"));
|
||||
assert!(pattern.pattern.is_match("expire = 86400"));
|
||||
}
|
||||
|
||||
// --- OAuth pattern tests ---
|
||||
|
||||
#[test]
|
||||
fn oauth_implicit_grant_matches() {
|
||||
let scanner = OAuthPatternScanner::new();
|
||||
let pattern = &scanner.patterns[0]; // oauth-implicit-grant
|
||||
assert!(pattern.pattern.is_match("response_type = \"token\""));
|
||||
assert!(pattern.pattern.is_match("grant_type: implicit"));
|
||||
assert!(pattern.pattern.is_match("response_type='token'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_implicit_grant_no_false_positive() {
|
||||
let scanner = OAuthPatternScanner::new();
|
||||
let pattern = &scanner.patterns[0];
|
||||
assert!(!pattern.pattern.is_match("response_type = \"code\""));
|
||||
assert!(!pattern.pattern.is_match("grant_type: authorization_code"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_authorization_code_matches() {
|
||||
let scanner = OAuthPatternScanner::new();
|
||||
let pattern = &scanner.patterns[1]; // oauth-missing-pkce
|
||||
assert!(pattern.pattern.is_match("uses authorization_code flow"));
|
||||
assert!(pattern.pattern.is_match("authorization code grant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_token_localstorage_matches() {
|
||||
let scanner = OAuthPatternScanner::new();
|
||||
let pattern = &scanner.patterns[2]; // oauth-token-localstorage
|
||||
assert!(pattern
|
||||
.pattern
|
||||
.is_match("localStorage.setItem('access_token', tok)"));
|
||||
assert!(pattern
|
||||
.pattern
|
||||
.is_match("localStorage.getItem(\"refresh_token\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_token_localstorage_no_false_positive() {
|
||||
let scanner = OAuthPatternScanner::new();
|
||||
let pattern = &scanner.patterns[2];
|
||||
assert!(!pattern
|
||||
.pattern
|
||||
.is_match("localStorage.setItem('theme', 'dark')"));
|
||||
assert!(!pattern
|
||||
.pattern
|
||||
.is_match("sessionStorage.setItem('token', t)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_token_url_matches() {
|
||||
let scanner = OAuthPatternScanner::new();
|
||||
let pattern = &scanner.patterns[3]; // oauth-token-url
|
||||
assert!(pattern.pattern.is_match("access_token = build_url(query)"));
|
||||
assert!(pattern.pattern.is_match("bearer = url.param"));
|
||||
}
|
||||
|
||||
// --- Pattern rule file extension filtering ---
|
||||
|
||||
#[test]
|
||||
fn gdpr_patterns_cover_common_languages() {
|
||||
let scanner = GdprPatternScanner::new();
|
||||
for pattern in &scanner.patterns {
|
||||
assert!(
|
||||
pattern.file_extensions.contains(&"rs".to_string()),
|
||||
"Pattern {} should cover .rs files",
|
||||
pattern.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_localstorage_only_js_ts() {
|
||||
let scanner = OAuthPatternScanner::new();
|
||||
let pattern = &scanner.patterns[2]; // oauth-token-localstorage
|
||||
assert!(pattern.file_extensions.contains(&"js".to_string()));
|
||||
assert!(pattern.file_extensions.contains(&"ts".to_string()));
|
||||
assert!(!pattern.file_extensions.contains(&"rs".to_string()));
|
||||
assert!(!pattern.file_extensions.contains(&"py".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
146
compliance-agent/src/pipeline/pr_review.rs
Normal file
146
compliance-agent/src/pipeline/pr_review.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use compliance_core::models::*;
|
||||
|
||||
use super::orchestrator::PipelineOrchestrator;
|
||||
use crate::error::AgentError;
|
||||
use crate::pipeline::code_review::CodeReviewScanner;
|
||||
use crate::pipeline::git::GitOps;
|
||||
use crate::pipeline::semgrep::SemgrepScanner;
|
||||
|
||||
use compliance_core::traits::Scanner;
|
||||
|
||||
impl PipelineOrchestrator {
|
||||
/// Run an incremental scan on a PR diff and post review comments.
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, pr_number))]
|
||||
pub async fn run_pr_review(
|
||||
&self,
|
||||
repo: &TrackedRepository,
|
||||
repo_id: &str,
|
||||
pr_number: u64,
|
||||
base_sha: &str,
|
||||
head_sha: &str,
|
||||
) -> Result<(), AgentError> {
|
||||
let tracker = match self.build_tracker(repo) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::warn!("[{repo_id}] No tracker configured, cannot post PR review");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let owner = repo.tracker_owner.as_deref().unwrap_or("");
|
||||
let tracker_repo_name = repo.tracker_repo.as_deref().unwrap_or("");
|
||||
if owner.is_empty() || tracker_repo_name.is_empty() {
|
||||
tracing::warn!("[{repo_id}] tracker_owner or tracker_repo not set");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Clone/fetch the repo
|
||||
let creds = GitOps::make_repo_credentials(&self.config, repo);
|
||||
let git_ops = GitOps::new(&self.config.git_clone_base_path, creds);
|
||||
let repo_path = git_ops.clone_or_fetch(&repo.git_url, &repo.name)?;
|
||||
|
||||
// Get diff between base and head
|
||||
let diff_files = GitOps::get_diff_content(&repo_path, base_sha, head_sha)?;
|
||||
if diff_files.is_empty() {
|
||||
tracing::info!("[{repo_id}] PR #{pr_number}: no diff files, skipping review");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Run semgrep on the full repo but we'll filter findings to changed files
|
||||
let changed_paths: std::collections::HashSet<String> =
|
||||
diff_files.iter().map(|f| f.path.clone()).collect();
|
||||
|
||||
let mut pr_findings: Vec<Finding> = Vec::new();
|
||||
|
||||
// SAST scan (semgrep)
|
||||
match SemgrepScanner.scan(&repo_path, repo_id).await {
|
||||
Ok(output) => {
|
||||
for f in output.findings {
|
||||
if let Some(fp) = &f.file_path {
|
||||
if changed_paths.contains(fp.as_str()) {
|
||||
pr_findings.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("[{repo_id}] PR semgrep failed: {e}"),
|
||||
}
|
||||
|
||||
// LLM code review on the diff
|
||||
let reviewer = CodeReviewScanner::new(self.llm.clone());
|
||||
let review_output = reviewer
|
||||
.review_diff(&repo_path, repo_id, base_sha, head_sha)
|
||||
.await;
|
||||
pr_findings.extend(review_output.findings);
|
||||
|
||||
if pr_findings.is_empty() {
|
||||
// Post a clean review
|
||||
if let Err(e) = tracker
|
||||
.create_pr_review(
|
||||
owner,
|
||||
tracker_repo_name,
|
||||
pr_number,
|
||||
"Compliance scan: no issues found in this PR.",
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[{repo_id}] Failed to post clean PR review: {e}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build review comments from findings
|
||||
let mut review_comments = Vec::new();
|
||||
for finding in &pr_findings {
|
||||
if let (Some(path), Some(line)) = (&finding.file_path, finding.line_number) {
|
||||
let comment_body = format!(
|
||||
"**[{}] {}**\n\n{}\n\n*Scanner: {} | {}*",
|
||||
finding.severity,
|
||||
finding.title,
|
||||
finding.description,
|
||||
finding.scanner,
|
||||
finding
|
||||
.cwe
|
||||
.as_deref()
|
||||
.map(|c| format!("CWE: {c}"))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
review_comments.push(compliance_core::traits::issue_tracker::ReviewComment {
|
||||
path: path.clone(),
|
||||
line,
|
||||
body: comment_body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let summary = format!(
|
||||
"Compliance scan found **{}** issue(s) in this PR:\n\n{}",
|
||||
pr_findings.len(),
|
||||
pr_findings
|
||||
.iter()
|
||||
.map(|f| format!("- **[{}]** {}: {}", f.severity, f.scanner, f.title))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
if let Err(e) = tracker
|
||||
.create_pr_review(
|
||||
owner,
|
||||
tracker_repo_name,
|
||||
pr_number,
|
||||
&summary,
|
||||
review_comments,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[{repo_id}] Failed to post PR review: {e}");
|
||||
} else {
|
||||
tracing::info!(
|
||||
"[{repo_id}] Posted PR review on #{pr_number} with {} findings",
|
||||
pr_findings.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
72
compliance-agent/src/pipeline/sbom/cargo_audit.rs
Normal file
72
compliance-agent/src/pipeline/sbom/cargo_audit.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::CoreError;
|
||||
|
||||
pub(super) struct AuditVuln {
|
||||
pub package: String,
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub(super) async fn run_cargo_audit(
|
||||
repo_path: &Path,
|
||||
_repo_id: &str,
|
||||
) -> Result<Vec<AuditVuln>, CoreError> {
|
||||
let cargo_lock = repo_path.join("Cargo.lock");
|
||||
if !cargo_lock.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("cargo")
|
||||
.args(["audit", "--json"])
|
||||
.current_dir(repo_path)
|
||||
.env("RUSTC_WRAPPER", "")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| CoreError::Scanner {
|
||||
scanner: "cargo-audit".to_string(),
|
||||
source: Box::new(e),
|
||||
})?;
|
||||
|
||||
let result: CargoAuditOutput =
|
||||
serde_json::from_slice(&output.stdout).unwrap_or_else(|_| CargoAuditOutput {
|
||||
vulnerabilities: CargoAuditVulns { list: Vec::new() },
|
||||
});
|
||||
|
||||
let vulns = result
|
||||
.vulnerabilities
|
||||
.list
|
||||
.into_iter()
|
||||
.map(|v| AuditVuln {
|
||||
package: v.advisory.package,
|
||||
id: v.advisory.id,
|
||||
url: v.advisory.url,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(vulns)
|
||||
}
|
||||
|
||||
// Cargo audit types
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoAuditOutput {
|
||||
vulnerabilities: CargoAuditVulns,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoAuditVulns {
|
||||
list: Vec<CargoAuditEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoAuditEntry {
|
||||
advisory: CargoAuditAdvisory,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoAuditAdvisory {
|
||||
id: String,
|
||||
package: String,
|
||||
url: String,
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
mod cargo_audit;
|
||||
mod syft;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::models::{SbomEntry, ScanType, VulnRef};
|
||||
@@ -23,7 +26,7 @@ impl Scanner for SbomScanner {
|
||||
generate_lockfiles(repo_path).await;
|
||||
|
||||
// Run syft for SBOM generation
|
||||
match run_syft(repo_path, repo_id).await {
|
||||
match syft::run_syft(repo_path, repo_id).await {
|
||||
Ok(syft_entries) => entries.extend(syft_entries),
|
||||
Err(e) => tracing::warn!("syft failed: {e}"),
|
||||
}
|
||||
@@ -32,7 +35,7 @@ impl Scanner for SbomScanner {
|
||||
enrich_cargo_licenses(repo_path, &mut entries).await;
|
||||
|
||||
// Run cargo-audit for Rust-specific vulns
|
||||
match run_cargo_audit(repo_path, repo_id).await {
|
||||
match cargo_audit::run_cargo_audit(repo_path, repo_id).await {
|
||||
Ok(vulns) => merge_audit_vulns(&mut entries, vulns),
|
||||
Err(e) => tracing::warn!("cargo-audit skipped: {e}"),
|
||||
}
|
||||
@@ -54,6 +57,7 @@ async fn generate_lockfiles(repo_path: &Path) {
|
||||
let result = tokio::process::Command::new("cargo")
|
||||
.args(["generate-lockfile"])
|
||||
.current_dir(repo_path)
|
||||
.env("RUSTC_WRAPPER", "")
|
||||
.output()
|
||||
.await;
|
||||
match result {
|
||||
@@ -137,6 +141,7 @@ async fn enrich_cargo_licenses(repo_path: &Path, entries: &mut [SbomEntry]) {
|
||||
let output = match tokio::process::Command::new("cargo")
|
||||
.args(["metadata", "--format-version", "1"])
|
||||
.current_dir(repo_path)
|
||||
.env("RUSTC_WRAPPER", "")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
@@ -184,94 +189,7 @@ async fn enrich_cargo_licenses(repo_path: &Path, entries: &mut [SbomEntry]) {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
async fn run_syft(repo_path: &Path, repo_id: &str) -> Result<Vec<SbomEntry>, CoreError> {
|
||||
let output = tokio::process::Command::new("syft")
|
||||
.arg(repo_path)
|
||||
.args(["-o", "cyclonedx-json"])
|
||||
// Enable remote license lookups for all ecosystems
|
||||
.env("SYFT_GOLANG_SEARCH_REMOTE_LICENSES", "true")
|
||||
.env("SYFT_JAVASCRIPT_SEARCH_REMOTE_LICENSES", "true")
|
||||
.env("SYFT_PYTHON_SEARCH_REMOTE_LICENSES", "true")
|
||||
.env("SYFT_JAVA_USE_NETWORK", "true")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| CoreError::Scanner {
|
||||
scanner: "syft".to_string(),
|
||||
source: Box::new(e),
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CoreError::Scanner {
|
||||
scanner: "syft".to_string(),
|
||||
source: format!("syft exited with {}: {stderr}", output.status).into(),
|
||||
});
|
||||
}
|
||||
|
||||
let cdx: CycloneDxBom = serde_json::from_slice(&output.stdout)?;
|
||||
let entries = cdx
|
||||
.components
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let package_manager = c
|
||||
.purl
|
||||
.as_deref()
|
||||
.and_then(extract_ecosystem_from_purl)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let mut entry = SbomEntry::new(
|
||||
repo_id.to_string(),
|
||||
c.name,
|
||||
c.version.unwrap_or_else(|| "unknown".to_string()),
|
||||
package_manager,
|
||||
);
|
||||
entry.purl = c.purl;
|
||||
entry.license = c.licenses.and_then(|ls| extract_license(&ls));
|
||||
entry
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn run_cargo_audit(repo_path: &Path, _repo_id: &str) -> Result<Vec<AuditVuln>, CoreError> {
|
||||
let cargo_lock = repo_path.join("Cargo.lock");
|
||||
if !cargo_lock.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("cargo")
|
||||
.args(["audit", "--json"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| CoreError::Scanner {
|
||||
scanner: "cargo-audit".to_string(),
|
||||
source: Box::new(e),
|
||||
})?;
|
||||
|
||||
let result: CargoAuditOutput =
|
||||
serde_json::from_slice(&output.stdout).unwrap_or_else(|_| CargoAuditOutput {
|
||||
vulnerabilities: CargoAuditVulns { list: Vec::new() },
|
||||
});
|
||||
|
||||
let vulns = result
|
||||
.vulnerabilities
|
||||
.list
|
||||
.into_iter()
|
||||
.map(|v| AuditVuln {
|
||||
package: v.advisory.package,
|
||||
id: v.advisory.id,
|
||||
url: v.advisory.url,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(vulns)
|
||||
}
|
||||
|
||||
fn merge_audit_vulns(entries: &mut [SbomEntry], vulns: Vec<AuditVuln>) {
|
||||
fn merge_audit_vulns(entries: &mut [SbomEntry], vulns: Vec<cargo_audit::AuditVuln>) {
|
||||
for vuln in vulns {
|
||||
if let Some(entry) = entries.iter_mut().find(|e| e.name == vuln.package) {
|
||||
entry.known_vulnerabilities.push(VulnRef {
|
||||
@@ -284,65 +202,6 @@ fn merge_audit_vulns(entries: &mut [SbomEntry], vulns: Vec<AuditVuln>) {
|
||||
}
|
||||
}
|
||||
|
||||
// CycloneDX JSON types
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CycloneDxBom {
|
||||
components: Option<Vec<CdxComponent>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdxComponent {
|
||||
name: String,
|
||||
version: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
#[allow(dead_code)]
|
||||
component_type: Option<String>,
|
||||
purl: Option<String>,
|
||||
licenses: Option<Vec<CdxLicenseWrapper>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdxLicenseWrapper {
|
||||
license: Option<CdxLicense>,
|
||||
/// SPDX license expression (e.g. "MIT OR Apache-2.0")
|
||||
expression: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdxLicense {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
// Cargo audit types
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoAuditOutput {
|
||||
vulnerabilities: CargoAuditVulns,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoAuditVulns {
|
||||
list: Vec<CargoAuditEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoAuditEntry {
|
||||
advisory: CargoAuditAdvisory,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoAuditAdvisory {
|
||||
id: String,
|
||||
package: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
struct AuditVuln {
|
||||
package: String,
|
||||
id: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
// Cargo metadata types
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CargoMetadata {
|
||||
@@ -355,49 +214,3 @@ struct CargoPackage {
|
||||
version: String,
|
||||
license: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract the best license string from CycloneDX license entries.
|
||||
/// Handles three formats: expression ("MIT OR Apache-2.0"), license.id ("MIT"), license.name ("MIT License").
|
||||
fn extract_license(entries: &[CdxLicenseWrapper]) -> Option<String> {
|
||||
// First pass: look for SPDX expressions (most precise for dual-licensed packages)
|
||||
for entry in entries {
|
||||
if let Some(ref expr) = entry.expression {
|
||||
if !expr.is_empty() {
|
||||
return Some(expr.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: collect license.id or license.name from all entries
|
||||
let parts: Vec<String> = entries
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
e.license.as_ref().and_then(|lic| {
|
||||
lic.id
|
||||
.clone()
|
||||
.or_else(|| lic.name.clone())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(parts.join(" OR "))
|
||||
}
|
||||
|
||||
/// Extract the ecosystem/package-manager from a PURL string.
|
||||
/// e.g. "pkg:npm/lodash@4.17.21" → "npm", "pkg:cargo/serde@1.0" → "cargo"
|
||||
fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
|
||||
let rest = purl.strip_prefix("pkg:")?;
|
||||
let ecosystem = rest.split('/').next()?;
|
||||
if ecosystem.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Normalise common PURL types to user-friendly names
|
||||
let normalised = match ecosystem {
|
||||
"golang" => "go",
|
||||
"pypi" => "pip",
|
||||
_ => ecosystem,
|
||||
};
|
||||
Some(normalised.to_string())
|
||||
}
|
||||
355
compliance-agent/src/pipeline/sbom/syft.rs
Normal file
355
compliance-agent/src/pipeline/sbom/syft.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::models::SbomEntry;
|
||||
use compliance_core::CoreError;
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub(super) async fn run_syft(repo_path: &Path, repo_id: &str) -> Result<Vec<SbomEntry>, CoreError> {
|
||||
let output = tokio::process::Command::new("syft")
|
||||
.arg(repo_path)
|
||||
.args(["-o", "cyclonedx-json"])
|
||||
// Enable remote license lookups for all ecosystems
|
||||
.env("SYFT_GOLANG_SEARCH_REMOTE_LICENSES", "true")
|
||||
.env("SYFT_JAVASCRIPT_SEARCH_REMOTE_LICENSES", "true")
|
||||
.env("SYFT_PYTHON_SEARCH_REMOTE_LICENSES", "true")
|
||||
.env("SYFT_JAVA_USE_NETWORK", "true")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| CoreError::Scanner {
|
||||
scanner: "syft".to_string(),
|
||||
source: Box::new(e),
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(CoreError::Scanner {
|
||||
scanner: "syft".to_string(),
|
||||
source: format!("syft exited with {}: {stderr}", output.status).into(),
|
||||
});
|
||||
}
|
||||
|
||||
let cdx: CycloneDxBom = serde_json::from_slice(&output.stdout)?;
|
||||
let entries = cdx
|
||||
.components
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
let package_manager = c
|
||||
.purl
|
||||
.as_deref()
|
||||
.and_then(extract_ecosystem_from_purl)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let mut entry = SbomEntry::new(
|
||||
repo_id.to_string(),
|
||||
c.name,
|
||||
c.version.unwrap_or_else(|| "unknown".to_string()),
|
||||
package_manager,
|
||||
);
|
||||
entry.purl = c.purl;
|
||||
entry.license = c.licenses.and_then(|ls| extract_license(&ls));
|
||||
entry
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
// CycloneDX JSON types
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CycloneDxBom {
|
||||
components: Option<Vec<CdxComponent>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdxComponent {
|
||||
name: String,
|
||||
version: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
#[allow(dead_code)]
|
||||
component_type: Option<String>,
|
||||
purl: Option<String>,
|
||||
licenses: Option<Vec<CdxLicenseWrapper>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdxLicenseWrapper {
|
||||
license: Option<CdxLicense>,
|
||||
/// SPDX license expression (e.g. "MIT OR Apache-2.0")
|
||||
expression: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CdxLicense {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract the best license string from CycloneDX license entries.
|
||||
/// Handles three formats: expression ("MIT OR Apache-2.0"), license.id ("MIT"), license.name ("MIT License").
|
||||
fn extract_license(entries: &[CdxLicenseWrapper]) -> Option<String> {
|
||||
// First pass: look for SPDX expressions (most precise for dual-licensed packages)
|
||||
for entry in entries {
|
||||
if let Some(ref expr) = entry.expression {
|
||||
if !expr.is_empty() {
|
||||
return Some(expr.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: collect license.id or license.name from all entries
|
||||
let parts: Vec<String> = entries
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
e.license.as_ref().and_then(|lic| {
|
||||
lic.id
|
||||
.clone()
|
||||
.or_else(|| lic.name.clone())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(parts.join(" OR "))
|
||||
}
|
||||
|
||||
/// Extract the ecosystem/package-manager from a PURL string.
|
||||
/// e.g. "pkg:npm/lodash@4.17.21" -> "npm", "pkg:cargo/serde@1.0" -> "cargo"
|
||||
fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
|
||||
let rest = purl.strip_prefix("pkg:")?;
|
||||
let ecosystem = rest.split('/').next()?;
|
||||
if ecosystem.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Normalise common PURL types to user-friendly names
|
||||
let normalised = match ecosystem {
|
||||
"golang" => "go",
|
||||
"pypi" => "pip",
|
||||
_ => ecosystem,
|
||||
};
|
||||
Some(normalised.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- extract_ecosystem_from_purl tests ---
|
||||
|
||||
#[test]
|
||||
fn purl_npm() {
|
||||
assert_eq!(
|
||||
extract_ecosystem_from_purl("pkg:npm/lodash@4.17.21"),
|
||||
Some("npm".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purl_cargo() {
|
||||
assert_eq!(
|
||||
extract_ecosystem_from_purl("pkg:cargo/serde@1.0.197"),
|
||||
Some("cargo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purl_golang_normalised() {
|
||||
assert_eq!(
|
||||
extract_ecosystem_from_purl("pkg:golang/github.com/gin-gonic/gin@1.9.1"),
|
||||
Some("go".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purl_pypi_normalised() {
|
||||
assert_eq!(
|
||||
extract_ecosystem_from_purl("pkg:pypi/requests@2.31.0"),
|
||||
Some("pip".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purl_maven() {
|
||||
assert_eq!(
|
||||
extract_ecosystem_from_purl("pkg:maven/org.apache.commons/commons-lang3@3.14.0"),
|
||||
Some("maven".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purl_missing_prefix() {
|
||||
assert_eq!(extract_ecosystem_from_purl("npm/lodash@4.17.21"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purl_empty_ecosystem() {
|
||||
assert_eq!(extract_ecosystem_from_purl("pkg:/lodash@4.17.21"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purl_empty_string() {
|
||||
assert_eq!(extract_ecosystem_from_purl(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purl_just_prefix() {
|
||||
assert_eq!(extract_ecosystem_from_purl("pkg:"), None);
|
||||
}
|
||||
|
||||
// --- extract_license tests ---
|
||||
|
||||
#[test]
|
||||
fn license_from_expression() {
|
||||
let entries = vec![CdxLicenseWrapper {
|
||||
license: None,
|
||||
expression: Some("MIT OR Apache-2.0".to_string()),
|
||||
}];
|
||||
assert_eq!(
|
||||
extract_license(&entries),
|
||||
Some("MIT OR Apache-2.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_from_id() {
|
||||
let entries = vec![CdxLicenseWrapper {
|
||||
license: Some(CdxLicense {
|
||||
id: Some("MIT".to_string()),
|
||||
name: None,
|
||||
}),
|
||||
expression: None,
|
||||
}];
|
||||
assert_eq!(extract_license(&entries), Some("MIT".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_from_name_fallback() {
|
||||
let entries = vec![CdxLicenseWrapper {
|
||||
license: Some(CdxLicense {
|
||||
id: None,
|
||||
name: Some("MIT License".to_string()),
|
||||
}),
|
||||
expression: None,
|
||||
}];
|
||||
assert_eq!(extract_license(&entries), Some("MIT License".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_expression_preferred_over_id() {
|
||||
let entries = vec![
|
||||
CdxLicenseWrapper {
|
||||
license: Some(CdxLicense {
|
||||
id: Some("MIT".to_string()),
|
||||
name: None,
|
||||
}),
|
||||
expression: None,
|
||||
},
|
||||
CdxLicenseWrapper {
|
||||
license: None,
|
||||
expression: Some("MIT AND Apache-2.0".to_string()),
|
||||
},
|
||||
];
|
||||
// Expression should be preferred (first pass finds it)
|
||||
assert_eq!(
|
||||
extract_license(&entries),
|
||||
Some("MIT AND Apache-2.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_multiple_ids_joined() {
|
||||
let entries = vec![
|
||||
CdxLicenseWrapper {
|
||||
license: Some(CdxLicense {
|
||||
id: Some("MIT".to_string()),
|
||||
name: None,
|
||||
}),
|
||||
expression: None,
|
||||
},
|
||||
CdxLicenseWrapper {
|
||||
license: Some(CdxLicense {
|
||||
id: Some("Apache-2.0".to_string()),
|
||||
name: None,
|
||||
}),
|
||||
expression: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
extract_license(&entries),
|
||||
Some("MIT OR Apache-2.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_empty_entries() {
|
||||
let entries: Vec<CdxLicenseWrapper> = vec![];
|
||||
assert_eq!(extract_license(&entries), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_all_empty_strings() {
|
||||
let entries = vec![CdxLicenseWrapper {
|
||||
license: Some(CdxLicense {
|
||||
id: Some(String::new()),
|
||||
name: Some(String::new()),
|
||||
}),
|
||||
expression: Some(String::new()),
|
||||
}];
|
||||
assert_eq!(extract_license(&entries), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_none_fields() {
|
||||
let entries = vec![CdxLicenseWrapper {
|
||||
license: None,
|
||||
expression: None,
|
||||
}];
|
||||
assert_eq!(extract_license(&entries), None);
|
||||
}
|
||||
|
||||
// --- CycloneDX deserialization tests ---
|
||||
|
||||
#[test]
|
||||
fn deserialize_cyclonedx_bom() {
|
||||
let json = r#"{
|
||||
"components": [
|
||||
{
|
||||
"name": "serde",
|
||||
"version": "1.0.197",
|
||||
"type": "library",
|
||||
"purl": "pkg:cargo/serde@1.0.197",
|
||||
"licenses": [
|
||||
{"expression": "MIT OR Apache-2.0"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let bom: CycloneDxBom = serde_json::from_str(json).unwrap();
|
||||
let components = bom.components.unwrap();
|
||||
assert_eq!(components.len(), 1);
|
||||
assert_eq!(components[0].name, "serde");
|
||||
assert_eq!(components[0].version, Some("1.0.197".to_string()));
|
||||
assert_eq!(
|
||||
components[0].purl,
|
||||
Some("pkg:cargo/serde@1.0.197".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_cyclonedx_no_components() {
|
||||
let json = r#"{}"#;
|
||||
let bom: CycloneDxBom = serde_json::from_str(json).unwrap();
|
||||
assert!(bom.components.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_cyclonedx_minimal_component() {
|
||||
let json = r#"{"components": [{"name": "foo"}]}"#;
|
||||
let bom: CycloneDxBom = serde_json::from_str(json).unwrap();
|
||||
let c = &bom.components.unwrap()[0];
|
||||
assert_eq!(c.name, "foo");
|
||||
assert!(c.version.is_none());
|
||||
assert!(c.purl.is_none());
|
||||
assert!(c.licenses.is_none());
|
||||
}
|
||||
}
|
||||
@@ -108,3 +108,124 @@ struct SemgrepExtra {
|
||||
#[serde(default)]
|
||||
metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_semgrep_output() {
|
||||
let json = r#"{
|
||||
"results": [
|
||||
{
|
||||
"check_id": "python.lang.security.audit.exec-detected",
|
||||
"path": "src/main.py",
|
||||
"start": {"line": 15},
|
||||
"extra": {
|
||||
"message": "Detected use of exec()",
|
||||
"severity": "ERROR",
|
||||
"lines": "exec(user_input)",
|
||||
"metadata": {"cwe": "CWE-78"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let output: SemgrepOutput = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(output.results.len(), 1);
|
||||
|
||||
let r = &output.results[0];
|
||||
assert_eq!(r.check_id, "python.lang.security.audit.exec-detected");
|
||||
assert_eq!(r.path, "src/main.py");
|
||||
assert_eq!(r.start.line, 15);
|
||||
assert_eq!(r.extra.message, "Detected use of exec()");
|
||||
assert_eq!(r.extra.severity, "ERROR");
|
||||
assert_eq!(r.extra.lines, "exec(user_input)");
|
||||
assert_eq!(
|
||||
r.extra
|
||||
.metadata
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get("cwe")
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
Some("CWE-78")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_semgrep_empty_results() {
|
||||
let json = r#"{"results": []}"#;
|
||||
let output: SemgrepOutput = serde_json::from_str(json).unwrap();
|
||||
assert!(output.results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_semgrep_no_metadata() {
|
||||
let json = r#"{
|
||||
"results": [
|
||||
{
|
||||
"check_id": "rule-1",
|
||||
"path": "app.py",
|
||||
"start": {"line": 1},
|
||||
"extra": {
|
||||
"message": "found something",
|
||||
"severity": "WARNING",
|
||||
"lines": "import os"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let output: SemgrepOutput = serde_json::from_str(json).unwrap();
|
||||
assert!(output.results[0].extra.metadata.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semgrep_severity_mapping() {
|
||||
let cases = vec![
|
||||
("ERROR", "High"),
|
||||
("WARNING", "Medium"),
|
||||
("INFO", "Low"),
|
||||
("UNKNOWN", "Info"),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let result = match input {
|
||||
"ERROR" => "High",
|
||||
"WARNING" => "Medium",
|
||||
"INFO" => "Low",
|
||||
_ => "Info",
|
||||
};
|
||||
assert_eq!(result, expected, "Severity for '{input}'");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_semgrep_multiple_results() {
|
||||
let json = r#"{
|
||||
"results": [
|
||||
{
|
||||
"check_id": "rule-a",
|
||||
"path": "a.py",
|
||||
"start": {"line": 1},
|
||||
"extra": {
|
||||
"message": "msg a",
|
||||
"severity": "ERROR",
|
||||
"lines": "line a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"check_id": "rule-b",
|
||||
"path": "b.py",
|
||||
"start": {"line": 99},
|
||||
"extra": {
|
||||
"message": "msg b",
|
||||
"severity": "INFO",
|
||||
"lines": "line b"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let output: SemgrepOutput = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(output.results.len(), 2);
|
||||
assert_eq!(output.results[1].start.line, 99);
|
||||
}
|
||||
}
|
||||
|
||||
81
compliance-agent/src/pipeline/tracker_dispatch.rs
Normal file
81
compliance-agent/src/pipeline/tracker_dispatch.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use compliance_core::models::TrackerIssue;
|
||||
use compliance_core::traits::issue_tracker::IssueTracker;
|
||||
|
||||
use crate::trackers;
|
||||
|
||||
/// Enum dispatch for issue trackers (async traits aren't dyn-compatible).
|
||||
pub(crate) enum TrackerDispatch {
|
||||
GitHub(trackers::github::GitHubTracker),
|
||||
GitLab(trackers::gitlab::GitLabTracker),
|
||||
Gitea(trackers::gitea::GiteaTracker),
|
||||
Jira(trackers::jira::JiraTracker),
|
||||
}
|
||||
|
||||
impl TrackerDispatch {
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::GitHub(t) => t.name(),
|
||||
Self::GitLab(t) => t.name(),
|
||||
Self::Gitea(t) => t.name(),
|
||||
Self::Jira(t) => t.name(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_issue(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
title: &str,
|
||||
body: &str,
|
||||
labels: &[String],
|
||||
) -> Result<TrackerIssue, compliance_core::error::CoreError> {
|
||||
match self {
|
||||
Self::GitHub(t) => t.create_issue(owner, repo, title, body, labels).await,
|
||||
Self::GitLab(t) => t.create_issue(owner, repo, title, body, labels).await,
|
||||
Self::Gitea(t) => t.create_issue(owner, repo, title, body, labels).await,
|
||||
Self::Jira(t) => t.create_issue(owner, repo, title, body, labels).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn find_existing_issue(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
fingerprint: &str,
|
||||
) -> Result<Option<TrackerIssue>, compliance_core::error::CoreError> {
|
||||
match self {
|
||||
Self::GitHub(t) => t.find_existing_issue(owner, repo, fingerprint).await,
|
||||
Self::GitLab(t) => t.find_existing_issue(owner, repo, fingerprint).await,
|
||||
Self::Gitea(t) => t.find_existing_issue(owner, repo, fingerprint).await,
|
||||
Self::Jira(t) => t.find_existing_issue(owner, repo, fingerprint).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_pr_review(
|
||||
&self,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: u64,
|
||||
body: &str,
|
||||
comments: Vec<compliance_core::traits::issue_tracker::ReviewComment>,
|
||||
) -> Result<(), compliance_core::error::CoreError> {
|
||||
match self {
|
||||
Self::GitHub(t) => {
|
||||
t.create_pr_review(owner, repo, pr_number, body, comments)
|
||||
.await
|
||||
}
|
||||
Self::GitLab(t) => {
|
||||
t.create_pr_review(owner, repo, pr_number, body, comments)
|
||||
.await
|
||||
}
|
||||
Self::Gitea(t) => {
|
||||
t.create_pr_review(owner, repo, pr_number, body, comments)
|
||||
.await
|
||||
}
|
||||
Self::Jira(t) => {
|
||||
t.create_pr_review(owner, repo, pr_number, body, comments)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ impl IssueTracker for GiteaTracker {
|
||||
fingerprint: &str,
|
||||
) -> Result<Option<TrackerIssue>, CoreError> {
|
||||
let url = self.api_url(&format!(
|
||||
"/repos/{owner}/{repo}/issues?type=issues&state=open&q={fingerprint}"
|
||||
"/repos/{owner}/{repo}/issues?type=issues&state=all&q={fingerprint}"
|
||||
));
|
||||
|
||||
let resp = self
|
||||
|
||||
3
compliance-agent/tests/common/mod.rs
Normal file
3
compliance-agent/tests/common/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Shared test helpers for compliance-agent integration tests.
|
||||
//
|
||||
// Add database mocks, fixtures, and test utilities here.
|
||||
4
compliance-agent/tests/integration/mod.rs
Normal file
4
compliance-agent/tests/integration/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
// Integration tests for the compliance-agent crate.
|
||||
//
|
||||
// Add tests that exercise the full pipeline, API handlers,
|
||||
// and cross-module interactions here.
|
||||
@@ -27,6 +27,16 @@ pub struct AgentConfig {
|
||||
pub ssh_key_path: String,
|
||||
pub keycloak_url: Option<String>,
|
||||
pub keycloak_realm: Option<String>,
|
||||
pub keycloak_admin_username: Option<String>,
|
||||
pub keycloak_admin_password: Option<SecretString>,
|
||||
// Pentest defaults
|
||||
pub pentest_verification_email: Option<String>,
|
||||
pub pentest_imap_host: Option<String>,
|
||||
pub pentest_imap_port: Option<u16>,
|
||||
/// Use implicit TLS (IMAPS, port 993) instead of plain IMAP.
|
||||
pub pentest_imap_tls: bool,
|
||||
pub pentest_imap_username: Option<String>,
|
||||
pub pentest_imap_password: Option<SecretString>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -176,6 +176,16 @@ pub enum DastVulnType {
|
||||
InformationDisclosure,
|
||||
SecurityMisconfiguration,
|
||||
BrokenAuth,
|
||||
DnsMisconfiguration,
|
||||
EmailSecurity,
|
||||
TlsMisconfiguration,
|
||||
CookieSecurity,
|
||||
CspIssue,
|
||||
CorsMisconfiguration,
|
||||
RateLimitAbsent,
|
||||
ConsoleLogLeakage,
|
||||
SecurityHeaderMissing,
|
||||
KnownCveExploit,
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -192,6 +202,16 @@ impl std::fmt::Display for DastVulnType {
|
||||
Self::InformationDisclosure => write!(f, "information_disclosure"),
|
||||
Self::SecurityMisconfiguration => write!(f, "security_misconfiguration"),
|
||||
Self::BrokenAuth => write!(f, "broken_auth"),
|
||||
Self::DnsMisconfiguration => write!(f, "dns_misconfiguration"),
|
||||
Self::EmailSecurity => write!(f, "email_security"),
|
||||
Self::TlsMisconfiguration => write!(f, "tls_misconfiguration"),
|
||||
Self::CookieSecurity => write!(f, "cookie_security"),
|
||||
Self::CspIssue => write!(f, "csp_issue"),
|
||||
Self::CorsMisconfiguration => write!(f, "cors_misconfiguration"),
|
||||
Self::RateLimitAbsent => write!(f, "rate_limit_absent"),
|
||||
Self::ConsoleLogLeakage => write!(f, "console_log_leakage"),
|
||||
Self::SecurityHeaderMissing => write!(f, "security_header_missing"),
|
||||
Self::KnownCveExploit => write!(f, "known_cve_exploit"),
|
||||
Self::Other => write!(f, "other"),
|
||||
}
|
||||
}
|
||||
@@ -244,6 +264,8 @@ pub struct DastFinding {
|
||||
pub remediation: Option<String>,
|
||||
/// Linked SAST finding ID (if correlated)
|
||||
pub linked_sast_finding_id: Option<String>,
|
||||
/// Pentest session that produced this finding (if AI-driven)
|
||||
pub session_id: Option<String>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -276,6 +298,7 @@ impl DastFinding {
|
||||
evidence: Vec::new(),
|
||||
remediation: None,
|
||||
linked_sast_finding_id: None,
|
||||
session_id: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod finding;
|
||||
pub mod graph;
|
||||
pub mod issue;
|
||||
pub mod mcp;
|
||||
pub mod pentest;
|
||||
pub mod repository;
|
||||
pub mod sbom;
|
||||
pub mod scan;
|
||||
@@ -26,6 +27,12 @@ pub use graph::{
|
||||
};
|
||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
||||
pub use pentest::{
|
||||
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
|
||||
PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats,
|
||||
PentestStatus, PentestStrategy, SeverityDistribution, TestUserRecord, TesterInfo,
|
||||
ToolCallRecord,
|
||||
};
|
||||
pub use repository::{ScanTrigger, TrackedRepository};
|
||||
pub use sbom::{SbomEntry, VulnRef};
|
||||
pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType};
|
||||
|
||||
437
compliance-core/src/models/pentest.rs
Normal file
437
compliance-core/src/models/pentest.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Status of a pentest session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PentestStatus {
|
||||
Running,
|
||||
Paused,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PentestStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Running => write!(f, "running"),
|
||||
Self::Paused => write!(f, "paused"),
|
||||
Self::Completed => write!(f, "completed"),
|
||||
Self::Failed => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strategy for the AI pentest orchestrator
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PentestStrategy {
|
||||
/// Quick scan focusing on common vulnerabilities
|
||||
Quick,
|
||||
/// Standard comprehensive scan
|
||||
Comprehensive,
|
||||
/// Focus on specific vulnerability types guided by SAST/SBOM
|
||||
Targeted,
|
||||
/// Aggressive testing with more payloads and deeper exploitation
|
||||
Aggressive,
|
||||
/// Stealth mode with slower rate and fewer noisy payloads
|
||||
Stealth,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PentestStrategy {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Quick => write!(f, "quick"),
|
||||
Self::Comprehensive => write!(f, "comprehensive"),
|
||||
Self::Targeted => write!(f, "targeted"),
|
||||
Self::Aggressive => write!(f, "aggressive"),
|
||||
Self::Stealth => write!(f, "stealth"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication mode for the pentest target
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMode {
|
||||
#[default]
|
||||
None,
|
||||
Manual,
|
||||
AutoRegister,
|
||||
}
|
||||
|
||||
/// Target environment classification
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Environment {
|
||||
#[default]
|
||||
Development,
|
||||
Staging,
|
||||
Production,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Environment {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Development => write!(f, "Development"),
|
||||
Self::Staging => write!(f, "Staging"),
|
||||
Self::Production => write!(f, "Production"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tester identity for the engagement record
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TesterInfo {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// Authentication configuration for the pentest session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PentestAuthConfig {
|
||||
pub mode: AuthMode,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
/// Optional — if omitted the orchestrator uses Playwright to discover it.
|
||||
pub registration_url: Option<String>,
|
||||
/// Base email for plus-addressing (e.g. `pentest@scanner.example.com`).
|
||||
/// The orchestrator generates `base+{session_id}@domain` per session.
|
||||
pub verification_email: Option<String>,
|
||||
/// IMAP server to poll for verification emails (e.g. `imap.example.com`).
|
||||
pub imap_host: Option<String>,
|
||||
/// IMAP port (default 993 for TLS).
|
||||
pub imap_port: Option<u16>,
|
||||
/// IMAP username (defaults to `verification_email` if omitted).
|
||||
pub imap_username: Option<String>,
|
||||
/// IMAP password / app-specific password.
|
||||
pub imap_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cleanup_test_user: bool,
|
||||
}
|
||||
|
||||
/// Full wizard configuration for a pentest session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PentestConfig {
|
||||
// Step 1: Target & Scope
|
||||
pub app_url: String,
|
||||
pub git_repo_url: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub commit_hash: Option<String>,
|
||||
pub app_type: Option<String>,
|
||||
pub rate_limit: Option<u32>,
|
||||
|
||||
// Step 2: Authentication
|
||||
#[serde(default)]
|
||||
pub auth: PentestAuthConfig,
|
||||
#[serde(default)]
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
|
||||
// Step 3: Strategy & Instructions
|
||||
pub strategy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allow_destructive: bool,
|
||||
pub initial_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scope_exclusions: Vec<String>,
|
||||
|
||||
// Step 4: Disclaimer & Confirm
|
||||
#[serde(default)]
|
||||
pub disclaimer_accepted: bool,
|
||||
pub disclaimer_accepted_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub environment: Environment,
|
||||
#[serde(default)]
|
||||
pub tester: TesterInfo,
|
||||
pub max_duration_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub skip_mode: bool,
|
||||
}
|
||||
|
||||
/// Identity provider type for cleanup routing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IdentityProvider {
|
||||
Keycloak,
|
||||
Auth0,
|
||||
Okta,
|
||||
Firebase,
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Details of a test user created during a pentest session.
|
||||
/// Stored so the cleanup step knows exactly what to delete and where.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TestUserRecord {
|
||||
/// Username used to register
|
||||
pub username: Option<String>,
|
||||
/// Email used to register
|
||||
pub email: Option<String>,
|
||||
/// User ID returned by the identity provider (if known)
|
||||
pub provider_user_id: Option<String>,
|
||||
/// Which identity provider holds this user
|
||||
pub provider: Option<IdentityProvider>,
|
||||
/// Whether cleanup has been completed
|
||||
#[serde(default)]
|
||||
pub cleaned_up: bool,
|
||||
}
|
||||
|
||||
/// A pentest session initiated via the chat interface
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PentestSession {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<bson::oid::ObjectId>,
|
||||
pub target_id: String,
|
||||
/// Linked repository for code-aware testing
|
||||
pub repo_id: Option<String>,
|
||||
pub status: PentestStatus,
|
||||
pub strategy: PentestStrategy,
|
||||
/// Wizard configuration (None for legacy sessions)
|
||||
pub config: Option<PentestConfig>,
|
||||
pub created_by: Option<String>,
|
||||
/// Test user created during auto-register (for cleanup)
|
||||
#[serde(default)]
|
||||
pub test_user: Option<TestUserRecord>,
|
||||
/// Total number of tool invocations in this session
|
||||
pub tool_invocations: u32,
|
||||
/// Total successful tool invocations
|
||||
pub tool_successes: u32,
|
||||
/// Number of findings discovered
|
||||
pub findings_count: u32,
|
||||
/// Number of confirmed exploitable findings
|
||||
pub exploitable_count: u32,
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
impl PentestSession {
|
||||
pub fn new(target_id: String, strategy: PentestStrategy) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
target_id,
|
||||
repo_id: None,
|
||||
status: PentestStatus::Running,
|
||||
strategy,
|
||||
config: None,
|
||||
created_by: None,
|
||||
test_user: None,
|
||||
tool_invocations: 0,
|
||||
tool_successes: 0,
|
||||
findings_count: 0,
|
||||
exploitable_count: 0,
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn success_rate(&self) -> f64 {
|
||||
if self.tool_invocations == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
(self.tool_successes as f64 / self.tool_invocations as f64) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of a node in the attack chain
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AttackNodeStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// A single step in the LLM-driven attack chain DAG
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttackChainNode {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<bson::oid::ObjectId>,
|
||||
pub session_id: String,
|
||||
/// Unique ID for DAG references
|
||||
pub node_id: String,
|
||||
/// Parent node IDs (multiple for merge nodes)
|
||||
pub parent_node_ids: Vec<String>,
|
||||
/// Tool that was invoked
|
||||
pub tool_name: String,
|
||||
/// Input parameters passed to the tool
|
||||
pub tool_input: serde_json::Value,
|
||||
/// Output from the tool
|
||||
pub tool_output: Option<serde_json::Value>,
|
||||
pub status: AttackNodeStatus,
|
||||
/// LLM's reasoning for choosing this action
|
||||
pub llm_reasoning: String,
|
||||
/// IDs of DastFindings produced by this step
|
||||
pub findings_produced: Vec<String>,
|
||||
/// Risk score (0-100) assigned by the LLM
|
||||
pub risk_score: Option<u8>,
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl AttackChainNode {
|
||||
pub fn new(
|
||||
session_id: String,
|
||||
node_id: String,
|
||||
tool_name: String,
|
||||
tool_input: serde_json::Value,
|
||||
llm_reasoning: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
session_id,
|
||||
node_id,
|
||||
parent_node_ids: Vec::new(),
|
||||
tool_name,
|
||||
tool_input,
|
||||
tool_output: None,
|
||||
status: AttackNodeStatus::Pending,
|
||||
llm_reasoning,
|
||||
findings_produced: Vec::new(),
|
||||
risk_score: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat message within a pentest session
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PentestMessage {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<bson::oid::ObjectId>,
|
||||
pub session_id: String,
|
||||
/// "user", "assistant", "tool_result", "system"
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
/// Tool calls made by the assistant in this message
|
||||
pub tool_calls: Option<Vec<ToolCallRecord>>,
|
||||
/// Link to the attack chain node (for tool_result messages)
|
||||
pub attack_node_id: Option<String>,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl PentestMessage {
|
||||
pub fn user(session_id: String, content: String) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
session_id,
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
tool_calls: None,
|
||||
attack_node_id: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant(session_id: String, content: String) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
session_id,
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
tool_calls: None,
|
||||
attack_node_id: None,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_result(session_id: String, content: String, node_id: String) -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
session_id,
|
||||
role: "tool_result".to_string(),
|
||||
content,
|
||||
tool_calls: None,
|
||||
attack_node_id: Some(node_id),
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record of a tool call made by the LLM
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallRecord {
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
pub arguments: serde_json::Value,
|
||||
pub result: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// SSE event types for real-time pentest streaming
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PentestEvent {
|
||||
/// LLM is thinking/reasoning
|
||||
Thinking { reasoning: String },
|
||||
/// A tool execution has started
|
||||
ToolStart {
|
||||
node_id: String,
|
||||
tool_name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
/// A tool execution completed
|
||||
ToolComplete {
|
||||
node_id: String,
|
||||
summary: String,
|
||||
findings_count: u32,
|
||||
},
|
||||
/// A new finding was discovered
|
||||
Finding {
|
||||
finding_id: String,
|
||||
title: String,
|
||||
severity: String,
|
||||
},
|
||||
/// Assistant message (streaming text)
|
||||
Message { content: String },
|
||||
/// Session completed
|
||||
Complete { summary: String },
|
||||
/// Error occurred
|
||||
Error { message: String },
|
||||
/// Session paused
|
||||
Paused,
|
||||
/// Session resumed
|
||||
Resumed,
|
||||
}
|
||||
|
||||
/// Aggregated stats for the pentest dashboard
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PentestStats {
|
||||
pub running_sessions: u32,
|
||||
pub total_vulnerabilities: u32,
|
||||
pub total_tool_invocations: u32,
|
||||
pub tool_success_rate: f64,
|
||||
pub severity_distribution: SeverityDistribution,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SeverityDistribution {
|
||||
pub critical: u32,
|
||||
pub high: u32,
|
||||
pub medium: u32,
|
||||
pub low: u32,
|
||||
pub info: u32,
|
||||
}
|
||||
|
||||
/// Code context hint linking a discovered endpoint to source code
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CodeContextHint {
|
||||
/// HTTP route pattern (e.g., "GET /api/users/:id")
|
||||
pub endpoint_pattern: String,
|
||||
/// Handler function name
|
||||
pub handler_function: String,
|
||||
/// Source file path
|
||||
pub file_path: String,
|
||||
/// Relevant code snippet
|
||||
pub code_snippet: String,
|
||||
/// SAST findings associated with this code
|
||||
pub known_vulnerabilities: Vec<String>,
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod dast_agent;
|
||||
pub mod graph_builder;
|
||||
pub mod issue_tracker;
|
||||
pub mod pentest_tool;
|
||||
pub mod scanner;
|
||||
|
||||
pub use dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter};
|
||||
pub use graph_builder::{LanguageParser, ParseOutput};
|
||||
pub use issue_tracker::IssueTracker;
|
||||
pub use pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
pub use scanner::{ScanOutput, Scanner};
|
||||
|
||||
63
compliance-core/src/traits/pentest_tool.rs
Normal file
63
compliance-core/src/traits/pentest_tool.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::error::CoreError;
|
||||
use crate::models::dast::{DastFinding, DastTarget};
|
||||
use crate::models::finding::Finding;
|
||||
use crate::models::pentest::CodeContextHint;
|
||||
use crate::models::sbom::SbomEntry;
|
||||
|
||||
/// Context passed to pentest tools during execution.
|
||||
///
|
||||
/// The HTTP client is not included here because `compliance-core` does not
|
||||
/// depend on `reqwest`. Tools that need HTTP should hold their own client
|
||||
/// or receive one via the `compliance-dast` orchestrator.
|
||||
pub struct PentestToolContext {
|
||||
/// The DAST target being tested
|
||||
pub target: DastTarget,
|
||||
/// Session ID for this pentest run
|
||||
pub session_id: String,
|
||||
/// SAST findings for the linked repo (if any)
|
||||
pub sast_findings: Vec<Finding>,
|
||||
/// SBOM entries with known CVEs (if any)
|
||||
pub sbom_entries: Vec<SbomEntry>,
|
||||
/// Code knowledge graph hints mapping endpoints to source code
|
||||
pub code_context: Vec<CodeContextHint>,
|
||||
/// Rate limit (requests per second)
|
||||
pub rate_limit: u32,
|
||||
/// Whether destructive operations are allowed
|
||||
pub allow_destructive: bool,
|
||||
}
|
||||
|
||||
/// Result from a pentest tool execution
|
||||
pub struct PentestToolResult {
|
||||
/// Human-readable summary of what the tool found
|
||||
pub summary: String,
|
||||
/// DAST findings produced by this tool
|
||||
pub findings: Vec<DastFinding>,
|
||||
/// Tool-specific structured output data
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A tool that the LLM pentest orchestrator can invoke.
|
||||
///
|
||||
/// Each tool represents a specific security testing capability
|
||||
/// (e.g., SQL injection scanner, DNS checker, TLS analyzer).
|
||||
/// Uses boxed futures for dyn-compatibility.
|
||||
pub trait PentestTool: Send + Sync {
|
||||
/// Tool name for LLM tool_use (e.g., "sql_injection_scanner")
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Human-readable description for the LLM system prompt
|
||||
fn description(&self) -> &str;
|
||||
|
||||
/// JSON Schema for the tool's input parameters
|
||||
fn input_schema(&self) -> serde_json::Value;
|
||||
|
||||
/// Execute the tool with the given input
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
context: &'a PentestToolContext,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>>;
|
||||
}
|
||||
619
compliance-core/tests/models.rs
Normal file
619
compliance-core/tests/models.rs
Normal file
@@ -0,0 +1,619 @@
|
||||
use compliance_core::models::*;
|
||||
|
||||
// ─── Severity ───
|
||||
|
||||
#[test]
|
||||
fn severity_display_all_variants() {
|
||||
assert_eq!(Severity::Info.to_string(), "info");
|
||||
assert_eq!(Severity::Low.to_string(), "low");
|
||||
assert_eq!(Severity::Medium.to_string(), "medium");
|
||||
assert_eq!(Severity::High.to_string(), "high");
|
||||
assert_eq!(Severity::Critical.to_string(), "critical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_ordering() {
|
||||
assert!(Severity::Info < Severity::Low);
|
||||
assert!(Severity::Low < Severity::Medium);
|
||||
assert!(Severity::Medium < Severity::High);
|
||||
assert!(Severity::High < Severity::Critical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_serde_roundtrip() {
|
||||
for sev in [
|
||||
Severity::Info,
|
||||
Severity::Low,
|
||||
Severity::Medium,
|
||||
Severity::High,
|
||||
Severity::Critical,
|
||||
] {
|
||||
let json = serde_json::to_string(&sev).unwrap();
|
||||
let back: Severity = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(sev, back);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_deserialize_lowercase() {
|
||||
let s: Severity = serde_json::from_str(r#""critical""#).unwrap();
|
||||
assert_eq!(s, Severity::Critical);
|
||||
}
|
||||
|
||||
// ─── FindingStatus ───
|
||||
|
||||
#[test]
|
||||
fn finding_status_display_all_variants() {
|
||||
assert_eq!(FindingStatus::Open.to_string(), "open");
|
||||
assert_eq!(FindingStatus::Triaged.to_string(), "triaged");
|
||||
assert_eq!(FindingStatus::FalsePositive.to_string(), "false_positive");
|
||||
assert_eq!(FindingStatus::Resolved.to_string(), "resolved");
|
||||
assert_eq!(FindingStatus::Ignored.to_string(), "ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finding_status_serde_roundtrip() {
|
||||
for status in [
|
||||
FindingStatus::Open,
|
||||
FindingStatus::Triaged,
|
||||
FindingStatus::FalsePositive,
|
||||
FindingStatus::Resolved,
|
||||
FindingStatus::Ignored,
|
||||
] {
|
||||
let json = serde_json::to_string(&status).unwrap();
|
||||
let back: FindingStatus = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(status, back);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Finding ───
|
||||
|
||||
#[test]
|
||||
fn finding_new_defaults() {
|
||||
let f = Finding::new(
|
||||
"repo1".into(),
|
||||
"fp123".into(),
|
||||
"semgrep".into(),
|
||||
ScanType::Sast,
|
||||
"Test title".into(),
|
||||
"Test desc".into(),
|
||||
Severity::High,
|
||||
);
|
||||
assert_eq!(f.repo_id, "repo1");
|
||||
assert_eq!(f.fingerprint, "fp123");
|
||||
assert_eq!(f.scanner, "semgrep");
|
||||
assert_eq!(f.scan_type, ScanType::Sast);
|
||||
assert_eq!(f.severity, Severity::High);
|
||||
assert_eq!(f.status, FindingStatus::Open);
|
||||
assert!(f.id.is_none());
|
||||
assert!(f.rule_id.is_none());
|
||||
assert!(f.confidence.is_none());
|
||||
assert!(f.file_path.is_none());
|
||||
assert!(f.remediation.is_none());
|
||||
assert!(f.suggested_fix.is_none());
|
||||
assert!(f.triage_action.is_none());
|
||||
assert!(f.developer_feedback.is_none());
|
||||
}
|
||||
|
||||
// ─── ScanType ───
|
||||
|
||||
#[test]
|
||||
fn scan_type_display_all_variants() {
|
||||
let cases = vec![
|
||||
(ScanType::Sast, "sast"),
|
||||
(ScanType::Sbom, "sbom"),
|
||||
(ScanType::Cve, "cve"),
|
||||
(ScanType::Gdpr, "gdpr"),
|
||||
(ScanType::OAuth, "oauth"),
|
||||
(ScanType::Graph, "graph"),
|
||||
(ScanType::Dast, "dast"),
|
||||
(ScanType::SecretDetection, "secret_detection"),
|
||||
(ScanType::Lint, "lint"),
|
||||
(ScanType::CodeReview, "code_review"),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(variant.to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_type_serde_roundtrip() {
|
||||
for st in [
|
||||
ScanType::Sast,
|
||||
ScanType::SecretDetection,
|
||||
ScanType::CodeReview,
|
||||
] {
|
||||
let json = serde_json::to_string(&st).unwrap();
|
||||
let back: ScanType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(st, back);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ScanRun ───
|
||||
|
||||
#[test]
|
||||
fn scan_run_new_defaults() {
|
||||
let sr = ScanRun::new("repo1".into(), ScanTrigger::Manual);
|
||||
assert_eq!(sr.repo_id, "repo1");
|
||||
assert_eq!(sr.trigger, ScanTrigger::Manual);
|
||||
assert_eq!(sr.status, ScanRunStatus::Running);
|
||||
assert_eq!(sr.current_phase, ScanPhase::ChangeDetection);
|
||||
assert!(sr.phases_completed.is_empty());
|
||||
assert_eq!(sr.new_findings_count, 0);
|
||||
assert!(sr.error_message.is_none());
|
||||
assert!(sr.completed_at.is_none());
|
||||
}
|
||||
|
||||
// ─── PentestStatus ───
|
||||
|
||||
#[test]
|
||||
fn pentest_status_display() {
|
||||
assert_eq!(pentest::PentestStatus::Running.to_string(), "running");
|
||||
assert_eq!(pentest::PentestStatus::Paused.to_string(), "paused");
|
||||
assert_eq!(pentest::PentestStatus::Completed.to_string(), "completed");
|
||||
assert_eq!(pentest::PentestStatus::Failed.to_string(), "failed");
|
||||
}
|
||||
|
||||
// ─── PentestStrategy ───
|
||||
|
||||
#[test]
|
||||
fn pentest_strategy_display() {
|
||||
assert_eq!(pentest::PentestStrategy::Quick.to_string(), "quick");
|
||||
assert_eq!(
|
||||
pentest::PentestStrategy::Comprehensive.to_string(),
|
||||
"comprehensive"
|
||||
);
|
||||
assert_eq!(pentest::PentestStrategy::Targeted.to_string(), "targeted");
|
||||
assert_eq!(
|
||||
pentest::PentestStrategy::Aggressive.to_string(),
|
||||
"aggressive"
|
||||
);
|
||||
assert_eq!(pentest::PentestStrategy::Stealth.to_string(), "stealth");
|
||||
}
|
||||
|
||||
// ─── PentestSession ───
|
||||
|
||||
#[test]
|
||||
fn pentest_session_new_defaults() {
|
||||
let s = pentest::PentestSession::new("target1".into(), pentest::PentestStrategy::Quick);
|
||||
assert_eq!(s.target_id, "target1");
|
||||
assert_eq!(s.status, pentest::PentestStatus::Running);
|
||||
assert_eq!(s.strategy, pentest::PentestStrategy::Quick);
|
||||
assert_eq!(s.tool_invocations, 0);
|
||||
assert_eq!(s.tool_successes, 0);
|
||||
assert_eq!(s.findings_count, 0);
|
||||
assert!(s.completed_at.is_none());
|
||||
assert!(s.repo_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_session_success_rate_zero_invocations() {
|
||||
let s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Comprehensive);
|
||||
assert_eq!(s.success_rate(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_session_success_rate_calculation() {
|
||||
let mut s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Comprehensive);
|
||||
s.tool_invocations = 10;
|
||||
s.tool_successes = 7;
|
||||
assert!((s.success_rate() - 70.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_session_success_rate_all_success() {
|
||||
let mut s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Quick);
|
||||
s.tool_invocations = 5;
|
||||
s.tool_successes = 5;
|
||||
assert_eq!(s.success_rate(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_session_success_rate_none_success() {
|
||||
let mut s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Quick);
|
||||
s.tool_invocations = 3;
|
||||
s.tool_successes = 0;
|
||||
assert_eq!(s.success_rate(), 0.0);
|
||||
}
|
||||
|
||||
// ─── PentestMessage factories ───
|
||||
|
||||
#[test]
|
||||
fn pentest_message_user() {
|
||||
let m = pentest::PentestMessage::user("sess1".into(), "hello".into());
|
||||
assert_eq!(m.role, "user");
|
||||
assert_eq!(m.session_id, "sess1");
|
||||
assert_eq!(m.content, "hello");
|
||||
assert!(m.attack_node_id.is_none());
|
||||
assert!(m.tool_calls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_message_assistant() {
|
||||
let m = pentest::PentestMessage::assistant("sess1".into(), "response".into());
|
||||
assert_eq!(m.role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_message_tool_result() {
|
||||
let m = pentest::PentestMessage::tool_result("sess1".into(), "output".into(), "node1".into());
|
||||
assert_eq!(m.role, "tool_result");
|
||||
assert_eq!(m.attack_node_id, Some("node1".to_string()));
|
||||
}
|
||||
|
||||
// ─── AttackChainNode ───
|
||||
|
||||
#[test]
|
||||
fn attack_chain_node_new_defaults() {
|
||||
let n = pentest::AttackChainNode::new(
|
||||
"sess1".into(),
|
||||
"node1".into(),
|
||||
"recon".into(),
|
||||
serde_json::json!({"target": "example.com"}),
|
||||
"Starting recon".into(),
|
||||
);
|
||||
assert_eq!(n.session_id, "sess1");
|
||||
assert_eq!(n.node_id, "node1");
|
||||
assert_eq!(n.tool_name, "recon");
|
||||
assert_eq!(n.status, pentest::AttackNodeStatus::Pending);
|
||||
assert!(n.parent_node_ids.is_empty());
|
||||
assert!(n.findings_produced.is_empty());
|
||||
assert!(n.risk_score.is_none());
|
||||
assert!(n.started_at.is_none());
|
||||
}
|
||||
|
||||
// ─── DastTarget ───
|
||||
|
||||
#[test]
|
||||
fn dast_target_new_defaults() {
|
||||
let t = dast::DastTarget::new(
|
||||
"My App".into(),
|
||||
"https://example.com".into(),
|
||||
dast::DastTargetType::WebApp,
|
||||
);
|
||||
assert_eq!(t.name, "My App");
|
||||
assert_eq!(t.base_url, "https://example.com");
|
||||
assert_eq!(t.target_type, dast::DastTargetType::WebApp);
|
||||
assert_eq!(t.max_crawl_depth, 5);
|
||||
assert_eq!(t.rate_limit, 10);
|
||||
assert!(!t.allow_destructive);
|
||||
assert!(t.excluded_paths.is_empty());
|
||||
assert!(t.auth_config.is_none());
|
||||
assert!(t.repo_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dast_target_type_display() {
|
||||
assert_eq!(dast::DastTargetType::WebApp.to_string(), "webapp");
|
||||
assert_eq!(dast::DastTargetType::RestApi.to_string(), "rest_api");
|
||||
assert_eq!(dast::DastTargetType::GraphQl.to_string(), "graphql");
|
||||
}
|
||||
|
||||
// ─── DastScanRun ───
|
||||
|
||||
#[test]
|
||||
fn dast_scan_run_new_defaults() {
|
||||
let sr = dast::DastScanRun::new("target1".into());
|
||||
assert_eq!(sr.status, dast::DastScanStatus::Running);
|
||||
assert_eq!(sr.current_phase, dast::DastScanPhase::Reconnaissance);
|
||||
assert!(sr.phases_completed.is_empty());
|
||||
assert_eq!(sr.endpoints_discovered, 0);
|
||||
assert_eq!(sr.findings_count, 0);
|
||||
assert!(!sr.exploitable_count > 0);
|
||||
assert!(sr.completed_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dast_scan_phase_display() {
|
||||
assert_eq!(
|
||||
dast::DastScanPhase::Reconnaissance.to_string(),
|
||||
"reconnaissance"
|
||||
);
|
||||
assert_eq!(dast::DastScanPhase::Crawling.to_string(), "crawling");
|
||||
assert_eq!(dast::DastScanPhase::Completed.to_string(), "completed");
|
||||
}
|
||||
|
||||
// ─── DastVulnType ───
|
||||
|
||||
#[test]
|
||||
fn dast_vuln_type_display_all_variants() {
|
||||
let cases = vec![
|
||||
(dast::DastVulnType::SqlInjection, "sql_injection"),
|
||||
(dast::DastVulnType::Xss, "xss"),
|
||||
(dast::DastVulnType::AuthBypass, "auth_bypass"),
|
||||
(dast::DastVulnType::Ssrf, "ssrf"),
|
||||
(dast::DastVulnType::Idor, "idor"),
|
||||
(dast::DastVulnType::Other, "other"),
|
||||
];
|
||||
for (variant, expected) in cases {
|
||||
assert_eq!(variant.to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DastFinding ───
|
||||
|
||||
#[test]
|
||||
fn dast_finding_new_defaults() {
|
||||
let f = dast::DastFinding::new(
|
||||
"run1".into(),
|
||||
"target1".into(),
|
||||
dast::DastVulnType::Xss,
|
||||
"XSS in search".into(),
|
||||
"Reflected XSS".into(),
|
||||
Severity::High,
|
||||
"https://example.com/search".into(),
|
||||
"GET".into(),
|
||||
);
|
||||
assert_eq!(f.vuln_type, dast::DastVulnType::Xss);
|
||||
assert_eq!(f.severity, Severity::High);
|
||||
assert!(!f.exploitable);
|
||||
assert!(f.evidence.is_empty());
|
||||
assert!(f.session_id.is_none());
|
||||
assert!(f.linked_sast_finding_id.is_none());
|
||||
}
|
||||
|
||||
// ─── SbomEntry ───
|
||||
|
||||
#[test]
|
||||
fn sbom_entry_new_defaults() {
|
||||
let e = SbomEntry::new(
|
||||
"repo1".into(),
|
||||
"lodash".into(),
|
||||
"4.17.21".into(),
|
||||
"npm".into(),
|
||||
);
|
||||
assert_eq!(e.name, "lodash");
|
||||
assert_eq!(e.version, "4.17.21");
|
||||
assert_eq!(e.package_manager, "npm");
|
||||
assert!(e.license.is_none());
|
||||
assert!(e.purl.is_none());
|
||||
assert!(e.known_vulnerabilities.is_empty());
|
||||
}
|
||||
|
||||
// ─── TrackedRepository ───
|
||||
|
||||
#[test]
|
||||
fn tracked_repository_new_defaults() {
|
||||
let r = TrackedRepository::new("My Repo".into(), "https://github.com/org/repo.git".into());
|
||||
assert_eq!(r.name, "My Repo");
|
||||
assert_eq!(r.git_url, "https://github.com/org/repo.git");
|
||||
assert_eq!(r.default_branch, "main");
|
||||
assert!(!r.webhook_enabled);
|
||||
assert!(r.webhook_secret.is_some());
|
||||
// Webhook secret should be 32 hex chars (UUID without dashes)
|
||||
assert_eq!(r.webhook_secret.as_ref().unwrap().len(), 32);
|
||||
assert!(r.tracker_type.is_none());
|
||||
assert_eq!(r.findings_count, 0);
|
||||
}
|
||||
|
||||
// ─── ScanTrigger ───
|
||||
|
||||
#[test]
|
||||
fn scan_trigger_serde_roundtrip() {
|
||||
for trigger in [
|
||||
ScanTrigger::Scheduled,
|
||||
ScanTrigger::Webhook,
|
||||
ScanTrigger::Manual,
|
||||
] {
|
||||
let json = serde_json::to_string(&trigger).unwrap();
|
||||
let back: ScanTrigger = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(trigger, back);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PentestEvent serde (tagged enum) ───
|
||||
|
||||
#[test]
|
||||
fn pentest_event_serde_thinking() {
|
||||
let event = pentest::PentestEvent::Thinking {
|
||||
reasoning: "analyzing target".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""type":"thinking""#));
|
||||
assert!(json.contains("analyzing target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_event_serde_finding() {
|
||||
let event = pentest::PentestEvent::Finding {
|
||||
finding_id: "f1".into(),
|
||||
title: "XSS".into(),
|
||||
severity: "high".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let back: pentest::PentestEvent = serde_json::from_str(&json).unwrap();
|
||||
match back {
|
||||
pentest::PentestEvent::Finding {
|
||||
finding_id,
|
||||
title,
|
||||
severity,
|
||||
} => {
|
||||
assert_eq!(finding_id, "f1");
|
||||
assert_eq!(title, "XSS");
|
||||
assert_eq!(severity, "high");
|
||||
}
|
||||
_ => panic!("wrong variant"),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PentestEvent Paused/Resumed ───
|
||||
|
||||
#[test]
|
||||
fn pentest_event_serde_paused() {
|
||||
let event = pentest::PentestEvent::Paused;
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""type":"paused""#));
|
||||
let back: pentest::PentestEvent = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, pentest::PentestEvent::Paused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_event_serde_resumed() {
|
||||
let event = pentest::PentestEvent::Resumed;
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains(r#""type":"resumed""#));
|
||||
let back: pentest::PentestEvent = serde_json::from_str(&json).unwrap();
|
||||
assert!(matches!(back, pentest::PentestEvent::Resumed));
|
||||
}
|
||||
|
||||
// ─── PentestConfig serde ───
|
||||
|
||||
#[test]
|
||||
fn pentest_config_serde_roundtrip() {
|
||||
let config = pentest::PentestConfig {
|
||||
app_url: "https://example.com".into(),
|
||||
git_repo_url: Some("https://github.com/org/repo".into()),
|
||||
branch: Some("main".into()),
|
||||
commit_hash: None,
|
||||
app_type: Some("web".into()),
|
||||
rate_limit: Some(10),
|
||||
auth: pentest::PentestAuthConfig {
|
||||
mode: pentest::AuthMode::Manual,
|
||||
username: Some("admin".into()),
|
||||
password: Some("pass123".into()),
|
||||
registration_url: None,
|
||||
verification_email: None,
|
||||
imap_host: None,
|
||||
imap_port: None,
|
||||
imap_username: None,
|
||||
imap_password: None,
|
||||
cleanup_test_user: true,
|
||||
},
|
||||
custom_headers: [("X-Token".to_string(), "abc".to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
strategy: Some("comprehensive".into()),
|
||||
allow_destructive: false,
|
||||
initial_instructions: Some("Test the login flow".into()),
|
||||
scope_exclusions: vec!["/admin".into()],
|
||||
disclaimer_accepted: true,
|
||||
disclaimer_accepted_at: Some(chrono::Utc::now()),
|
||||
environment: pentest::Environment::Staging,
|
||||
tester: pentest::TesterInfo {
|
||||
name: "Alice".into(),
|
||||
email: "alice@example.com".into(),
|
||||
},
|
||||
max_duration_minutes: Some(30),
|
||||
skip_mode: false,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let back: pentest::PentestConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.app_url, "https://example.com");
|
||||
assert_eq!(back.auth.mode, pentest::AuthMode::Manual);
|
||||
assert_eq!(back.auth.username, Some("admin".into()));
|
||||
assert!(back.auth.cleanup_test_user);
|
||||
assert_eq!(back.scope_exclusions, vec!["/admin".to_string()]);
|
||||
assert_eq!(back.environment, pentest::Environment::Staging);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_auth_config_default() {
|
||||
let auth = pentest::PentestAuthConfig::default();
|
||||
assert_eq!(auth.mode, pentest::AuthMode::None);
|
||||
assert!(auth.username.is_none());
|
||||
assert!(auth.password.is_none());
|
||||
assert!(auth.verification_email.is_none());
|
||||
assert!(auth.imap_host.is_none());
|
||||
assert!(!auth.cleanup_test_user);
|
||||
}
|
||||
|
||||
// ─── TestUserRecord ───
|
||||
|
||||
#[test]
|
||||
fn test_user_record_default() {
|
||||
let r = pentest::TestUserRecord::default();
|
||||
assert!(r.username.is_none());
|
||||
assert!(r.email.is_none());
|
||||
assert!(r.provider_user_id.is_none());
|
||||
assert!(r.provider.is_none());
|
||||
assert!(!r.cleaned_up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_record_serde_roundtrip() {
|
||||
let r = pentest::TestUserRecord {
|
||||
username: Some("pentestuser".into()),
|
||||
email: Some("pentest+abc@scanner.example.com".into()),
|
||||
provider_user_id: Some("kc-uuid-123".into()),
|
||||
provider: Some(pentest::IdentityProvider::Keycloak),
|
||||
cleaned_up: false,
|
||||
};
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
let back: pentest::TestUserRecord = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.username, Some("pentestuser".into()));
|
||||
assert_eq!(back.provider, Some(pentest::IdentityProvider::Keycloak));
|
||||
assert!(!back.cleaned_up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_provider_serde_all_variants() {
|
||||
for (variant, expected) in [
|
||||
(pentest::IdentityProvider::Keycloak, "\"keycloak\""),
|
||||
(pentest::IdentityProvider::Auth0, "\"auth0\""),
|
||||
(pentest::IdentityProvider::Okta, "\"okta\""),
|
||||
(pentest::IdentityProvider::Firebase, "\"firebase\""),
|
||||
(pentest::IdentityProvider::Custom, "\"custom\""),
|
||||
] {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected);
|
||||
let back: pentest::IdentityProvider = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_session_with_test_user() {
|
||||
let mut s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Quick);
|
||||
assert!(s.test_user.is_none());
|
||||
s.test_user = Some(pentest::TestUserRecord {
|
||||
username: Some("pentester".into()),
|
||||
email: Some("pentest+123@example.com".into()),
|
||||
provider_user_id: None,
|
||||
provider: Some(pentest::IdentityProvider::Auth0),
|
||||
cleaned_up: false,
|
||||
});
|
||||
let bson_doc = bson::to_document(&s).unwrap();
|
||||
let back: pentest::PentestSession = bson::from_document(bson_doc).unwrap();
|
||||
assert!(back.test_user.is_some());
|
||||
let tu = back.test_user.as_ref().unwrap();
|
||||
assert_eq!(tu.username, Some("pentester".into()));
|
||||
assert_eq!(tu.provider, Some(pentest::IdentityProvider::Auth0));
|
||||
}
|
||||
|
||||
// ─── Serde helpers (BSON datetime) ───
|
||||
|
||||
#[test]
|
||||
fn bson_datetime_roundtrip_via_finding() {
|
||||
let f = Finding::new(
|
||||
"repo1".into(),
|
||||
"fp".into(),
|
||||
"test".into(),
|
||||
ScanType::Sast,
|
||||
"t".into(),
|
||||
"d".into(),
|
||||
Severity::Low,
|
||||
);
|
||||
// Serialize to BSON and back
|
||||
let bson_doc = bson::to_document(&f).unwrap();
|
||||
let back: Finding = bson::from_document(bson_doc).unwrap();
|
||||
// Timestamps should survive (within 1 second tolerance due to ms precision)
|
||||
assert!((back.created_at - f.created_at).num_milliseconds().abs() < 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_bson_datetime_roundtrip_with_none() {
|
||||
let s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Quick);
|
||||
assert!(s.completed_at.is_none());
|
||||
let bson_doc = bson::to_document(&s).unwrap();
|
||||
let back: pentest::PentestSession = bson::from_document(bson_doc).unwrap();
|
||||
assert!(back.completed_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opt_bson_datetime_roundtrip_with_some() {
|
||||
let mut s = pentest::PentestSession::new("t".into(), pentest::PentestStrategy::Quick);
|
||||
s.completed_at = Some(chrono::Utc::now());
|
||||
let bson_doc = bson::to_document(&s).unwrap();
|
||||
let back: pentest::PentestSession = bson::from_document(bson_doc).unwrap();
|
||||
assert!(back.completed_at.is_some());
|
||||
}
|
||||
@@ -262,13 +262,120 @@ code {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 14px 20px;
|
||||
.sidebar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-user {
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(56, 189, 248, 0.2), rgba(139, 92, 246, 0.15));
|
||||
border: 2px solid rgba(56, 189, 248, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.user-avatar:hover {
|
||||
border-color: rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.logout-link {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.logout-link:hover {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.logout-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.logout-btn-icon:hover {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.sidebar-legal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px 14px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sidebar-legal a {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-legal a:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.legal-dot {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
@@ -2767,3 +2874,774 @@ tbody tr:last-child td {
|
||||
.sbom-diff-row-changed {
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════
|
||||
ATTACK CHAIN VISUALIZATION
|
||||
═══════════════════════════════════ */
|
||||
|
||||
/* KPI bar */
|
||||
.ac-kpi-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ac-kpi-card {
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 12px 14px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ac-kpi-card:first-child { border-radius: 10px 0 0 10px; }
|
||||
.ac-kpi-card:last-child { border-radius: 0 10px 10px 0; }
|
||||
.ac-kpi-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
}
|
||||
.ac-kpi-card:nth-child(1)::before { background: var(--accent, #3b82f6); opacity: 0.4; }
|
||||
.ac-kpi-card:nth-child(2)::before { background: var(--danger, #dc2626); opacity: 0.5; }
|
||||
.ac-kpi-card:nth-child(3)::before { background: var(--success, #16a34a); opacity: 0.4; }
|
||||
.ac-kpi-card:nth-child(4)::before { background: var(--warning, #d97706); opacity: 0.4; }
|
||||
|
||||
.ac-kpi-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.ac-kpi-label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Phase progress rail */
|
||||
.ac-phase-rail {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.ac-phase-rail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background: var(--border-color);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.ac-rail-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
min-width: 56px;
|
||||
flex: 1;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.ac-rail-node:hover .ac-rail-dot { transform: scale(1.25); }
|
||||
.ac-rail-node.active .ac-rail-label { color: var(--accent, #3b82f6); }
|
||||
.ac-rail-node.active .ac-rail-dot { box-shadow: 0 0 0 3px rgba(59,130,246,0.2), 0 0 12px rgba(59,130,246,0.15); }
|
||||
|
||||
.ac-rail-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid var(--bg-primary, #0f172a);
|
||||
transition: transform 0.2s cubic-bezier(0.16,1,0.3,1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ac-rail-dot.done { background: var(--success, #16a34a); box-shadow: 0 0 8px rgba(22,163,74,0.25); }
|
||||
.ac-rail-dot.running { background: var(--warning, #d97706); box-shadow: 0 0 10px rgba(217,119,6,0.35); animation: ac-dot-pulse 2s ease-in-out infinite; }
|
||||
.ac-rail-dot.pending { background: var(--text-tertiary, #6b7280); opacity: 0.5; }
|
||||
.ac-rail-dot.mixed { background: conic-gradient(var(--success, #16a34a) 0deg 270deg, var(--danger, #dc2626) 270deg 360deg); box-shadow: 0 0 8px rgba(22,163,74,0.2); }
|
||||
|
||||
@keyframes ac-dot-pulse {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(217,119,6,0.35); }
|
||||
50% { box-shadow: 0 0 18px rgba(217,119,6,0.55); }
|
||||
}
|
||||
|
||||
.ac-rail-label {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
margin-top: 5px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.ac-rail-findings {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.ac-rail-findings.has { color: var(--danger, #dc2626); }
|
||||
.ac-rail-findings.none { color: var(--text-tertiary, #6b7280); opacity: 0.4; }
|
||||
|
||||
.ac-rail-heatmap {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.ac-hm-cell {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
.ac-hm-cell.ok { background: var(--success, #16a34a); opacity: 0.5; }
|
||||
.ac-hm-cell.fail { background: var(--danger, #dc2626); opacity: 0.65; }
|
||||
.ac-hm-cell.run { background: var(--warning, #d97706); opacity: 0.5; animation: ac-pulse 1.5s ease-in-out infinite; }
|
||||
.ac-hm-cell.wait { background: var(--text-tertiary, #6b7280); opacity: 0.15; }
|
||||
|
||||
.ac-rail-bar {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
margin-top: 7px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.ac-rail-bar-inner {
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.ac-rail-bar-inner.done { background: var(--success, #16a34a); opacity: 0.35; }
|
||||
.ac-rail-bar-inner.running { background: linear-gradient(to right, var(--success, #16a34a), var(--warning, #d97706)); opacity: 0.35; }
|
||||
|
||||
/* Progress track */
|
||||
.ac-progress-track {
|
||||
height: 3px;
|
||||
background: var(--border-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ac-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--success, #16a34a) 0%, var(--accent, #3b82f6) 100%);
|
||||
transition: width 0.6s cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
|
||||
/* Expand all controls */
|
||||
.ac-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ac-btn-toggle {
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
color: var(--accent, #3b82f6);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.ac-btn-toggle:hover {
|
||||
background: rgba(59,130,246,0.08);
|
||||
border-color: rgba(59,130,246,0.12);
|
||||
}
|
||||
|
||||
/* Phase accordion */
|
||||
.ac-phases {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ac-phase {
|
||||
animation: ac-phase-in 0.35s cubic-bezier(0.16,1,0.3,1) both;
|
||||
}
|
||||
@keyframes ac-phase-in {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.ac-phase-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ac-phase.open .ac-phase-header {
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
.ac-phase-header:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.ac-phase-num {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #3b82f6);
|
||||
background: rgba(59,130,246,0.08);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(59,130,246,0.1);
|
||||
}
|
||||
|
||||
.ac-phase-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ac-phase-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
}
|
||||
.ac-phase-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ac-phase-dot.completed { background: var(--success, #16a34a); }
|
||||
.ac-phase-dot.failed { background: var(--danger, #dc2626); }
|
||||
.ac-phase-dot.running { background: var(--warning, #d97706); animation: ac-pulse 1.5s ease-in-out infinite; }
|
||||
.ac-phase-dot.pending { background: var(--text-tertiary, #6b7280); opacity: 0.4; }
|
||||
|
||||
@keyframes ac-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.ac-phase-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ac-phase-meta .findings-ct { color: var(--danger, #dc2626); font-weight: 600; }
|
||||
.ac-phase-meta .running-ct { color: var(--warning, #d97706); font-weight: 500; }
|
||||
|
||||
.ac-phase-chevron {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
font-size: 11px;
|
||||
transition: transform 0.25s cubic-bezier(0.16,1,0.3,1);
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.ac-phase.open .ac-phase-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.ac-phase-body {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s cubic-bezier(0.16,1,0.3,1);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
border-right: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
.ac-phase.open .ac-phase-body {
|
||||
max-height: 2000px;
|
||||
}
|
||||
.ac-phase-body-inner {
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* Tool rows */
|
||||
.ac-tool-row {
|
||||
display: grid;
|
||||
grid-template-columns: 5px 26px 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.ac-tool-row:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.ac-tool-row.expanded {
|
||||
background: rgba(59,130,246,0.03);
|
||||
}
|
||||
.ac-tool-row.is-pending {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ac-status-bar {
|
||||
width: 4px;
|
||||
height: 26px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ac-status-bar.completed { background: var(--success, #16a34a); }
|
||||
.ac-status-bar.failed { background: var(--danger, #dc2626); }
|
||||
.ac-status-bar.running { background: var(--warning, #d97706); animation: ac-pulse 1.5s ease-in-out infinite; }
|
||||
.ac-status-bar.pending { background: var(--text-tertiary, #6b7280); opacity: 0.25; }
|
||||
|
||||
.ac-tool-icon {
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.ac-tool-info { min-width: 0; }
|
||||
.ac-tool-name {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Category chips */
|
||||
.ac-cat-chip {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ac-cat-chip.recon { color: #38bdf8; background: rgba(56,189,248,0.1); }
|
||||
.ac-cat-chip.api { color: #818cf8; background: rgba(129,140,248,0.1); }
|
||||
.ac-cat-chip.headers { color: #06b6d4; background: rgba(6,182,212,0.1); }
|
||||
.ac-cat-chip.csp { color: #d946ef; background: rgba(217,70,239,0.1); }
|
||||
.ac-cat-chip.cookies { color: #f59e0b; background: rgba(245,158,11,0.1); }
|
||||
.ac-cat-chip.logs { color: #78716c; background: rgba(120,113,108,0.1); }
|
||||
.ac-cat-chip.ratelimit { color: #64748b; background: rgba(100,116,139,0.1); }
|
||||
.ac-cat-chip.cors { color: #8b5cf6; background: rgba(139,92,246,0.1); }
|
||||
.ac-cat-chip.tls { color: #14b8a6; background: rgba(20,184,166,0.1); }
|
||||
.ac-cat-chip.redirect { color: #fb923c; background: rgba(251,146,60,0.1); }
|
||||
.ac-cat-chip.email { color: #0ea5e9; background: rgba(14,165,233,0.1); }
|
||||
.ac-cat-chip.auth { color: #f43f5e; background: rgba(244,63,94,0.1); }
|
||||
.ac-cat-chip.xss { color: #f97316; background: rgba(249,115,22,0.1); }
|
||||
.ac-cat-chip.sqli { color: #ef4444; background: rgba(239,68,68,0.1); }
|
||||
.ac-cat-chip.ssrf { color: #a855f7; background: rgba(168,85,247,0.1); }
|
||||
.ac-cat-chip.idor { color: #ec4899; background: rgba(236,72,153,0.1); }
|
||||
.ac-cat-chip.fuzzer { color: #a78bfa; background: rgba(167,139,250,0.1); }
|
||||
.ac-cat-chip.cve { color: #dc2626; background: rgba(220,38,38,0.1); }
|
||||
.ac-cat-chip.default { color: #94a3b8; background: rgba(148,163,184,0.1); }
|
||||
|
||||
.ac-tool-duration {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
white-space: nowrap;
|
||||
min-width: 48px;
|
||||
text-align: right;
|
||||
}
|
||||
.ac-tool-duration.running-text {
|
||||
color: var(--warning, #d97706);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ac-findings-pill {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
.ac-findings-pill.has { background: rgba(220,38,38,0.12); color: var(--danger, #dc2626); }
|
||||
.ac-findings-pill.zero { background: transparent; color: var(--text-tertiary, #6b7280); font-weight: 400; opacity: 0.5; }
|
||||
|
||||
.ac-risk-val {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
.ac-risk-val.high { color: var(--danger, #dc2626); }
|
||||
.ac-risk-val.medium { color: var(--warning, #d97706); }
|
||||
.ac-risk-val.low { color: var(--text-secondary); }
|
||||
.ac-risk-val.none { color: transparent; }
|
||||
|
||||
/* Tool detail (expanded) */
|
||||
.ac-tool-detail {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.28s cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
.ac-tool-detail.open {
|
||||
max-height: 800px;
|
||||
}
|
||||
.ac-tool-detail-inner {
|
||||
padding: 6px 10px 10px 49px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ac-reasoning-block {
|
||||
background: rgba(59,130,246,0.03);
|
||||
border-left: 2px solid var(--accent, #3b82f6);
|
||||
padding: 7px 12px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-style: italic;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ac-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 14px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ac-detail-label {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.ac-detail-value {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Running node pulse animation */
|
||||
.ac-node-running {
|
||||
animation: ac-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ac-pulse {
|
||||
0%, 100% { box-shadow: inset 0 0 0 transparent; }
|
||||
50% { box-shadow: inset 0 0 12px rgba(217, 119, 6, 0.15); }
|
||||
}
|
||||
|
||||
/* Tool input/output data blocks */
|
||||
.ac-data-section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.ac-data-label {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.ac-data-block {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border, #162038);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Pentest Wizard
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
.wizard-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.wizard-dialog {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 600px;
|
||||
max-width: 92vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Close button (top-right corner, always visible) */
|
||||
.wizard-close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.wizard-close-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Dropdown for existing targets/repos */
|
||||
.wizard-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: var(--bg-elevated, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 0 8px 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.wizard-dropdown-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.wizard-dropdown-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.wizard-dropdown-item:hover {
|
||||
background: var(--bg-card-hover, rgba(255,255,255,0.04));
|
||||
}
|
||||
|
||||
/* SSH key display */
|
||||
.wizard-ssh-key {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 200, 255, 0.04);
|
||||
border: 1px solid var(--border-accent, rgba(0,200,255,0.15));
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wizard-ssh-key-box {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wizard-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-tertiary);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.wizard-step + .wizard-step::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.wizard-step.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.wizard-step.completed {
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.wizard-step-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-step.active .wizard-step-dot {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wizard-step.completed .wizard-step-dot {
|
||||
background: var(--status-success);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wizard-step-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.wizard-step-label {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-body {
|
||||
padding: 20px 24px;
|
||||
min-height: 300px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wizard-body h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wizard-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.wizard-field label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wizard-field .chat-input,
|
||||
.wizard-field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wizard-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.wizard-disclaimer {
|
||||
background: rgba(255, 176, 32, 0.08);
|
||||
border: 1px solid rgba(255, 176, 32, 0.25);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.wizard-summary {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.wizard-summary dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.wizard-summary dt {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wizard-summary dd {
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-toggle {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-toggle.active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.wizard-toggle-knob {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.wizard-toggle.active .wizard-toggle-knob {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ pub enum Route {
|
||||
DastFindingsPage {},
|
||||
#[route("/dast/findings/:id")]
|
||||
DastFindingDetailPage { id: String },
|
||||
#[route("/pentest")]
|
||||
PentestDashboardPage {},
|
||||
#[route("/pentest/:session_id")]
|
||||
PentestSessionPage { session_id: String },
|
||||
#[route("/mcp-servers")]
|
||||
McpServersPage {},
|
||||
#[route("/settings")]
|
||||
@@ -49,7 +53,6 @@ const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||
const VIS_NETWORK_JS: Asset = asset!("/assets/vis-network.min.js");
|
||||
const GRAPH_VIZ_JS: Asset = asset!("/assets/graph-viz.js");
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
|
||||
293
compliance-dashboard/src/components/attack_chain/helpers.rs
Normal file
293
compliance-dashboard/src/components/attack_chain/helpers.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
/// Get category CSS class from tool name
|
||||
pub(crate) fn tool_category(name: &str) -> &'static str {
|
||||
let lower = name.to_lowercase();
|
||||
if lower.contains("recon") {
|
||||
return "recon";
|
||||
}
|
||||
if lower.contains("openapi") || lower.contains("api") || lower.contains("swagger") {
|
||||
return "api";
|
||||
}
|
||||
if lower.contains("header") {
|
||||
return "headers";
|
||||
}
|
||||
if lower.contains("csp") {
|
||||
return "csp";
|
||||
}
|
||||
if lower.contains("cookie") {
|
||||
return "cookies";
|
||||
}
|
||||
if lower.contains("log") || lower.contains("console") {
|
||||
return "logs";
|
||||
}
|
||||
if lower.contains("rate") || lower.contains("limit") {
|
||||
return "ratelimit";
|
||||
}
|
||||
if lower.contains("cors") {
|
||||
return "cors";
|
||||
}
|
||||
if lower.contains("tls") || lower.contains("ssl") {
|
||||
return "tls";
|
||||
}
|
||||
if lower.contains("redirect") {
|
||||
return "redirect";
|
||||
}
|
||||
if lower.contains("dns")
|
||||
|| lower.contains("dmarc")
|
||||
|| lower.contains("email")
|
||||
|| lower.contains("spf")
|
||||
{
|
||||
return "email";
|
||||
}
|
||||
if lower.contains("auth")
|
||||
|| lower.contains("jwt")
|
||||
|| lower.contains("token")
|
||||
|| lower.contains("session")
|
||||
{
|
||||
return "auth";
|
||||
}
|
||||
if lower.contains("xss") {
|
||||
return "xss";
|
||||
}
|
||||
if lower.contains("sql") || lower.contains("sqli") {
|
||||
return "sqli";
|
||||
}
|
||||
if lower.contains("ssrf") {
|
||||
return "ssrf";
|
||||
}
|
||||
if lower.contains("idor") {
|
||||
return "idor";
|
||||
}
|
||||
if lower.contains("fuzz") {
|
||||
return "fuzzer";
|
||||
}
|
||||
if lower.contains("cve") || lower.contains("exploit") {
|
||||
return "cve";
|
||||
}
|
||||
"default"
|
||||
}
|
||||
|
||||
/// Get emoji icon from tool category
|
||||
pub(crate) fn tool_emoji(cat: &str) -> &'static str {
|
||||
match cat {
|
||||
"recon" => "\u{1F50D}",
|
||||
"api" => "\u{1F517}",
|
||||
"headers" => "\u{1F6E1}",
|
||||
"csp" => "\u{1F6A7}",
|
||||
"cookies" => "\u{1F36A}",
|
||||
"logs" => "\u{1F4DD}",
|
||||
"ratelimit" => "\u{23F1}",
|
||||
"cors" => "\u{1F30D}",
|
||||
"tls" => "\u{1F510}",
|
||||
"redirect" => "\u{21AA}",
|
||||
"email" => "\u{1F4E7}",
|
||||
"auth" => "\u{1F512}",
|
||||
"xss" => "\u{26A1}",
|
||||
"sqli" => "\u{1F489}",
|
||||
"ssrf" => "\u{1F310}",
|
||||
"idor" => "\u{1F511}",
|
||||
"fuzzer" => "\u{1F9EA}",
|
||||
"cve" => "\u{1F4A3}",
|
||||
_ => "\u{1F527}",
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute display label for category
|
||||
pub(crate) fn cat_label(cat: &str) -> &'static str {
|
||||
match cat {
|
||||
"recon" => "Recon",
|
||||
"api" => "API",
|
||||
"headers" => "Headers",
|
||||
"csp" => "CSP",
|
||||
"cookies" => "Cookies",
|
||||
"logs" => "Logs",
|
||||
"ratelimit" => "Rate Limit",
|
||||
"cors" => "CORS",
|
||||
"tls" => "TLS",
|
||||
"redirect" => "Redirect",
|
||||
"email" => "Email/DNS",
|
||||
"auth" => "Auth",
|
||||
"xss" => "XSS",
|
||||
"sqli" => "SQLi",
|
||||
"ssrf" => "SSRF",
|
||||
"idor" => "IDOR",
|
||||
"fuzzer" => "Fuzzer",
|
||||
"cve" => "CVE",
|
||||
_ => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum number of display phases — deeper iterations are merged into the last.
|
||||
const MAX_PHASES: usize = 8;
|
||||
|
||||
/// Phase name heuristic based on phase index (not raw BFS depth)
|
||||
pub(crate) fn phase_name(phase_idx: usize) -> &'static str {
|
||||
match phase_idx {
|
||||
0 => "Reconnaissance",
|
||||
1 => "Analysis",
|
||||
2 => "Boundary Testing",
|
||||
3 => "Injection & Exploitation",
|
||||
4 => "Authentication Testing",
|
||||
5 => "Validation",
|
||||
6 => "Deep Scan",
|
||||
_ => "Final",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short label for phase rail
|
||||
pub(crate) fn phase_short_name(phase_idx: usize) -> &'static str {
|
||||
match phase_idx {
|
||||
0 => "Recon",
|
||||
1 => "Analysis",
|
||||
2 => "Boundary",
|
||||
3 => "Exploit",
|
||||
4 => "Auth",
|
||||
5 => "Validate",
|
||||
6 => "Deep",
|
||||
_ => "Final",
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute BFS phases from attack chain nodes
|
||||
pub(crate) fn compute_phases(steps: &[serde_json::Value]) -> Vec<Vec<usize>> {
|
||||
let node_ids: Vec<String> = steps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let id_to_idx: HashMap<String, usize> = node_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| (id.clone(), i))
|
||||
.collect();
|
||||
|
||||
// Compute depth via BFS
|
||||
let mut depths = vec![usize::MAX; steps.len()];
|
||||
let mut queue = VecDeque::new();
|
||||
|
||||
// Root nodes: those with no parents or parents not in the set
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let parents = step
|
||||
.get("parent_node_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|p| p.as_str())
|
||||
.filter(|p| id_to_idx.contains_key(*p))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if parents == 0 {
|
||||
depths[i] = 0;
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
// BFS to compute min depth
|
||||
while let Some(idx) = queue.pop_front() {
|
||||
let current_depth = depths[idx];
|
||||
let node_id = &node_ids[idx];
|
||||
// Find children: nodes that list this node as a parent
|
||||
for (j, step) in steps.iter().enumerate() {
|
||||
if depths[j] <= current_depth + 1 {
|
||||
continue;
|
||||
}
|
||||
let is_child = step
|
||||
.get("parent_node_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().any(|p| p.as_str() == Some(node_id.as_str())))
|
||||
.unwrap_or(false);
|
||||
if is_child {
|
||||
depths[j] = current_depth + 1;
|
||||
queue.push_back(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle unreachable nodes
|
||||
for d in depths.iter_mut() {
|
||||
if *d == usize::MAX {
|
||||
*d = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cap depths at MAX_PHASES - 1 so deeper iterations merge into the last phase
|
||||
for d in depths.iter_mut() {
|
||||
if *d >= MAX_PHASES {
|
||||
*d = MAX_PHASES - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Group by (capped) depth
|
||||
let max_depth = depths.iter().copied().max().unwrap_or(0);
|
||||
let mut phases: Vec<Vec<usize>> = Vec::new();
|
||||
for d in 0..=max_depth {
|
||||
let indices: Vec<usize> = depths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &dep)| dep == d)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if !indices.is_empty() {
|
||||
phases.push(indices);
|
||||
}
|
||||
}
|
||||
phases
|
||||
}
|
||||
|
||||
/// Format BSON datetime to readable string
|
||||
pub(crate) fn format_bson_time(val: &serde_json::Value) -> String {
|
||||
// Handle BSON {"$date":{"$numberLong":"..."}}
|
||||
if let Some(date_obj) = val.get("$date") {
|
||||
if let Some(ms_str) = date_obj.get("$numberLong").and_then(|v| v.as_str()) {
|
||||
if let Ok(ms) = ms_str.parse::<i64>() {
|
||||
let secs = ms / 1000;
|
||||
let h = (secs / 3600) % 24;
|
||||
let m = (secs / 60) % 60;
|
||||
let s = secs % 60;
|
||||
return format!("{h:02}:{m:02}:{s:02}");
|
||||
}
|
||||
}
|
||||
// Handle {"$date": "2025-..."}
|
||||
if let Some(s) = date_obj.as_str() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
// Handle plain string
|
||||
if let Some(s) = val.as_str() {
|
||||
return s.to_string();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Compute duration string from started_at and completed_at
|
||||
pub(crate) fn compute_duration(step: &serde_json::Value) -> String {
|
||||
let extract_ms = |val: &serde_json::Value| -> Option<i64> {
|
||||
val.get("$date")?
|
||||
.get("$numberLong")?
|
||||
.as_str()?
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
};
|
||||
|
||||
let started = step.get("started_at").and_then(extract_ms);
|
||||
let completed = step.get("completed_at").and_then(extract_ms);
|
||||
|
||||
match (started, completed) {
|
||||
(Some(s), Some(c)) => {
|
||||
let diff_ms = c - s;
|
||||
if diff_ms < 1000 {
|
||||
format!("{}ms", diff_ms)
|
||||
} else {
|
||||
format!("{:.1}s", diff_ms as f64 / 1000.0)
|
||||
}
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
4
compliance-dashboard/src/components/attack_chain/mod.rs
Normal file
4
compliance-dashboard/src/components/attack_chain/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod helpers;
|
||||
mod view;
|
||||
|
||||
pub use view::AttackChainView;
|
||||
382
compliance-dashboard/src/components/attack_chain/view.rs
Normal file
382
compliance-dashboard/src/components/attack_chain/view.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use super::helpers::*;
|
||||
|
||||
/// (phase_index, steps, findings_count, has_failed, has_running, all_done)
|
||||
type PhaseData<'a> = (usize, Vec<&'a serde_json::Value>, usize, bool, bool, bool);
|
||||
|
||||
#[component]
|
||||
pub fn AttackChainView(
|
||||
steps: Vec<serde_json::Value>,
|
||||
is_running: bool,
|
||||
session_findings: usize,
|
||||
session_tool_invocations: usize,
|
||||
session_success_rate: f64,
|
||||
) -> Element {
|
||||
let phases = compute_phases(&steps);
|
||||
|
||||
// Compute KPIs — prefer session-level stats, fall back to node-level
|
||||
let total_tools = steps.len();
|
||||
let node_findings: usize = steps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.get("findings_produced")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum();
|
||||
// Use session-level findings count if nodes don't have findings linked
|
||||
let total_findings = if node_findings > 0 {
|
||||
node_findings
|
||||
} else {
|
||||
session_findings
|
||||
};
|
||||
|
||||
let completed_count = steps
|
||||
.iter()
|
||||
.filter(|s| s.get("status").and_then(|v| v.as_str()) == Some("completed"))
|
||||
.count();
|
||||
let failed_count = steps
|
||||
.iter()
|
||||
.filter(|s| s.get("status").and_then(|v| v.as_str()) == Some("failed"))
|
||||
.count();
|
||||
let finished = completed_count + failed_count;
|
||||
let success_pct = if finished == 0 {
|
||||
100
|
||||
} else {
|
||||
(completed_count * 100) / finished
|
||||
};
|
||||
let max_risk: u8 = steps
|
||||
.iter()
|
||||
.filter_map(|s| s.get("risk_score").and_then(|v| v.as_u64()))
|
||||
.map(|v| v as u8)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let progress_pct = if total_tools == 0 {
|
||||
0
|
||||
} else {
|
||||
((completed_count + failed_count) * 100) / total_tools
|
||||
};
|
||||
|
||||
// Build phase data for rail and accordion
|
||||
let phase_data: Vec<PhaseData<'_>> = phases
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(pi, indices)| {
|
||||
let phase_steps: Vec<&serde_json::Value> = indices.iter().map(|&i| &steps[i]).collect();
|
||||
let phase_findings: usize = phase_steps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.get("findings_produced")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum();
|
||||
let has_failed = phase_steps
|
||||
.iter()
|
||||
.any(|s| s.get("status").and_then(|v| v.as_str()) == Some("failed"));
|
||||
let has_running = phase_steps
|
||||
.iter()
|
||||
.any(|s| s.get("status").and_then(|v| v.as_str()) == Some("running"));
|
||||
let all_done = phase_steps.iter().all(|s| {
|
||||
let st = s.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
st == "completed" || st == "failed" || st == "skipped"
|
||||
});
|
||||
(
|
||||
pi,
|
||||
phase_steps,
|
||||
phase_findings,
|
||||
has_failed,
|
||||
has_running,
|
||||
all_done,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut active_rail = use_signal(|| 0usize);
|
||||
|
||||
rsx! {
|
||||
// KPI bar
|
||||
div { class: "ac-kpi-bar",
|
||||
div { class: "ac-kpi-card",
|
||||
div { class: "ac-kpi-value", style: "color: var(--text-primary);", "{total_tools}" }
|
||||
div { class: "ac-kpi-label", "Tools Run" }
|
||||
}
|
||||
div { class: "ac-kpi-card",
|
||||
div { class: "ac-kpi-value", style: "color: var(--danger, #dc2626);", "{total_findings}" }
|
||||
div { class: "ac-kpi-label", "Findings" }
|
||||
}
|
||||
div { class: "ac-kpi-card",
|
||||
div { class: "ac-kpi-value", style: "color: var(--success, #16a34a);", "{success_pct}%" }
|
||||
div { class: "ac-kpi-label", "Success Rate" }
|
||||
}
|
||||
div { class: "ac-kpi-card",
|
||||
div { class: "ac-kpi-value", style: "color: var(--warning, #d97706);", "{max_risk}" }
|
||||
div { class: "ac-kpi-label", "Max Risk" }
|
||||
}
|
||||
}
|
||||
|
||||
// Phase rail
|
||||
div { class: "ac-phase-rail",
|
||||
for (pi, (_phase_idx, phase_steps, phase_findings, has_failed, has_running, all_done)) in phase_data.iter().enumerate() {
|
||||
{
|
||||
if pi > 0 {
|
||||
let prev_done = phase_data.get(pi - 1).map(|p| p.5).unwrap_or(false);
|
||||
let bar_class = if prev_done && *all_done {
|
||||
"done"
|
||||
} else if prev_done {
|
||||
"running"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
rsx! {
|
||||
div { class: "ac-rail-bar",
|
||||
div { class: "ac-rail-bar-inner {bar_class}" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! {}
|
||||
}
|
||||
}
|
||||
{
|
||||
let dot_class = if *has_running {
|
||||
"running"
|
||||
} else if *has_failed && *all_done {
|
||||
"mixed"
|
||||
} else if *all_done {
|
||||
"done"
|
||||
} else {
|
||||
"pending"
|
||||
};
|
||||
let is_active = *active_rail.read() == pi;
|
||||
let active_cls = if is_active { " active" } else { "" };
|
||||
let findings_cls = if *phase_findings > 0 { "has" } else { "none" };
|
||||
let findings_text = if *phase_findings > 0 {
|
||||
format!("{phase_findings}")
|
||||
} else {
|
||||
"\u{2014}".to_string()
|
||||
};
|
||||
let short = phase_short_name(pi);
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "ac-rail-node{active_cls}",
|
||||
onclick: move |_| {
|
||||
active_rail.set(pi);
|
||||
let js = format!(
|
||||
"document.getElementById('ac-phase-{pi}')?.scrollIntoView({{behavior:'smooth',block:'nearest'}});document.getElementById('ac-phase-{pi}')?.classList.add('open');"
|
||||
);
|
||||
document::eval(&js);
|
||||
},
|
||||
div { class: "ac-rail-dot {dot_class}" }
|
||||
div { class: "ac-rail-label", "{short}" }
|
||||
div { class: "ac-rail-findings {findings_cls}", "{findings_text}" }
|
||||
div { class: "ac-rail-heatmap",
|
||||
for step in phase_steps.iter() {
|
||||
{
|
||||
let st = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending");
|
||||
let hm_cls = match st {
|
||||
"completed" => "ok",
|
||||
"failed" => "fail",
|
||||
"running" => "run",
|
||||
_ => "wait",
|
||||
};
|
||||
rsx! { div { class: "ac-hm-cell {hm_cls}" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
div { class: "ac-progress-track",
|
||||
div { class: "ac-progress-fill", style: "width: {progress_pct}%;" }
|
||||
}
|
||||
|
||||
// Expand all
|
||||
div { class: "ac-controls",
|
||||
button {
|
||||
class: "ac-btn-toggle",
|
||||
onclick: move |_| {
|
||||
document::eval(
|
||||
"document.querySelectorAll('.ac-phase').forEach(p => p.classList.toggle('open', !document.querySelector('.ac-phase.open') || !document.querySelectorAll('.ac-phase:not(.open)').length === 0));(function(){var ps=document.querySelectorAll('.ac-phase');var allOpen=Array.from(ps).every(p=>p.classList.contains('open'));ps.forEach(p=>{if(allOpen)p.classList.remove('open');else p.classList.add('open');});})();"
|
||||
);
|
||||
},
|
||||
"Expand all"
|
||||
}
|
||||
}
|
||||
|
||||
// Phase accordion
|
||||
div { class: "ac-phases",
|
||||
for (pi, (_, phase_steps, phase_findings, _has_failed, has_running, _all_done)) in phase_data.iter().enumerate() {
|
||||
{
|
||||
let open_cls = if pi == 0 { " open" } else { "" };
|
||||
let phase_label = phase_name(pi);
|
||||
let tool_count = phase_steps.len();
|
||||
let meta_text = if *has_running {
|
||||
"in progress".to_string()
|
||||
} else {
|
||||
format!("{phase_findings} findings")
|
||||
};
|
||||
let meta_cls = if *has_running { "running-ct" } else { "findings-ct" };
|
||||
let phase_num_label = format!("PHASE {}", pi + 1);
|
||||
let phase_el_id = format!("ac-phase-{pi}");
|
||||
let phase_el_id2 = phase_el_id.clone();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "ac-phase{open_cls}",
|
||||
id: "{phase_el_id}",
|
||||
div {
|
||||
class: "ac-phase-header",
|
||||
onclick: move |_| {
|
||||
let js = format!("document.getElementById('{phase_el_id2}').classList.toggle('open');");
|
||||
document::eval(&js);
|
||||
},
|
||||
span { class: "ac-phase-num", "{phase_num_label}" }
|
||||
span { class: "ac-phase-title", "{phase_label}" }
|
||||
div { class: "ac-phase-dots",
|
||||
for step in phase_steps.iter() {
|
||||
{
|
||||
let st = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending");
|
||||
rsx! { div { class: "ac-phase-dot {st}" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "ac-phase-meta",
|
||||
span { "{tool_count} tools" }
|
||||
span { class: "{meta_cls}", "{meta_text}" }
|
||||
}
|
||||
span { class: "ac-phase-chevron", "\u{25B8}" }
|
||||
}
|
||||
div { class: "ac-phase-body",
|
||||
div { class: "ac-phase-body-inner",
|
||||
for step in phase_steps.iter() {
|
||||
{
|
||||
let tool_name_val = step.get("tool_name").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string();
|
||||
let status = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending").to_string();
|
||||
let cat = tool_category(&tool_name_val);
|
||||
let emoji = tool_emoji(cat);
|
||||
let label = cat_label(cat);
|
||||
let findings_n = step.get("findings_produced").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
|
||||
let risk = step.get("risk_score").and_then(|v| v.as_u64()).map(|v| v as u8);
|
||||
let reasoning = step.get("llm_reasoning").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let duration = compute_duration(step);
|
||||
let started = step.get("started_at").map(format_bson_time).unwrap_or_default();
|
||||
|
||||
let tool_input_json = step.get("tool_input")
|
||||
.map(|v| serde_json::to_string_pretty(v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
let tool_output_json = step.get("tool_output")
|
||||
.map(|v| serde_json::to_string_pretty(v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_pending = status == "pending";
|
||||
let is_node_running = status == "running";
|
||||
let pending_cls = if is_pending { " is-pending" } else { "" };
|
||||
let running_cls = if is_node_running { " ac-node-running" } else { "" };
|
||||
|
||||
let duration_cls = if status == "running" { "ac-tool-duration running-text" } else { "ac-tool-duration" };
|
||||
let duration_text = if status == "running" {
|
||||
"running\u{2026}".to_string()
|
||||
} else if duration.is_empty() {
|
||||
"\u{2014}".to_string()
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
|
||||
let pill_cls = if findings_n > 0 { "ac-findings-pill has" } else { "ac-findings-pill zero" };
|
||||
let pill_text = if findings_n > 0 { format!("{findings_n}") } else { "\u{2014}".to_string() };
|
||||
|
||||
let (risk_cls, risk_text) = match risk {
|
||||
Some(r) if r >= 75 => ("ac-risk-val high", format!("{r}")),
|
||||
Some(r) if r >= 40 => ("ac-risk-val medium", format!("{r}")),
|
||||
Some(r) => ("ac-risk-val low", format!("{r}")),
|
||||
None => ("ac-risk-val none", "\u{2014}".to_string()),
|
||||
};
|
||||
|
||||
let node_id = step.get("node_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let detail_id = format!("ac-detail-{node_id}");
|
||||
let row_id = format!("ac-row-{node_id}");
|
||||
let detail_id_clone = detail_id.clone();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "ac-tool-row{pending_cls}{running_cls}",
|
||||
id: "{row_id}",
|
||||
onclick: move |_| {
|
||||
if is_pending { return; }
|
||||
let js = format!(
|
||||
"(function(){{var r=document.getElementById('{row_id}');var d=document.getElementById('{detail_id}');if(r.classList.contains('expanded')){{r.classList.remove('expanded');d.classList.remove('open');}}else{{r.classList.add('expanded');d.classList.add('open');}}}})()"
|
||||
);
|
||||
document::eval(&js);
|
||||
},
|
||||
div { class: "ac-status-bar {status}" }
|
||||
div { class: "ac-tool-icon", "{emoji}" }
|
||||
div { class: "ac-tool-info",
|
||||
div { class: "ac-tool-name", "{tool_name_val}" }
|
||||
span { class: "ac-cat-chip {cat}", "{label}" }
|
||||
}
|
||||
div { class: "{duration_cls}", "{duration_text}" }
|
||||
div { span { class: "{pill_cls}", "{pill_text}" } }
|
||||
div { class: "{risk_cls}", "{risk_text}" }
|
||||
}
|
||||
div {
|
||||
class: "ac-tool-detail",
|
||||
id: "{detail_id_clone}",
|
||||
div { class: "ac-tool-detail-inner",
|
||||
if !reasoning.is_empty() {
|
||||
div { class: "ac-reasoning-block", "{reasoning}" }
|
||||
}
|
||||
if !started.is_empty() {
|
||||
div { class: "ac-detail-grid",
|
||||
span { class: "ac-detail-label", "Started" }
|
||||
span { class: "ac-detail-value", "{started}" }
|
||||
if !duration_text.is_empty() && status != "running" && duration_text != "\u{2014}" {
|
||||
span { class: "ac-detail-label", "Duration" }
|
||||
span { class: "ac-detail-value", "{duration_text}" }
|
||||
}
|
||||
span { class: "ac-detail-label", "Status" }
|
||||
if status == "completed" {
|
||||
span { class: "ac-detail-value", style: "color: var(--success, #16a34a);", "Completed" }
|
||||
} else if status == "failed" {
|
||||
span { class: "ac-detail-value", style: "color: var(--danger, #dc2626);", "Failed" }
|
||||
} else if status == "running" {
|
||||
span { class: "ac-detail-value", style: "color: var(--warning, #d97706);", "Running" }
|
||||
} else {
|
||||
span { class: "ac-detail-value", "{status}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !tool_input_json.is_empty() && tool_input_json != "null" {
|
||||
div { class: "ac-data-section",
|
||||
div { class: "ac-data-label", "Input" }
|
||||
pre { class: "ac-data-block", "{tool_input_json}" }
|
||||
}
|
||||
}
|
||||
if !tool_output_json.is_empty() && tool_output_json != "null" {
|
||||
div { class: "ac-data-section",
|
||||
div { class: "ac-data-label", "Output" }
|
||||
pre { class: "ac-data-block", "{tool_output_json}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod app_shell;
|
||||
pub mod attack_chain;
|
||||
pub mod code_inspector;
|
||||
pub mod code_snippet;
|
||||
pub mod file_tree;
|
||||
pub mod page_header;
|
||||
pub mod pagination;
|
||||
pub mod pentest_wizard;
|
||||
pub mod severity_badge;
|
||||
pub mod sidebar;
|
||||
pub mod stat_card;
|
||||
|
||||
925
compliance-dashboard/src/components/pentest_wizard.rs
Normal file
925
compliance-dashboard/src/components/pentest_wizard.rs
Normal file
@@ -0,0 +1,925 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::*;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::infrastructure::dast::fetch_dast_targets;
|
||||
use crate::infrastructure::pentest::{create_pentest_session_wizard, lookup_repo_by_url};
|
||||
use crate::infrastructure::repositories::{fetch_repositories, fetch_ssh_public_key};
|
||||
|
||||
const DISCLAIMER_TEXT: &str = "I confirm that I have authorization to perform security testing \
|
||||
against the specified target. I understand that penetration testing may cause disruption to the \
|
||||
target application. I accept full responsibility for ensuring this test is conducted within \
|
||||
legal boundaries and with proper authorization from the system owner.";
|
||||
|
||||
/// Returns true if a git URL looks like an SSH URL (git@ or ssh://)
|
||||
fn is_ssh_url(url: &str) -> bool {
|
||||
let trimmed = url.trim();
|
||||
trimmed.starts_with("git@") || trimmed.starts_with("ssh://")
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn PentestWizard(show: Signal<bool>) -> Element {
|
||||
let mut step = use_signal(|| 1u8);
|
||||
let mut creating = use_signal(|| false);
|
||||
|
||||
// Step 1: Target & Scope
|
||||
let mut app_url = use_signal(String::new);
|
||||
let mut git_repo_url = use_signal(String::new);
|
||||
let mut branch = use_signal(String::new);
|
||||
let mut commit_hash = use_signal(String::new);
|
||||
let mut app_type = use_signal(|| "web_app".to_string());
|
||||
let mut rate_limit = use_signal(|| "10".to_string());
|
||||
|
||||
// Repo lookup state
|
||||
let mut repo_looked_up = use_signal(|| false);
|
||||
let mut repo_name = use_signal(String::new);
|
||||
|
||||
// Dropdown state: existing targets and repos
|
||||
let mut show_target_dropdown = use_signal(|| false);
|
||||
let mut show_repo_dropdown = use_signal(|| false);
|
||||
let existing_targets = use_resource(|| async { fetch_dast_targets().await.ok() });
|
||||
let existing_repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||
|
||||
// SSH key state for private repos
|
||||
let mut ssh_public_key = use_signal(String::new);
|
||||
let mut ssh_key_loaded = use_signal(|| false);
|
||||
|
||||
// Step 2: Authentication
|
||||
let mut requires_auth = use_signal(|| false);
|
||||
let mut auth_mode = use_signal(|| "manual".to_string()); // "manual" | "auto_register"
|
||||
let mut auth_username = use_signal(String::new);
|
||||
let mut auth_password = use_signal(String::new);
|
||||
let mut registration_url = use_signal(String::new);
|
||||
let mut verification_email = use_signal(String::new);
|
||||
let mut imap_host = use_signal(String::new);
|
||||
let mut imap_port = use_signal(|| "993".to_string());
|
||||
let mut imap_username = use_signal(String::new);
|
||||
let mut imap_password = use_signal(String::new);
|
||||
let mut show_imap_settings = use_signal(|| false);
|
||||
let mut cleanup_test_user = use_signal(|| false);
|
||||
let mut custom_headers = use_signal(Vec::<(String, String)>::new);
|
||||
|
||||
// Step 3: Strategy & Instructions
|
||||
let mut strategy = use_signal(|| "comprehensive".to_string());
|
||||
let mut allow_destructive = use_signal(|| false);
|
||||
let mut initial_instructions = use_signal(String::new);
|
||||
let mut scope_exclusions = use_signal(String::new);
|
||||
let mut environment = use_signal(|| "development".to_string());
|
||||
let mut max_duration = use_signal(|| "30".to_string());
|
||||
let mut tester_name = use_signal(String::new);
|
||||
let mut tester_email = use_signal(String::new);
|
||||
|
||||
// Step 4: Disclaimer
|
||||
let mut disclaimer_accepted = use_signal(|| false);
|
||||
|
||||
let close = move |_| {
|
||||
show.set(false);
|
||||
step.set(1);
|
||||
};
|
||||
|
||||
let on_skip_to_blackbox = move |_| {
|
||||
// Jump to step 4 with skip mode
|
||||
step.set(4);
|
||||
};
|
||||
|
||||
let can_skip = !app_url.read().is_empty();
|
||||
|
||||
let on_submit = move |_| {
|
||||
creating.set(true);
|
||||
let url = app_url.read().clone();
|
||||
let git = git_repo_url.read().clone();
|
||||
let br = branch.read().clone();
|
||||
let ch = commit_hash.read().clone();
|
||||
let at = app_type.read().clone();
|
||||
let rl = rate_limit.read().parse::<u32>().unwrap_or(10);
|
||||
let req_auth = *requires_auth.read();
|
||||
let am = auth_mode.read().clone();
|
||||
let au = auth_username.read().clone();
|
||||
let ap = auth_password.read().clone();
|
||||
let ru = registration_url.read().clone();
|
||||
let ve = verification_email.read().clone();
|
||||
let ih = imap_host.read().clone();
|
||||
let ip = imap_port.read().parse::<u16>().unwrap_or(993);
|
||||
let iu = imap_username.read().clone();
|
||||
let iw = imap_password.read().clone();
|
||||
let cu = *cleanup_test_user.read();
|
||||
let hdrs = custom_headers.read().clone();
|
||||
let strat = strategy.read().clone();
|
||||
let ad = *allow_destructive.read();
|
||||
let ii = initial_instructions.read().clone();
|
||||
let se = scope_exclusions.read().clone();
|
||||
let env = environment.read().clone();
|
||||
let md = max_duration.read().parse::<u32>().unwrap_or(30);
|
||||
let tn = tester_name.read().clone();
|
||||
let te = tester_email.read().clone();
|
||||
let skip = *step.read() == 4 && !req_auth; // simplified skip check
|
||||
|
||||
let mut show = show;
|
||||
spawn(async move {
|
||||
let headers_map: std::collections::HashMap<String, String> = hdrs
|
||||
.into_iter()
|
||||
.filter(|(k, v)| !k.is_empty() && !v.is_empty())
|
||||
.collect();
|
||||
let scope_excl: Vec<String> = se
|
||||
.lines()
|
||||
.map(|l| l.trim().to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
let config = serde_json::json!({
|
||||
"app_url": url,
|
||||
"git_repo_url": if git.is_empty() { None } else { Some(git) },
|
||||
"branch": if br.is_empty() { None } else { Some(br) },
|
||||
"commit_hash": if ch.is_empty() { None } else { Some(ch) },
|
||||
"app_type": if at.is_empty() { None } else { Some(at) },
|
||||
"rate_limit": rl,
|
||||
"auth": {
|
||||
"mode": if !req_auth { "none" } else { &am },
|
||||
"username": if au.is_empty() { None } else { Some(&au) },
|
||||
"password": if ap.is_empty() { None } else { Some(&ap) },
|
||||
"registration_url": if ru.is_empty() { None } else { Some(&ru) },
|
||||
"verification_email": if ve.is_empty() { None } else { Some(&ve) },
|
||||
"imap_host": if ih.is_empty() { None } else { Some(&ih) },
|
||||
"imap_port": ip,
|
||||
"imap_username": if iu.is_empty() { None } else { Some(&iu) },
|
||||
"imap_password": if iw.is_empty() { None } else { Some(&iw) },
|
||||
"cleanup_test_user": cu,
|
||||
},
|
||||
"custom_headers": headers_map,
|
||||
"strategy": strat,
|
||||
"allow_destructive": ad,
|
||||
"initial_instructions": if ii.is_empty() { None } else { Some(&ii) },
|
||||
"scope_exclusions": scope_excl,
|
||||
"disclaimer_accepted": true,
|
||||
"disclaimer_accepted_at": chrono::Utc::now().to_rfc3339(),
|
||||
"environment": env,
|
||||
"tester": { "name": tn, "email": te },
|
||||
"max_duration_minutes": md,
|
||||
"skip_mode": skip,
|
||||
});
|
||||
|
||||
match create_pentest_session_wizard(config.to_string()).await {
|
||||
Ok(resp) => {
|
||||
let session_id = resp
|
||||
.data
|
||||
.get("_id")
|
||||
.and_then(|v| v.get("$oid"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
creating.set(false);
|
||||
show.set(false);
|
||||
if !session_id.is_empty() {
|
||||
navigator().push(Route::PentestSessionPage {
|
||||
session_id: session_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
creating.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Build filtered target list for dropdown
|
||||
let target_options: Vec<(String, String)> = {
|
||||
let t = existing_targets.read();
|
||||
match &*t {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let url = t.get("base_url").and_then(|v| v.as_str())?.to_string();
|
||||
let name = t
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&url)
|
||||
.to_string();
|
||||
Some((url, name))
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// Build filtered repo list for dropdown
|
||||
let repo_options: Vec<(String, String)> = {
|
||||
let r = existing_repos.read();
|
||||
match &*r {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.iter()
|
||||
.map(|r| (r.git_url.clone(), r.name.clone()))
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
|
||||
// Filter targets based on current input
|
||||
let app_url_val = app_url.read().clone();
|
||||
let filtered_targets: Vec<(String, String)> = if app_url_val.is_empty() {
|
||||
target_options.clone()
|
||||
} else {
|
||||
let lower = app_url_val.to_lowercase();
|
||||
target_options
|
||||
.iter()
|
||||
.filter(|(url, name)| {
|
||||
url.to_lowercase().contains(&lower) || name.to_lowercase().contains(&lower)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Filter repos based on current input
|
||||
let git_url_val = git_repo_url.read().clone();
|
||||
let filtered_repos: Vec<(String, String)> = if git_url_val.is_empty() {
|
||||
repo_options.clone()
|
||||
} else {
|
||||
let lower = git_url_val.to_lowercase();
|
||||
repo_options
|
||||
.iter()
|
||||
.filter(|(url, name)| {
|
||||
url.to_lowercase().contains(&lower) || name.to_lowercase().contains(&lower)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let current_step = *step.read();
|
||||
let show_ssh_section = is_ssh_url(&git_repo_url.read());
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "wizard-backdrop",
|
||||
onclick: close,
|
||||
div {
|
||||
class: "wizard-dialog",
|
||||
onclick: move |e| e.stop_propagation(),
|
||||
|
||||
// Close button (always visible)
|
||||
button {
|
||||
class: "wizard-close-btn",
|
||||
onclick: close,
|
||||
Icon { icon: BsXLg, width: 16, height: 16 }
|
||||
}
|
||||
|
||||
// Step indicator
|
||||
div { class: "wizard-steps",
|
||||
for (i, label) in [(1, "Target"), (2, "Auth"), (3, "Strategy"), (4, "Confirm")].iter() {
|
||||
{
|
||||
let step_class = if current_step == *i {
|
||||
"wizard-step active"
|
||||
} else if current_step > *i {
|
||||
"wizard-step completed"
|
||||
} else {
|
||||
"wizard-step"
|
||||
};
|
||||
rsx! {
|
||||
div { class: "{step_class}",
|
||||
div { class: "wizard-step-dot", "{i}" }
|
||||
span { class: "wizard-step-label", "{label}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
div { class: "wizard-body",
|
||||
match current_step {
|
||||
1 => rsx! {
|
||||
h3 { style: "margin: 0 0 16px 0;", "Target & Scope" }
|
||||
|
||||
// App URL with dropdown
|
||||
div { class: "wizard-field", style: "position: relative;",
|
||||
label { "App URL " span { style: "color: #dc2626;", "*" } }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "url",
|
||||
placeholder: "https://example.com",
|
||||
value: "{app_url}",
|
||||
oninput: move |e| {
|
||||
app_url.set(e.value());
|
||||
show_target_dropdown.set(true);
|
||||
},
|
||||
onfocus: move |_| show_target_dropdown.set(true),
|
||||
}
|
||||
// Dropdown of existing targets
|
||||
if *show_target_dropdown.read() && !filtered_targets.is_empty() {
|
||||
div { class: "wizard-dropdown",
|
||||
for (url, name) in filtered_targets.iter() {
|
||||
{
|
||||
let url_clone = url.clone();
|
||||
let display_name = name.clone();
|
||||
let display_url = url.clone();
|
||||
rsx! {
|
||||
div {
|
||||
class: "wizard-dropdown-item",
|
||||
onclick: move |_| {
|
||||
app_url.set(url_clone.clone());
|
||||
show_target_dropdown.set(false);
|
||||
},
|
||||
div { style: "font-weight: 500;", "{display_name}" }
|
||||
div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace;", "{display_url}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Git Repo URL with dropdown
|
||||
div { class: "wizard-field", style: "position: relative;",
|
||||
label { "Git Repository URL" }
|
||||
div { style: "display: flex; gap: 8px;",
|
||||
div { style: "flex: 1; position: relative;",
|
||||
input {
|
||||
class: "chat-input",
|
||||
style: "width: 100%;",
|
||||
placeholder: "https://github.com/org/repo.git",
|
||||
value: "{git_repo_url}",
|
||||
oninput: move |e| {
|
||||
git_repo_url.set(e.value());
|
||||
repo_looked_up.set(false);
|
||||
show_repo_dropdown.set(true);
|
||||
// Fetch SSH key if it looks like an SSH URL
|
||||
if is_ssh_url(&e.value()) && !*ssh_key_loaded.read() {
|
||||
spawn(async move {
|
||||
match fetch_ssh_public_key().await {
|
||||
Ok(key) => {
|
||||
ssh_public_key.set(key);
|
||||
ssh_key_loaded.set(true);
|
||||
}
|
||||
Err(_) => {
|
||||
ssh_public_key.set("(not available)".to_string());
|
||||
ssh_key_loaded.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onfocus: move |_| show_repo_dropdown.set(true),
|
||||
}
|
||||
// Dropdown of existing repos
|
||||
if *show_repo_dropdown.read() && !filtered_repos.is_empty() {
|
||||
div { class: "wizard-dropdown",
|
||||
for (url, name) in filtered_repos.iter() {
|
||||
{
|
||||
let url_clone = url.clone();
|
||||
let display_name = name.clone();
|
||||
let display_url = url.clone();
|
||||
let is_ssh = is_ssh_url(&url_clone);
|
||||
rsx! {
|
||||
div {
|
||||
class: "wizard-dropdown-item",
|
||||
onclick: move |_| {
|
||||
git_repo_url.set(url_clone.clone());
|
||||
show_repo_dropdown.set(false);
|
||||
repo_looked_up.set(false);
|
||||
// Auto-fetch SSH key if SSH URL selected
|
||||
if is_ssh && !*ssh_key_loaded.read() {
|
||||
spawn(async move {
|
||||
match fetch_ssh_public_key().await {
|
||||
Ok(key) => {
|
||||
ssh_public_key.set(key);
|
||||
ssh_key_loaded.set(true);
|
||||
}
|
||||
Err(_) => {
|
||||
ssh_public_key.set("(not available)".to_string());
|
||||
ssh_key_loaded.set(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
div { style: "font-weight: 500;", "{display_name}" }
|
||||
div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace;", "{display_url}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
disabled: git_repo_url.read().is_empty(),
|
||||
onclick: move |_| {
|
||||
let url = git_repo_url.read().clone();
|
||||
spawn(async move {
|
||||
if let Ok(resp) = lookup_repo_by_url(url).await {
|
||||
if let Some(name) = resp.get("name").and_then(|v| v.as_str()) {
|
||||
repo_name.set(name.to_string());
|
||||
if let Some(b) = resp.get("default_branch").and_then(|v| v.as_str()) {
|
||||
branch.set(b.to_string());
|
||||
}
|
||||
if let Some(c) = resp.get("last_scanned_commit").and_then(|v| v.as_str()) {
|
||||
commit_hash.set(c.to_string());
|
||||
}
|
||||
}
|
||||
repo_looked_up.set(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
"Lookup"
|
||||
}
|
||||
}
|
||||
if *repo_looked_up.read() && !repo_name.read().is_empty() {
|
||||
div { style: "font-size: 0.8rem; color: var(--accent); margin-top: 4px;",
|
||||
Icon { icon: BsCheckCircle, width: 12, height: 12 }
|
||||
" Found: {repo_name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSH deploy key section (shown for SSH URLs)
|
||||
if show_ssh_section {
|
||||
div { class: "wizard-ssh-key",
|
||||
div { style: "display: flex; align-items: center; gap: 6px; margin-bottom: 6px;",
|
||||
Icon { icon: BsKeyFill, width: 14, height: 14 }
|
||||
span { style: "font-size: 0.8rem; font-weight: 600;", "SSH Deploy Key" }
|
||||
}
|
||||
p { style: "font-size: 0.75rem; color: var(--text-secondary); margin: 0 0 6px 0;",
|
||||
"Add this read-only deploy key to your repository settings:"
|
||||
}
|
||||
div { class: "wizard-ssh-key-box",
|
||||
if ssh_public_key.read().is_empty() {
|
||||
"Loading..."
|
||||
} else {
|
||||
"{ssh_public_key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "Branch" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "main",
|
||||
value: "{branch}",
|
||||
oninput: move |e| branch.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Commit" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "HEAD",
|
||||
value: "{commit_hash}",
|
||||
oninput: move |e| commit_hash.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "App Type" }
|
||||
select {
|
||||
class: "chat-input",
|
||||
value: "{app_type}",
|
||||
onchange: move |e| app_type.set(e.value()),
|
||||
option { value: "web_app", "Web Application" }
|
||||
option { value: "api", "API" }
|
||||
option { value: "spa", "Single-Page App" }
|
||||
option { value: "mobile_backend", "Mobile Backend" }
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Rate Limit (req/s)" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "number",
|
||||
value: "{rate_limit}",
|
||||
oninput: move |e| rate_limit.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
2 => rsx! {
|
||||
h3 { style: "margin: 0 0 16px 0;", "Authentication" }
|
||||
|
||||
div { class: "wizard-field",
|
||||
label { style: "display: flex; align-items: center; gap: 8px;",
|
||||
"Requires authentication?"
|
||||
div {
|
||||
class: if *requires_auth.read() { "wizard-toggle active" } else { "wizard-toggle" },
|
||||
onclick: move |_| { let v = *requires_auth.read(); requires_auth.set(!v); },
|
||||
div { class: "wizard-toggle-knob" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *requires_auth.read() {
|
||||
div { class: "wizard-field",
|
||||
div { style: "display: flex; gap: 12px; margin-bottom: 12px;",
|
||||
label { style: "display: flex; align-items: center; gap: 4px; cursor: pointer;",
|
||||
input {
|
||||
r#type: "radio",
|
||||
name: "auth_mode",
|
||||
value: "manual",
|
||||
checked: auth_mode.read().as_str() == "manual",
|
||||
onchange: move |_| auth_mode.set("manual".to_string()),
|
||||
}
|
||||
"Manual Credentials"
|
||||
}
|
||||
label { style: "display: flex; align-items: center; gap: 4px; cursor: pointer;",
|
||||
input {
|
||||
r#type: "radio",
|
||||
name: "auth_mode",
|
||||
value: "auto_register",
|
||||
checked: auth_mode.read().as_str() == "auto_register",
|
||||
onchange: move |_| auth_mode.set("auto_register".to_string()),
|
||||
}
|
||||
"Auto-Register"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if auth_mode.read().as_str() == "manual" {
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "Username" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
value: "{auth_username}",
|
||||
oninput: move |e| auth_username.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Password" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "password",
|
||||
value: "{auth_password}",
|
||||
oninput: move |e| auth_password.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if auth_mode.read().as_str() == "auto_register" {
|
||||
div { class: "wizard-field",
|
||||
label { "Registration URL"
|
||||
span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(optional)" }
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "https://example.com/register",
|
||||
value: "{registration_url}",
|
||||
oninput: move |e| registration_url.set(e.value()),
|
||||
}
|
||||
div { style: "font-size: 0.75rem; color: var(--text-tertiary); margin-top: 3px;",
|
||||
"If omitted, the orchestrator will use Playwright to discover the registration page automatically."
|
||||
}
|
||||
}
|
||||
|
||||
// Verification email (plus-addressing) — optional override
|
||||
div { class: "wizard-field",
|
||||
label { "Verification Email"
|
||||
span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(optional override)" }
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "pentest@scanner.example.com",
|
||||
value: "{verification_email}",
|
||||
oninput: move |e| verification_email.set(e.value()),
|
||||
}
|
||||
div { style: "font-size: 0.75rem; color: var(--text-tertiary); margin-top: 3px;",
|
||||
"Overrides the agent's default mailbox. Uses plus-addressing: "
|
||||
code { style: "font-size: 0.7rem;", "base+sessionid@domain" }
|
||||
". Leave blank to use the server default."
|
||||
}
|
||||
}
|
||||
|
||||
// IMAP settings (collapsible)
|
||||
div { class: "wizard-field",
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
style: "font-size: 0.8rem; padding: 2px 8px;",
|
||||
onclick: move |_| { let v = *show_imap_settings.read(); show_imap_settings.set(!v); },
|
||||
if *show_imap_settings.read() {
|
||||
Icon { icon: BsChevronDown, width: 10, height: 10 }
|
||||
} else {
|
||||
Icon { icon: BsChevronRight, width: 10, height: 10 }
|
||||
}
|
||||
" IMAP Settings"
|
||||
}
|
||||
}
|
||||
if *show_imap_settings.read() {
|
||||
div { style: "display: grid; grid-template-columns: 2fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "IMAP Host" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "imap.example.com",
|
||||
value: "{imap_host}",
|
||||
oninput: move |e| imap_host.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Port" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "number",
|
||||
value: "{imap_port}",
|
||||
oninput: move |e| imap_port.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "IMAP Username"
|
||||
span { style: "font-weight: 400; color: var(--text-tertiary); font-size: 0.75rem; margin-left: 6px;", "(defaults to email)" }
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
placeholder: "pentest@scanner.example.com",
|
||||
value: "{imap_username}",
|
||||
oninput: move |e| imap_username.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "IMAP Password" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "password",
|
||||
placeholder: "App password",
|
||||
value: "{imap_password}",
|
||||
oninput: move |e| imap_password.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup option
|
||||
div { style: "margin-top: 8px;",
|
||||
label { style: "display: flex; align-items: center; gap: 6px; font-size: 0.85rem; cursor: pointer;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: *cleanup_test_user.read(),
|
||||
onchange: move |_| { let v = *cleanup_test_user.read(); cleanup_test_user.set(!v); },
|
||||
}
|
||||
"Cleanup test user after"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
div { class: "wizard-field", style: "margin-top: 16px;",
|
||||
label { "Custom HTTP Headers" }
|
||||
for (idx, _) in custom_headers.read().iter().enumerate() {
|
||||
{
|
||||
let key = custom_headers.read().get(idx).map(|(k, _)| k.clone()).unwrap_or_default();
|
||||
let val = custom_headers.read().get(idx).map(|(_, v)| v.clone()).unwrap_or_default();
|
||||
rsx! {
|
||||
div { style: "display: flex; gap: 8px; margin-bottom: 4px;",
|
||||
input {
|
||||
class: "chat-input",
|
||||
style: "flex: 1;",
|
||||
placeholder: "Header name",
|
||||
value: "{key}",
|
||||
oninput: move |e| {
|
||||
let mut h = custom_headers.write();
|
||||
if let Some(pair) = h.get_mut(idx) {
|
||||
pair.0 = e.value();
|
||||
}
|
||||
},
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
style: "flex: 1;",
|
||||
placeholder: "Value",
|
||||
value: "{val}",
|
||||
oninput: move |e| {
|
||||
let mut h = custom_headers.write();
|
||||
if let Some(pair) = h.get_mut(idx) {
|
||||
pair.1 = e.value();
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
style: "color: #dc2626;",
|
||||
onclick: move |_| {
|
||||
custom_headers.write().remove(idx);
|
||||
},
|
||||
Icon { icon: BsXCircle, width: 14, height: 14 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
onclick: move |_| {
|
||||
custom_headers.write().push((String::new(), String::new()));
|
||||
},
|
||||
Icon { icon: BsPlusCircle, width: 12, height: 12 }
|
||||
" Add Header"
|
||||
}
|
||||
}
|
||||
},
|
||||
3 => rsx! {
|
||||
h3 { style: "margin: 0 0 16px 0;", "Strategy & Instructions" }
|
||||
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "Strategy" }
|
||||
select {
|
||||
class: "chat-input",
|
||||
value: "{strategy}",
|
||||
onchange: move |e| strategy.set(e.value()),
|
||||
option { value: "comprehensive", "Comprehensive" }
|
||||
option { value: "quick", "Quick Scan" }
|
||||
option { value: "targeted", "Targeted (SAST-guided)" }
|
||||
option { value: "aggressive", "Aggressive" }
|
||||
option { value: "stealth", "Stealth" }
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Environment" }
|
||||
select {
|
||||
class: "chat-input",
|
||||
value: "{environment}",
|
||||
onchange: move |e| environment.set(e.value()),
|
||||
option { value: "development", "Development" }
|
||||
option { value: "staging", "Staging" }
|
||||
option { value: "production", "Production" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "wizard-field",
|
||||
label { style: "display: flex; align-items: center; gap: 8px;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: *allow_destructive.read(),
|
||||
onchange: move |_| { let v = *allow_destructive.read(); allow_destructive.set(!v); },
|
||||
}
|
||||
"Allow destructive tests (DELETE, PUT, data modification)"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "wizard-field",
|
||||
label { "Initial Instructions" }
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; min-height: 80px;",
|
||||
placeholder: "Describe focus areas, known issues, or specific test scenarios...",
|
||||
value: "{initial_instructions}",
|
||||
oninput: move |e| initial_instructions.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "wizard-field",
|
||||
label { "Scope Exclusions (one path per line)" }
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; min-height: 60px;",
|
||||
placeholder: "/admin\n/health\n/api/v1/internal",
|
||||
value: "{scope_exclusions}",
|
||||
oninput: move |e| scope_exclusions.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;",
|
||||
div { class: "wizard-field",
|
||||
label { "Max Duration (min)" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "number",
|
||||
value: "{max_duration}",
|
||||
oninput: move |e| max_duration.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Tester Name" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
value: "{tester_name}",
|
||||
oninput: move |e| tester_name.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "wizard-field",
|
||||
label { "Tester Email" }
|
||||
input {
|
||||
class: "chat-input",
|
||||
r#type: "email",
|
||||
value: "{tester_email}",
|
||||
oninput: move |e| tester_email.set(e.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
4 => rsx! {
|
||||
h3 { style: "margin: 0 0 16px 0;", "Review & Confirm" }
|
||||
|
||||
// Summary
|
||||
div { class: "wizard-summary",
|
||||
dl {
|
||||
dt { "Target URL" }
|
||||
dd { code { "{app_url}" } }
|
||||
|
||||
if !git_repo_url.read().is_empty() {
|
||||
dt { "Git Repository" }
|
||||
dd { "{git_repo_url}" }
|
||||
}
|
||||
|
||||
dt { "Strategy" }
|
||||
dd { "{strategy}" }
|
||||
|
||||
dt { "Environment" }
|
||||
dd { "{environment}" }
|
||||
|
||||
dt { "Auth Mode" }
|
||||
dd { if *requires_auth.read() { "{auth_mode}" } else { "None" } }
|
||||
|
||||
dt { "Max Duration" }
|
||||
dd { "{max_duration} minutes" }
|
||||
|
||||
if *allow_destructive.read() {
|
||||
dt { "Destructive Tests" }
|
||||
dd { "Allowed" }
|
||||
}
|
||||
|
||||
if !tester_name.read().is_empty() {
|
||||
dt { "Tester" }
|
||||
dd { "{tester_name} ({tester_email})" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disclaimer
|
||||
div { class: "wizard-disclaimer",
|
||||
Icon { icon: BsExclamationTriangle, width: 16, height: 16 }
|
||||
p { style: "margin: 8px 0;", "{DISCLAIMER_TEXT}" }
|
||||
label { style: "display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600;",
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: *disclaimer_accepted.read(),
|
||||
onchange: move |_| { let v = *disclaimer_accepted.read(); disclaimer_accepted.set(!v); },
|
||||
}
|
||||
"I accept this disclaimer"
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => rsx! {},
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
div { class: "wizard-footer",
|
||||
// Left side: skip button
|
||||
div {
|
||||
if current_step == 1 && can_skip {
|
||||
button {
|
||||
class: "btn btn-ghost btn-sm",
|
||||
onclick: on_skip_to_blackbox,
|
||||
Icon { icon: BsLightning, width: 12, height: 12 }
|
||||
" Skip to Black Box"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Right side: navigation
|
||||
div { style: "display: flex; gap: 8px;",
|
||||
if current_step == 1 {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: close,
|
||||
"Cancel"
|
||||
}
|
||||
} else {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| step.set(current_step - 1),
|
||||
"Back"
|
||||
}
|
||||
}
|
||||
if current_step < 4 {
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: current_step == 1 && app_url.read().is_empty(),
|
||||
onclick: move |_| step.set(current_step + 1),
|
||||
"Next"
|
||||
}
|
||||
}
|
||||
if current_step == 4 {
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: !*disclaimer_accepted.read() || *creating.read(),
|
||||
onclick: on_submit,
|
||||
if *creating.read() { "Starting..." } else { "Start Pentest" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ pub fn Sidebar() -> Element {
|
||||
route: Route::DastOverviewPage {},
|
||||
icon: rsx! { Icon { icon: BsBug, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Pentest",
|
||||
route: Route::PentestDashboardPage {},
|
||||
icon: rsx! { Icon { icon: BsLightningCharge, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "Settings",
|
||||
route: Route::SettingsPage {},
|
||||
@@ -78,6 +83,7 @@ pub fn Sidebar() -> Element {
|
||||
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
|
||||
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
||||
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
||||
(Route::PentestSessionPage { .. }, Route::PentestDashboardPage {}) => true,
|
||||
(a, b) => a == b,
|
||||
};
|
||||
let class = if is_active { "nav-item active" } else { "nav-item" };
|
||||
@@ -103,6 +109,8 @@ pub fn Sidebar() -> Element {
|
||||
span { "Docs" }
|
||||
}
|
||||
}
|
||||
// Spacer pushes footer to the bottom
|
||||
div { class: "sidebar-spacer" }
|
||||
button {
|
||||
class: "sidebar-toggle",
|
||||
onclick: move |_| collapsed.set(!collapsed()),
|
||||
@@ -116,9 +124,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 +134,29 @@ pub fn Sidebar() -> Element {
|
||||
}
|
||||
}
|
||||
if !collapsed() {
|
||||
span { class: "user-name", "{info.name}" }
|
||||
div { class: "user-info",
|
||||
span { class: "user-name", "{info.name}" }
|
||||
a {
|
||||
href: "/logout",
|
||||
class: "logout-link",
|
||||
"Sign out"
|
||||
}
|
||||
}
|
||||
}
|
||||
a {
|
||||
href: "/logout",
|
||||
class: if collapsed() { "logout-btn logout-btn-collapsed" } else { "logout-btn" },
|
||||
title: "Sign out",
|
||||
Icon { icon: BsBoxArrowRight, width: 16, height: 16 }
|
||||
if collapsed() {
|
||||
a {
|
||||
href: "/logout",
|
||||
class: "logout-btn-icon",
|
||||
title: "Sign out",
|
||||
Icon { icon: BsBoxArrowRight, width: 14, height: 14 }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !collapsed() {
|
||||
div { class: "sidebar-legal",
|
||||
a { href: "/privacy", "Privacy" }
|
||||
span { class: "legal-dot", "·" }
|
||||
a { href: "/impressum", "Impressum" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod findings;
|
||||
pub mod graph;
|
||||
pub mod issues;
|
||||
pub mod mcp;
|
||||
pub mod pentest;
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub mod repositories;
|
||||
pub mod sbom;
|
||||
|
||||
414
compliance-dashboard/src/infrastructure/pentest.rs
Normal file
414
compliance-dashboard/src/infrastructure/pentest.rs
Normal file
@@ -0,0 +1,414 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::dast::DastFindingsResponse;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PentestSessionsResponse {
|
||||
pub data: Vec<serde_json::Value>,
|
||||
pub total: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PentestSessionResponse {
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PentestMessagesResponse {
|
||||
pub data: Vec<serde_json::Value>,
|
||||
pub total: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PentestStatsResponse {
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AttackChainResponse {
|
||||
pub data: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_pentest_sessions() -> Result<PentestSessionsResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
// Fetch sessions
|
||||
let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let mut body: PentestSessionsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
// Fetch DAST targets to resolve target names
|
||||
let targets_url = format!("{}/api/v1/dast/targets", state.agent_api_url);
|
||||
if let Ok(tresp) = reqwest::get(&targets_url).await {
|
||||
if let Ok(tbody) = tresp.json::<serde_json::Value>().await {
|
||||
let targets = tbody.get("data").and_then(|v| v.as_array());
|
||||
if let Some(targets) = targets {
|
||||
// Build target_id -> name lookup
|
||||
let target_map: std::collections::HashMap<String, String> = targets
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let id = t.get("_id")?.get("$oid")?.as_str()?.to_string();
|
||||
let name = t.get("name")?.as_str()?.to_string();
|
||||
Some((id, name))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Enrich sessions with target_name
|
||||
for session in body.data.iter_mut() {
|
||||
if let Some(tid) = session.get("target_id").and_then(|v| v.as_str()) {
|
||||
if let Some(name) = target_map.get(tid) {
|
||||
session.as_object_mut().map(|obj| {
|
||||
obj.insert(
|
||||
"target_name".to_string(),
|
||||
serde_json::Value::String(name.clone()),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_pentest_session(id: String) -> Result<PentestSessionResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/pentest/sessions/{id}", state.agent_api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let mut body: PentestSessionResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
// Resolve target name from targets list
|
||||
if let Some(tid) = body.data.get("target_id").and_then(|v| v.as_str()) {
|
||||
let targets_url = format!("{}/api/v1/dast/targets", state.agent_api_url);
|
||||
if let Ok(tresp) = reqwest::get(&targets_url).await {
|
||||
if let Ok(tbody) = tresp.json::<serde_json::Value>().await {
|
||||
if let Some(targets) = tbody.get("data").and_then(|v| v.as_array()) {
|
||||
for t in targets {
|
||||
let t_id = t
|
||||
.get("_id")
|
||||
.and_then(|v| v.get("$oid"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if t_id == tid {
|
||||
if let Some(name) = t.get("name").and_then(|v| v.as_str()) {
|
||||
body.data.as_object_mut().map(|obj| {
|
||||
obj.insert(
|
||||
"target_name".to_string(),
|
||||
serde_json::Value::String(name.to_string()),
|
||||
)
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_pentest_messages(
|
||||
session_id: String,
|
||||
) -> Result<PentestMessagesResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/messages",
|
||||
state.agent_api_url
|
||||
);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: PentestMessagesResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_pentest_stats() -> Result<PentestStatsResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/pentest/stats", state.agent_api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: PentestStatsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_attack_chain(session_id: String) -> Result<AttackChainResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/attack-chain",
|
||||
state.agent_api_url
|
||||
);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: AttackChainResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn create_pentest_session(
|
||||
target_id: String,
|
||||
strategy: String,
|
||||
message: String,
|
||||
) -> Result<PentestSessionResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"target_id": target_id,
|
||||
"strategy": strategy,
|
||||
"message": message,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: PentestSessionResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Create a pentest session using the wizard configuration
|
||||
#[server]
|
||||
pub async fn create_pentest_session_wizard(
|
||||
config_json: String,
|
||||
) -> Result<PentestSessionResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/pentest/sessions", state.agent_api_url);
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(&config_json).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "config": config }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!(
|
||||
"Failed to create session: {text}"
|
||||
)));
|
||||
}
|
||||
let body: PentestSessionResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Look up a tracked repository by its git URL
|
||||
#[server]
|
||||
pub async fn lookup_repo_by_url(url: String) -> Result<serde_json::Value, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let encoded_url: String = url
|
||||
.bytes()
|
||||
.flat_map(|b| {
|
||||
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' {
|
||||
vec![b as char]
|
||||
} else {
|
||||
format!("%{:02X}", b).chars().collect()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let api_url = format!(
|
||||
"{}/api/v1/pentest/lookup-repo?url={}",
|
||||
state.agent_api_url, encoded_url
|
||||
);
|
||||
let resp = reqwest::get(&api_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()))?;
|
||||
Ok(body.get("data").cloned().unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn send_pentest_message(
|
||||
session_id: String,
|
||||
message: String,
|
||||
) -> Result<PentestMessagesResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/chat",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"message": message,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: PentestMessagesResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn stop_pentest_session(session_id: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/stop",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn pause_pentest_session(session_id: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/pause",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("Pause failed: {text}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn resume_pentest_session(session_id: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/resume",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("Resume failed: {text}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_pentest_findings(
|
||||
session_id: String,
|
||||
) -> Result<DastFindingsResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/findings",
|
||||
state.agent_api_url
|
||||
);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: DastFindingsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ExportReportResponse {
|
||||
pub archive_base64: String,
|
||||
pub sha256: String,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn export_pentest_report(
|
||||
session_id: String,
|
||||
password: String,
|
||||
requester_name: String,
|
||||
requester_email: String,
|
||||
) -> Result<ExportReportResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
"{}/api/v1/pentest/sessions/{session_id}/export",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"password": password,
|
||||
"requester_name": requester_name,
|
||||
"requester_email": requester_email,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(ServerFnError::new(format!("Export failed: {text}")));
|
||||
}
|
||||
let body: ExportReportResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
@@ -150,16 +150,23 @@ async fn seed_default_mcp_servers(db: &Database, mcp_endpoint_url: Option<&str>)
|
||||
|
||||
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();
|
||||
let expected_url = format!("{endpoint}/mcp");
|
||||
|
||||
if exists {
|
||||
for (name, description, tools) in defaults {
|
||||
// If it already exists, update the endpoint URL if it changed
|
||||
if let Ok(Some(existing)) = collection.find_one(doc! { "name": name }).await {
|
||||
if existing.endpoint_url != expected_url {
|
||||
let _ = collection
|
||||
.update_one(
|
||||
doc! { "name": name },
|
||||
doc! { "$set": { "endpoint_url": &expected_url } },
|
||||
)
|
||||
.await;
|
||||
tracing::info!(
|
||||
"Updated MCP server '{name}' endpoint: {} -> {expected_url}",
|
||||
existing.endpoint_url
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ use crate::infrastructure::dast::fetch_dast_findings;
|
||||
pub fn DastFindingsPage() -> Element {
|
||||
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
|
||||
|
||||
let mut filter_severity = use_signal(|| "all".to_string());
|
||||
let mut filter_vuln_type = use_signal(|| "all".to_string());
|
||||
let mut filter_exploitable = use_signal(|| "all".to_string());
|
||||
let mut search_text = use_signal(String::new);
|
||||
|
||||
rsx! {
|
||||
div { class: "back-nav",
|
||||
button {
|
||||
@@ -26,14 +31,105 @@ pub fn DastFindingsPage() -> Element {
|
||||
description: "Vulnerabilities discovered through dynamic application security testing",
|
||||
}
|
||||
|
||||
// Filter bar
|
||||
div { style: "display: flex; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; align-items: center;",
|
||||
// Search
|
||||
div { style: "flex: 1; min-width: 180px;",
|
||||
input {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; padding: 6px 10px; font-size: 0.85rem;",
|
||||
placeholder: "Search title or endpoint...",
|
||||
value: "{search_text}",
|
||||
oninput: move |e| search_text.set(e.value()),
|
||||
}
|
||||
}
|
||||
// Severity
|
||||
select {
|
||||
style: "padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem;",
|
||||
value: "{filter_severity}",
|
||||
onchange: move |e| filter_severity.set(e.value()),
|
||||
option { value: "all", "All Severities" }
|
||||
option { value: "critical", "Critical" }
|
||||
option { value: "high", "High" }
|
||||
option { value: "medium", "Medium" }
|
||||
option { value: "low", "Low" }
|
||||
option { value: "info", "Info" }
|
||||
}
|
||||
// Vuln type
|
||||
select {
|
||||
style: "padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem;",
|
||||
value: "{filter_vuln_type}",
|
||||
onchange: move |e| filter_vuln_type.set(e.value()),
|
||||
option { value: "all", "All Types" }
|
||||
option { value: "sql_injection", "SQL Injection" }
|
||||
option { value: "xss", "XSS" }
|
||||
option { value: "auth_bypass", "Auth Bypass" }
|
||||
option { value: "ssrf", "SSRF" }
|
||||
option { value: "api_misconfiguration", "API Misconfiguration" }
|
||||
option { value: "open_redirect", "Open Redirect" }
|
||||
option { value: "idor", "IDOR" }
|
||||
option { value: "information_disclosure", "Information Disclosure" }
|
||||
option { value: "security_misconfiguration", "Security Misconfiguration" }
|
||||
option { value: "broken_auth", "Broken Auth" }
|
||||
option { value: "dns_misconfiguration", "DNS Misconfiguration" }
|
||||
option { value: "email_security", "Email Security" }
|
||||
option { value: "tls_misconfiguration", "TLS Misconfiguration" }
|
||||
option { value: "cookie_security", "Cookie Security" }
|
||||
option { value: "csp_issue", "CSP Issue" }
|
||||
option { value: "cors_misconfiguration", "CORS Misconfiguration" }
|
||||
option { value: "rate_limit_absent", "Rate Limit Absent" }
|
||||
option { value: "console_log_leakage", "Console Log Leakage" }
|
||||
option { value: "security_header_missing", "Security Header Missing" }
|
||||
option { value: "known_cve_exploit", "Known CVE Exploit" }
|
||||
option { value: "other", "Other" }
|
||||
}
|
||||
// Exploitable
|
||||
select {
|
||||
style: "padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem;",
|
||||
value: "{filter_exploitable}",
|
||||
onchange: move |e| filter_exploitable.set(e.value()),
|
||||
option { value: "all", "All" }
|
||||
option { value: "yes", "Exploitable" }
|
||||
option { value: "no", "Unconfirmed" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
match &*findings.read() {
|
||||
Some(Some(data)) => {
|
||||
let finding_list = &data.data;
|
||||
if finding_list.is_empty() {
|
||||
rsx! { p { "No DAST findings yet. Run a scan to discover vulnerabilities." } }
|
||||
let sev_filter = filter_severity.read().clone();
|
||||
let vt_filter = filter_vuln_type.read().clone();
|
||||
let exp_filter = filter_exploitable.read().clone();
|
||||
let search = search_text.read().to_lowercase();
|
||||
|
||||
let filtered: Vec<_> = data.data.iter().filter(|f| {
|
||||
let severity = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info");
|
||||
let vuln_type = f.get("vuln_type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let exploitable = f.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let title = f.get("title").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
|
||||
let endpoint = f.get("endpoint").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
|
||||
|
||||
(sev_filter == "all" || severity == sev_filter)
|
||||
&& (vt_filter == "all" || vuln_type == vt_filter)
|
||||
&& match exp_filter.as_str() {
|
||||
"yes" => exploitable,
|
||||
"no" => !exploitable,
|
||||
_ => true,
|
||||
}
|
||||
&& (search.is_empty() || title.contains(&search) || endpoint.contains(&search))
|
||||
}).collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
if data.data.is_empty() {
|
||||
rsx! { p { style: "padding: 16px;", "No DAST findings yet. Run a scan to discover vulnerabilities." } }
|
||||
} else {
|
||||
rsx! { p { style: "padding: 16px; color: var(--text-secondary);", "No findings match the current filters." } }
|
||||
}
|
||||
} else {
|
||||
rsx! {
|
||||
div { style: "padding: 8px 16px; font-size: 0.8rem; color: var(--text-secondary);",
|
||||
"Showing {filtered.len()} of {data.data.len()} findings"
|
||||
}
|
||||
table { class: "table",
|
||||
thead {
|
||||
tr {
|
||||
@@ -46,7 +142,7 @@ pub fn DastFindingsPage() -> Element {
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for finding in finding_list {
|
||||
for finding in filtered {
|
||||
{
|
||||
let id = finding.get("_id").and_then(|v| v.get("$oid")).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
|
||||
|
||||
@@ -123,7 +123,6 @@ pub fn FindingsPage() -> Element {
|
||||
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); },
|
||||
|
||||
@@ -12,6 +12,8 @@ pub mod impact_analysis;
|
||||
pub mod issues;
|
||||
pub mod mcp_servers;
|
||||
pub mod overview;
|
||||
pub mod pentest_dashboard;
|
||||
pub mod pentest_session;
|
||||
pub mod repositories;
|
||||
pub mod sbom;
|
||||
pub mod settings;
|
||||
@@ -30,6 +32,8 @@ pub use impact_analysis::ImpactAnalysisPage;
|
||||
pub use issues::IssuesPage;
|
||||
pub use mcp_servers::McpServersPage;
|
||||
pub use overview::OverviewPage;
|
||||
pub use pentest_dashboard::PentestDashboardPage;
|
||||
pub use pentest_session::PentestSessionPage;
|
||||
pub use repositories::RepositoriesPage;
|
||||
pub use sbom::SbomPage;
|
||||
pub use settings::SettingsPage;
|
||||
|
||||
309
compliance-dashboard/src/pages/pentest_dashboard.rs
Normal file
309
compliance-dashboard/src/pages/pentest_dashboard.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
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::pentest_wizard::PentestWizard;
|
||||
use crate::infrastructure::pentest::{
|
||||
fetch_pentest_sessions, fetch_pentest_stats, pause_pentest_session, resume_pentest_session,
|
||||
stop_pentest_session,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn PentestDashboardPage() -> Element {
|
||||
let mut sessions = use_resource(|| async { fetch_pentest_sessions().await.ok() });
|
||||
let stats = use_resource(|| async { fetch_pentest_stats().await.ok() });
|
||||
|
||||
let mut show_wizard = use_signal(|| false);
|
||||
|
||||
// Extract stats values
|
||||
let running_sessions = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("running_sessions")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let total_vulns = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("total_vulnerabilities")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let tool_invocations = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("total_tool_invocations")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let success_rate = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("tool_success_rate")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0),
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
|
||||
// Severity counts from stats (nested under severity_distribution)
|
||||
let sev_dist = {
|
||||
let s = stats.read();
|
||||
match &*s {
|
||||
Some(Some(data)) => data
|
||||
.data
|
||||
.get("severity_distribution")
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
_ => serde_json::Value::Null,
|
||||
}
|
||||
};
|
||||
let severity_critical = sev_dist
|
||||
.get("critical")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let severity_high = sev_dist.get("high").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let severity_medium = sev_dist.get("medium").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let severity_low = sev_dist.get("low").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Pentest Dashboard",
|
||||
description: "AI-powered penetration testing sessions — autonomous security assessment",
|
||||
}
|
||||
|
||||
// Stat cards
|
||||
div { class: "stat-cards", style: "margin-bottom: 24px;",
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", "{running_sessions}" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsPlayCircle, width: 14, height: 14 }
|
||||
" Running Sessions"
|
||||
}
|
||||
}
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", "{total_vulns}" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
||||
" Total Vulnerabilities"
|
||||
}
|
||||
}
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", "{tool_invocations}" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsWrench, width: 14, height: 14 }
|
||||
" Tool Invocations"
|
||||
}
|
||||
}
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", "{success_rate:.0}%" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsCheckCircle, width: 14, height: 14 }
|
||||
" Success Rate"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Severity distribution
|
||||
div { class: "card", style: "margin-bottom: 24px; padding: 16px;",
|
||||
div { style: "display: flex; align-items: center; gap: 16px; flex-wrap: wrap;",
|
||||
span { style: "font-weight: 600; color: var(--text-secondary); font-size: 0.85rem;", "Severity Distribution" }
|
||||
span {
|
||||
class: "badge",
|
||||
style: "background: #dc2626; color: #fff;",
|
||||
"Critical: {severity_critical}"
|
||||
}
|
||||
span {
|
||||
class: "badge",
|
||||
style: "background: #ea580c; color: #fff;",
|
||||
"High: {severity_high}"
|
||||
}
|
||||
span {
|
||||
class: "badge",
|
||||
style: "background: #d97706; color: #fff;",
|
||||
"Medium: {severity_medium}"
|
||||
}
|
||||
span {
|
||||
class: "badge",
|
||||
style: "background: #2563eb; color: #fff;",
|
||||
"Low: {severity_low}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions row
|
||||
div { style: "display: flex; gap: 12px; margin-bottom: 24px;",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| show_wizard.set(true),
|
||||
Icon { icon: BsPlusCircle, width: 14, height: 14 }
|
||||
" New Pentest"
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions list
|
||||
div { class: "card",
|
||||
div { class: "card-header", "Recent Pentest Sessions" }
|
||||
match &*sessions.read() {
|
||||
Some(Some(data)) => {
|
||||
let sess_list = &data.data;
|
||||
if sess_list.is_empty() {
|
||||
rsx! {
|
||||
div { style: "padding: 32px; text-align: center; color: var(--text-secondary);",
|
||||
p { "No pentest sessions yet. Start one to begin autonomous security testing." }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! {
|
||||
div { style: "display: grid; gap: 12px; padding: 16px;",
|
||||
for session in sess_list {
|
||||
{
|
||||
let id = session.get("_id")
|
||||
.and_then(|v| v.get("$oid"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-").to_string();
|
||||
let target_name = session.get("target_name").and_then(|v| v.as_str()).unwrap_or("Unknown Target").to_string();
|
||||
let status = session.get("status").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
|
||||
let strategy = session.get("strategy").and_then(|v| v.as_str()).unwrap_or("-").to_string();
|
||||
let findings_count = session.get("findings_count").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let tool_count = session.get("tool_invocations").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let created_at = session.get("created_at").and_then(|v| v.as_str()).unwrap_or("-").to_string();
|
||||
let status_style = match status.as_str() {
|
||||
"running" => "background: #16a34a; color: #fff;",
|
||||
"completed" => "background: #2563eb; color: #fff;",
|
||||
"failed" => "background: #dc2626; color: #fff;",
|
||||
"paused" => "background: #d97706; color: #fff;",
|
||||
_ => "background: var(--bg-tertiary); color: var(--text-secondary);",
|
||||
};
|
||||
{
|
||||
let is_session_running = status == "running";
|
||||
let is_session_paused = status == "paused";
|
||||
let stop_id = id.clone();
|
||||
let pause_id = id.clone();
|
||||
let resume_id = id.clone();
|
||||
rsx! {
|
||||
div { class: "card", style: "padding: 16px; transition: border-color 0.15s;",
|
||||
Link {
|
||||
to: Route::PentestSessionPage { session_id: id.clone() },
|
||||
style: "text-decoration: none; cursor: pointer; display: block;",
|
||||
div { style: "display: flex; justify-content: space-between; align-items: flex-start;",
|
||||
div {
|
||||
div { style: "font-weight: 600; font-size: 1rem; margin-bottom: 4px; color: var(--text-primary);",
|
||||
"{target_name}"
|
||||
}
|
||||
div { style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;",
|
||||
span {
|
||||
class: "badge",
|
||||
style: "{status_style}",
|
||||
"{status}"
|
||||
}
|
||||
span {
|
||||
class: "badge",
|
||||
style: "background: var(--bg-tertiary); color: var(--text-secondary);",
|
||||
"{strategy}"
|
||||
}
|
||||
}
|
||||
}
|
||||
div { style: "text-align: right; font-size: 0.85rem; color: var(--text-secondary);",
|
||||
div { style: "margin-bottom: 4px;",
|
||||
Icon { icon: BsShieldExclamation, width: 12, height: 12 }
|
||||
" {findings_count} findings"
|
||||
}
|
||||
div { style: "margin-bottom: 4px;",
|
||||
Icon { icon: BsWrench, width: 12, height: 12 }
|
||||
" {tool_count} tools"
|
||||
}
|
||||
div { "{created_at}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_session_running || is_session_paused {
|
||||
div { style: "margin-top: 8px; display: flex; justify-content: flex-end; gap: 6px;",
|
||||
if is_session_running {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.8rem; padding: 4px 12px; color: #d97706; border-color: #d97706;",
|
||||
onclick: move |e| {
|
||||
e.stop_propagation();
|
||||
e.prevent_default();
|
||||
let sid = pause_id.clone();
|
||||
spawn(async move {
|
||||
let _ = pause_pentest_session(sid).await;
|
||||
sessions.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPauseCircle, width: 12, height: 12 }
|
||||
" Pause"
|
||||
}
|
||||
}
|
||||
if is_session_paused {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.8rem; padding: 4px 12px; color: #16a34a; border-color: #16a34a;",
|
||||
onclick: move |e| {
|
||||
e.stop_propagation();
|
||||
e.prevent_default();
|
||||
let sid = resume_id.clone();
|
||||
spawn(async move {
|
||||
let _ = resume_pentest_session(sid).await;
|
||||
sessions.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPlayCircle, width: 12, height: 12 }
|
||||
" Resume"
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.8rem; padding: 4px 12px; color: #dc2626; border-color: #dc2626;",
|
||||
onclick: move |e| {
|
||||
e.stop_propagation();
|
||||
e.prevent_default();
|
||||
let sid = stop_id.clone();
|
||||
spawn(async move {
|
||||
let _ = stop_pentest_session(sid).await;
|
||||
sessions.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsStopCircle, width: 12, height: 12 }
|
||||
" Stop"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { style: "padding: 16px;", "Failed to load sessions." } },
|
||||
None => rsx! { p { style: "padding: 16px;", "Loading..." } },
|
||||
}
|
||||
}
|
||||
|
||||
// Pentest Wizard
|
||||
if *show_wizard.read() {
|
||||
PentestWizard { show: show_wizard }
|
||||
}
|
||||
}
|
||||
}
|
||||
597
compliance-dashboard/src/pages/pentest_session.rs
Normal file
597
compliance-dashboard/src/pages/pentest_session.rs
Normal file
@@ -0,0 +1,597 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::*;
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::attack_chain::AttackChainView;
|
||||
use crate::components::severity_badge::SeverityBadge;
|
||||
use crate::infrastructure::pentest::{
|
||||
export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_pentest_session,
|
||||
pause_pentest_session, resume_pentest_session,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn PentestSessionPage(session_id: String) -> Element {
|
||||
let sid_for_session = session_id.clone();
|
||||
let sid_for_findings = session_id.clone();
|
||||
let sid_for_chain = session_id.clone();
|
||||
|
||||
let mut session = use_resource(move || {
|
||||
let id = sid_for_session.clone();
|
||||
async move { fetch_pentest_session(id).await.ok() }
|
||||
});
|
||||
let mut findings = use_resource(move || {
|
||||
let id = sid_for_findings.clone();
|
||||
async move { fetch_pentest_findings(id).await.ok() }
|
||||
});
|
||||
let mut attack_chain = use_resource(move || {
|
||||
let id = sid_for_chain.clone();
|
||||
async move { fetch_attack_chain(id).await.ok() }
|
||||
});
|
||||
|
||||
let mut active_tab = use_signal(|| "findings".to_string());
|
||||
let mut show_export_modal = use_signal(|| false);
|
||||
let mut export_password = use_signal(String::new);
|
||||
let mut exporting = use_signal(|| false);
|
||||
let mut export_sha256 = use_signal(|| Option::<String>::None);
|
||||
let mut export_error = use_signal(|| Option::<String>::None);
|
||||
let mut poll_gen = use_signal(|| 0u32);
|
||||
|
||||
// Extract session data
|
||||
let session_data = session.read().clone();
|
||||
let sess = session_data.as_ref().and_then(|s| s.as_ref());
|
||||
|
||||
let session_status = sess
|
||||
.and_then(|s| s.data.get("status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let target_name = sess
|
||||
.and_then(|s| s.data.get("target_name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Pentest Session")
|
||||
.to_string();
|
||||
let strategy = sess
|
||||
.and_then(|s| s.data.get("strategy"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-")
|
||||
.to_string();
|
||||
let tool_invocations = sess
|
||||
.and_then(|s| s.data.get("tool_invocations"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let tool_successes = sess
|
||||
.and_then(|s| s.data.get("tool_successes"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let findings_count = {
|
||||
let f = findings.read();
|
||||
match &*f {
|
||||
Some(Some(data)) => data.total.unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let started_at = sess
|
||||
.and_then(|s| s.data.get("started_at"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("-")
|
||||
.to_string();
|
||||
let completed_at = sess
|
||||
.and_then(|s| s.data.get("completed_at"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let success_rate = if tool_invocations == 0 {
|
||||
100.0
|
||||
} else {
|
||||
(tool_successes as f64 / tool_invocations as f64) * 100.0
|
||||
};
|
||||
|
||||
let is_running = session_status == "running";
|
||||
let is_paused = session_status == "paused";
|
||||
let is_active = is_running || is_paused;
|
||||
|
||||
// Poll while running or paused
|
||||
use_effect(move || {
|
||||
let _gen = *poll_gen.read();
|
||||
if is_active {
|
||||
spawn(async move {
|
||||
#[cfg(feature = "web")]
|
||||
gloo_timers::future::TimeoutFuture::new(3_000).await;
|
||||
#[cfg(not(feature = "web"))]
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
findings.restart();
|
||||
attack_chain.restart();
|
||||
session.restart();
|
||||
let next = poll_gen.peek().wrapping_add(1);
|
||||
poll_gen.set(next);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Severity counts from findings data
|
||||
let (sev_critical, sev_high, sev_medium, sev_low, sev_info, exploitable_count) = {
|
||||
let f = findings.read();
|
||||
match &*f {
|
||||
Some(Some(data)) => {
|
||||
let list = &data.data;
|
||||
let c = list
|
||||
.iter()
|
||||
.filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("critical"))
|
||||
.count();
|
||||
let h = list
|
||||
.iter()
|
||||
.filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("high"))
|
||||
.count();
|
||||
let m = list
|
||||
.iter()
|
||||
.filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("medium"))
|
||||
.count();
|
||||
let l = list
|
||||
.iter()
|
||||
.filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("low"))
|
||||
.count();
|
||||
let i = list
|
||||
.iter()
|
||||
.filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("info"))
|
||||
.count();
|
||||
let e = list
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
f.get("exploitable")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
(c, h, m, l, i, e)
|
||||
}
|
||||
_ => (0, 0, 0, 0, 0, 0),
|
||||
}
|
||||
};
|
||||
|
||||
let status_style = match session_status.as_str() {
|
||||
"running" => "background: #16a34a; color: #fff;",
|
||||
"completed" => "background: #2563eb; color: #fff;",
|
||||
"failed" => "background: #dc2626; color: #fff;",
|
||||
"paused" => "background: #d97706; color: #fff;",
|
||||
_ => "background: var(--bg-tertiary); color: var(--text-secondary);",
|
||||
};
|
||||
|
||||
// Export handler
|
||||
let sid_for_export = session_id.clone();
|
||||
let do_export = move |_| {
|
||||
let pw = export_password.read().clone();
|
||||
if pw.len() < 8 {
|
||||
export_error.set(Some("Password must be at least 8 characters".to_string()));
|
||||
return;
|
||||
}
|
||||
export_error.set(None);
|
||||
export_sha256.set(None);
|
||||
exporting.set(true);
|
||||
let sid = sid_for_export.clone();
|
||||
spawn(async move {
|
||||
// TODO: get real user info from auth context
|
||||
match export_pentest_report(sid.clone(), pw, String::new(), String::new()).await {
|
||||
Ok(resp) => {
|
||||
export_sha256.set(Some(resp.sha256.clone()));
|
||||
// Trigger download via JS
|
||||
let js = format!(
|
||||
r#"
|
||||
try {{
|
||||
var raw = atob("{}");
|
||||
var bytes = new Uint8Array(raw.length);
|
||||
for (var i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
var blob = new Blob([bytes], {{ type: "application/octet-stream" }});
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "{}";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}} catch(e) {{ console.error("Download failed:", e); }}
|
||||
"#,
|
||||
resp.archive_base64, resp.filename,
|
||||
);
|
||||
document::eval(&js);
|
||||
}
|
||||
Err(e) => {
|
||||
export_error.set(Some(format!("{e}")));
|
||||
}
|
||||
}
|
||||
exporting.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "back-nav",
|
||||
Link {
|
||||
to: Route::PentestDashboardPage {},
|
||||
class: "btn btn-ghost btn-back",
|
||||
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
||||
"Back to Pentest Dashboard"
|
||||
}
|
||||
}
|
||||
|
||||
// Session header
|
||||
div { style: "display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 8px;",
|
||||
div {
|
||||
h2 { style: "margin: 0 0 4px 0;", "{target_name}" }
|
||||
div { style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;",
|
||||
span { class: "badge", style: "{status_style}", "{session_status}" }
|
||||
span { class: "badge", style: "background: var(--bg-tertiary); color: var(--text-secondary);",
|
||||
"{strategy}"
|
||||
}
|
||||
if is_running {
|
||||
span { style: "font-size: 0.8rem; color: var(--text-secondary);",
|
||||
Icon { icon: BsPlayCircle, width: 12, height: 12 }
|
||||
" Running..."
|
||||
}
|
||||
}
|
||||
if is_paused {
|
||||
span { style: "font-size: 0.8rem; color: #d97706;",
|
||||
Icon { icon: BsPauseCircle, width: 12, height: 12 }
|
||||
" Paused"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { style: "display: flex; gap: 8px;",
|
||||
if is_running {
|
||||
{
|
||||
let sid_pause = session_id.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.85rem; color: #d97706; border-color: #d97706;",
|
||||
onclick: move |_| {
|
||||
let sid = sid_pause.clone();
|
||||
spawn(async move {
|
||||
let _ = pause_pentest_session(sid).await;
|
||||
session.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPauseCircle, width: 14, height: 14 }
|
||||
" Pause"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_paused {
|
||||
{
|
||||
let sid_resume = session_id.clone();
|
||||
rsx! {
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "font-size: 0.85rem; color: #16a34a; border-color: #16a34a;",
|
||||
onclick: move |_| {
|
||||
let sid = sid_resume.clone();
|
||||
spawn(async move {
|
||||
let _ = resume_pentest_session(sid).await;
|
||||
session.restart();
|
||||
});
|
||||
},
|
||||
Icon { icon: BsPlayCircle, width: 14, height: 14 }
|
||||
" Resume"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
style: "font-size: 0.85rem;",
|
||||
onclick: move |_| {
|
||||
export_password.set(String::new());
|
||||
export_sha256.set(None);
|
||||
export_error.set(None);
|
||||
show_export_modal.set(true);
|
||||
},
|
||||
Icon { icon: BsDownload, width: 14, height: 14 }
|
||||
" Export Report"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary cards
|
||||
div { class: "stat-cards", style: "margin-bottom: 20px;",
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", "{findings_count}" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
||||
" Findings"
|
||||
}
|
||||
}
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", style: "color: #dc2626;", "{exploitable_count}" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsExclamationTriangle, width: 14, height: 14 }
|
||||
" Exploitable"
|
||||
}
|
||||
}
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", "{tool_invocations}" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsWrench, width: 14, height: 14 }
|
||||
" Tool Invocations"
|
||||
}
|
||||
}
|
||||
div { class: "stat-card-item",
|
||||
div { class: "stat-card-value", "{success_rate:.0}%" }
|
||||
div { class: "stat-card-label",
|
||||
Icon { icon: BsCheckCircle, width: 14, height: 14 }
|
||||
" Success Rate"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Severity distribution bar
|
||||
div { class: "card", style: "margin-bottom: 20px; padding: 14px;",
|
||||
div { style: "display: flex; align-items: center; gap: 14px; flex-wrap: wrap;",
|
||||
span { style: "font-weight: 600; color: var(--text-secondary); font-size: 0.85rem;", "Severity Distribution" }
|
||||
span { class: "badge", style: "background: #dc2626; color: #fff;", "Critical: {sev_critical}" }
|
||||
span { class: "badge", style: "background: #ea580c; color: #fff;", "High: {sev_high}" }
|
||||
span { class: "badge", style: "background: #d97706; color: #fff;", "Medium: {sev_medium}" }
|
||||
span { class: "badge", style: "background: #2563eb; color: #fff;", "Low: {sev_low}" }
|
||||
span { class: "badge", style: "background: #6b7280; color: #fff;", "Info: {sev_info}" }
|
||||
}
|
||||
}
|
||||
|
||||
// Session details row
|
||||
div { class: "card", style: "margin-bottom: 20px; padding: 14px;",
|
||||
div { style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; font-size: 0.85rem;",
|
||||
div {
|
||||
span { style: "color: var(--text-secondary);", "Started: " }
|
||||
span { "{started_at}" }
|
||||
}
|
||||
if !completed_at.is_empty() {
|
||||
div {
|
||||
span { style: "color: var(--text-secondary);", "Completed: " }
|
||||
span { "{completed_at}" }
|
||||
}
|
||||
}
|
||||
div {
|
||||
span { style: "color: var(--text-secondary);", "Tools: " }
|
||||
span { "{tool_successes}/{tool_invocations} successful" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs: Findings / Attack Chain
|
||||
div { class: "card", style: "overflow: hidden;",
|
||||
div { style: "display: flex; border-bottom: 1px solid var(--border-color);",
|
||||
button {
|
||||
style: if *active_tab.read() == "findings" {
|
||||
"flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600; font-size: 0.9rem;"
|
||||
} else {
|
||||
"flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; font-size: 0.9rem;"
|
||||
},
|
||||
onclick: move |_| active_tab.set("findings".to_string()),
|
||||
Icon { icon: BsShieldExclamation, width: 14, height: 14 }
|
||||
" Findings ({findings_count})"
|
||||
}
|
||||
button {
|
||||
style: if *active_tab.read() == "chain" {
|
||||
"flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600; font-size: 0.9rem;"
|
||||
} else {
|
||||
"flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; font-size: 0.9rem;"
|
||||
},
|
||||
onclick: move |_| {
|
||||
active_tab.set("chain".to_string());
|
||||
},
|
||||
Icon { icon: BsDiagram3, width: 14, height: 14 }
|
||||
" Attack Chain"
|
||||
}
|
||||
}
|
||||
|
||||
// Tab content
|
||||
div { style: "padding: 16px;",
|
||||
if *active_tab.read() == "findings" {
|
||||
// Findings list
|
||||
match &*findings.read() {
|
||||
Some(Some(data)) => {
|
||||
let finding_list = &data.data;
|
||||
if finding_list.is_empty() {
|
||||
rsx! {
|
||||
div { style: "text-align: center; color: var(--text-secondary); padding: 24px;",
|
||||
if is_running {
|
||||
p { "Scan in progress — findings will appear here." }
|
||||
} else {
|
||||
p { "No findings discovered." }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! {
|
||||
div { style: "display: flex; flex-direction: column; gap: 10px;",
|
||||
for finding in finding_list {
|
||||
{
|
||||
let title = finding.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string();
|
||||
let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
|
||||
let vuln_type = finding.get("vuln_type").and_then(|v| v.as_str()).unwrap_or("-").to_string();
|
||||
let endpoint = finding.get("endpoint").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let method = finding.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let exploitable = finding.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let description = finding.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let remediation = finding.get("remediation").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let cwe = finding.get("cwe").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let linked_sast = finding.get("linked_sast_finding_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
|
||||
rsx! {
|
||||
div { style: "background: var(--bg-tertiary); border-radius: 8px; padding: 14px;",
|
||||
// Header
|
||||
div { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;",
|
||||
div { style: "display: flex; align-items: center; gap: 8px;",
|
||||
SeverityBadge { severity: severity }
|
||||
span { style: "font-weight: 600; font-size: 0.95rem;", "{title}" }
|
||||
}
|
||||
div { style: "display: flex; gap: 4px;",
|
||||
if exploitable {
|
||||
span { class: "badge", style: "background: #dc2626; color: #fff; font-size: 0.7rem;", "Exploitable" }
|
||||
}
|
||||
span { class: "badge", style: "font-size: 0.7rem;", "{vuln_type}" }
|
||||
}
|
||||
}
|
||||
// Endpoint
|
||||
if !endpoint.is_empty() {
|
||||
div { style: "font-family: monospace; font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 6px;",
|
||||
"{method} {endpoint}"
|
||||
}
|
||||
}
|
||||
// CWE
|
||||
if !cwe.is_empty() {
|
||||
div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 4px;",
|
||||
"CWE: {cwe}"
|
||||
}
|
||||
}
|
||||
// Description
|
||||
if !description.is_empty() {
|
||||
div { style: "font-size: 0.85rem; margin-bottom: 8px; line-height: 1.5;",
|
||||
"{description}"
|
||||
}
|
||||
}
|
||||
// Remediation
|
||||
if !remediation.is_empty() {
|
||||
div { style: "font-size: 0.8rem; padding: 8px 10px; background: rgba(56, 189, 248, 0.08); border-left: 3px solid #38bdf8; border-radius: 0 4px 4px 0; margin-top: 6px;",
|
||||
span { style: "font-weight: 600;", "Recommendation: " }
|
||||
"{remediation}"
|
||||
}
|
||||
}
|
||||
// Linked SAST
|
||||
if !linked_sast.is_empty() {
|
||||
div { style: "font-size: 0.75rem; color: var(--text-secondary); margin-top: 4px;",
|
||||
"Correlated SAST finding: "
|
||||
code { "{linked_sast}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load findings." } },
|
||||
None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } },
|
||||
}
|
||||
} else {
|
||||
// Attack chain visualization
|
||||
match &*attack_chain.read() {
|
||||
Some(Some(data)) => {
|
||||
let steps = &data.data;
|
||||
if steps.is_empty() {
|
||||
rsx! {
|
||||
div { style: "text-align: center; color: var(--text-secondary); padding: 24px;",
|
||||
if is_running {
|
||||
p { "Scan in progress — attack chain will appear here." }
|
||||
} else {
|
||||
p { "No attack chain steps recorded." }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! { AttackChainView {
|
||||
steps: steps.clone(),
|
||||
is_running: is_running,
|
||||
session_findings: findings_count as usize,
|
||||
session_tool_invocations: tool_invocations as usize,
|
||||
session_success_rate: success_rate,
|
||||
} }
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load attack chain." } },
|
||||
None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export modal
|
||||
if *show_export_modal.read() {
|
||||
div {
|
||||
style: "position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;",
|
||||
onclick: move |_| show_export_modal.set(false),
|
||||
div {
|
||||
style: "background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 24px; width: 480px; max-width: 90vw;",
|
||||
onclick: move |e| e.stop_propagation(),
|
||||
h3 { style: "margin: 0 0 4px 0;", "Export Pentest Report" }
|
||||
p { style: "font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 16px;",
|
||||
"The report will be exported as a password-protected ZIP archive (AES-256) containing a professional HTML report and raw findings data. Open with any standard archive tool."
|
||||
}
|
||||
|
||||
div { style: "margin-bottom: 14px;",
|
||||
label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;",
|
||||
"Encryption Password"
|
||||
}
|
||||
input {
|
||||
class: "chat-input",
|
||||
style: "width: 100%; padding: 8px;",
|
||||
r#type: "password",
|
||||
placeholder: "Minimum 8 characters",
|
||||
value: "{export_password}",
|
||||
oninput: move |e| {
|
||||
export_password.set(e.value());
|
||||
export_error.set(None);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = &*export_error.read() {
|
||||
div { style: "padding: 8px 12px; background: rgba(220, 38, 38, 0.1); border: 1px solid #dc2626; border-radius: 6px; color: #dc2626; font-size: 0.85rem; margin-bottom: 14px;",
|
||||
"{err}"
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sha) = &*export_sha256.read() {
|
||||
{
|
||||
let sha_copy = sha.clone();
|
||||
rsx! {
|
||||
div { style: "padding: 10px 12px; background: rgba(22, 163, 74, 0.08); border: 1px solid #16a34a; border-radius: 6px; margin-bottom: 14px;",
|
||||
div { style: "font-size: 0.8rem; font-weight: 600; color: #16a34a; margin-bottom: 4px;",
|
||||
Icon { icon: BsCheckCircle, width: 12, height: 12 }
|
||||
" Archive downloaded successfully"
|
||||
}
|
||||
div { style: "font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 2px;",
|
||||
"SHA-256 Checksum:"
|
||||
}
|
||||
div { style: "display: flex; align-items: center; gap: 6px;",
|
||||
div { style: "flex: 1; font-family: monospace; font-size: 0.7rem; word-break: break-all; color: var(--text-primary); background: var(--bg-primary); padding: 6px 8px; border-radius: 4px;",
|
||||
"{sha_copy}"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
style: "padding: 4px 8px; font-size: 0.75rem; flex-shrink: 0;",
|
||||
onclick: move |_| {
|
||||
let js = format!(
|
||||
"navigator.clipboard.writeText('{}');",
|
||||
sha_copy
|
||||
);
|
||||
document::eval(&js);
|
||||
},
|
||||
Icon { icon: BsClipboard, width: 12, height: 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "display: flex; justify-content: flex-end; gap: 8px;",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
onclick: move |_| show_export_modal.set(false),
|
||||
"Close"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: *exporting.read() || export_password.read().len() < 8,
|
||||
onclick: do_export,
|
||||
if *exporting.read() { "Encrypting..." } else { "Export" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,11 +394,15 @@ pub fn RepositoriesPage() -> Element {
|
||||
r#type: "text",
|
||||
readonly: true,
|
||||
style: "font-family: monospace; font-size: 12px;",
|
||||
value: format!("/webhook/{}/{eid}", edit_webhook_tracker()),
|
||||
}
|
||||
p {
|
||||
style: "font-size: 11px; color: var(--text-secondary); margin-top: 4px;",
|
||||
"Use the full dashboard URL as the base, e.g. https://your-domain.com/webhook/..."
|
||||
value: {
|
||||
#[cfg(feature = "web")]
|
||||
let origin = web_sys::window()
|
||||
.and_then(|w: web_sys::Window| w.location().origin().ok())
|
||||
.unwrap_or_default();
|
||||
#[cfg(not(feature = "web"))]
|
||||
let origin = String::new();
|
||||
format!("{origin}/webhook/{}/{eid}", edit_webhook_tracker())
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
|
||||
@@ -27,6 +27,15 @@ chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-feature
|
||||
# Docker sandboxing
|
||||
bollard = "0.18"
|
||||
|
||||
# TLS analysis
|
||||
native-tls = "0.2"
|
||||
tokio-native-tls = "0.3"
|
||||
|
||||
# CDP WebSocket (browser tool)
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
base64 = "0.22"
|
||||
|
||||
# Serialization
|
||||
bson = { version = "2", features = ["chrono-0_4"] }
|
||||
url = "2"
|
||||
|
||||
@@ -2,5 +2,7 @@ pub mod agents;
|
||||
pub mod crawler;
|
||||
pub mod orchestrator;
|
||||
pub mod recon;
|
||||
pub mod tools;
|
||||
|
||||
pub use orchestrator::DastOrchestrator;
|
||||
pub use tools::ToolRegistry;
|
||||
|
||||
178
compliance-dast/src/tools/api_fuzzer.rs
Normal file
178
compliance-dast/src/tools/api_fuzzer.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::traits::dast_agent::{
|
||||
DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter,
|
||||
};
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::agents::api_fuzzer::ApiFuzzerAgent;
|
||||
|
||||
/// PentestTool wrapper around the existing ApiFuzzerAgent.
|
||||
pub struct ApiFuzzerTool {
|
||||
_http: reqwest::Client,
|
||||
agent: ApiFuzzerAgent,
|
||||
}
|
||||
|
||||
impl ApiFuzzerTool {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
let agent = ApiFuzzerAgent::new(http.clone());
|
||||
Self { _http: http, agent }
|
||||
}
|
||||
|
||||
fn parse_endpoints(input: &serde_json::Value) -> Vec<DiscoveredEndpoint> {
|
||||
let mut endpoints = Vec::new();
|
||||
if let Some(arr) = input.get("endpoints").and_then(|v| v.as_array()) {
|
||||
for ep in arr {
|
||||
let url = ep
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let method = ep
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("GET")
|
||||
.to_string();
|
||||
let mut parameters = Vec::new();
|
||||
if let Some(params) = ep.get("parameters").and_then(|v| v.as_array()) {
|
||||
for p in params {
|
||||
parameters.push(EndpointParameter {
|
||||
name: p
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
location: p
|
||||
.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("query")
|
||||
.to_string(),
|
||||
param_type: p
|
||||
.get("param_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
example_value: p
|
||||
.get("example_value")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
});
|
||||
}
|
||||
}
|
||||
endpoints.push(DiscoveredEndpoint {
|
||||
url,
|
||||
method,
|
||||
parameters,
|
||||
content_type: ep
|
||||
.get("content_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
requires_auth: ep
|
||||
.get("requires_auth")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
endpoints
|
||||
}
|
||||
}
|
||||
|
||||
impl PentestTool for ApiFuzzerTool {
|
||||
fn name(&self) -> &str {
|
||||
"api_fuzzer"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Fuzzes API endpoints to discover misconfigurations, information disclosure, and hidden \
|
||||
endpoints. Probes common sensitive paths and tests for verbose error messages."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"description": "Known endpoints to fuzz",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "type": "string" },
|
||||
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
|
||||
"parameters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"location": { "type": "string" },
|
||||
"param_type": { "type": "string" },
|
||||
"example_value": { "type": "string" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
},
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"description": "Base URL to probe for common sensitive paths (used if no endpoints provided)"
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
context: &'a PentestToolContext,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
let mut endpoints = Self::parse_endpoints(&input);
|
||||
|
||||
// If a base_url is provided but no endpoints, create a default endpoint
|
||||
if endpoints.is_empty() {
|
||||
if let Some(base) = input.get("base_url").and_then(|v| v.as_str()) {
|
||||
endpoints.push(DiscoveredEndpoint {
|
||||
url: base.to_string(),
|
||||
method: "GET".to_string(),
|
||||
parameters: Vec::new(),
|
||||
content_type: None,
|
||||
requires_auth: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if endpoints.is_empty() {
|
||||
return Ok(PentestToolResult {
|
||||
summary: "No endpoints or base_url provided to fuzz.".to_string(),
|
||||
findings: Vec::new(),
|
||||
data: json!({}),
|
||||
});
|
||||
}
|
||||
|
||||
let dast_context = DastContext {
|
||||
endpoints,
|
||||
technologies: Vec::new(),
|
||||
sast_hints: Vec::new(),
|
||||
};
|
||||
|
||||
let findings = self.agent.run(&context.target, &dast_context).await?;
|
||||
let count = findings.len();
|
||||
|
||||
Ok(PentestToolResult {
|
||||
summary: if count > 0 {
|
||||
format!("Found {count} API misconfigurations or information disclosures.")
|
||||
} else {
|
||||
"No API misconfigurations detected.".to_string()
|
||||
},
|
||||
findings,
|
||||
data: json!({ "endpoints_tested": dast_context.endpoints.len() }),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
162
compliance-dast/src/tools/auth_bypass.rs
Normal file
162
compliance-dast/src/tools/auth_bypass.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::traits::dast_agent::{
|
||||
DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter,
|
||||
};
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::agents::auth_bypass::AuthBypassAgent;
|
||||
|
||||
/// PentestTool wrapper around the existing AuthBypassAgent.
|
||||
pub struct AuthBypassTool {
|
||||
_http: reqwest::Client,
|
||||
agent: AuthBypassAgent,
|
||||
}
|
||||
|
||||
impl AuthBypassTool {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
let agent = AuthBypassAgent::new(http.clone());
|
||||
Self { _http: http, agent }
|
||||
}
|
||||
|
||||
fn parse_endpoints(input: &serde_json::Value) -> Vec<DiscoveredEndpoint> {
|
||||
let mut endpoints = Vec::new();
|
||||
if let Some(arr) = input.get("endpoints").and_then(|v| v.as_array()) {
|
||||
for ep in arr {
|
||||
let url = ep
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let method = ep
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("GET")
|
||||
.to_string();
|
||||
let mut parameters = Vec::new();
|
||||
if let Some(params) = ep.get("parameters").and_then(|v| v.as_array()) {
|
||||
for p in params {
|
||||
parameters.push(EndpointParameter {
|
||||
name: p
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
location: p
|
||||
.get("location")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("query")
|
||||
.to_string(),
|
||||
param_type: p
|
||||
.get("param_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
example_value: p
|
||||
.get("example_value")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
});
|
||||
}
|
||||
}
|
||||
endpoints.push(DiscoveredEndpoint {
|
||||
url,
|
||||
method,
|
||||
parameters,
|
||||
content_type: ep
|
||||
.get("content_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
requires_auth: ep
|
||||
.get("requires_auth")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
endpoints
|
||||
}
|
||||
}
|
||||
|
||||
impl PentestTool for AuthBypassTool {
|
||||
fn name(&self) -> &str {
|
||||
"auth_bypass_scanner"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Tests endpoints for authentication bypass vulnerabilities. Tries accessing protected \
|
||||
endpoints without credentials, with manipulated tokens, and with common default credentials."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoints": {
|
||||
"type": "array",
|
||||
"description": "Endpoints to test for authentication bypass",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "type": "string" },
|
||||
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
|
||||
"parameters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"location": { "type": "string" },
|
||||
"param_type": { "type": "string" },
|
||||
"example_value": { "type": "string" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
},
|
||||
"requires_auth": { "type": "boolean", "description": "Whether this endpoint requires authentication" }
|
||||
},
|
||||
"required": ["url", "method"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["endpoints"]
|
||||
})
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
context: &'a PentestToolContext,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
let endpoints = Self::parse_endpoints(&input);
|
||||
if endpoints.is_empty() {
|
||||
return Ok(PentestToolResult {
|
||||
summary: "No endpoints provided to test.".to_string(),
|
||||
findings: Vec::new(),
|
||||
data: json!({}),
|
||||
});
|
||||
}
|
||||
|
||||
let dast_context = DastContext {
|
||||
endpoints,
|
||||
technologies: Vec::new(),
|
||||
sast_hints: Vec::new(),
|
||||
};
|
||||
|
||||
let findings = self.agent.run(&context.target, &dast_context).await?;
|
||||
let count = findings.len();
|
||||
|
||||
Ok(PentestToolResult {
|
||||
summary: if count > 0 {
|
||||
format!("Found {count} authentication bypass vulnerabilities.")
|
||||
} else {
|
||||
"No authentication bypass vulnerabilities detected.".to_string()
|
||||
},
|
||||
findings,
|
||||
data: json!({ "endpoints_tested": dast_context.endpoints.len() }),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
650
compliance-dast/src/tools/browser.rs
Normal file
650
compliance-dast/src/tools/browser.rs
Normal file
@@ -0,0 +1,650 @@
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::info;
|
||||
|
||||
type WsStream =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
/// Global pool of persistent browser sessions keyed by pentest session ID.
|
||||
/// Each pentest session gets one Chrome tab that stays alive across tool calls.
|
||||
static BROWSER_SESSIONS: std::sync::LazyLock<Arc<Mutex<HashMap<String, BrowserSession>>>> =
|
||||
std::sync::LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
|
||||
|
||||
/// A browser automation tool that exposes headless Chrome actions to the LLM
|
||||
/// via the Chrome DevTools Protocol.
|
||||
///
|
||||
/// **Session-persistent**: the same Chrome tab is reused across all invocations
|
||||
/// within a pentest session, so cookies, auth state, and page context are
|
||||
/// preserved between navigate → click → fill → screenshot calls.
|
||||
///
|
||||
/// Supported actions: navigate, screenshot, click, fill, get_content, evaluate, close.
|
||||
pub struct BrowserTool;
|
||||
|
||||
impl Default for BrowserTool {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl PentestTool for BrowserTool {
|
||||
fn name(&self) -> &str {
|
||||
"browser"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Headless browser automation via Chrome DevTools Protocol. The browser tab persists \
|
||||
across calls within the same pentest session — cookies, login state, and page context \
|
||||
are preserved. Supports navigating to URLs, taking screenshots, clicking elements, \
|
||||
filling form fields, reading page content, and evaluating JavaScript. \
|
||||
Use CSS selectors to target elements. After navigating, use get_content to read the \
|
||||
page HTML and find elements to click or fill. Use this to discover registration pages, \
|
||||
fill out signup forms, complete email verification, and test authenticated flows."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["navigate", "screenshot", "click", "fill", "get_content", "evaluate", "close"],
|
||||
"description": "Action to perform. The browser tab persists between calls — use navigate first, then get_content to see the page, then click/fill to interact."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to navigate to (for 'navigate' action)"
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector for click/fill actions (e.g. '#username', 'a[href*=register]', 'button[type=submit]')"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "Text value for 'fill' action, or JS expression for 'evaluate'"
|
||||
},
|
||||
"wait_ms": {
|
||||
"type": "integer",
|
||||
"description": "Milliseconds to wait after action (default: 1000)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
context: &'a PentestToolContext,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>>
|
||||
{
|
||||
Box::pin(async move {
|
||||
let action = input.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let url = input.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let selector = input.get("selector").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let value = input.get("value").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let wait_ms = input
|
||||
.get("wait_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1000);
|
||||
let session_key = context.session_id.clone();
|
||||
|
||||
// Handle close action — tear down the persistent session
|
||||
if action == "close" {
|
||||
let mut pool = BROWSER_SESSIONS.lock().await;
|
||||
if let Some(mut sess) = pool.remove(&session_key) {
|
||||
let _ = sess.close().await;
|
||||
}
|
||||
return Ok(PentestToolResult {
|
||||
summary: "Browser session closed".to_string(),
|
||||
findings: Vec::new(),
|
||||
data: json!({ "closed": true }),
|
||||
});
|
||||
}
|
||||
|
||||
// Get or create persistent session for this pentest
|
||||
let mut pool = BROWSER_SESSIONS.lock().await;
|
||||
if !pool.contains_key(&session_key) {
|
||||
match BrowserSession::connect().await {
|
||||
Ok(sess) => {
|
||||
pool.insert(session_key.clone(), sess);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(CoreError::Other(format!("Browser connect failed: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session = pool.get_mut(&session_key);
|
||||
let Some(session) = session else {
|
||||
return Err(CoreError::Other("Browser session not found".to_string()));
|
||||
};
|
||||
|
||||
let result = match action {
|
||||
"navigate" => session.navigate(url, wait_ms).await,
|
||||
"screenshot" => session.screenshot().await,
|
||||
"click" => session.click(selector, wait_ms).await,
|
||||
"fill" => session.fill(selector, value, wait_ms).await,
|
||||
"get_content" => session.get_content().await,
|
||||
"evaluate" => session.evaluate(value).await,
|
||||
_ => Err(format!("Unknown browser action: {action}")),
|
||||
};
|
||||
|
||||
// If the session errored, remove it so the next call creates a fresh one
|
||||
if result.is_err() {
|
||||
if let Some(mut dead) = pool.remove(&session_key) {
|
||||
let _ = dead.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Release the lock before building the response
|
||||
drop(pool);
|
||||
|
||||
match result {
|
||||
Ok(data) => {
|
||||
let summary = match action {
|
||||
"navigate" => format!("Navigated to {url}"),
|
||||
"screenshot" => "Captured page screenshot".to_string(),
|
||||
"click" => format!("Clicked element: {selector}"),
|
||||
"fill" => format!("Filled element: {selector}"),
|
||||
"get_content" => "Retrieved page content".to_string(),
|
||||
"evaluate" => "Evaluated JavaScript".to_string(),
|
||||
_ => "Browser action completed".to_string(),
|
||||
};
|
||||
info!(action, %summary, "Browser tool executed");
|
||||
Ok(PentestToolResult {
|
||||
summary,
|
||||
findings: Vec::new(),
|
||||
data,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(PentestToolResult {
|
||||
summary: format!("Browser action '{action}' failed: {e}"),
|
||||
findings: Vec::new(),
|
||||
data: json!({ "error": e }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A single CDP session wrapping a browser tab.
|
||||
struct BrowserSession {
|
||||
ws: WsStream,
|
||||
next_id: u64,
|
||||
session_id: String,
|
||||
target_id: String,
|
||||
}
|
||||
|
||||
impl BrowserSession {
|
||||
/// Connect to headless Chrome and create a new tab.
|
||||
async fn connect() -> Result<Self, String> {
|
||||
let ws_url = std::env::var("CHROME_WS_URL").map_err(|_| {
|
||||
"CHROME_WS_URL not set — headless Chrome is required for browser actions".to_string()
|
||||
})?;
|
||||
|
||||
// Discover browser WS endpoint
|
||||
let http_url = ws_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://");
|
||||
let version_url = format!("{http_url}/json/version");
|
||||
|
||||
let version: serde_json::Value = reqwest::get(&version_url)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot reach Chrome at {version_url}: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Invalid /json/version response: {e}"))?;
|
||||
|
||||
let browser_ws = version["webSocketDebuggerUrl"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "No webSocketDebuggerUrl in /json/version".to_string())?;
|
||||
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(browser_ws)
|
||||
.await
|
||||
.map_err(|e| format!("WebSocket connect failed: {e}"))?;
|
||||
|
||||
let mut next_id: u64 = 1;
|
||||
|
||||
// Create tab
|
||||
let resp = cdp_send(
|
||||
&mut ws,
|
||||
next_id,
|
||||
"Target.createTarget",
|
||||
json!({ "url": "about:blank" }),
|
||||
)
|
||||
.await?;
|
||||
next_id += 1;
|
||||
|
||||
let target_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("targetId"))
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or("No targetId in createTarget response")?
|
||||
.to_string();
|
||||
|
||||
// Attach
|
||||
let resp = cdp_send(
|
||||
&mut ws,
|
||||
next_id,
|
||||
"Target.attachToTarget",
|
||||
json!({ "targetId": target_id, "flatten": true }),
|
||||
)
|
||||
.await?;
|
||||
next_id += 1;
|
||||
|
||||
let session_id = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("sessionId"))
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or("No sessionId in attachToTarget response")?
|
||||
.to_string();
|
||||
|
||||
// Enable domains
|
||||
cdp_send_session(&mut ws, next_id, &session_id, "Page.enable", json!({})).await?;
|
||||
next_id += 1;
|
||||
|
||||
cdp_send_session(&mut ws, next_id, &session_id, "Runtime.enable", json!({})).await?;
|
||||
next_id += 1;
|
||||
|
||||
Ok(Self {
|
||||
ws,
|
||||
next_id,
|
||||
session_id,
|
||||
target_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn navigate(&mut self, url: &str, wait_ms: u64) -> Result<serde_json::Value, String> {
|
||||
cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Page.navigate",
|
||||
json!({ "url": url }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
// Get page title and current URL (may have redirected)
|
||||
let title = self
|
||||
.evaluate_raw("document.title")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let page_url = self
|
||||
.evaluate_raw("window.location.href")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Auto-get a summary of interactive elements on the page
|
||||
let links_js = r#"(function(){
|
||||
var items = [];
|
||||
document.querySelectorAll('a[href]').forEach(function(a, i) {
|
||||
if (i < 20) items.push({tag:'a', text:a.textContent.trim().substring(0,60), href:a.href});
|
||||
});
|
||||
document.querySelectorAll('input,select,textarea,button[type=submit]').forEach(function(el, i) {
|
||||
if (i < 20) items.push({tag:el.tagName.toLowerCase(), type:el.type||'', name:el.name||'', id:el.id||'', placeholder:el.placeholder||''});
|
||||
});
|
||||
return JSON.stringify(items);
|
||||
})()"#;
|
||||
let elements_json = self.evaluate_raw(links_js).await.unwrap_or_default();
|
||||
let elements: serde_json::Value = serde_json::from_str(&elements_json).unwrap_or(json!([]));
|
||||
|
||||
// Auto-capture screenshot after every navigation
|
||||
let screenshot_b64 = self.capture_screenshot_b64().await.unwrap_or_default();
|
||||
|
||||
Ok(json!({
|
||||
"navigated": true,
|
||||
"url": page_url,
|
||||
"title": title,
|
||||
"elements": elements,
|
||||
"screenshot_base64": screenshot_b64,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Capture a screenshot and return the base64 string (empty on failure).
|
||||
async fn capture_screenshot_b64(&mut self) -> Result<String, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Page.captureScreenshot",
|
||||
json!({ "format": "png", "quality": 80 }),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
Ok(resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("data"))
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
async fn screenshot(&mut self) -> Result<serde_json::Value, String> {
|
||||
let b64 = self.capture_screenshot_b64().await?;
|
||||
|
||||
let size_kb = base64::engine::general_purpose::STANDARD
|
||||
.decode(&b64)
|
||||
.map(|b| b.len() / 1024)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(json!({
|
||||
"screenshot_base64": b64,
|
||||
"size_kb": size_kb,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn click(&mut self, selector: &str, wait_ms: u64) -> Result<serde_json::Value, String> {
|
||||
let js = format!(
|
||||
r#"(function() {{
|
||||
var el = document.querySelector({sel});
|
||||
if (!el) return JSON.stringify({{error: "Element not found: {raw}"}});
|
||||
var rect = el.getBoundingClientRect();
|
||||
el.click();
|
||||
return JSON.stringify({{
|
||||
clicked: true,
|
||||
tag: el.tagName,
|
||||
text: el.textContent.substring(0, 100),
|
||||
x: rect.x + rect.width/2,
|
||||
y: rect.y + rect.height/2
|
||||
}});
|
||||
}})()"#,
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
raw = selector.replace('"', r#"\""#),
|
||||
);
|
||||
|
||||
let result = self.evaluate_raw(&js).await?;
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
// After click, get current URL (may have navigated)
|
||||
let current_url = self
|
||||
.evaluate_raw("window.location.href")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let title = self
|
||||
.evaluate_raw("document.title")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Auto-capture screenshot after click
|
||||
let screenshot_b64 = self.capture_screenshot_b64().await.unwrap_or_default();
|
||||
|
||||
let mut click_result: serde_json::Value =
|
||||
serde_json::from_str(&result).unwrap_or(json!({ "result": result }));
|
||||
if let Some(obj) = click_result.as_object_mut() {
|
||||
obj.insert("current_url".to_string(), json!(current_url));
|
||||
obj.insert("page_title".to_string(), json!(title));
|
||||
if !screenshot_b64.is_empty() {
|
||||
obj.insert("screenshot_base64".to_string(), json!(screenshot_b64));
|
||||
}
|
||||
}
|
||||
Ok(click_result)
|
||||
}
|
||||
|
||||
async fn fill(
|
||||
&mut self,
|
||||
selector: &str,
|
||||
value: &str,
|
||||
wait_ms: u64,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// Step 1: Focus the element via JS
|
||||
let focus_js = format!(
|
||||
"(function(){{var e=document.querySelector({sel});\
|
||||
if(!e)return 'notfound';e.focus();e.select();return 'ok'}})()",
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
);
|
||||
let found = self.evaluate_raw(&focus_js).await?;
|
||||
if found == "notfound" {
|
||||
return Ok(json!({ "error": format!("Element not found: {selector}") }));
|
||||
}
|
||||
|
||||
// Step 2: Clear existing content with Select All + Delete
|
||||
cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({"type": "keyDown", "key": "a", "code": "KeyA", "modifiers": 2}),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({"type": "keyUp", "key": "a", "code": "KeyA", "modifiers": 2}),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({"type": "keyDown", "key": "Backspace", "code": "Backspace"}),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Input.dispatchKeyEvent",
|
||||
json!({"type": "keyUp", "key": "Backspace", "code": "Backspace"}),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
// Step 3: Insert the text using Input.insertText (single CDP command, no JS eval)
|
||||
cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Input.insertText",
|
||||
json!({"text": value}),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
// Step 4: Verify the value was set
|
||||
let verify_js = format!(
|
||||
"(function(){{var e=document.querySelector({sel});return e?e.value:''}})()",
|
||||
sel = serde_json::to_string(selector).unwrap_or_default(),
|
||||
);
|
||||
let final_value = self.evaluate_raw(&verify_js).await.unwrap_or_default();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(wait_ms)).await;
|
||||
|
||||
Ok(json!({
|
||||
"filled": true,
|
||||
"selector": selector,
|
||||
"value": final_value,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_content(&mut self) -> Result<serde_json::Value, String> {
|
||||
let title = self
|
||||
.evaluate_raw("document.title")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let url = self
|
||||
.evaluate_raw("window.location.href")
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get a structured summary instead of raw HTML (more useful for LLM)
|
||||
let summary_js = r#"(function(){
|
||||
var result = {forms:[], links:[], inputs:[], buttons:[], headings:[], text:''};
|
||||
|
||||
// Forms
|
||||
document.querySelectorAll('form').forEach(function(f,i){
|
||||
if(i<10) result.forms.push({action:f.action, method:f.method, id:f.id});
|
||||
});
|
||||
|
||||
// Links
|
||||
document.querySelectorAll('a[href]').forEach(function(a,i){
|
||||
if(i<30) result.links.push({text:a.textContent.trim().substring(0,80), href:a.href});
|
||||
});
|
||||
|
||||
// Inputs
|
||||
document.querySelectorAll('input,select,textarea').forEach(function(el,i){
|
||||
if(i<30) result.inputs.push({
|
||||
tag:el.tagName.toLowerCase(),
|
||||
type:el.type||'',
|
||||
name:el.name||'',
|
||||
id:el.id||'',
|
||||
placeholder:el.placeholder||'',
|
||||
value:el.type==='password'?'***':el.value.substring(0,50)
|
||||
});
|
||||
});
|
||||
|
||||
// Buttons
|
||||
document.querySelectorAll('button,[type=submit],[role=button]').forEach(function(b,i){
|
||||
if(i<20) result.buttons.push({text:b.textContent.trim().substring(0,60), type:b.type||'', id:b.id||''});
|
||||
});
|
||||
|
||||
// Headings
|
||||
document.querySelectorAll('h1,h2,h3').forEach(function(h,i){
|
||||
if(i<10) result.headings.push(h.textContent.trim().substring(0,100));
|
||||
});
|
||||
|
||||
// Page text (truncated)
|
||||
result.text = document.body ? document.body.innerText.substring(0, 3000) : '';
|
||||
|
||||
return JSON.stringify(result);
|
||||
})()"#;
|
||||
|
||||
let summary = self.evaluate_raw(summary_js).await.unwrap_or_default();
|
||||
let page_data: serde_json::Value = serde_json::from_str(&summary).unwrap_or(json!({}));
|
||||
|
||||
Ok(json!({
|
||||
"url": url,
|
||||
"title": title,
|
||||
"page": page_data,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn evaluate(&mut self, expression: &str) -> Result<serde_json::Value, String> {
|
||||
let result = self.evaluate_raw(expression).await?;
|
||||
Ok(json!({
|
||||
"result": result,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Execute JS and return the string result.
|
||||
async fn evaluate_raw(&mut self, expression: &str) -> Result<String, String> {
|
||||
let resp = cdp_send_session(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
&self.session_id,
|
||||
"Runtime.evaluate",
|
||||
json!({
|
||||
"expression": expression,
|
||||
"returnByValue": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
self.next_id += 1;
|
||||
|
||||
let result = resp
|
||||
.get("result")
|
||||
.and_then(|r| r.get("result"))
|
||||
.and_then(|r| r.get("value"));
|
||||
|
||||
match result {
|
||||
Some(serde_json::Value::String(s)) => Ok(s.clone()),
|
||||
Some(v) => Ok(v.to_string()),
|
||||
None => Ok(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> Result<(), String> {
|
||||
let _ = cdp_send(
|
||||
&mut self.ws,
|
||||
self.next_id,
|
||||
"Target.closeTarget",
|
||||
json!({ "targetId": self.target_id }),
|
||||
)
|
||||
.await;
|
||||
let _ = self.ws.close(None).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up the browser session for a pentest session (call when session ends).
|
||||
pub async fn cleanup_browser_session(session_id: &str) {
|
||||
let mut pool = BROWSER_SESSIONS.lock().await;
|
||||
if let Some(mut sess) = pool.remove(session_id) {
|
||||
let _ = sess.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDP helpers ──
|
||||
|
||||
async fn cdp_send(
|
||||
ws: &mut WsStream,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = json!({ "id": id, "method": method, "params": params });
|
||||
ws.send(Message::Text(msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("WS send failed: {e}"))?;
|
||||
read_until_result(ws, id).await
|
||||
}
|
||||
|
||||
async fn cdp_send_session(
|
||||
ws: &mut WsStream,
|
||||
id: u64,
|
||||
session_id: &str,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let msg = json!({
|
||||
"id": id,
|
||||
"sessionId": session_id,
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
ws.send(Message::Text(msg.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("WS send failed: {e}"))?;
|
||||
read_until_result(ws, id).await
|
||||
}
|
||||
|
||||
async fn read_until_result(ws: &mut WsStream, id: u64) -> Result<serde_json::Value, String> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let msg = tokio::time::timeout_at(deadline, ws.next())
|
||||
.await
|
||||
.map_err(|_| format!("Timeout waiting for CDP response id={id}"))?
|
||||
.ok_or_else(|| "WebSocket closed unexpectedly".to_string())?
|
||||
.map_err(|e| format!("WebSocket read error: {e}"))?;
|
||||
|
||||
if let Message::Text(text) = msg {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
if val.get("id").and_then(|i| i.as_u64()) == Some(id) {
|
||||
if let Some(err) = val.get("error") {
|
||||
return Err(format!("CDP error: {err}"));
|
||||
}
|
||||
return Ok(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
423
compliance-dast/src/tools/console_log_detector.rs
Normal file
423
compliance-dast/src/tools/console_log_detector.rs
Normal file
@@ -0,0 +1,423 @@
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::dast::{DastEvidence, DastFinding, DastVulnType};
|
||||
use compliance_core::models::Severity;
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
|
||||
/// Tool that detects console.log and similar debug statements in frontend JavaScript.
|
||||
pub struct ConsoleLogDetectorTool {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
/// A detected console statement with its context.
|
||||
#[derive(Debug)]
|
||||
struct ConsoleMatch {
|
||||
pattern: String,
|
||||
file_url: String,
|
||||
line_snippet: String,
|
||||
line_number: Option<usize>,
|
||||
}
|
||||
|
||||
impl ConsoleLogDetectorTool {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self { http }
|
||||
}
|
||||
|
||||
/// Patterns that indicate debug/logging statements left in production code.
|
||||
fn patterns() -> Vec<&'static str> {
|
||||
vec![
|
||||
"console.log(",
|
||||
"console.debug(",
|
||||
"console.error(",
|
||||
"console.warn(",
|
||||
"console.info(",
|
||||
"console.trace(",
|
||||
"console.dir(",
|
||||
"console.table(",
|
||||
"debugger;",
|
||||
"alert(",
|
||||
]
|
||||
}
|
||||
|
||||
/// Extract JavaScript file URLs from an HTML page body.
|
||||
fn extract_js_urls(html: &str, base_url: &str) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
let base = url::Url::parse(base_url).ok();
|
||||
|
||||
// Simple regex-free extraction of <script src="...">
|
||||
let mut search_from = 0;
|
||||
while let Some(start) = html[search_from..].find("src=") {
|
||||
let abs_start = search_from + start + 4;
|
||||
if abs_start >= html.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let quote = html.as_bytes().get(abs_start).copied();
|
||||
let (_open, close) = match quote {
|
||||
Some(b'"') => ('"', '"'),
|
||||
Some(b'\'') => ('\'', '\''),
|
||||
_ => {
|
||||
search_from = abs_start + 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let val_start = abs_start + 1;
|
||||
if let Some(end) = html[val_start..].find(close) {
|
||||
let src = &html[val_start..val_start + end];
|
||||
if src.ends_with(".js") || src.contains(".js?") || src.contains("/js/") {
|
||||
let full_url = if src.starts_with("http://") || src.starts_with("https://") {
|
||||
src.to_string()
|
||||
} else if src.starts_with("//") {
|
||||
format!("https:{src}")
|
||||
} else if let Some(ref base) = base {
|
||||
base.join(src).map(|u| u.to_string()).unwrap_or_default()
|
||||
} else {
|
||||
format!("{base_url}/{}", src.trim_start_matches('/'))
|
||||
};
|
||||
if !full_url.is_empty() {
|
||||
urls.push(full_url);
|
||||
}
|
||||
}
|
||||
search_from = val_start + end + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
urls
|
||||
}
|
||||
|
||||
/// Search a JS file's contents for console/debug patterns.
|
||||
fn scan_js_content(content: &str, file_url: &str) -> Vec<ConsoleMatch> {
|
||||
let mut matches = Vec::new();
|
||||
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
// Skip comments (basic heuristic)
|
||||
if trimmed.starts_with("//") || trimmed.starts_with('*') || trimmed.starts_with("/*") {
|
||||
continue;
|
||||
}
|
||||
|
||||
for pattern in Self::patterns() {
|
||||
if line.contains(pattern) {
|
||||
let snippet = if line.len() > 200 {
|
||||
format!("{}...", &line[..200])
|
||||
} else {
|
||||
line.to_string()
|
||||
};
|
||||
matches.push(ConsoleMatch {
|
||||
pattern: pattern.trim_end_matches('(').to_string(),
|
||||
file_url: file_url.to_string(),
|
||||
line_snippet: snippet.trim().to_string(),
|
||||
line_number: Some(line_num + 1),
|
||||
});
|
||||
break; // One match per line is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_js_urls_from_html() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="https://cdn.example.com/lib.js"></script>
|
||||
<script src='//cdn2.example.com/vendor.js'></script>
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
let urls = ConsoleLogDetectorTool::extract_js_urls(html, "https://example.com");
|
||||
assert_eq!(urls.len(), 3);
|
||||
assert!(urls.contains(&"https://example.com/static/app.js".to_string()));
|
||||
assert!(urls.contains(&"https://cdn.example.com/lib.js".to_string()));
|
||||
assert!(urls.contains(&"https://cdn2.example.com/vendor.js".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_js_urls_no_scripts() {
|
||||
let html = "<html><body><p>Hello</p></body></html>";
|
||||
let urls = ConsoleLogDetectorTool::extract_js_urls(html, "https://example.com");
|
||||
assert!(urls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_js_urls_filters_non_js() {
|
||||
let html = r#"<link src="/style.css"><script src="/app.js"></script>"#;
|
||||
let urls = ConsoleLogDetectorTool::extract_js_urls(html, "https://example.com");
|
||||
// Only .js files should be extracted
|
||||
assert_eq!(urls.len(), 1);
|
||||
assert!(urls[0].ends_with("/app.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_js_content_finds_console_log() {
|
||||
let js = r#"
|
||||
function init() {
|
||||
console.log("debug info");
|
||||
doStuff();
|
||||
}
|
||||
"#;
|
||||
let matches = ConsoleLogDetectorTool::scan_js_content(js, "https://example.com/app.js");
|
||||
assert_eq!(matches.len(), 1);
|
||||
assert_eq!(matches[0].pattern, "console.log");
|
||||
assert_eq!(matches[0].line_number, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_js_content_finds_multiple_patterns() {
|
||||
let js =
|
||||
"console.log('a');\nconsole.debug('b');\nconsole.error('c');\ndebugger;\nalert('x');";
|
||||
let matches = ConsoleLogDetectorTool::scan_js_content(js, "test.js");
|
||||
assert_eq!(matches.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_js_content_skips_comments() {
|
||||
let js = "// console.log('commented out');\n* console.log('also comment');\n/* console.log('block comment') */";
|
||||
let matches = ConsoleLogDetectorTool::scan_js_content(js, "test.js");
|
||||
assert!(matches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_js_content_one_match_per_line() {
|
||||
let js = "console.log('a'); console.debug('b');";
|
||||
let matches = ConsoleLogDetectorTool::scan_js_content(js, "test.js");
|
||||
// Only one match per line
|
||||
assert_eq!(matches.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_js_content_empty_input() {
|
||||
let matches = ConsoleLogDetectorTool::scan_js_content("", "test.js");
|
||||
assert!(matches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_list_is_not_empty() {
|
||||
let patterns = ConsoleLogDetectorTool::patterns();
|
||||
assert!(patterns.len() >= 8);
|
||||
assert!(patterns.contains(&"console.log("));
|
||||
assert!(patterns.contains(&"debugger;"));
|
||||
}
|
||||
}
|
||||
|
||||
impl PentestTool for ConsoleLogDetectorTool {
|
||||
fn name(&self) -> &str {
|
||||
"console_log_detector"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Detects console.log, console.debug, console.error, debugger, and similar debug \
|
||||
statements left in production JavaScript. Fetches the HTML page and referenced JS files."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL of the page to check for console.log leakage"
|
||||
},
|
||||
"additional_js_urls": {
|
||||
"type": "array",
|
||||
"description": "Optional additional JavaScript file URLs to scan",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
context: &'a PentestToolContext,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
let url = input
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| CoreError::Dast("Missing required 'url' parameter".to_string()))?;
|
||||
|
||||
let additional_js: Vec<String> = input
|
||||
.get("additional_js_urls")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let target_id = context
|
||||
.target
|
||||
.id
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Fetch the main page
|
||||
let response = self
|
||||
.http
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::Dast(format!("Failed to fetch {url}: {e}")))?;
|
||||
|
||||
let html = response.text().await.unwrap_or_default();
|
||||
|
||||
// Scan inline scripts in the HTML
|
||||
let mut all_matches = Vec::new();
|
||||
let inline_matches = Self::scan_js_content(&html, url);
|
||||
all_matches.extend(inline_matches);
|
||||
|
||||
// Extract JS file URLs from the HTML
|
||||
let mut js_urls = Self::extract_js_urls(&html, url);
|
||||
js_urls.extend(additional_js);
|
||||
js_urls.dedup();
|
||||
|
||||
// Fetch and scan each JS file
|
||||
for js_url in &js_urls {
|
||||
match self.http.get(js_url).send().await {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
let js_content = resp.text().await.unwrap_or_default();
|
||||
// Only scan non-minified-looking files or files where we can still
|
||||
// find patterns (minifiers typically strip console calls, but not always)
|
||||
let file_matches = Self::scan_js_content(&js_content, js_url);
|
||||
all_matches.extend(file_matches);
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let mut findings = Vec::new();
|
||||
let match_data: Vec<serde_json::Value> = all_matches
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"pattern": m.pattern,
|
||||
"file": m.file_url,
|
||||
"line": m.line_number,
|
||||
"snippet": m.line_snippet,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !all_matches.is_empty() {
|
||||
// Group by file for the finding
|
||||
let mut by_file: std::collections::HashMap<&str, Vec<&ConsoleMatch>> =
|
||||
std::collections::HashMap::new();
|
||||
for m in &all_matches {
|
||||
by_file.entry(&m.file_url).or_default().push(m);
|
||||
}
|
||||
|
||||
for (file_url, matches) in &by_file {
|
||||
let pattern_summary: Vec<String> = matches
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|m| {
|
||||
format!(
|
||||
" Line {}: {} - {}",
|
||||
m.line_number.unwrap_or(0),
|
||||
m.pattern,
|
||||
if m.line_snippet.len() > 80 {
|
||||
format!("{}...", &m.line_snippet[..80])
|
||||
} else {
|
||||
m.line_snippet.clone()
|
||||
}
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let evidence = DastEvidence {
|
||||
request_method: "GET".to_string(),
|
||||
request_url: file_url.to_string(),
|
||||
request_headers: None,
|
||||
request_body: None,
|
||||
response_status: 200,
|
||||
response_headers: None,
|
||||
response_snippet: Some(pattern_summary.join("\n")),
|
||||
screenshot_path: None,
|
||||
payload: None,
|
||||
response_time_ms: None,
|
||||
};
|
||||
|
||||
let total = matches.len();
|
||||
let extra = if total > 5 {
|
||||
format!(" (and {} more)", total - 5)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut finding = DastFinding::new(
|
||||
String::new(),
|
||||
target_id.clone(),
|
||||
DastVulnType::ConsoleLogLeakage,
|
||||
format!("Console/debug statements in {}", file_url),
|
||||
format!(
|
||||
"Found {total} console/debug statements in {file_url}{extra}. \
|
||||
These can leak sensitive information such as API responses, user data, \
|
||||
or internal state to anyone with browser developer tools open."
|
||||
),
|
||||
Severity::Low,
|
||||
file_url.to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
finding.cwe = Some("CWE-532".to_string());
|
||||
finding.evidence = vec![evidence];
|
||||
finding.remediation = Some(
|
||||
"Remove console.log/debug/error statements from production code. \
|
||||
Use a build step (e.g., babel plugin, terser) to strip console calls \
|
||||
during the production build."
|
||||
.to_string(),
|
||||
);
|
||||
findings.push(finding);
|
||||
}
|
||||
}
|
||||
|
||||
let total_matches = all_matches.len();
|
||||
let count = findings.len();
|
||||
info!(
|
||||
url,
|
||||
js_files = js_urls.len(),
|
||||
total_matches,
|
||||
"Console log detection complete"
|
||||
);
|
||||
|
||||
Ok(PentestToolResult {
|
||||
summary: if total_matches > 0 {
|
||||
format!(
|
||||
"Found {total_matches} console/debug statements across {} files.",
|
||||
count
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"No console/debug statements found in HTML or {} JS files.",
|
||||
js_urls.len()
|
||||
)
|
||||
},
|
||||
findings,
|
||||
data: json!({
|
||||
"total_matches": total_matches,
|
||||
"js_files_scanned": js_urls.len(),
|
||||
"matches": match_data,
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
477
compliance-dast/src/tools/cookie_analyzer.rs
Normal file
477
compliance-dast/src/tools/cookie_analyzer.rs
Normal file
@@ -0,0 +1,477 @@
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::dast::{DastEvidence, DastFinding, DastVulnType};
|
||||
use compliance_core::models::Severity;
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
|
||||
/// Tool that inspects cookies set by a target for security issues.
|
||||
pub struct CookieAnalyzerTool {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
/// Parsed attributes from a Set-Cookie header.
|
||||
#[derive(Debug)]
|
||||
struct ParsedCookie {
|
||||
name: String,
|
||||
#[allow(dead_code)]
|
||||
value: String,
|
||||
secure: bool,
|
||||
http_only: bool,
|
||||
same_site: Option<String>,
|
||||
domain: Option<String>,
|
||||
path: Option<String>,
|
||||
raw: String,
|
||||
}
|
||||
|
||||
impl CookieAnalyzerTool {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self { http }
|
||||
}
|
||||
|
||||
/// Parse a Set-Cookie header value into a structured representation.
|
||||
fn parse_set_cookie(header: &str) -> ParsedCookie {
|
||||
let raw = header.to_string();
|
||||
let parts: Vec<&str> = header.split(';').collect();
|
||||
|
||||
let (name, value) = if let Some(kv) = parts.first() {
|
||||
let mut kv_split = kv.splitn(2, '=');
|
||||
let k = kv_split.next().unwrap_or("").trim().to_string();
|
||||
let v = kv_split.next().unwrap_or("").trim().to_string();
|
||||
(k, v)
|
||||
} else {
|
||||
(String::new(), String::new())
|
||||
};
|
||||
|
||||
let mut secure = false;
|
||||
let mut http_only = false;
|
||||
let mut same_site = None;
|
||||
let mut domain = None;
|
||||
let mut path = None;
|
||||
|
||||
for part in parts.iter().skip(1) {
|
||||
let trimmed = part.trim().to_lowercase();
|
||||
if trimmed == "secure" {
|
||||
secure = true;
|
||||
} else if trimmed == "httponly" {
|
||||
http_only = true;
|
||||
} else if let Some(ss) = trimmed.strip_prefix("samesite=") {
|
||||
same_site = Some(ss.trim().to_string());
|
||||
} else if let Some(d) = trimmed.strip_prefix("domain=") {
|
||||
domain = Some(d.trim().to_string());
|
||||
} else if let Some(p) = trimmed.strip_prefix("path=") {
|
||||
path = Some(p.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
ParsedCookie {
|
||||
name,
|
||||
value,
|
||||
secure,
|
||||
http_only,
|
||||
same_site,
|
||||
domain,
|
||||
path,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic: does this cookie name suggest it's a session / auth cookie?
|
||||
fn is_sensitive_cookie(name: &str) -> bool {
|
||||
let lower = name.to_lowercase();
|
||||
lower.contains("session")
|
||||
|| lower.contains("sess")
|
||||
|| lower.contains("token")
|
||||
|| lower.contains("auth")
|
||||
|| lower.contains("jwt")
|
||||
|| lower.contains("csrf")
|
||||
|| lower.contains("sid")
|
||||
|| lower == "connect.sid"
|
||||
|| lower == "phpsessid"
|
||||
|| lower == "jsessionid"
|
||||
|| lower == "asp.net_sessionid"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_simple_cookie() {
|
||||
let cookie = CookieAnalyzerTool::parse_set_cookie("session_id=abc123");
|
||||
assert_eq!(cookie.name, "session_id");
|
||||
assert_eq!(cookie.value, "abc123");
|
||||
assert!(!cookie.secure);
|
||||
assert!(!cookie.http_only);
|
||||
assert!(cookie.same_site.is_none());
|
||||
assert!(cookie.domain.is_none());
|
||||
assert!(cookie.path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cookie_with_all_attributes() {
|
||||
let raw = "token=xyz; Secure; HttpOnly; SameSite=Strict; Domain=.example.com; Path=/api";
|
||||
let cookie = CookieAnalyzerTool::parse_set_cookie(raw);
|
||||
assert_eq!(cookie.name, "token");
|
||||
assert_eq!(cookie.value, "xyz");
|
||||
assert!(cookie.secure);
|
||||
assert!(cookie.http_only);
|
||||
assert_eq!(cookie.same_site.as_deref(), Some("strict"));
|
||||
assert_eq!(cookie.domain.as_deref(), Some(".example.com"));
|
||||
assert_eq!(cookie.path.as_deref(), Some("/api"));
|
||||
assert_eq!(cookie.raw, raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cookie_samesite_none() {
|
||||
let cookie = CookieAnalyzerTool::parse_set_cookie("id=1; SameSite=None; Secure");
|
||||
assert_eq!(cookie.same_site.as_deref(), Some("none"));
|
||||
assert!(cookie.secure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cookie_with_equals_in_value() {
|
||||
let cookie = CookieAnalyzerTool::parse_set_cookie("data=a=b=c; HttpOnly");
|
||||
assert_eq!(cookie.name, "data");
|
||||
assert_eq!(cookie.value, "a=b=c");
|
||||
assert!(cookie.http_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sensitive_cookie_known_names() {
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("session_id"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("PHPSESSID"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("JSESSIONID"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("connect.sid"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("asp.net_sessionid"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("auth_token"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("jwt_access"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("csrf_token"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("my_sess_cookie"));
|
||||
assert!(CookieAnalyzerTool::is_sensitive_cookie("SID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_sensitive_cookie_non_sensitive() {
|
||||
assert!(!CookieAnalyzerTool::is_sensitive_cookie("theme"));
|
||||
assert!(!CookieAnalyzerTool::is_sensitive_cookie("language"));
|
||||
assert!(!CookieAnalyzerTool::is_sensitive_cookie("_ga"));
|
||||
assert!(!CookieAnalyzerTool::is_sensitive_cookie("tracking"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_cookie_header() {
|
||||
let cookie = CookieAnalyzerTool::parse_set_cookie("");
|
||||
assert_eq!(cookie.name, "");
|
||||
assert_eq!(cookie.value, "");
|
||||
}
|
||||
}
|
||||
|
||||
impl PentestTool for CookieAnalyzerTool {
|
||||
fn name(&self) -> &str {
|
||||
"cookie_analyzer"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Analyzes cookies set by a target URL. Checks for Secure, HttpOnly, SameSite attributes \
|
||||
and overly broad Domain/Path settings. Focuses on session and authentication cookies."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to fetch and analyze cookies from"
|
||||
},
|
||||
"login_url": {
|
||||
"type": "string",
|
||||
"description": "Optional login URL to also check (may set auth cookies)"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
context: &'a PentestToolContext,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
let url = input
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| CoreError::Dast("Missing required 'url' parameter".to_string()))?;
|
||||
|
||||
let login_url = input.get("login_url").and_then(|v| v.as_str());
|
||||
|
||||
let target_id = context
|
||||
.target
|
||||
.id
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let mut findings = Vec::new();
|
||||
let mut cookie_data = Vec::new();
|
||||
|
||||
// Collect Set-Cookie headers from the main URL and optional login URL
|
||||
let urls_to_check: Vec<&str> = std::iter::once(url).chain(login_url).collect();
|
||||
|
||||
for check_url in &urls_to_check {
|
||||
// Use a client that does NOT follow redirects so we catch cookies on redirect responses
|
||||
let no_redirect_client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| CoreError::Dast(format!("Client build error: {e}")))?;
|
||||
|
||||
let response = match no_redirect_client.get(*check_url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_e) => {
|
||||
// Try with the main client that follows redirects
|
||||
match self.http.get(*check_url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let set_cookie_headers: Vec<String> = response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok().map(String::from))
|
||||
.collect();
|
||||
|
||||
for raw_cookie in &set_cookie_headers {
|
||||
let cookie = Self::parse_set_cookie(raw_cookie);
|
||||
let is_sensitive = Self::is_sensitive_cookie(&cookie.name);
|
||||
let is_https = check_url.starts_with("https://");
|
||||
|
||||
let cookie_info = json!({
|
||||
"name": cookie.name,
|
||||
"secure": cookie.secure,
|
||||
"http_only": cookie.http_only,
|
||||
"same_site": cookie.same_site,
|
||||
"domain": cookie.domain,
|
||||
"path": cookie.path,
|
||||
"is_sensitive": is_sensitive,
|
||||
"url": check_url,
|
||||
});
|
||||
cookie_data.push(cookie_info);
|
||||
|
||||
// Check: missing Secure flag
|
||||
if !cookie.secure && (is_https || is_sensitive) {
|
||||
let severity = if is_sensitive {
|
||||
Severity::High
|
||||
} else {
|
||||
Severity::Medium
|
||||
};
|
||||
|
||||
let evidence = DastEvidence {
|
||||
request_method: "GET".to_string(),
|
||||
request_url: check_url.to_string(),
|
||||
request_headers: None,
|
||||
request_body: None,
|
||||
response_status: status,
|
||||
response_headers: None,
|
||||
response_snippet: Some(cookie.raw.clone()),
|
||||
screenshot_path: None,
|
||||
payload: None,
|
||||
response_time_ms: None,
|
||||
};
|
||||
|
||||
let mut finding = DastFinding::new(
|
||||
String::new(),
|
||||
target_id.clone(),
|
||||
DastVulnType::CookieSecurity,
|
||||
format!("Cookie '{}' missing Secure flag", cookie.name),
|
||||
format!(
|
||||
"The cookie '{}' does not have the Secure attribute set. \
|
||||
Without this flag, the cookie can be transmitted over unencrypted HTTP connections.",
|
||||
cookie.name
|
||||
),
|
||||
severity,
|
||||
check_url.to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
finding.cwe = Some("CWE-614".to_string());
|
||||
finding.evidence = vec![evidence];
|
||||
finding.remediation = Some(
|
||||
"Add the 'Secure' attribute to the Set-Cookie header to ensure the \
|
||||
cookie is only sent over HTTPS connections."
|
||||
.to_string(),
|
||||
);
|
||||
findings.push(finding);
|
||||
}
|
||||
|
||||
// Check: missing HttpOnly flag on sensitive cookies
|
||||
if !cookie.http_only && is_sensitive {
|
||||
let evidence = DastEvidence {
|
||||
request_method: "GET".to_string(),
|
||||
request_url: check_url.to_string(),
|
||||
request_headers: None,
|
||||
request_body: None,
|
||||
response_status: status,
|
||||
response_headers: None,
|
||||
response_snippet: Some(cookie.raw.clone()),
|
||||
screenshot_path: None,
|
||||
payload: None,
|
||||
response_time_ms: None,
|
||||
};
|
||||
|
||||
let mut finding = DastFinding::new(
|
||||
String::new(),
|
||||
target_id.clone(),
|
||||
DastVulnType::CookieSecurity,
|
||||
format!("Cookie '{}' missing HttpOnly flag", cookie.name),
|
||||
format!(
|
||||
"The session/auth cookie '{}' does not have the HttpOnly attribute. \
|
||||
This makes it accessible to JavaScript, increasing the impact of XSS attacks.",
|
||||
cookie.name
|
||||
),
|
||||
Severity::High,
|
||||
check_url.to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
finding.cwe = Some("CWE-1004".to_string());
|
||||
finding.evidence = vec![evidence];
|
||||
finding.remediation = Some(
|
||||
"Add the 'HttpOnly' attribute to the Set-Cookie header to prevent \
|
||||
JavaScript access to the cookie."
|
||||
.to_string(),
|
||||
);
|
||||
findings.push(finding);
|
||||
}
|
||||
|
||||
// Check: missing or weak SameSite
|
||||
if is_sensitive {
|
||||
let weak_same_site = match &cookie.same_site {
|
||||
None => true,
|
||||
Some(ss) => ss == "none",
|
||||
};
|
||||
|
||||
if weak_same_site {
|
||||
let evidence = DastEvidence {
|
||||
request_method: "GET".to_string(),
|
||||
request_url: check_url.to_string(),
|
||||
request_headers: None,
|
||||
request_body: None,
|
||||
response_status: status,
|
||||
response_headers: None,
|
||||
response_snippet: Some(cookie.raw.clone()),
|
||||
screenshot_path: None,
|
||||
payload: None,
|
||||
response_time_ms: None,
|
||||
};
|
||||
|
||||
let desc = if cookie.same_site.is_none() {
|
||||
format!(
|
||||
"The session/auth cookie '{}' does not have a SameSite attribute. \
|
||||
This may allow cross-site request forgery (CSRF) attacks.",
|
||||
cookie.name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"The session/auth cookie '{}' has SameSite=None, which allows it \
|
||||
to be sent in cross-site requests, enabling CSRF attacks.",
|
||||
cookie.name
|
||||
)
|
||||
};
|
||||
|
||||
let mut finding = DastFinding::new(
|
||||
String::new(),
|
||||
target_id.clone(),
|
||||
DastVulnType::CookieSecurity,
|
||||
format!("Cookie '{}' missing or weak SameSite", cookie.name),
|
||||
desc,
|
||||
Severity::Medium,
|
||||
check_url.to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
finding.cwe = Some("CWE-1275".to_string());
|
||||
finding.evidence = vec![evidence];
|
||||
finding.remediation = Some(
|
||||
"Set 'SameSite=Strict' or 'SameSite=Lax' on session/auth cookies \
|
||||
to prevent cross-site request inclusion."
|
||||
.to_string(),
|
||||
);
|
||||
findings.push(finding);
|
||||
}
|
||||
}
|
||||
|
||||
// Check: overly broad domain
|
||||
if let Some(ref domain) = cookie.domain {
|
||||
// A domain starting with a dot applies to all subdomains
|
||||
let dot_domain = domain.starts_with('.');
|
||||
// Count domain parts - if only 2 parts (e.g., .example.com), it's broad
|
||||
let parts: Vec<&str> = domain.trim_start_matches('.').split('.').collect();
|
||||
if dot_domain && parts.len() <= 2 && is_sensitive {
|
||||
let evidence = DastEvidence {
|
||||
request_method: "GET".to_string(),
|
||||
request_url: check_url.to_string(),
|
||||
request_headers: None,
|
||||
request_body: None,
|
||||
response_status: status,
|
||||
response_headers: None,
|
||||
response_snippet: Some(cookie.raw.clone()),
|
||||
screenshot_path: None,
|
||||
payload: None,
|
||||
response_time_ms: None,
|
||||
};
|
||||
|
||||
let mut finding = DastFinding::new(
|
||||
String::new(),
|
||||
target_id.clone(),
|
||||
DastVulnType::CookieSecurity,
|
||||
format!("Cookie '{}' has overly broad domain", cookie.name),
|
||||
format!(
|
||||
"The cookie '{}' is scoped to domain '{}' which includes all \
|
||||
subdomains. If any subdomain is compromised, the attacker can \
|
||||
access this cookie.",
|
||||
cookie.name, domain
|
||||
),
|
||||
Severity::Low,
|
||||
check_url.to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
finding.cwe = Some("CWE-1004".to_string());
|
||||
finding.evidence = vec![evidence];
|
||||
finding.remediation = Some(
|
||||
"Restrict the cookie domain to the specific subdomain that needs it \
|
||||
rather than the entire parent domain."
|
||||
.to_string(),
|
||||
);
|
||||
findings.push(finding);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let count = findings.len();
|
||||
info!(url, findings = count, "Cookie analysis complete");
|
||||
|
||||
Ok(PentestToolResult {
|
||||
summary: if count > 0 {
|
||||
format!("Found {count} cookie security issues.")
|
||||
} else if cookie_data.is_empty() {
|
||||
"No cookies were set by the target.".to_string()
|
||||
} else {
|
||||
"All cookies have proper security attributes.".to_string()
|
||||
},
|
||||
findings,
|
||||
data: json!({
|
||||
"cookies": cookie_data,
|
||||
"total_cookies": cookie_data.len(),
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
448
compliance-dast/src/tools/cors_checker.rs
Normal file
448
compliance-dast/src/tools/cors_checker.rs
Normal file
@@ -0,0 +1,448 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::dast::{DastEvidence, DastFinding, DastVulnType};
|
||||
use compliance_core::models::Severity;
|
||||
use compliance_core::traits::pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
use serde_json::json;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Tool that checks CORS configuration for security issues.
|
||||
pub struct CorsCheckerTool {
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CorsCheckerTool {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self { http }
|
||||
}
|
||||
|
||||
/// Origins to test against the target.
|
||||
fn test_origins(target_host: &str) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("null_origin", "null".to_string()),
|
||||
("evil_domain", "https://evil.com".to_string()),
|
||||
("subdomain_spoof", format!("https://{target_host}.evil.com")),
|
||||
("prefix_spoof", format!("https://evil-{target_host}")),
|
||||
("http_downgrade", format!("http://{target_host}")),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_origins_contains_expected_variants() {
|
||||
let origins = CorsCheckerTool::test_origins("example.com");
|
||||
assert_eq!(origins.len(), 5);
|
||||
|
||||
let names: Vec<&str> = origins.iter().map(|(name, _)| *name).collect();
|
||||
assert!(names.contains(&"null_origin"));
|
||||
assert!(names.contains(&"evil_domain"));
|
||||
assert!(names.contains(&"subdomain_spoof"));
|
||||
assert!(names.contains(&"prefix_spoof"));
|
||||
assert!(names.contains(&"http_downgrade"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_origins_uses_target_host() {
|
||||
let origins = CorsCheckerTool::test_origins("myapp.io");
|
||||
|
||||
let subdomain = origins
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "subdomain_spoof")
|
||||
.unwrap();
|
||||
assert_eq!(subdomain.1, "https://myapp.io.evil.com");
|
||||
|
||||
let prefix = origins.iter().find(|(n, _)| *n == "prefix_spoof").unwrap();
|
||||
assert_eq!(prefix.1, "https://evil-myapp.io");
|
||||
|
||||
let http_downgrade = origins
|
||||
.iter()
|
||||
.find(|(n, _)| *n == "http_downgrade")
|
||||
.unwrap();
|
||||
assert_eq!(http_downgrade.1, "http://myapp.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_origins_null_and_evil_are_static() {
|
||||
let origins = CorsCheckerTool::test_origins("anything.com");
|
||||
let null_origin = origins.iter().find(|(n, _)| *n == "null_origin").unwrap();
|
||||
assert_eq!(null_origin.1, "null");
|
||||
let evil = origins.iter().find(|(n, _)| *n == "evil_domain").unwrap();
|
||||
assert_eq!(evil.1, "https://evil.com");
|
||||
}
|
||||
}
|
||||
|
||||
impl PentestTool for CorsCheckerTool {
|
||||
fn name(&self) -> &str {
|
||||
"cors_checker"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Checks CORS configuration by sending requests with various Origin headers. Tests for \
|
||||
wildcard origins, reflected origins, null origin acceptance, and dangerous \
|
||||
Access-Control-Allow-Credentials combinations."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to test CORS configuration on"
|
||||
},
|
||||
"additional_origins": {
|
||||
"type": "array",
|
||||
"description": "Optional additional origin values to test",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
fn execute<'a>(
|
||||
&'a self,
|
||||
input: serde_json::Value,
|
||||
context: &'a PentestToolContext,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn std::future::Future<Output = Result<PentestToolResult, CoreError>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
let url = input
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| CoreError::Dast("Missing required 'url' parameter".to_string()))?;
|
||||
|
||||
let additional_origins: Vec<String> = input
|
||||
.get("additional_origins")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let target_id = context
|
||||
.target
|
||||
.id
|
||||
.map(|oid| oid.to_hex())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let target_host = url::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(String::from))
|
||||
.unwrap_or_else(|| url.to_string());
|
||||
|
||||
let mut findings = Vec::new();
|
||||
let mut cors_data: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// First, send a request without Origin to get baseline
|
||||
let baseline = self
|
||||
.http
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::Dast(format!("Failed to fetch {url}: {e}")))?;
|
||||
|
||||
let baseline_acao = baseline
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
cors_data.push(json!({
|
||||
"origin": null,
|
||||
"acao": baseline_acao,
|
||||
}));
|
||||
|
||||
// Check for wildcard + credentials (dangerous combo)
|
||||
if let Some(ref acao) = baseline_acao {
|
||||
if acao == "*" {
|
||||
let acac = baseline
|
||||
.headers()
|
||||
.get("access-control-allow-credentials")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if acac.to_lowercase() == "true" {
|
||||
let evidence = DastEvidence {
|
||||
request_method: "GET".to_string(),
|
||||
request_url: url.to_string(),
|
||||
request_headers: None,
|
||||
request_body: None,
|
||||
response_status: baseline.status().as_u16(),
|
||||
response_headers: None,
|
||||
response_snippet: Some("Access-Control-Allow-Origin: *\nAccess-Control-Allow-Credentials: true".to_string()),
|
||||
screenshot_path: None,
|
||||
payload: None,
|
||||
response_time_ms: None,
|
||||
};
|
||||
|
||||
let mut finding = DastFinding::new(
|
||||
String::new(),
|
||||
target_id.clone(),
|
||||
DastVulnType::CorsMisconfiguration,
|
||||
"CORS wildcard with credentials".to_string(),
|
||||
format!(
|
||||
"The endpoint {url} returns Access-Control-Allow-Origin: * with \
|
||||
Access-Control-Allow-Credentials: true. While browsers should block this \
|
||||
combination, it indicates a serious CORS misconfiguration."
|
||||
),
|
||||
Severity::High,
|
||||
url.to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
finding.cwe = Some("CWE-942".to_string());
|
||||
finding.evidence = vec![evidence];
|
||||
finding.remediation = Some(
|
||||
"Never combine Access-Control-Allow-Origin: * with \
|
||||
Access-Control-Allow-Credentials: true. Specify explicit allowed origins."
|
||||
.to_string(),
|
||||
);
|
||||
findings.push(finding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test with various Origin headers
|
||||
let mut test_origins = Self::test_origins(&target_host);
|
||||
for origin in &additional_origins {
|
||||
test_origins.push(("custom", origin.clone()));
|
||||
}
|
||||
|
||||
for (test_name, origin) in &test_origins {
|
||||
let resp = match self
|
||||
.http
|
||||
.get(url)
|
||||
.header("Origin", origin.as_str())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
let acao = resp
|
||||
.headers()
|
||||
.get("access-control-allow-origin")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
let acac = resp
|
||||
.headers()
|
||||
.get("access-control-allow-credentials")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
let acam = resp
|
||||
.headers()
|
||||
.get("access-control-allow-methods")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
cors_data.push(json!({
|
||||
"test": test_name,
|
||||
"origin": origin,
|
||||
"acao": acao,
|
||||
"acac": acac,
|
||||
"acam": acam,
|
||||
"status": status,
|
||||
}));
|
||||
|
||||
// Check if the origin was reflected back
|
||||
if let Some(ref acao_val) = acao {
|
||||
let origin_reflected = acao_val == origin;
|
||||
let credentials_allowed = acac
|
||||
.as_ref()
|
||||
.map(|v| v.to_lowercase() == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
if origin_reflected && *test_name != "http_downgrade" {
|
||||
let severity = if credentials_allowed {
|
||||
Severity::Critical
|
||||
} else {
|
||||
Severity::High
|
||||
};
|
||||
|
||||
let resp_headers: HashMap<String, String> = resp
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
|
||||
.collect();
|
||||
|
||||
let evidence = DastEvidence {
|
||||
request_method: "GET".to_string(),
|
||||
request_url: url.to_string(),
|
||||
request_headers: Some(
|
||||
[("Origin".to_string(), origin.clone())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
request_body: None,
|
||||
response_status: status,
|
||||
response_headers: Some(resp_headers),
|
||||
response_snippet: Some(format!(
|
||||
"Origin: {origin}\nAccess-Control-Allow-Origin: {acao_val}\n\
|
||||
Access-Control-Allow-Credentials: {}",
|
||||
acac.as_deref().unwrap_or("not set")
|
||||
)),
|
||||
screenshot_path: None,
|
||||
payload: Some(origin.clone()),
|
||||
response_time_ms: None,
|
||||
};
|
||||
|
||||
let title = match *test_name {
|
||||
"null_origin" => "CORS accepts null origin".to_string(),
|
||||
"evil_domain" => "CORS reflects arbitrary origin".to_string(),
|
||||
"subdomain_spoof" => {
|
||||
"CORS vulnerable to subdomain spoofing".to_string()
|
||||
}
|
||||
"prefix_spoof" => "CORS vulnerable to prefix spoofing".to_string(),
|
||||
_ => format!("CORS reflects untrusted origin ({test_name})"),
|
||||
};
|
||||
|
||||
let cred_note = if credentials_allowed {
|
||||
" Combined with Access-Control-Allow-Credentials: true, this allows \
|
||||
the attacker to steal authenticated data."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let mut finding = DastFinding::new(
|
||||
String::new(),
|
||||
target_id.clone(),
|
||||
DastVulnType::CorsMisconfiguration,
|
||||
title,
|
||||
format!(
|
||||
"The endpoint {url} reflects the Origin header '{origin}' back in \
|
||||
Access-Control-Allow-Origin, allowing cross-origin requests from \
|
||||
untrusted domains.{cred_note}"
|
||||
),
|
||||
severity,
|
||||
url.to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
finding.cwe = Some("CWE-942".to_string());
|
||||
finding.exploitable = credentials_allowed;
|
||||
finding.evidence = vec![evidence];
|
||||
finding.remediation = Some(
|
||||
"Validate the Origin header against a whitelist of trusted origins. \
|
||||
Do not reflect the Origin header value directly. Use specific allowed \
|
||||
origins instead of wildcards."
|
||||
.to_string(),
|
||||
);
|
||||
findings.push(finding);
|
||||
|
||||
warn!(
|
||||
url,
|
||||
test_name,
|
||||
origin,
|
||||
credentials = credentials_allowed,
|
||||
"CORS misconfiguration detected"
|
||||
);
|
||||
}
|
||||
|
||||
// Special case: HTTP downgrade
|
||||
if *test_name == "http_downgrade" && origin_reflected && credentials_allowed {
|
||||
let evidence = DastEvidence {
|
||||
request_method: "GET".to_string(),
|
||||
request_url: url.to_string(),
|
||||
request_headers: Some(
|
||||
[("Origin".to_string(), origin.clone())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
request_body: None,
|
||||
response_status: status,
|
||||
response_headers: None,
|
||||
response_snippet: Some(format!(
|
||||
"HTTP origin accepted: {origin} -> ACAO: {acao_val}"
|
||||
)),
|
||||
screenshot_path: None,
|
||||
payload: Some(origin.clone()),
|
||||
response_time_ms: None,
|
||||
};
|
||||
|
||||
let mut finding = DastFinding::new(
|
||||
String::new(),
|
||||
target_id.clone(),
|
||||
DastVulnType::CorsMisconfiguration,
|
||||
"CORS allows HTTP origin with credentials".to_string(),
|
||||
format!(
|
||||
"The HTTPS endpoint {url} accepts the HTTP origin {origin} with \
|
||||
credentials. An attacker performing a man-in-the-middle attack on \
|
||||
the HTTP version could steal authenticated data."
|
||||
),
|
||||
Severity::High,
|
||||
url.to_string(),
|
||||
"GET".to_string(),
|
||||
);
|
||||
finding.cwe = Some("CWE-942".to_string());
|
||||
finding.evidence = vec![evidence];
|
||||
finding.remediation = Some(
|
||||
"Do not accept HTTP origins for HTTPS endpoints. Ensure CORS \
|
||||
origin validation enforces the https:// scheme."
|
||||
.to_string(),
|
||||
);
|
||||
findings.push(finding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also send a preflight OPTIONS request
|
||||
if let Ok(resp) = self
|
||||
.http
|
||||
.request(reqwest::Method::OPTIONS, url)
|
||||
.header("Origin", "https://evil.com")
|
||||
.header("Access-Control-Request-Method", "POST")
|
||||
.header(
|
||||
"Access-Control-Request-Headers",
|
||||
"Authorization, Content-Type",
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
let acam = resp
|
||||
.headers()
|
||||
.get("access-control-allow-methods")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
let acah = resp
|
||||
.headers()
|
||||
.get("access-control-allow-headers")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
cors_data.push(json!({
|
||||
"test": "preflight",
|
||||
"status": resp.status().as_u16(),
|
||||
"allow_methods": acam,
|
||||
"allow_headers": acah,
|
||||
}));
|
||||
}
|
||||
|
||||
let count = findings.len();
|
||||
info!(url, findings = count, "CORS check complete");
|
||||
|
||||
Ok(PentestToolResult {
|
||||
summary: if count > 0 {
|
||||
format!("Found {count} CORS misconfiguration issues for {url}.")
|
||||
} else {
|
||||
format!("CORS configuration appears secure for {url}.")
|
||||
},
|
||||
findings,
|
||||
data: json!({
|
||||
"tests": cors_data,
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user