Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f474699279 |
@@ -7,17 +7,4 @@ ignore = [
|
||||
# not a realistic attack surface here. Revisit when mongodb bumps hickory.
|
||||
"RUSTSEC-2026-0118", # NSEC3 loop, no fix available upstream
|
||||
"RUSTSEC-2026-0119", # O(n²) name compression, fixed in hickory-proto >=0.26.1
|
||||
|
||||
# rmcp 0.16.0 — DNS rebinding in Streamable HTTP server transport (missing
|
||||
# Host header validation). Patched in rmcp >= 1.4.0, which is a major API
|
||||
# version jump from our pin; rmcp shipped 0.x → 1.x → 2.x in three months
|
||||
# and the migration touches every tool handler + the auth middleware we
|
||||
# just landed in #92. Threat model in our deployment: the MCP server is
|
||||
# exposed at a public hostname (comp-mcp-dev.meghsakha.com) behind orca's
|
||||
# TLS-terminating ingress with per-tenant bearer auth — the attack model
|
||||
# (browser DNS-rebinding into localhost MCP server) doesn't directly apply.
|
||||
# Defense-in-depth Host-header check is still a worthwhile follow-up.
|
||||
# FOLLOW-UP: bump rmcp to 2.x in a dedicated PR (M7.3 follow-up, sized
|
||||
# multi-hour due to API surface change).
|
||||
"RUSTSEC-2026-0189",
|
||||
]
|
||||
|
||||
@@ -34,24 +34,6 @@ SCAN_SCHEDULE=0 0 */6 * * *
|
||||
CVE_MONITOR_SCHEDULE=0 0 0 * * *
|
||||
GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos
|
||||
|
||||
# Dynamic PLC testing — ephemeral soft-PLC provisioning (#183). Off unless
|
||||
# enabled; requires the agent container to have Docker access (socket mount).
|
||||
# When on, a PLC/SPS target with control logic but no reachable device gets its
|
||||
# logic instantiated on a throwaway OpenPLC, probed, then torn down.
|
||||
PLC_RUNTIME_ENABLED=0
|
||||
PLC_RUNTIME_IMAGE=registry.meghsakha.com/openplc:latest
|
||||
PLC_RUNTIME_NETWORK=certifai
|
||||
PLC_RUNTIME_MEMORY=512m
|
||||
PLC_RUNTIME_CPUS=0.5
|
||||
PLC_RUNTIME_MAX_LIFETIME_SECS=180
|
||||
PLC_RUNTIME_OPENPLC_USER=openplc
|
||||
PLC_RUNTIME_OPENPLC_PASSWORD=openplc
|
||||
|
||||
# Werkbank runner API (/api/v1/werkbank/jobs/*, /api/v1/werkbank/artifacts/*).
|
||||
# When set, mounts the runner-facing queue + artifact endpoints behind this
|
||||
# bearer token; runners present the same token. Unset = endpoints not mounted.
|
||||
WERKBANK_RUNNER_TOKEN=
|
||||
|
||||
# Dashboard
|
||||
DASHBOARD_PORT=8080
|
||||
AGENT_API_URL=http://localhost:3001
|
||||
|
||||
+12
-71
@@ -9,33 +9,15 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-D warnings"
|
||||
# Compile cache: sccache -> Hetzner S3 (breakpilot-sccache), runner-independent
|
||||
# and persistent across CI runs (own key prefix). Reuses the shared cluster S3
|
||||
# creds (same bucket as werkpilot). Requires repo secrets HETZNER_S3_ACCESS_KEY
|
||||
# and HETZNER_S3_SECRET_KEY.
|
||||
# sccache caches compilation artifacts within a job so that compiling
|
||||
# both --features server and --features web shares common crate work.
|
||||
RUSTC_WRAPPER: /usr/local/bin/sccache
|
||||
SCCACHE_BUCKET: breakpilot-sccache
|
||||
SCCACHE_ENDPOINT: https://nbg1.your-objectstorage.com
|
||||
SCCACHE_REGION: auto
|
||||
SCCACHE_S3_USE_SSL: "true"
|
||||
SCCACHE_S3_KEY_PREFIX: compliance-scanner
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.HETZNER_S3_ACCESS_KEY }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.HETZNER_S3_SECRET_KEY }}
|
||||
# compliance-agent depends on tramiton-core via git; use the system git so the
|
||||
# credential rewrite below (see "Configure git auth ...") is honored on fetch.
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
|
||||
# Throttle cargo so a ~670-crate concurrent download burst doesn't 429 the
|
||||
# Kellnr mirror: fewer concurrent connections (HTTP/1.1) + more retries.
|
||||
CARGO_NET_RETRY: "10"
|
||||
CARGO_HTTP_MULTIPLEXING: "false"
|
||||
SCCACHE_DIR: /tmp/sccache
|
||||
|
||||
# Cancel superseded PR runs, but NEVER cancel main-branch runs — those build and
|
||||
# deploy per-service images, and cancelling one merge's deploy when the next
|
||||
# merge lands leaves a service un-deployed (as happened between two back-to-back
|
||||
# merges). So cancel-in-progress only for pull_request events.
|
||||
# Cancel in-progress runs for the same branch/PR
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -54,44 +36,16 @@ jobs:
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout FETCH_HEAD
|
||||
# Resolve crates.io deps through the self-hosted Kellnr mirror (cached,
|
||||
# crates.io-independent). Git deps (tramiton-core) are unaffected — source
|
||||
# replacement only applies to crates.io-sourced crates.
|
||||
- name: Use Kellnr crates.io mirror
|
||||
run: |
|
||||
: "${CARGO_HOME:=/usr/local/cargo}"
|
||||
mkdir -p "$CARGO_HOME"
|
||||
{
|
||||
echo '[source.crates-io]'
|
||||
echo 'replace-with = "kellnr"'
|
||||
echo '[registries.kellnr]'
|
||||
echo 'index = "sparse+https://crates.meghsakha.com/api/v1/cratesio/"'
|
||||
} >> "$CARGO_HOME/config.toml"
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
- name: Install tools
|
||||
run: |
|
||||
rustup component add rustfmt clippy
|
||||
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.10.0-x86_64-unknown-linux-musl/sccache
|
||||
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
|
||||
cargo install cargo-audit --locked
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
# compliance-agent has a git dependency on tramiton-core (a private repo on
|
||||
# this Gitea instance). Rewrite its SSH URL to HTTPS + a PAT so the runner
|
||||
# can fetch it. Requires the repo secret TRAMITON_FETCH_TOKEN (a Gitea PAT
|
||||
# with read:repository, owned by a user with access to sharang/tramiton).
|
||||
# (Honored on fetch because CARGO_NET_GIT_FETCH_WITH_CLI=true uses system git.)
|
||||
- name: Configure git auth for private tramiton dependency
|
||||
run: |
|
||||
git config --global \
|
||||
url."https://sharang:${{ secrets.TRAMITON_FETCH_TOKEN }}@gitea.meghsakha.com/".insteadOf \
|
||||
"ssh://git@gitea.meghsakha.com:22222/"
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
# Format (no compilation needed)
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
@@ -107,8 +61,6 @@ jobs:
|
||||
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: Clippy (werkbank-exec)
|
||||
run: cargo clippy -p werkbank-exec -- -D warnings
|
||||
|
||||
# Security audit
|
||||
- name: Security Audit
|
||||
@@ -117,8 +69,8 @@ jobs:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
# Tests (reuses compilation artifacts from clippy)
|
||||
- name: Tests (core + agent + werkbank-exec)
|
||||
run: cargo test -p compliance-core -p compliance-agent -p werkbank-exec --lib
|
||||
- name: Tests (core + agent)
|
||||
run: cargo test -p compliance-core -p compliance-agent --lib
|
||||
- name: Tests (dashboard server)
|
||||
run: cargo test -p compliance-dashboard --features server --no-default-features
|
||||
- name: Tests (dashboard web)
|
||||
@@ -196,18 +148,13 @@ jobs:
|
||||
image: docker:27-cli
|
||||
steps:
|
||||
- name: Build, push and trigger orca redeploy
|
||||
env:
|
||||
# PAT for fetching the private tramiton-core git dependency during the
|
||||
# image build (injected as a BuildKit secret, never baked into a layer).
|
||||
TRAMITON_FETCH_TOKEN: ${{ secrets.TRAMITON_FETCH_TOKEN }}
|
||||
run: |
|
||||
apk add --no-cache git curl openssl
|
||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||
IMAGE=registry.meghsakha.com/compliance-agent
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||
-f Dockerfile.agent -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker build -f Dockerfile.agent -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
||||
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy agent"}}' "${GITHUB_SHA}")
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||
@@ -222,16 +169,13 @@ jobs:
|
||||
image: docker:27-cli
|
||||
steps:
|
||||
- name: Build, push and trigger orca redeploy
|
||||
env:
|
||||
TRAMITON_FETCH_TOKEN: ${{ secrets.TRAMITON_FETCH_TOKEN }}
|
||||
run: |
|
||||
apk add --no-cache git curl openssl
|
||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||
IMAGE=registry.meghsakha.com/compliance-dashboard
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||
-f Dockerfile.dashboard -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker build -f Dockerfile.dashboard -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
||||
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy dashboard"}}' "${GITHUB_SHA}")
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||
@@ -267,16 +211,13 @@ jobs:
|
||||
image: docker:27-cli
|
||||
steps:
|
||||
- name: Build, push and trigger orca redeploy
|
||||
env:
|
||||
TRAMITON_FETCH_TOKEN: ${{ secrets.TRAMITON_FETCH_TOKEN }}
|
||||
run: |
|
||||
apk add --no-cache git curl openssl
|
||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||
IMAGE=registry.meghsakha.com/compliance-mcp
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||
-f Dockerfile.mcp -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker build -f Dockerfile.mcp -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
||||
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy mcp"}}' "${GITHUB_SHA}")
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||
|
||||
Generated
+7
-149
@@ -676,10 +676,8 @@ dependencies = [
|
||||
"jsonwebtoken",
|
||||
"mongodb",
|
||||
"octocrab",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -689,17 +687,12 @@ dependencies = [
|
||||
"tokio-cron-scheduler",
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tramiton-core",
|
||||
"tramiton-repro",
|
||||
"tramiton-sbom",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"werkbank-exec",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -724,7 +717,6 @@ dependencies = [
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
@@ -825,15 +817,12 @@ dependencies = [
|
||||
"bson",
|
||||
"chrono",
|
||||
"compliance-core",
|
||||
"dashmap",
|
||||
"dotenvy",
|
||||
"hex",
|
||||
"mongodb",
|
||||
"rmcp",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
@@ -1124,9 +1113,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
version = "0.9.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -3772,15 +3761,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "octocrab"
|
||||
version = "0.44.1"
|
||||
@@ -4212,7 +4192,7 @@ version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||
dependencies = [
|
||||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4297,9 +4277,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
@@ -4631,12 +4611,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rust-stemmers"
|
||||
version = "1.2.0"
|
||||
@@ -5017,15 +4991,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -5089,12 +5054,6 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -5230,7 +5189,7 @@ version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"heck 0.4.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
@@ -5846,27 +5805,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
@@ -5876,20 +5814,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
@@ -5897,7 +5821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
]
|
||||
@@ -5911,12 +5835,6 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.12.3"
|
||||
@@ -6163,46 +6081,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tramiton-core"
|
||||
version = "0.4.1"
|
||||
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"toml",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tramiton-repro"
|
||||
version = "0.4.1"
|
||||
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"toml",
|
||||
"tramiton-core",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tramiton-sbom"
|
||||
version = "0.4.1"
|
||||
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
dependencies = [
|
||||
"object",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tramiton-core",
|
||||
"tramiton-repro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.24.7"
|
||||
@@ -6478,7 +6356,6 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -6722,25 +6599,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkbank-exec"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"compliance-core",
|
||||
"compliance-dast",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"secrecy",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "6.0.3"
|
||||
|
||||
+2
-5
@@ -7,7 +7,6 @@ members = [
|
||||
"compliance-dast",
|
||||
"compliance-mcp",
|
||||
"compliance-smoke",
|
||||
"werkbank-exec",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -24,16 +23,14 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
mongodb = { version = "3", features = ["rustls-tls", "compat-3-0-0"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart", "cookies"], default-features = false }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
thiserror = "2"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
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"
|
||||
rand = "0.9"
|
||||
base64 = "0.22"
|
||||
|
||||
+2
-41
@@ -2,22 +2,7 @@ FROM rust:1.94-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
# compliance-agent depends on the private tramiton-core git repo. Authenticate
|
||||
# the fetch with a PAT passed as a BuildKit secret (never baked into a layer).
|
||||
# Build with: DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN ...
|
||||
RUN --mount=type=secret,id=tramiton_token \
|
||||
if [ -s /run/secrets/tramiton_token ]; then \
|
||||
git config --global \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||
fi && \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-agent
|
||||
|
||||
# A throwaway stage that packs a real nix store (store paths + the validity DB)
|
||||
# into a compressed bootstrap tarball. Only the tarball is copied into the final
|
||||
# image, so we don't carry a raw /nix copy layer.
|
||||
FROM nixos/nix:latest AS nixseed
|
||||
RUN tar -C / -czf /nix-bootstrap.tar.gz nix
|
||||
RUN cargo build --release -p compliance-agent
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl python3 python3-pip npm golang-go php-cli && rm -rf /var/lib/apt/lists/*
|
||||
@@ -46,30 +31,7 @@ RUN pip3 install --break-system-packages semgrep
|
||||
# Install ruff for Python linting
|
||||
RUN pip3 install --break-system-packages ruff
|
||||
|
||||
# Real nix for the tramiton reproducible-build firmware SBOM.
|
||||
#
|
||||
# nix-portable's proot fallback can't run here: user namespaces are blocked by
|
||||
# the container's default seccomp/apparmor profile, and orca exposes no way to
|
||||
# relax it. So ship a *real* nix and disable its build sandbox
|
||||
# (`sandbox = false`) — a plain gcc/make firmware build needs no user namespace,
|
||||
# so it runs fine under the locked-down profile with no proot involved.
|
||||
#
|
||||
# The store is shipped as a bootstrap tarball and seeded onto /nix at first
|
||||
# start (see docker/agent-entrypoint.sh), so a persistent /nix volume survives
|
||||
# redeploys. A missing/broken nix just falls back to the analysis-only SBOM.
|
||||
COPY --from=nixseed /nix-bootstrap.tar.gz /opt/nix-bootstrap.tar.gz
|
||||
ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}"
|
||||
RUN mkdir -p /etc/nix && printf '%s\n' \
|
||||
'experimental-features = nix-command flakes' \
|
||||
'sandbox = false' \
|
||||
'build-users-group =' \
|
||||
'substituters = https://cache.nixos.org' \
|
||||
'trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=' \
|
||||
> /etc/nix/nix.conf
|
||||
|
||||
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
|
||||
COPY docker/agent-entrypoint.sh /usr/local/bin/agent-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/agent-entrypoint.sh
|
||||
|
||||
# Copy documentation for the help chat assistant
|
||||
COPY --from=builder /app/README.md /app/README.md
|
||||
@@ -81,6 +43,5 @@ RUN mkdir -p /data/compliance-scanner/ssh
|
||||
|
||||
EXPOSE 3001 3002
|
||||
|
||||
# Seeds /nix (fresh volume) from the bootstrap tarball, then runs the agent.
|
||||
ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"]
|
||||
ENTRYPOINT ["compliance-agent"]
|
||||
|
||||
|
||||
+1
-10
@@ -7,16 +7,7 @@ ARG DOCS_URL=/docs
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
ENV DOCS_URL=${DOCS_URL}
|
||||
# compliance-agent (a workspace member) depends on the private tramiton-core git
|
||||
# repo, so the workspace resolve needs it even to build the dashboard.
|
||||
# Authenticate the fetch with a PAT passed as a BuildKit secret.
|
||||
RUN --mount=type=secret,id=tramiton_token \
|
||||
if [ -s /run/secrets/tramiton_token ]; then \
|
||||
git config --global \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||
fi && \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true dx build --release --package compliance-dashboard
|
||||
RUN dx build --release --package compliance-dashboard
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
+1
-10
@@ -2,16 +2,7 @@ FROM rust:1.94-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
# compliance-agent (a workspace member) depends on the private tramiton-core git
|
||||
# repo, so the workspace resolve needs it even to build the mcp binary.
|
||||
# Authenticate the fetch with a PAT passed as a BuildKit secret.
|
||||
RUN --mount=type=secret,id=tramiton_token \
|
||||
if [ -s /run/secrets/tramiton_token ]; then \
|
||||
git config --global \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||
fi && \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-mcp
|
||||
RUN cargo build --release -p compliance-mcp
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -7,22 +7,9 @@ edition = "2021"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] }
|
||||
compliance-core = { workspace = true, features = ["mongodb", "telemetry"] }
|
||||
compliance-graph = { path = "../compliance-graph" }
|
||||
compliance-dast = { path = "../compliance-dast" }
|
||||
# Shared dynamic-execution logic (soft-PLC provisioning + ICS probing), also
|
||||
# used by the Werkbank runner.
|
||||
werkbank-exec = { path = "../werkbank-exec" }
|
||||
# Native firmware build/target detection for bare-metal & RTOS artifacts.
|
||||
# Same-company IP, used directly (not via CLI) so the whole tramiton suite is
|
||||
# available to the onboarding classifier. NOTE: CI must be able to fetch this
|
||||
# private repo (see the git-auth step in .gitea/workflows/ci.yml).
|
||||
tramiton-core = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
|
||||
# tramiton-repro drives the reproducible build (NixBackend seal_and_build) that
|
||||
# yields a sealed lock; `libraries_from_inputs` is the analysis-only fallback.
|
||||
tramiton-repro = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
|
||||
# tramiton-sbom renders the bill of materials from a sealed lock (+ binary SCA).
|
||||
tramiton-sbom = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -37,7 +24,7 @@ hex = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
secrecy = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "set-header"] }
|
||||
git2 = "0.20"
|
||||
octocrab = "0.44"
|
||||
@@ -45,8 +32,6 @@ tokio-cron-scheduler = "0.13"
|
||||
dotenvy = "0.15"
|
||||
hmac = "0.12"
|
||||
walkdir = "2"
|
||||
# Read-only XML tree parsing for PLCopen project files (POU extraction).
|
||||
roxmltree = "0.20"
|
||||
base64 = "0.22"
|
||||
urlencoding = "2"
|
||||
futures-util = "0.3"
|
||||
@@ -57,16 +42,14 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-core = "0.3"
|
||||
dashmap = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
compliance-core = { workspace = true, features = ["mongodb", "axum"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
compliance-core = { workspace = true, features = ["mongodb"] }
|
||||
reqwest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
mongodb = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
secrecy = { workspace = true }
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
axum = "0.8"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
|
||||
@@ -6,7 +6,7 @@ use tokio::sync::{broadcast, watch, Semaphore};
|
||||
use compliance_core::models::pentest::PentestEvent;
|
||||
use compliance_core::AgentConfig;
|
||||
|
||||
use crate::database::DatabasePool;
|
||||
use crate::database::Database;
|
||||
use crate::llm::LlmClient;
|
||||
use crate::pipeline::orchestrator::PipelineOrchestrator;
|
||||
|
||||
@@ -16,10 +16,7 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 5;
|
||||
#[derive(Clone)]
|
||||
pub struct ComplianceAgent {
|
||||
pub config: AgentConfig,
|
||||
/// Per-tenant Mongo broker. Every code path must obtain a
|
||||
/// tenant-scoped [`crate::database::Database`] from this pool —
|
||||
/// there is no single shared database any more.
|
||||
pub db_pool: DatabasePool,
|
||||
pub db: Database,
|
||||
pub llm: Arc<LlmClient>,
|
||||
pub http: reqwest::Client,
|
||||
/// Per-session broadcast senders for SSE streaming.
|
||||
@@ -31,7 +28,7 @@ pub struct ComplianceAgent {
|
||||
}
|
||||
|
||||
impl ComplianceAgent {
|
||||
pub fn new(config: AgentConfig, db_pool: DatabasePool) -> Self {
|
||||
pub fn new(config: AgentConfig, db: Database) -> Self {
|
||||
let llm = Arc::new(LlmClient::new(
|
||||
config.litellm_url.clone(),
|
||||
config.litellm_api_key.clone(),
|
||||
@@ -45,7 +42,7 @@ impl ComplianceAgent {
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
config,
|
||||
db_pool,
|
||||
db,
|
||||
llm,
|
||||
http,
|
||||
session_streams: Arc::new(DashMap::new()),
|
||||
@@ -56,57 +53,44 @@ impl ComplianceAgent {
|
||||
|
||||
pub async fn run_scan(
|
||||
&self,
|
||||
tenant_id: &str,
|
||||
repo_id: &str,
|
||||
trigger: compliance_core::models::ScanTrigger,
|
||||
) -> Result<(), crate::error::AgentError> {
|
||||
let db = self.db_pool.for_tenant_id(tenant_id).await?;
|
||||
let orchestrator =
|
||||
PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone());
|
||||
orchestrator.run_target(repo_id, trigger).await
|
||||
}
|
||||
|
||||
/// Alias for [`Self::run_scan`] — every scan runs the unified onboarded-target
|
||||
/// pipeline. Kept as a distinct name for the `/targets/{id}/scan` endpoint's
|
||||
/// intent.
|
||||
pub async fn run_target_scan(
|
||||
&self,
|
||||
tenant_id: &str,
|
||||
target_id: &str,
|
||||
trigger: compliance_core::models::ScanTrigger,
|
||||
) -> Result<(), crate::error::AgentError> {
|
||||
let db = self.db_pool.for_tenant_id(tenant_id).await?;
|
||||
let orchestrator =
|
||||
PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone());
|
||||
orchestrator.run_target(target_id, trigger).await
|
||||
let orchestrator = PipelineOrchestrator::new(
|
||||
self.config.clone(),
|
||||
self.db.clone(),
|
||||
self.llm.clone(),
|
||||
self.http.clone(),
|
||||
);
|
||||
orchestrator.run(repo_id, trigger).await
|
||||
}
|
||||
|
||||
/// Run a PR review: scan the diff and post review comments.
|
||||
pub async fn run_pr_review(
|
||||
&self,
|
||||
tenant_id: &str,
|
||||
repo_id: &str,
|
||||
pr_number: u64,
|
||||
base_sha: &str,
|
||||
head_sha: &str,
|
||||
) -> Result<(), crate::error::AgentError> {
|
||||
let db = self.db_pool.for_tenant_id(tenant_id).await?;
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(repo_id)
|
||||
.map_err(|e| crate::error::AgentError::Other(e.to_string()))?;
|
||||
let target = db
|
||||
.onboarded_targets()
|
||||
.find_one(mongodb::bson::doc! { "_id": oid })
|
||||
let repo = self
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(mongodb::bson::doc! {
|
||||
"_id": mongodb::bson::oid::ObjectId::parse_str(repo_id)
|
||||
.map_err(|e| crate::error::AgentError::Other(e.to_string()))?
|
||||
})
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::error::AgentError::Other(format!("Target {repo_id} not found"))
|
||||
crate::error::AgentError::Other(format!("Repository {repo_id} not found"))
|
||||
})?;
|
||||
let code = target.code_artifact().ok_or_else(|| {
|
||||
crate::error::AgentError::Other(format!("Target {repo_id} has no code artifact"))
|
||||
})?;
|
||||
let repo = crate::pipeline::repo_view::RepoView::from_target(&target, code);
|
||||
|
||||
let orchestrator =
|
||||
PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone());
|
||||
let orchestrator = PipelineOrchestrator::new(
|
||||
self.config.clone(),
|
||||
self.db.clone(),
|
||||
self.llm.clone(),
|
||||
self.http.clone(),
|
||||
);
|
||||
orchestrator
|
||||
.run_pr_review(&repo, repo_id, pr_number, base_sha, head_sha)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Cached JWKS from Keycloak for token validation.
|
||||
#[derive(Clone)]
|
||||
pub struct JwksState {
|
||||
pub jwks: Arc<RwLock<Option<JwkSet>>>,
|
||||
pub jwks_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Claims {
|
||||
#[allow(dead_code)]
|
||||
sub: String,
|
||||
}
|
||||
|
||||
const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"];
|
||||
|
||||
/// Middleware that validates Bearer JWT tokens against Keycloak's JWKS.
|
||||
///
|
||||
/// Skips validation for health check endpoints.
|
||||
/// If `JwksState` is not present as an extension (keycloak not configured),
|
||||
/// all requests pass through.
|
||||
pub async fn require_jwt_auth(request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path();
|
||||
|
||||
if PUBLIC_ENDPOINTS.contains(&path) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
let jwks_state = match request.extensions().get::<JwksState>() {
|
||||
Some(s) => s.clone(),
|
||||
None => return next.run(request).await,
|
||||
};
|
||||
|
||||
let auth_header = match request.headers().get("authorization") {
|
||||
Some(h) => h,
|
||||
None => return (StatusCode::UNAUTHORIZED, "Missing authorization header").into_response(),
|
||||
};
|
||||
|
||||
let token = match auth_header.to_str() {
|
||||
Ok(s) if s.starts_with("Bearer ") => &s[7..],
|
||||
_ => return (StatusCode::UNAUTHORIZED, "Invalid authorization header").into_response(),
|
||||
};
|
||||
|
||||
match validate_token(token, &jwks_state).await {
|
||||
Ok(()) => next.run(request).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("JWT validation failed: {e}");
|
||||
(StatusCode::UNAUTHORIZED, "Invalid token").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_token(token: &str, state: &JwksState) -> Result<(), String> {
|
||||
let header = decode_header(token).map_err(|e| format!("failed to decode JWT header: {e}"))?;
|
||||
|
||||
let kid = header
|
||||
.kid
|
||||
.ok_or_else(|| "JWT missing kid header".to_string())?;
|
||||
|
||||
let jwks = fetch_or_get_jwks(state).await?;
|
||||
|
||||
let jwk = jwks
|
||||
.keys
|
||||
.iter()
|
||||
.find(|k| k.common.key_id.as_deref() == Some(&kid))
|
||||
.ok_or_else(|| "no matching key found in JWKS".to_string())?;
|
||||
|
||||
let decoding_key =
|
||||
DecodingKey::from_jwk(jwk).map_err(|e| format!("failed to create decoding key: {e}"))?;
|
||||
|
||||
let mut validation = Validation::new(header.alg);
|
||||
validation.validate_exp = true;
|
||||
validation.validate_aud = false;
|
||||
|
||||
decode::<Claims>(token, &decoding_key, &validation)
|
||||
.map_err(|e| format!("token validation failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_or_get_jwks(state: &JwksState) -> Result<JwkSet, String> {
|
||||
{
|
||||
let cached = state.jwks.read().await;
|
||||
if let Some(ref jwks) = *cached {
|
||||
return Ok(jwks.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let resp = reqwest::get(&state.jwks_url)
|
||||
.await
|
||||
.map_err(|e| format!("failed to fetch JWKS: {e}"))?;
|
||||
|
||||
let jwks: JwkSet = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse JWKS: {e}"))?;
|
||||
|
||||
let mut cached = state.jwks.write().await;
|
||||
*cached = Some(jwks.clone());
|
||||
|
||||
Ok(jwks)
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! Cross-tenant admin endpoints (`/api/v1/admin/*`).
|
||||
//!
|
||||
//! Operator-only. Auth is a **static bearer token** (`ADMIN_API_TOKEN`
|
||||
//! env on the agent) — explicitly NOT a Keycloak JWT, because the
|
||||
//! whole point of these endpoints is to operate ACROSS tenants. A
|
||||
//! customer JWT (which always carries a single tenant_id) has no
|
||||
//! business mounting them.
|
||||
//!
|
||||
//! Routes are only registered when `ADMIN_API_TOKEN` is set. With no
|
||||
//! token, the endpoints don't exist at all (404), which is a stronger
|
||||
//! guarantee than "401 if you guess the path".
|
||||
//!
|
||||
//! Operations:
|
||||
//! - `GET /api/v1/admin/tenants` — list tenant DBs
|
||||
//! - `DELETE /api/v1/admin/tenants/{tenant_id}` — GDPR delete
|
||||
//!
|
||||
//! Tenant ids in URLs are passed as-is to `DatabasePool::drop_tenant`,
|
||||
//! which sanitises them the same way it does for creation. Listing
|
||||
//! returns the raw DB names from `list_tenant_db_names` — operators
|
||||
//! can reverse-derive the tenant_id from the prefix.
|
||||
|
||||
use axum::extract::{Extension, Path, Request};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::dto::AgentExt;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ListTenantDbsResponse {
|
||||
pub tenant_db_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_tenant_dbs(
|
||||
Extension(agent): AgentExt,
|
||||
) -> Result<Json<ListTenantDbsResponse>, StatusCode> {
|
||||
let names = agent.db_pool.list_tenant_db_names().await.map_err(|e| {
|
||||
tracing::error!("admin: list_tenant_db_names failed: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(Json(ListTenantDbsResponse {
|
||||
tenant_db_names: names,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(tenant_id = %tenant_id))]
|
||||
pub async fn drop_tenant_db(
|
||||
Extension(agent): AgentExt,
|
||||
Path(tenant_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
agent.db_pool.drop_tenant(&tenant_id).await.map_err(|e| {
|
||||
tracing::error!("admin: drop_tenant failed: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
Ok(Json(serde_json::json!({ "status": "dropped" })))
|
||||
}
|
||||
|
||||
/// Constant-time-ish comparison of the configured admin token against
|
||||
/// the incoming bearer. Uses `subtle`-style byte equality so timing
|
||||
/// attacks can't probe the token character by character.
|
||||
fn tokens_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.bytes().zip(b.bytes()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// Middleware enforcing the static `ADMIN_API_TOKEN`. Mounted only on
|
||||
/// the admin sub-router, so this never runs on customer routes.
|
||||
pub async fn require_admin_token(
|
||||
Extension(agent): AgentExt,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let Some(expected) = agent.config.admin_api_token.as_ref() else {
|
||||
// Belt-and-braces — if the routes were somehow mounted without
|
||||
// a token configured, refuse rather than no-op-pass.
|
||||
return (StatusCode::NOT_FOUND, "admin disabled").into_response();
|
||||
};
|
||||
let presented = request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(|s| s.trim());
|
||||
let Some(presented) = presented.filter(|s| !s.is_empty()) else {
|
||||
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
|
||||
};
|
||||
if !tokens_eq(presented, expected.expose_secret()) {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid admin token").into_response();
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tokens_eq_basic() {
|
||||
assert!(tokens_eq("abc", "abc"));
|
||||
assert!(!tokens_eq("abc", "abd"));
|
||||
assert!(!tokens_eq("abc", "abcd"));
|
||||
assert!(!tokens_eq("", "x"));
|
||||
assert!(tokens_eq("", ""));
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,11 @@ use mongodb::bson::doc;
|
||||
|
||||
use compliance_core::models::chat::{ChatRequest, ChatResponse, SourceReference};
|
||||
use compliance_core::models::embedding::EmbeddingBuildRun;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
use compliance_graph::graph::embedding_store::EmbeddingStore;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
use crate::rag::pipeline::RagPipeline;
|
||||
|
||||
use super::dto::tenant_db;
|
||||
use super::ApiResponse;
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
@@ -22,12 +20,10 @@ type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub async fn chat(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
Json(req): Json<ChatRequest>,
|
||||
) -> Result<Json<ApiResponse<ChatResponse>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let pipeline = RagPipeline::new(agent.llm.clone(), db.inner());
|
||||
let pipeline = RagPipeline::new(agent.llm.clone(), agent.db.inner());
|
||||
|
||||
// Step 1: Embed the user's message
|
||||
let query_vectors = agent
|
||||
@@ -137,16 +133,13 @@ pub async fn chat(
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub async fn build_embeddings(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
// Resolve the tenant DB up front so we can move it into the spawn;
|
||||
// the JWT/dev context isn't available inside detached tasks.
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let agent_clone = (*agent).clone();
|
||||
tokio::spawn(async move {
|
||||
let repo = match db
|
||||
.onboarded_targets()
|
||||
let repo = match agent_clone
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
|
||||
.await
|
||||
{
|
||||
@@ -158,7 +151,8 @@ pub async fn build_embeddings(
|
||||
};
|
||||
|
||||
// Get latest graph build
|
||||
let build = match db
|
||||
let build = match agent_clone
|
||||
.db
|
||||
.graph_builds()
|
||||
.find_one(doc! { "repo_id": &repo_id })
|
||||
.sort(doc! { "started_at": -1 })
|
||||
@@ -177,39 +171,35 @@ pub async fn build_embeddings(
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Get nodes
|
||||
let nodes: Vec<compliance_core::models::graph::CodeNode> =
|
||||
match db.graph_nodes().find(doc! { "repo_id": &repo_id }).await {
|
||||
Ok(cursor) => {
|
||||
use futures_util::StreamExt;
|
||||
let mut items = Vec::new();
|
||||
let mut cursor = cursor;
|
||||
while let Some(Ok(item)) = cursor.next().await {
|
||||
items.push(item);
|
||||
}
|
||||
items
|
||||
let nodes: Vec<compliance_core::models::graph::CodeNode> = match agent_clone
|
||||
.db
|
||||
.graph_nodes()
|
||||
.find(doc! { "repo_id": &repo_id })
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => {
|
||||
use futures_util::StreamExt;
|
||||
let mut items = Vec::new();
|
||||
let mut cursor = cursor;
|
||||
while let Some(Ok(item)) = cursor.next().await {
|
||||
items.push(item);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[{repo_id}] Failed to fetch nodes: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let code = match repo.code_artifact() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::error!("Target {repo_id} has no code artifact for embedding build");
|
||||
items
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[{repo_id}] Failed to fetch nodes: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let view = crate::pipeline::repo_view::RepoView::from_target(&repo, code);
|
||||
|
||||
let creds = crate::pipeline::git::RepoCredentials {
|
||||
ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()),
|
||||
auth_token: view.auth_token.clone(),
|
||||
auth_username: view.auth_username.clone(),
|
||||
auth_token: repo.auth_token.clone(),
|
||||
auth_username: repo.auth_username.clone(),
|
||||
};
|
||||
let git_ops =
|
||||
crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds);
|
||||
let repo_path = match git_ops.clone_or_fetch(&view.git_url, &view.name) {
|
||||
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to clone repo for embedding build: {e}");
|
||||
@@ -217,7 +207,7 @@ pub async fn build_embeddings(
|
||||
}
|
||||
};
|
||||
|
||||
let pipeline = RagPipeline::new(agent_clone.llm.clone(), db.inner());
|
||||
let pipeline = RagPipeline::new(agent_clone.llm.clone(), agent_clone.db.inner());
|
||||
match pipeline
|
||||
.build_embeddings(&repo_id, &repo_path, &graph_build_id, &nodes)
|
||||
.await
|
||||
@@ -244,11 +234,9 @@ pub async fn build_embeddings(
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub async fn embedding_status(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Option<EmbeddingBuildRun>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let store = EmbeddingStore::new(db.inner());
|
||||
let store = EmbeddingStore::new(agent.db.inner());
|
||||
let build = store.get_latest_build(&repo_id).await.map_err(|e| {
|
||||
tracing::error!("Failed to get embedding status: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
|
||||
@@ -7,11 +7,9 @@ use mongodb::bson::doc;
|
||||
use serde::Deserialize;
|
||||
|
||||
use compliance_core::models::dast::{DastFinding, DastScanRun, DastTarget, DastTargetType};
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
use super::dto::tenant_db;
|
||||
use super::{collect_cursor_async, ApiResponse, PaginationParams};
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
@@ -47,11 +45,9 @@ fn default_rate_limit() -> u32 {
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_targets(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<DastTarget>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.dast_targets()
|
||||
@@ -84,7 +80,6 @@ pub async fn list_targets(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn add_target(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Json(req): Json<AddTargetRequest>,
|
||||
) -> Result<Json<ApiResponse<DastTarget>>, StatusCode> {
|
||||
let mut target = DastTarget::new(req.name, req.base_url, req.target_type);
|
||||
@@ -94,8 +89,9 @@ pub async fn add_target(
|
||||
target.rate_limit = req.rate_limit;
|
||||
target.allow_destructive = req.allow_destructive;
|
||||
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
db.dast_targets()
|
||||
agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.insert_one(&target)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
@@ -111,19 +107,19 @@ pub async fn add_target(
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn trigger_scan(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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 = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let target = db
|
||||
let target = agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let db = agent.db.clone();
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = compliance_dast::DastOrchestrator::new(100);
|
||||
match orchestrator.run_scan(&target, Vec::new()).await {
|
||||
@@ -151,11 +147,9 @@ pub async fn trigger_scan(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_scan_runs(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<DastScanRun>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.dast_scan_runs()
|
||||
@@ -189,11 +183,9 @@ pub async fn list_scan_runs(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_findings(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<DastFinding>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.dast_findings()
|
||||
@@ -227,13 +219,12 @@ pub async fn list_findings(
|
||||
#[tracing::instrument(skip_all, fields(finding_id = %id))]
|
||||
pub async fn get_finding(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<DastFinding>>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let finding = db
|
||||
let finding = agent
|
||||
.db
|
||||
.dast_findings()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
|
||||
@@ -180,27 +180,6 @@ pub struct SbomVersionDiff {
|
||||
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>;
|
||||
|
||||
/// Resolve a tenant-scoped [`Database`] from the request's
|
||||
/// [`TenantContext`] (inserted by the M7.1 JWT middleware, or by the
|
||||
/// dev fallback in unsecured environments). The pool ensures the
|
||||
/// tenant's indexes idempotently.
|
||||
///
|
||||
/// Returns 500 on the rare path where Mongo refuses the database
|
||||
/// handle — the M7.1 auth/status middleware already rejects every
|
||||
/// other failure mode with 4xx before we get here.
|
||||
pub(crate) async fn tenant_db(
|
||||
agent: &crate::agent::ComplianceAgent,
|
||||
tenant: &compliance_core::tenant_ctx::TenantCtx,
|
||||
) -> Result<crate::database::Database, axum::http::StatusCode> {
|
||||
agent.db_pool.for_tenant(&tenant.0).await.map_err(|e| {
|
||||
tracing::error!(
|
||||
tenant_id = %tenant.0.tenant_id,
|
||||
"Failed to acquire tenant database: {e}"
|
||||
);
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn collect_cursor_async<T: serde::de::DeserializeOwned + Unpin + Send>(
|
||||
mut cursor: mongodb::Cursor<T>,
|
||||
) -> Vec<T> {
|
||||
|
||||
@@ -5,16 +5,13 @@ use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::Finding;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
#[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,
|
||||
tenant: TenantCtx,
|
||||
Query(filter): Query<FindingsFilter>,
|
||||
) -> ApiResult<Vec<Finding>> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let mut query = doc! {};
|
||||
if let Some(repo_id) = &filter.repo_id {
|
||||
query.insert("repo_id", repo_id);
|
||||
@@ -84,12 +81,11 @@ pub async fn list_findings(
|
||||
#[tracing::instrument(skip_all, fields(finding_id = %id))]
|
||||
pub async fn get_finding(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Finding>>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let finding = db
|
||||
let finding = agent
|
||||
.db
|
||||
.findings()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -106,14 +102,14 @@ pub async fn get_finding(
|
||||
#[tracing::instrument(skip_all, fields(finding_id = %id))]
|
||||
pub async fn update_finding_status(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
db.findings()
|
||||
agent
|
||||
.db
|
||||
.findings()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } },
|
||||
@@ -127,7 +123,6 @@ pub async fn update_finding_status(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn bulk_update_finding_status(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Json(req): Json<BulkUpdateStatusRequest>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oids: Vec<mongodb::bson::oid::ObjectId> = req
|
||||
@@ -140,8 +135,8 @@ pub async fn bulk_update_finding_status(
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let result = db
|
||||
let result = agent
|
||||
.db
|
||||
.findings()
|
||||
.update_many(
|
||||
doc! { "_id": { "$in": oids } },
|
||||
@@ -158,14 +153,14 @@ pub async fn bulk_update_finding_status(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn update_finding_feedback(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
db.findings()
|
||||
agent
|
||||
.db
|
||||
.findings()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$set": { "developer_feedback": &req.feedback, "updated_at": mongodb::bson::DateTime::now() } },
|
||||
|
||||
@@ -7,11 +7,9 @@ use mongodb::bson::doc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use compliance_core::models::graph::{CodeEdge, CodeNode, GraphBuildRun, ImpactAnalysis};
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
use super::dto::tenant_db;
|
||||
use super::{collect_cursor_async, ApiResponse};
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
@@ -38,11 +36,9 @@ fn default_search_limit() -> usize {
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub async fn get_graph(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<GraphData>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
|
||||
// Get latest build
|
||||
let build: Option<GraphBuildRun> = db
|
||||
@@ -102,11 +98,9 @@ pub async fn get_graph(
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub async fn get_nodes(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Vec<CodeNode>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let filter = doc! { "repo_id": &repo_id };
|
||||
|
||||
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter).await {
|
||||
@@ -129,11 +123,9 @@ pub async fn get_nodes(
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub async fn get_communities(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<Vec<CommunityInfo>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let filter = doc! { "repo_id": &repo_id };
|
||||
|
||||
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter).await {
|
||||
@@ -184,11 +176,9 @@ pub struct CommunityInfo {
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, finding_id = %finding_id))]
|
||||
pub async fn get_impact(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path((repo_id, finding_id)): Path<(String, String)>,
|
||||
) -> Result<Json<ApiResponse<Option<ImpactAnalysis>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let filter = doc! { "repo_id": &repo_id, "finding_id": &finding_id };
|
||||
|
||||
let impact = db
|
||||
@@ -208,12 +198,10 @@ pub async fn get_impact(
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, query = %params.q))]
|
||||
pub async fn search_symbols(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<CodeNode>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
|
||||
// Simple text search on qualified_name and name fields
|
||||
let filter = doc! {
|
||||
@@ -246,16 +234,14 @@ pub async fn search_symbols(
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub async fn get_file_content(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
Query(params): Query<FileContentParams>,
|
||||
) -> Result<Json<ApiResponse<FileContent>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
|
||||
// Look up the repository to get repo name
|
||||
let repo = db
|
||||
.onboarded_targets()
|
||||
.repositories()
|
||||
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
@@ -310,14 +296,13 @@ pub struct FileContent {
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub async fn trigger_build(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(repo_id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let agent_clone = (*agent).clone();
|
||||
tokio::spawn(async move {
|
||||
let repo = match db
|
||||
.onboarded_targets()
|
||||
let repo = match agent_clone
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
|
||||
.await
|
||||
{
|
||||
@@ -328,22 +313,14 @@ pub async fn trigger_build(
|
||||
}
|
||||
};
|
||||
|
||||
let code = match repo.code_artifact() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::error!("Target {repo_id} has no code artifact for graph build");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let view = crate::pipeline::repo_view::RepoView::from_target(&repo, code);
|
||||
let creds = crate::pipeline::git::RepoCredentials {
|
||||
ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()),
|
||||
auth_token: view.auth_token.clone(),
|
||||
auth_username: view.auth_username.clone(),
|
||||
auth_token: repo.auth_token.clone(),
|
||||
auth_username: repo.auth_username.clone(),
|
||||
};
|
||||
let git_ops =
|
||||
crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds);
|
||||
let repo_path = match git_ops.clone_or_fetch(&view.git_url, &view.name) {
|
||||
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to clone repo for graph build: {e}");
|
||||
@@ -356,7 +333,8 @@ pub async fn trigger_build(
|
||||
|
||||
match engine.build_graph(&repo_path, &repo_id, &graph_build_id) {
|
||||
Ok((code_graph, build_run)) => {
|
||||
let store = compliance_graph::graph::persistence::GraphStore::new(db.inner());
|
||||
let store =
|
||||
compliance_graph::graph::persistence::GraphStore::new(agent_clone.db.inner());
|
||||
let _ = store.delete_repo_graph(&repo_id).await;
|
||||
let _ = store
|
||||
.store_graph(&build_run, &code_graph.nodes, &code_graph.edges)
|
||||
|
||||
@@ -3,35 +3,18 @@ use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::ScanRun;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
/// GET /api/v1/settings/ssh-public-key — the agent's SSH deploy public key,
|
||||
/// for adding as a read-only deploy key on private git targets.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn get_ssh_public_key(
|
||||
axum::extract::Extension(agent): AgentExt,
|
||||
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
|
||||
let public_path = format!("{}.pub", agent.config.ssh_key_path);
|
||||
let public_key =
|
||||
std::fs::read_to_string(&public_path).map_err(|_| axum::http::StatusCode::NOT_FOUND)?;
|
||||
Ok(Json(serde_json::json!({ "public_key": public_key.trim() })))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn stats_overview(
|
||||
axum::extract::Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
) -> ApiResult<OverviewStats> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
pub async fn stats_overview(axum::extract::Extension(agent): AgentExt) -> ApiResult<OverviewStats> {
|
||||
let db = &agent.db;
|
||||
|
||||
let total_repositories = db
|
||||
.onboarded_targets()
|
||||
.repositories()
|
||||
.count_documents(doc! {})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
@@ -4,16 +4,13 @@ use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::TrackerIssue;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_issues(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> ApiResult<Vec<TrackerIssue>> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.tracker_issues()
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
//! `/api/v1/mcp-tokens` — per-tenant API tokens for the MCP server.
|
||||
//!
|
||||
//! These are opaque static bearers issued via the dashboard (or a
|
||||
//! direct curl with a KC JWT) and copied into LLM clients (Claude
|
||||
//! Desktop / Cursor / ChatGPT). The MCP server hashes incoming bearers
|
||||
//! and looks them up in the cross-tenant `<prefix>__admin.mcp_tokens`
|
||||
//! collection to derive the tenant_id for routing.
|
||||
//!
|
||||
//! The raw token is shown to the caller exactly once at creation; the
|
||||
//! database only ever stores the SHA-256 hash. Revocation is a soft
|
||||
//! delete (sets `revoked: true`) so the audit log keeps the record.
|
||||
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use compliance_core::models::{McpToken, McpTokenView};
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
use mongodb::bson::doc;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::dto::{AgentExt, ApiResponse};
|
||||
|
||||
/// Mongo collection name inside the admin DB.
|
||||
const COLLECTION: &str = "mcp_tokens";
|
||||
|
||||
/// Token prefix the MCP server expects on every bearer.
|
||||
const TOKEN_PREFIX: &str = "mcpt_";
|
||||
|
||||
/// Bytes of randomness behind each token. 32 → ~256 bits.
|
||||
/// Encoded as URL-safe base64 without padding → 43 chars.
|
||||
/// Combined with `mcpt_` → 48-char tokens.
|
||||
const TOKEN_RAND_BYTES: usize = 32;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CreateMcpTokenRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Returned exactly once at creation. The `token` field is gone from
|
||||
/// the listing endpoint — the user must save it now.
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct CreateMcpTokenResponse {
|
||||
pub token: String,
|
||||
pub view: McpTokenView,
|
||||
}
|
||||
|
||||
/// `POST /api/v1/mcp-tokens` — mint a new token for the caller's tenant.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn create_mcp_token(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Json(req): Json<CreateMcpTokenRequest>,
|
||||
) -> Result<Json<CreateMcpTokenResponse>, StatusCode> {
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let raw = generate_token();
|
||||
let token_hash = sha256_hex(&raw);
|
||||
let token_prefix: String = raw.chars().take(12).collect();
|
||||
|
||||
let mut token = McpToken {
|
||||
id: None,
|
||||
token_hash,
|
||||
token_prefix,
|
||||
tenant_id: tenant.0.tenant_id.clone(),
|
||||
name: req.name.trim().to_string(),
|
||||
created_by: tenant.0.user_id.clone(),
|
||||
created_at: chrono::Utc::now(),
|
||||
last_used_at: None,
|
||||
revoked: false,
|
||||
};
|
||||
|
||||
let col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
|
||||
let res = col.insert_one(&token).await.map_err(|e| {
|
||||
tracing::error!("Failed to insert MCP token: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
token.id = res.inserted_id.as_object_id();
|
||||
|
||||
Ok(Json(CreateMcpTokenResponse {
|
||||
view: McpTokenView::from(&token),
|
||||
token: raw,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/mcp-tokens` — list tokens for the caller's tenant.
|
||||
/// Hash is never returned; only metadata + the 12-char prefix so the
|
||||
/// user can identify which row is which.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_mcp_tokens(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
) -> Result<Json<ApiResponse<Vec<McpTokenView>>>, StatusCode> {
|
||||
let col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
|
||||
let mut cursor = col
|
||||
.find(doc! { "tenant_id": &tenant.0.tenant_id })
|
||||
.sort(doc! { "created_at": -1 })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to list MCP tokens: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await.map_err(|e| {
|
||||
tracing::warn!("MCP tokens cursor advance failed: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})? {
|
||||
match cursor.deserialize_current() {
|
||||
Ok(t) => out.push(McpTokenView::from(&t)),
|
||||
Err(e) => tracing::warn!("Failed to deserialize MCP token: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(Json(ApiResponse {
|
||||
data: out,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/mcp-tokens/{id}` — revoke (soft delete).
|
||||
/// Scoped to the caller's tenant: a user can't revoke another tenant's
|
||||
/// token even if they guess its id.
|
||||
#[tracing::instrument(skip_all, fields(id = %id))]
|
||||
pub async fn revoke_mcp_token(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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 col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
|
||||
let result = col
|
||||
.update_one(
|
||||
doc! { "_id": oid, "tenant_id": &tenant.0.tenant_id },
|
||||
doc! { "$set": { "revoked": true } },
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to revoke MCP token: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
if result.matched_count == 0 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "status": "revoked" })))
|
||||
}
|
||||
|
||||
/// 32 bytes random → URL-safe base64 → 43 chars, no padding.
|
||||
/// Prefixed with `mcpt_` so the MCP server can sniff the format
|
||||
/// before bothering with the DB lookup.
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; TOKEN_RAND_BYTES];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(s.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_tokens_are_unique_and_prefixed() {
|
||||
let a = generate_token();
|
||||
let b = generate_token();
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with(TOKEN_PREFIX));
|
||||
assert!(b.starts_with(TOKEN_PREFIX));
|
||||
// 5 + 43 = 48 chars
|
||||
assert_eq!(a.len(), 5 + 43);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_is_stable_and_64_hex() {
|
||||
let h = sha256_hex("mcpt_abc");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_eq!(sha256_hex("mcpt_abc"), h);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod admin;
|
||||
pub mod chat;
|
||||
pub mod dast;
|
||||
pub mod dto;
|
||||
@@ -7,20 +6,18 @@ pub mod graph;
|
||||
pub mod health;
|
||||
pub mod help_chat;
|
||||
pub mod issues;
|
||||
pub mod mcp_tokens;
|
||||
pub mod notifications;
|
||||
pub mod onboarding;
|
||||
pub mod oscal;
|
||||
pub mod pentest_handlers;
|
||||
pub use pentest_handlers as pentest;
|
||||
pub mod repos;
|
||||
pub mod sbom;
|
||||
pub mod scans;
|
||||
pub mod werkbank_jobs;
|
||||
|
||||
// Re-export all handler functions so routes.rs can use `handlers::function_name`
|
||||
pub use dto::*;
|
||||
pub use findings::*;
|
||||
pub use health::*;
|
||||
pub use issues::*;
|
||||
pub use repos::*;
|
||||
pub use sbom::*;
|
||||
pub use scans::*;
|
||||
|
||||
@@ -5,18 +5,15 @@ use mongodb::bson::doc;
|
||||
use serde::Deserialize;
|
||||
|
||||
use compliance_core::models::notification::CveNotification;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use super::dto::{tenant_db, AgentExt, ApiResponse};
|
||||
use super::dto::{AgentExt, ApiResponse};
|
||||
|
||||
/// GET /api/v1/notifications — List CVE notifications (newest first)
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_notifications(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
axum::extract::Query(params): axum::extract::Query<NotificationFilter>,
|
||||
) -> Result<Json<ApiResponse<Vec<CveNotification>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let mut filter = doc! {};
|
||||
|
||||
// Filter by status (default: show new + read, exclude dismissed)
|
||||
@@ -44,13 +41,15 @@ pub async fn list_notifications(
|
||||
let limit = params.limit.unwrap_or(50).min(200);
|
||||
let skip = (page - 1) * limit as u64;
|
||||
|
||||
let total = db
|
||||
let total = agent
|
||||
.db
|
||||
.cve_notifications()
|
||||
.count_documents(filter.clone())
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let notifications: Vec<CveNotification> = match db
|
||||
let notifications: Vec<CveNotification> = match agent
|
||||
.db
|
||||
.cve_notifications()
|
||||
.find(filter)
|
||||
.sort(doc! { "created_at": -1 })
|
||||
@@ -84,10 +83,9 @@ pub async fn list_notifications(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn notification_count(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let count = db
|
||||
let count = agent
|
||||
.db
|
||||
.cve_notifications()
|
||||
.count_documents(doc! { "status": "new" })
|
||||
.await
|
||||
@@ -100,13 +98,12 @@ pub async fn notification_count(
|
||||
#[tracing::instrument(skip_all, fields(id = %id))]
|
||||
pub async fn mark_read(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
axum::extract::Path(id): axum::extract::Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let result = db
|
||||
let result = agent
|
||||
.db
|
||||
.cve_notifications()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
@@ -128,13 +125,12 @@ pub async fn mark_read(
|
||||
#[tracing::instrument(skip_all, fields(id = %id))]
|
||||
pub async fn dismiss_notification(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
axum::extract::Path(id): axum::extract::Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let result = db
|
||||
let result = agent
|
||||
.db
|
||||
.cve_notifications()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
@@ -153,10 +149,9 @@ pub async fn dismiss_notification(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn mark_all_read(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let result = db
|
||||
let result = agent
|
||||
.db
|
||||
.cve_notifications()
|
||||
.update_many(
|
||||
doc! { "status": "new" },
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
//! Onboarding API — CRUD for unified targets, artifact add, classification, and
|
||||
//! the scan-applicability matrix. The wizard (and future integrations) drive
|
||||
//! onboarding through these endpoints.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Extension, Multipart, Path, Query};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use mongodb::bson::{doc, oid::ObjectId, to_bson};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use compliance_core::models::{
|
||||
Artifact, ArtifactKind, ComplianceProfile, OnboardedTarget, PlcFormat, TargetScanConfig,
|
||||
TargetType,
|
||||
};
|
||||
use compliance_core::scan_matrix::{applicable_scans, supports_pentest};
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
use crate::classify::{classify_target, MockFirmwareDetector};
|
||||
|
||||
use super::dto::tenant_db;
|
||||
use super::{collect_cursor_async, ApiResponse, PaginationParams};
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
/// A client-supplied artifact spec. The server builds the [`Artifact`] (and its
|
||||
/// id) from it, so clients never set internal fields.
|
||||
#[derive(Deserialize)]
|
||||
pub struct ArtifactInput {
|
||||
pub kind: ArtifactKind,
|
||||
pub source_ref: String,
|
||||
#[serde(default)]
|
||||
pub branch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub plc_format: Option<PlcFormat>,
|
||||
}
|
||||
|
||||
impl ArtifactInput {
|
||||
fn build(&self) -> Artifact {
|
||||
let s = self.source_ref.clone();
|
||||
match self.kind {
|
||||
ArtifactKind::GitRepo => {
|
||||
Artifact::git_repo(s, self.branch.clone().unwrap_or_else(|| "main".to_string()))
|
||||
}
|
||||
ArtifactKind::LiveUrl => Artifact::live_url(s),
|
||||
ArtifactKind::FirmwareImage => Artifact::firmware_image(s),
|
||||
ArtifactKind::SourceArchive => Artifact::source_archive(s),
|
||||
ArtifactKind::MobilePackage => Artifact::mobile_package(s),
|
||||
ArtifactKind::ContainerImage => Artifact::container_image(s),
|
||||
ArtifactKind::PlcProject => {
|
||||
Artifact::plc_project(s, self.plc_format.unwrap_or(PlcFormat::PlcopenXml))
|
||||
}
|
||||
ArtifactKind::PlaintextDescription => Artifact::plaintext(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateTargetRequest {
|
||||
pub name: String,
|
||||
pub target_type: TargetType,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub artifacts: Vec<ArtifactInput>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTargetRequest {
|
||||
pub name: Option<String>,
|
||||
pub target_type: Option<TargetType>,
|
||||
pub scan_config: Option<TargetScanConfig>,
|
||||
pub compliance_profile: Option<ComplianceProfile>,
|
||||
pub scan_schedule: Option<String>,
|
||||
/// Replace the target's artifacts wholesale (used by the dashboard editor).
|
||||
#[serde(default)]
|
||||
pub artifacts: Option<Vec<ArtifactInput>>,
|
||||
}
|
||||
|
||||
/// One applicable-scan option, serialized for the wizard.
|
||||
#[derive(Serialize)]
|
||||
pub struct ScanOptionDto {
|
||||
pub scan: String,
|
||||
pub default_on: bool,
|
||||
pub rationale: String,
|
||||
pub required_artifact: Option<String>,
|
||||
pub blocked_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApplicableScansResponse {
|
||||
pub scans: Vec<ScanOptionDto>,
|
||||
pub pentest_supported: bool,
|
||||
}
|
||||
|
||||
fn parse_oid(id: &str) -> Result<ObjectId, StatusCode> {
|
||||
ObjectId::parse_str(id).map_err(|_| StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
/// GET /api/v1/targets — list onboarded targets (paginated).
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_targets(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<OnboardedTarget>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.onboarded_targets()
|
||||
.count_documents(doc! {})
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let targets = match db
|
||||
.onboarded_targets()
|
||||
.find(doc! {})
|
||||
.skip(skip)
|
||||
.limit(params.limit)
|
||||
.await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch onboarded targets: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
Ok(Json(ApiResponse {
|
||||
data: targets,
|
||||
total: Some(total),
|
||||
page: Some(params.page),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/targets — create an onboarded target.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn create_target(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Json(req): Json<CreateTargetRequest>,
|
||||
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
|
||||
let mut target = OnboardedTarget::new(req.name, req.target_type);
|
||||
target.description = req.description;
|
||||
target.artifacts = req.artifacts.iter().map(ArtifactInput::build).collect();
|
||||
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let res = db
|
||||
.onboarded_targets()
|
||||
.insert_one(&target)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
target.id = res.inserted_id.as_object_id();
|
||||
Ok(Json(ApiResponse {
|
||||
data: target,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/v1/targets/{id} — fetch one target.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn get_target(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let target = db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
Ok(Json(ApiResponse {
|
||||
data: target,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// PATCH /api/v1/targets/{id} — update mutable fields.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn update_target(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateTargetRequest>,
|
||||
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let mut set = doc! { "updated_at": mongodb::bson::DateTime::now() };
|
||||
if let Some(name) = req.name {
|
||||
set.insert("name", name);
|
||||
}
|
||||
if let Some(tt) = req.target_type {
|
||||
set.insert(
|
||||
"target_type",
|
||||
to_bson(&tt).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
if let Some(sc) = req.scan_config {
|
||||
set.insert(
|
||||
"scan_config",
|
||||
to_bson(&sc).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
if let Some(cp) = req.compliance_profile {
|
||||
set.insert(
|
||||
"compliance_profile",
|
||||
to_bson(&cp).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
if let Some(ss) = req.scan_schedule {
|
||||
set.insert("scan_schedule", ss);
|
||||
}
|
||||
if let Some(arts) = req.artifacts {
|
||||
let built: Vec<Artifact> = arts.iter().map(ArtifactInput::build).collect();
|
||||
set.insert(
|
||||
"artifacts",
|
||||
to_bson(&built).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
|
||||
db.onboarded_targets()
|
||||
.update_one(doc! { "_id": oid }, doc! { "$set": set })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
get_target(Extension(agent), tenant, Path(id)).await
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/targets/{id} — remove the target and its findings/scans.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn delete_target(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
db.onboarded_targets()
|
||||
.delete_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
// Cascade all data keyed by repo_id == target id (best-effort).
|
||||
let db = &db;
|
||||
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;
|
||||
|
||||
// DAST targets linked to this target, and all their downstream data.
|
||||
if let Ok(mut cursor) = db.dast_targets().find(doc! { "repo_id": &id }).await {
|
||||
use futures_util::StreamExt;
|
||||
while let Some(Ok(dt)) = cursor.next().await {
|
||||
let dast_target_id = dt.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
if !dast_target_id.is_empty() {
|
||||
cascade_delete_dast_target(db, &dast_target_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pentest sessions linked directly to this target (not via a DAST target).
|
||||
if let Ok(mut cursor) = db.pentest_sessions().find(doc! { "repo_id": &id }).await {
|
||||
use futures_util::StreamExt;
|
||||
while let Some(Ok(session)) = cursor.next().await {
|
||||
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
if !session_id.is_empty() {
|
||||
let _ = db
|
||||
.attack_chain_nodes()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.pentest_messages()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.dast_findings()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = db
|
||||
.pentest_sessions()
|
||||
.delete_many(doc! { "repo_id": &id })
|
||||
.await;
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "deleted" })))
|
||||
}
|
||||
|
||||
/// Delete a DAST target and everything downstream of it (pentest sessions +
|
||||
/// their attack chains / messages / findings, DAST scan runs + findings).
|
||||
async fn cascade_delete_dast_target(db: &crate::database::Database, target_id: &str) {
|
||||
use futures_util::StreamExt;
|
||||
if let Ok(mut cursor) = db
|
||||
.pentest_sessions()
|
||||
.find(doc! { "target_id": target_id })
|
||||
.await
|
||||
{
|
||||
while let Some(Ok(session)) = cursor.next().await {
|
||||
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
if !session_id.is_empty() {
|
||||
let _ = db
|
||||
.attack_chain_nodes()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.pentest_messages()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.dast_findings()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = db
|
||||
.pentest_sessions()
|
||||
.delete_many(doc! { "target_id": target_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.dast_findings()
|
||||
.delete_many(doc! { "target_id": target_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.dast_scan_runs()
|
||||
.delete_many(doc! { "target_id": target_id })
|
||||
.await;
|
||||
if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(target_id) {
|
||||
let _ = db.dast_targets().delete_one(doc! { "_id": oid }).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v1/targets/{id}/artifacts — attach an artifact (by reference).
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn add_artifact(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<ArtifactInput>,
|
||||
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let artifact = to_bson(&input.build()).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
db.onboarded_targets()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$push": { "artifacts": artifact }, "$set": { "updated_at": mongodb::bson::DateTime::now() } },
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
get_target(Extension(agent), tenant, Path(id)).await
|
||||
}
|
||||
|
||||
/// POST /api/v1/targets/{id}/artifacts/upload — attach an artifact by uploading
|
||||
/// its file (PLC project, firmware image, source archive, mobile package). The
|
||||
/// bytes are written to the artifact blob store and referenced by `stored_path`,
|
||||
/// so ingest resolves them locally (no URL fetch).
|
||||
///
|
||||
/// Multipart fields: `file` (required), `kind` (required, snake_case
|
||||
/// `ArtifactKind`), `plc_format` (optional, for PLC projects).
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn upload_artifact(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
if db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.is_none()
|
||||
{
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let mut kind: Option<ArtifactKind> = None;
|
||||
let mut plc_format: Option<PlcFormat> = None;
|
||||
let mut filename = String::from("upload.bin");
|
||||
let mut bytes: Option<axum::body::Bytes> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
{
|
||||
match field.name().unwrap_or("") {
|
||||
"kind" => {
|
||||
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
kind = parse_enum(&v);
|
||||
}
|
||||
"plc_format" => {
|
||||
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
plc_format = parse_enum(&v);
|
||||
}
|
||||
"file" => {
|
||||
if let Some(fname) = field.file_name() {
|
||||
filename = fname.to_string();
|
||||
}
|
||||
bytes = Some(field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (Some(kind), Some(bytes)) = (kind, bytes) else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
|
||||
// Store the uploaded bytes under the artifact blob store.
|
||||
let safe_name: String = filename
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let dir = std::path::Path::new(&agent.config.artifact_store_base_path)
|
||||
.join("uploads")
|
||||
.join(&id);
|
||||
std::fs::create_dir_all(&dir).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let dest = dir.join(format!("{}_{safe_name}", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&dest, bytes.as_ref()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Build the artifact for this kind, referencing the stored file.
|
||||
let mut artifact = match kind {
|
||||
ArtifactKind::PlcProject => Artifact::plc_project(
|
||||
filename.clone(),
|
||||
plc_format.unwrap_or(PlcFormat::PlcopenXml),
|
||||
),
|
||||
ArtifactKind::FirmwareImage => Artifact::firmware_image(filename.clone()),
|
||||
ArtifactKind::SourceArchive => Artifact::source_archive(filename.clone()),
|
||||
ArtifactKind::MobilePackage => Artifact::mobile_package(filename.clone()),
|
||||
// Non-file kinds (git repo, live URL, container ref, text) use the JSON
|
||||
// add-artifact endpoint, not upload.
|
||||
_ => return Err(StatusCode::BAD_REQUEST),
|
||||
};
|
||||
artifact.stored_path = Some(dest.to_string_lossy().to_string());
|
||||
artifact.size_bytes = Some(bytes.len() as u64);
|
||||
|
||||
let artifact_bson = to_bson(&artifact).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
db.onboarded_targets()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$push": { "artifacts": artifact_bson }, "$set": { "updated_at": mongodb::bson::DateTime::now() } },
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
get_target(Extension(agent), tenant, Path(id)).await
|
||||
}
|
||||
|
||||
/// Deserialize a snake_case enum value from a plain string.
|
||||
fn parse_enum<T: for<'de> Deserialize<'de>>(s: &str) -> Option<T> {
|
||||
serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
|
||||
}
|
||||
|
||||
/// GET /api/v1/targets/{id}/applicable-scans — the scan-applicability matrix.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn applicable_scans_for_target(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<ApplicableScansResponse>>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let target = db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
let scans = applicable_scans(&target)
|
||||
.into_iter()
|
||||
.map(|o| ScanOptionDto {
|
||||
scan: o.scan.to_string(),
|
||||
default_on: o.default_on,
|
||||
rationale: o.rationale,
|
||||
required_artifact: o.required_artifact.map(|k| k.to_string()),
|
||||
blocked_reason: o.blocked_reason,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: ApplicableScansResponse {
|
||||
scans,
|
||||
pentest_supported: supports_pentest(target.target_type),
|
||||
},
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/targets/{id}/detect — classify the target from its artifacts.
|
||||
///
|
||||
/// This is the lightweight pass: it classifies from artifact kinds without
|
||||
/// ingesting (cloning) sources, so it returns immediately. Deep detection (after
|
||||
/// ingest, with tramiton firmware analysis) is a follow-up background step.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn detect_target(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let mut target = db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// No ingested working paths here → kind-based classification only; the mock
|
||||
// firmware detector is never invoked (no firmware working path present).
|
||||
let empty = HashMap::new();
|
||||
let detector = MockFirmwareDetector { detection: None };
|
||||
let classification = classify_target(&target, &empty, &detector)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let classification_bson =
|
||||
to_bson(&classification).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
db.onboarded_targets()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$set": { "classification": classification_bson, "updated_at": mongodb::bson::DateTime::now() } },
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
target.classification = Some(classification);
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: target,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/targets/{id}/scan — trigger a scan for the target.
|
||||
///
|
||||
/// Dispatches to the unified pipeline when `UNIFIED_PIPELINE` is set (else the
|
||||
/// legacy path). Runs in the background and returns immediately.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %id))]
|
||||
pub async fn trigger_target_scan(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oid = parse_oid(&id)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
// 404 if the target doesn't exist for this tenant.
|
||||
if db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.is_none()
|
||||
{
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
let agent_clone = (*agent).clone();
|
||||
let tenant_id = tenant.0.tenant_id.clone();
|
||||
tokio::spawn(async move {
|
||||
// Always the unified target pipeline — this endpoint is about an
|
||||
// onboarded target by construction, independent of the global
|
||||
// `unified_pipeline` transition flag used by the legacy paths.
|
||||
if let Err(e) = agent_clone
|
||||
.run_target_scan(
|
||||
&tenant_id,
|
||||
&id,
|
||||
compliance_core::models::ScanTrigger::Manual,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Manual target scan failed for {id}: {e}");
|
||||
}
|
||||
});
|
||||
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//! OSCAL assessment endpoint.
|
||||
//!
|
||||
//! Assesses a target's findings against the breakpilot-compliance control
|
||||
//! catalog and returns a standard OSCAL assessment-results document. Ties
|
||||
//! together the ingest provider ([`OscalControlsProvider`]) and the assessment
|
||||
//! emitter (`compliance_core::models::oscal_assessment`).
|
||||
|
||||
use axum::extract::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
use serde::Deserialize;
|
||||
|
||||
use compliance_core::models::onboarding::ComplianceFramework;
|
||||
use compliance_core::models::oscal_assessment::{assess, ControlLinker};
|
||||
use compliance_core::models::Finding;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use super::dto::{collect_cursor_async, tenant_db, AgentExt};
|
||||
use crate::controls::OscalControlsProvider;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssessRequest {
|
||||
/// The target / repo id whose findings are assessed.
|
||||
pub target_id: String,
|
||||
/// Frameworks to assess against; defaults to `[Cra]` when empty.
|
||||
#[serde(default)]
|
||||
pub frameworks: Vec<ComplianceFramework>,
|
||||
}
|
||||
|
||||
/// `POST /api/v1/oscal/assess` — pull the catalog(s), load the target's findings,
|
||||
/// and emit an OSCAL assessment-results document linking findings to controls.
|
||||
pub async fn assess_target(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Json(req): Json<AssessRequest>,
|
||||
) -> Response {
|
||||
let cfg = &agent.config.breakpilot;
|
||||
let Some(base_url) = cfg.base_url.clone() else {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"breakpilot base URL not configured (set BREAKPILOT_BASE_URL)",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let frameworks = if req.frameworks.is_empty() {
|
||||
vec![ComplianceFramework::Cra]
|
||||
} else {
|
||||
req.frameworks.clone()
|
||||
};
|
||||
|
||||
let db = match tenant_db(&agent, &tenant).await {
|
||||
Ok(db) => db,
|
||||
Err(code) => return code.into_response(),
|
||||
};
|
||||
|
||||
let findings: Vec<Finding> = match db.findings().find(doc! { "repo_id": &req.target_id }).await
|
||||
{
|
||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to load findings for OSCAL assessment");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let provider = OscalControlsProvider::new(
|
||||
agent.http.clone(),
|
||||
base_url,
|
||||
cfg.token.clone(),
|
||||
&cfg.snapshot_dir,
|
||||
);
|
||||
let mut controls = Vec::new();
|
||||
for framework in &frameworks {
|
||||
match provider.load(*framework).await {
|
||||
Ok(document) => controls.extend(document.to_controls()),
|
||||
Err(e) => tracing::warn!(?framework, error = %e, "OSCAL catalog load failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let assessment = assess(
|
||||
&controls,
|
||||
&findings,
|
||||
&ControlLinker::cra_seed(),
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
Json(assessment).into_response()
|
||||
}
|
||||
@@ -13,11 +13,10 @@ use compliance_core::models::dast::DastFinding;
|
||||
use compliance_core::models::finding::Finding;
|
||||
use compliance_core::models::pentest::*;
|
||||
use compliance_core::models::sbom::SbomEntry;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
use super::super::dto::{collect_cursor_async, tenant_db};
|
||||
use super::super::dto::collect_cursor_async;
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
@@ -36,15 +35,11 @@ pub struct ExportBody {
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn export_session_report(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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()))?;
|
||||
let db = tenant_db(&agent, &tenant)
|
||||
.await
|
||||
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
|
||||
|
||||
if body.password.len() < 8 {
|
||||
return Err((
|
||||
@@ -54,7 +49,8 @@ pub async fn export_session_report(
|
||||
}
|
||||
|
||||
// Fetch session
|
||||
let session = db
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -68,7 +64,9 @@ pub async fn export_session_report(
|
||||
|
||||
// Resolve target name
|
||||
let target = if let Ok(tid) = mongodb::bson::oid::ObjectId::parse_str(&session.target_id) {
|
||||
db.dast_targets()
|
||||
agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": tid })
|
||||
.await
|
||||
.ok()
|
||||
@@ -86,7 +84,8 @@ pub async fn export_session_report(
|
||||
.unwrap_or_default();
|
||||
|
||||
// Fetch attack chain nodes
|
||||
let nodes: Vec<AttackChainNode> = match db
|
||||
let nodes: Vec<AttackChainNode> = match agent
|
||||
.db
|
||||
.attack_chain_nodes()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "started_at": 1 })
|
||||
@@ -97,7 +96,8 @@ pub async fn export_session_report(
|
||||
};
|
||||
|
||||
// Fetch DAST findings for this session, then deduplicate
|
||||
let raw_findings: Vec<DastFinding> = match db
|
||||
let raw_findings: Vec<DastFinding> = match agent
|
||||
.db
|
||||
.dast_findings()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "severity": -1, "created_at": -1 })
|
||||
@@ -122,7 +122,8 @@ pub async fn export_session_report(
|
||||
.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 db
|
||||
let sast: Vec<Finding> = match agent
|
||||
.db
|
||||
.findings()
|
||||
.find(doc! {
|
||||
"repo_id": rid,
|
||||
@@ -142,7 +143,8 @@ pub async fn export_session_report(
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let sbom: Vec<SbomEntry> = match db
|
||||
let sbom: Vec<SbomEntry> = match agent
|
||||
.db
|
||||
.sbom_entries()
|
||||
.find(doc! {
|
||||
"repo_id": rid,
|
||||
@@ -162,7 +164,8 @@ pub async fn export_session_report(
|
||||
};
|
||||
|
||||
// Build code context from graph nodes
|
||||
let code_ctx: Vec<CodeContextHint> = match db
|
||||
let code_ctx: Vec<CodeContextHint> = match agent
|
||||
.db
|
||||
.graph_nodes()
|
||||
.find(doc! { "repo_id": rid, "is_entry_point": true })
|
||||
.limit(50)
|
||||
|
||||
@@ -7,12 +7,11 @@ use mongodb::bson::doc;
|
||||
use serde::Deserialize;
|
||||
|
||||
use compliance_core::models::pentest::*;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
use crate::pentest::PentestOrchestrator;
|
||||
|
||||
use super::super::dto::{collect_cursor_async, tenant_db, ApiResponse, PaginationParams};
|
||||
use super::super::dto::{collect_cursor_async, ApiResponse, PaginationParams};
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
@@ -44,7 +43,6 @@ pub struct LookupRepoQuery {
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn create_session(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Json(req): Json<CreateSessionRequest>,
|
||||
) -> Result<Json<ApiResponse<PentestSession>>, (StatusCode, String)> {
|
||||
// Try to acquire a concurrency permit
|
||||
@@ -59,10 +57,6 @@ pub async fn create_session(
|
||||
)
|
||||
})?;
|
||||
|
||||
let db = tenant_db(&agent, &tenant)
|
||||
.await
|
||||
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
|
||||
|
||||
if let Some(ref config) = req.config {
|
||||
// ── Wizard path ──────────────────────────────────────────────
|
||||
if !config.disclaimer_accepted {
|
||||
@@ -73,7 +67,8 @@ pub async fn create_session(
|
||||
}
|
||||
|
||||
// Look up or auto-create DastTarget by app_url
|
||||
let target = match db
|
||||
let target = match agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "base_url": &config.app_url })
|
||||
.await
|
||||
@@ -92,7 +87,7 @@ pub async fn create_session(
|
||||
}
|
||||
t.allow_destructive = config.allow_destructive;
|
||||
t.excluded_paths = config.scope_exclusions.clone();
|
||||
let res = db.dast_targets().insert_one(&t).await.map_err(|e| {
|
||||
let res = agent.db.dast_targets().insert_one(&t).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create target: {e}"),
|
||||
@@ -113,18 +108,20 @@ pub async fn create_session(
|
||||
session.config = Some(config.clone());
|
||||
session.repo_id = target.repo_id.clone();
|
||||
|
||||
// Resolve repo_id (target id) from git_repo_url if provided
|
||||
// Resolve repo_id from git_repo_url if provided
|
||||
if let Some(ref git_url) = config.git_repo_url {
|
||||
if let Ok(Some(target)) = db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "artifacts.source_ref": git_url })
|
||||
if let Ok(Some(repo)) = agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "git_url": git_url })
|
||||
.await
|
||||
{
|
||||
session.repo_id = target.id.map(|oid| oid.to_hex());
|
||||
session.repo_id = repo.id.map(|oid| oid.to_hex());
|
||||
}
|
||||
}
|
||||
|
||||
let insert_result = db
|
||||
let insert_result = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
@@ -215,7 +212,8 @@ pub async fn create_session(
|
||||
// Persist encrypted credentials to DB
|
||||
if session_for_task.config.is_some() {
|
||||
if let Some(sid) = session.id {
|
||||
let _ = db
|
||||
let _ = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": sid },
|
||||
@@ -247,13 +245,12 @@ pub async fn create_session(
|
||||
});
|
||||
|
||||
let llm = agent.llm.clone();
|
||||
let db_for_orchestrator = db.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_for_orchestrator, event_tx, Some(pause_rx));
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, Some(pause_rx));
|
||||
orchestrator
|
||||
.run_session_guarded(&session_clone, &target_clone, &initial_message)
|
||||
.await;
|
||||
@@ -295,7 +292,8 @@ pub async fn create_session(
|
||||
)
|
||||
})?;
|
||||
|
||||
let target = db
|
||||
let target = agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -312,7 +310,8 @@ pub async fn create_session(
|
||||
let mut session = PentestSession::new(target_id, strategy);
|
||||
session.repo_id = target.repo_id.clone();
|
||||
|
||||
let insert_result = db
|
||||
let insert_result = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.insert_one(&session)
|
||||
.await
|
||||
@@ -339,13 +338,12 @@ pub async fn create_session(
|
||||
});
|
||||
|
||||
let llm = agent.llm.clone();
|
||||
let db_for_orchestrator = db.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_for_orchestrator, event_tx, Some(pause_rx));
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, Some(pause_rx));
|
||||
orchestrator
|
||||
.run_session_guarded(&session_clone, &target_clone, &initial_message)
|
||||
.await;
|
||||
@@ -375,25 +373,21 @@ fn parse_strategy(s: &str) -> PentestStrategy {
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn lookup_repo(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<LookupRepoQuery>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let repo = db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "artifacts.source_ref": ¶ms.url })
|
||||
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) => {
|
||||
let git = r.code_artifact().and_then(|c| c.git.as_ref());
|
||||
serde_json::json!({
|
||||
"name": r.name,
|
||||
"default_branch": git.map(|g| g.default_branch.clone()),
|
||||
"last_scanned_commit": git.and_then(|g| g.last_scanned_commit.clone()),
|
||||
})
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -408,11 +402,9 @@ pub async fn lookup_repo(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_sessions(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> Result<Json<ApiResponse<Vec<PentestSession>>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
.pentest_sessions()
|
||||
@@ -446,13 +438,12 @@ pub async fn list_sessions(
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn get_session(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ApiResponse<PentestSession>>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let mut session = db
|
||||
let mut session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -480,18 +471,15 @@ pub async fn get_session(
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn send_message(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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()))?;
|
||||
let db = tenant_db(&agent, &tenant)
|
||||
.await
|
||||
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
|
||||
|
||||
// Verify session exists and is running
|
||||
let session = db
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -518,7 +506,8 @@ pub async fn send_message(
|
||||
)
|
||||
})?;
|
||||
|
||||
let target = db
|
||||
let target = agent
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "_id": target_oid })
|
||||
.await
|
||||
@@ -538,13 +527,13 @@ pub async fn send_message(
|
||||
// Store user message
|
||||
let session_id = id.clone();
|
||||
let user_msg = PentestMessage::user(session_id.clone(), req.message.clone());
|
||||
let _ = db.pentest_messages().insert_one(&user_msg).await;
|
||||
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_for_orchestrator = db.clone();
|
||||
let db = agent.db.clone();
|
||||
let message = req.message.clone();
|
||||
|
||||
// Use existing broadcast sender if available, otherwise create a new one
|
||||
@@ -559,7 +548,7 @@ pub async fn send_message(
|
||||
.unwrap_or_else(|| agent.register_session_stream(&session_id));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let orchestrator = PentestOrchestrator::new(llm, db_for_orchestrator, event_tx, None);
|
||||
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, None);
|
||||
orchestrator
|
||||
.run_session_guarded(&session, &target, &message)
|
||||
.await;
|
||||
@@ -576,16 +565,13 @@ pub async fn send_message(
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn stop_session(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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 db = tenant_db(&agent, &tenant)
|
||||
.await
|
||||
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
|
||||
|
||||
let session = db
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -604,7 +590,9 @@ pub async fn stop_session(
|
||||
));
|
||||
}
|
||||
|
||||
db.pentest_sessions()
|
||||
agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$set": {
|
||||
@@ -624,7 +612,8 @@ pub async fn stop_session(
|
||||
// Clean up session resources
|
||||
agent.cleanup_session(&id);
|
||||
|
||||
let updated = db
|
||||
let updated = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -652,16 +641,13 @@ pub async fn stop_session(
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn pause_session(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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 db = tenant_db(&agent, &tenant)
|
||||
.await
|
||||
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
|
||||
|
||||
let session = db
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -698,16 +684,13 @@ pub async fn pause_session(
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn resume_session(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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 db = tenant_db(&agent, &tenant)
|
||||
.await
|
||||
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
|
||||
|
||||
let session = db
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -744,13 +727,12 @@ pub async fn resume_session(
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn get_attack_chain(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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 db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let nodes = match db
|
||||
let nodes = match agent
|
||||
.db
|
||||
.attack_chain_nodes()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "started_at": 1 })
|
||||
@@ -775,21 +757,21 @@ pub async fn get_attack_chain(
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn get_messages(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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 db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
let total = agent
|
||||
.db
|
||||
.pentest_messages()
|
||||
.count_documents(doc! { "session_id": &id })
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let messages = match db
|
||||
let messages = match agent
|
||||
.db
|
||||
.pentest_messages()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "created_at": 1 })
|
||||
@@ -815,21 +797,21 @@ pub async fn get_messages(
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn get_session_findings(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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 db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
|
||||
let total = db
|
||||
let total = agent
|
||||
.db
|
||||
.dast_findings()
|
||||
.count_documents(doc! { "session_id": &id })
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let findings = match db
|
||||
let findings = match agent
|
||||
.db
|
||||
.dast_findings()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "created_at": -1 })
|
||||
|
||||
@@ -6,11 +6,10 @@ use axum::Json;
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use compliance_core::models::pentest::*;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
use super::super::dto::{collect_cursor_async, tenant_db, ApiResponse};
|
||||
use super::super::dto::{collect_cursor_async, ApiResponse};
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
@@ -18,10 +17,8 @@ type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn pentest_stats(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
) -> Result<Json<ApiResponse<PentestStats>>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
|
||||
let running_sessions = db
|
||||
.pentest_sessions()
|
||||
|
||||
@@ -11,11 +11,10 @@ use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use compliance_core::models::pentest::*;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
|
||||
use super::super::dto::{collect_cursor_async, tenant_db};
|
||||
use super::super::dto::collect_cursor_async;
|
||||
|
||||
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
|
||||
@@ -26,14 +25,13 @@ type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||
#[tracing::instrument(skip_all, fields(session_id = %id))]
|
||||
pub async fn session_stream(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
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)?;
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
|
||||
// Verify session exists
|
||||
let _session = db
|
||||
let _session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
@@ -45,7 +43,8 @@ pub async fn session_stream(
|
||||
let mut initial_events: Vec<Result<Event, Infallible>> = Vec::new();
|
||||
|
||||
// Fetch recent messages for this session
|
||||
let messages: Vec<PentestMessage> = match db
|
||||
let messages: Vec<PentestMessage> = match agent
|
||||
.db
|
||||
.pentest_messages()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "created_at": 1 })
|
||||
@@ -57,7 +56,8 @@ pub async fn session_stream(
|
||||
};
|
||||
|
||||
// Fetch recent attack chain nodes
|
||||
let nodes: Vec<AttackChainNode> = match db
|
||||
let nodes: Vec<AttackChainNode> = match agent
|
||||
.db
|
||||
.attack_chain_nodes()
|
||||
.find(doc! { "session_id": &id })
|
||||
.sort(doc! { "started_at": 1 })
|
||||
@@ -94,7 +94,8 @@ pub async fn session_stream(
|
||||
}
|
||||
|
||||
// Add current session status event
|
||||
let session = db
|
||||
let session = agent
|
||||
.db
|
||||
.pentest_sessions()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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;
|
||||
|
||||
// Cascade delete DAST targets linked to this repo, and all their downstream data
|
||||
// (scan runs, findings, pentest sessions, attack chains, messages)
|
||||
if let Ok(mut cursor) = db.dast_targets().find(doc! { "repo_id": &id }).await {
|
||||
use futures_util::StreamExt;
|
||||
while let Some(Ok(target)) = cursor.next().await {
|
||||
let target_id = target.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
if !target_id.is_empty() {
|
||||
cascade_delete_dast_target(db, &target_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete pentest sessions linked directly to this repo (not via target)
|
||||
if let Ok(mut cursor) = db.pentest_sessions().find(doc! { "repo_id": &id }).await {
|
||||
use futures_util::StreamExt;
|
||||
while let Some(Ok(session)) = cursor.next().await {
|
||||
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
if !session_id.is_empty() {
|
||||
let _ = db
|
||||
.attack_chain_nodes()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.pentest_messages()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
// Delete DAST findings produced by this session
|
||||
let _ = db
|
||||
.dast_findings()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = db
|
||||
.pentest_sessions()
|
||||
.delete_many(doc! { "repo_id": &id })
|
||||
.await;
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "deleted" })))
|
||||
}
|
||||
|
||||
/// Cascade-delete a DAST target and all its downstream data.
|
||||
async fn cascade_delete_dast_target(db: &crate::database::Database, target_id: &str) {
|
||||
// Delete pentest sessions for this target (and their attack chains + messages)
|
||||
if let Ok(mut cursor) = db
|
||||
.pentest_sessions()
|
||||
.find(doc! { "target_id": target_id })
|
||||
.await
|
||||
{
|
||||
use futures_util::StreamExt;
|
||||
while let Some(Ok(session)) = cursor.next().await {
|
||||
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
||||
if !session_id.is_empty() {
|
||||
let _ = db
|
||||
.attack_chain_nodes()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.pentest_messages()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.dast_findings()
|
||||
.delete_many(doc! { "session_id": &session_id })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = db
|
||||
.pentest_sessions()
|
||||
.delete_many(doc! { "target_id": target_id })
|
||||
.await;
|
||||
|
||||
// Delete DAST scan runs and their findings
|
||||
let _ = db
|
||||
.dast_findings()
|
||||
.delete_many(doc! { "target_id": target_id })
|
||||
.await;
|
||||
let _ = db
|
||||
.dast_scan_runs()
|
||||
.delete_many(doc! { "target_id": target_id })
|
||||
.await;
|
||||
|
||||
// Delete the target itself
|
||||
if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(target_id) {
|
||||
let _ = db.dast_targets().delete_one(doc! { "_id": oid }).await;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::SbomEntry;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
const COPYLEFT_LICENSES: &[&str] = &[
|
||||
"GPL-2.0",
|
||||
@@ -30,10 +29,8 @@ const COPYLEFT_LICENSES: &[&str] = &[
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn sbom_filters(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
|
||||
let managers: Vec<String> = db
|
||||
.sbom_entries()
|
||||
@@ -64,11 +61,9 @@ pub async fn sbom_filters(
|
||||
#[tracing::instrument(skip_all, fields(repo_id = ?filter.repo_id, package_manager = ?filter.package_manager))]
|
||||
pub async fn list_sbom(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(filter): Query<SbomFilter>,
|
||||
) -> ApiResult<Vec<SbomEntry>> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let mut query = doc! {};
|
||||
|
||||
if let Some(repo_id) = &filter.repo_id {
|
||||
@@ -125,11 +120,9 @@ pub async fn list_sbom(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn export_sbom(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<SbomExportParams>,
|
||||
) -> Result<impl IntoResponse, StatusCode> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let entries: Vec<SbomEntry> = match db
|
||||
.sbom_entries()
|
||||
.find(doc! { "repo_id": ¶ms.repo_id })
|
||||
@@ -243,11 +236,9 @@ pub async fn export_sbom(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn license_summary(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<SbomFilter>,
|
||||
) -> ApiResult<Vec<LicenseSummary>> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
let mut query = doc! {};
|
||||
if let Some(repo_id) = ¶ms.repo_id {
|
||||
query.insert("repo_id", repo_id);
|
||||
@@ -282,7 +273,7 @@ pub async fn license_summary(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
summaries.sort_by_key(|s| std::cmp::Reverse(s.count));
|
||||
summaries.sort_by(|a, b| b.count.cmp(&a.count));
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
data: summaries,
|
||||
@@ -294,11 +285,9 @@ pub async fn license_summary(
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn sbom_diff(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<SbomDiffParams>,
|
||||
) -> ApiResult<SbomDiffResult> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
let db = &agent.db;
|
||||
|
||||
let entries_a: Vec<SbomEntry> = match db
|
||||
.sbom_entries()
|
||||
|
||||
@@ -4,16 +4,13 @@ use mongodb::bson::doc;
|
||||
|
||||
use super::dto::*;
|
||||
use compliance_core::models::ScanRun;
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_scan_runs(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Query(params): Query<PaginationParams>,
|
||||
) -> ApiResult<Vec<ScanRun>> {
|
||||
let db = tenant_db(&agent, &tenant).await?;
|
||||
let db = &db;
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
//! Werkbank runner endpoints (`/api/v1/werkbank/jobs/*`).
|
||||
//!
|
||||
//! The pull API a Werkbank runner talks to: lease a job, heartbeat while it runs,
|
||||
//! and post the result back. Machine auth is a **static bearer token**
|
||||
//! (`WERKBANK_RUNNER_TOKEN`) — not a Keycloak JWT, because a runner acts across
|
||||
//! tenants (each request names its `tenant`). Routes are only mounted when the
|
||||
//! token is configured; with none set they don't exist (404).
|
||||
//!
|
||||
//! On completion the runner's findings are persisted against the job's target,
|
||||
//! so a job run by a remote runner lands the same findings an in-process run
|
||||
//! would (WB-05, the control-plane cut-over).
|
||||
|
||||
use axum::extract::{Extension, Path, Request};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use mongodb::bson::{doc, oid::ObjectId};
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use compliance_core::models::werkbank::{
|
||||
CompleteRequest, CompleteResponse, HeartbeatRequest, InputRef, Job, JobResult, LeaseRequest,
|
||||
};
|
||||
use compliance_core::models::ArtifactKind;
|
||||
|
||||
use super::dto::AgentExt;
|
||||
use crate::database::Database;
|
||||
use crate::werkbank::JobQueue;
|
||||
|
||||
/// Gate the runner endpoints behind the static runner bearer token.
|
||||
pub async fn require_runner_token(
|
||||
Extension(agent): AgentExt,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let Some(expected) = agent.config.werkbank_runner_token.as_ref() else {
|
||||
return (StatusCode::NOT_FOUND, "werkbank runner API disabled").into_response();
|
||||
};
|
||||
let presented = request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let Some(presented) = presented else {
|
||||
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
|
||||
};
|
||||
if !constant_time_eq(presented, expected.expose_secret()) {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid runner token").into_response();
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/werkbank/jobs/lease` — lease the oldest runnable job, or `204`.
|
||||
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, runner = %req.runner_id))]
|
||||
pub async fn lease(
|
||||
Extension(agent): AgentExt,
|
||||
Json(req): Json<LeaseRequest>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let queue = JobQueue::new(&tenant_db(&agent, &req.tenant).await?);
|
||||
let leased = queue
|
||||
.lease(
|
||||
&req.runner_id,
|
||||
req.executor,
|
||||
&req.labels,
|
||||
Duration::from_secs(req.lease_ttl_secs),
|
||||
chrono::Utc::now(),
|
||||
)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
Ok(match leased {
|
||||
Some(job) => Json(job).into_response(),
|
||||
None => StatusCode::NO_CONTENT.into_response(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `POST /api/v1/werkbank/jobs/heartbeat` — extend the lease; `409` if it's lost.
|
||||
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, job = %req.job_id))]
|
||||
pub async fn heartbeat(
|
||||
Extension(agent): AgentExt,
|
||||
Json(req): Json<HeartbeatRequest>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let queue = JobQueue::new(&tenant_db(&agent, &req.tenant).await?);
|
||||
let ack = queue
|
||||
.heartbeat(
|
||||
&req.job_id,
|
||||
&req.lease_token,
|
||||
Duration::from_secs(req.lease_ttl_secs),
|
||||
chrono::Utc::now(),
|
||||
)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
Ok(match ack {
|
||||
Some(ack) => Json(ack).into_response(),
|
||||
// Lease lost — the runner should abandon the job.
|
||||
None => StatusCode::CONFLICT.into_response(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `POST /api/v1/werkbank/jobs/complete` — record the result and persist findings.
|
||||
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, job = %req.job_id))]
|
||||
pub async fn complete(
|
||||
Extension(agent): AgentExt,
|
||||
Json(req): Json<CompleteRequest>,
|
||||
) -> Result<Json<CompleteResponse>, StatusCode> {
|
||||
let db = tenant_db(&agent, &req.tenant).await?;
|
||||
let queue = JobQueue::new(&db);
|
||||
let now = chrono::Utc::now();
|
||||
let recorded = queue
|
||||
.complete(&req.job_id, &req.lease_token, &req.result, now)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
|
||||
// Only persist findings for the run that actually recorded the result, so a
|
||||
// duplicate/late completion can't double-insert.
|
||||
if recorded {
|
||||
if let Some(record) = queue.get(&req.job_id).await.map_err(internal)? {
|
||||
persist_findings(&db, &record.job.target_id, &req.result).await;
|
||||
}
|
||||
}
|
||||
Ok(Json(CompleteResponse { recorded }))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/werkbank/artifacts/{hash}` — serve a content-addressed blob (the
|
||||
/// program a runner needs to load). The hash is validated against traversal by
|
||||
/// [`crate::ingest::blob::read_blob`]; a runner fetches this for a job's `blob`
|
||||
/// input.
|
||||
#[tracing::instrument(skip_all, fields(hash = %hash))]
|
||||
pub async fn serve_artifact(
|
||||
Extension(agent): AgentExt,
|
||||
Path(hash): Path<String>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let base = std::path::Path::new(&agent.config.artifact_store_base_path);
|
||||
match crate::ingest::blob::read_blob(base, &hash) {
|
||||
Ok(bytes) => {
|
||||
Ok(([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
|
||||
}
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueue a `plc-provision` job for a target: extract its control-logic program,
|
||||
/// stash it as a content-addressed blob (which the runner fetches via
|
||||
/// [`serve_artifact`]), and queue the job. This is the control-plane "enqueue"
|
||||
/// half of the loop — a runner then leases it, provisions, and posts results.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnqueueRequest {
|
||||
/// The tenant whose queue to enqueue into.
|
||||
pub tenant: String,
|
||||
/// The onboarded target to test.
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
/// The enqueued job's id.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EnqueueResponse {
|
||||
/// The new job id.
|
||||
pub job_id: String,
|
||||
/// Whether this call inserted it (false = already queued).
|
||||
pub enqueued: bool,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, target = %req.target_id))]
|
||||
pub async fn enqueue(
|
||||
Extension(agent): AgentExt,
|
||||
Json(req): Json<EnqueueRequest>,
|
||||
) -> Result<Json<EnqueueResponse>, StatusCode> {
|
||||
let db = tenant_db(&agent, &req.tenant).await?;
|
||||
let oid = ObjectId::parse_str(&req.target_id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let target = db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await
|
||||
.map_err(internal)?
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Extract the control-logic program from the target's PLC-source artifacts
|
||||
// (same selection as the in-process PLC scan).
|
||||
let ctx = crate::ingest::IngestContext::from_config(&agent.config, &req.target_id);
|
||||
let ingest_set = crate::ingest::ingest_all(&target, &ctx).map_err(internal)?;
|
||||
let program = target
|
||||
.artifacts
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
matches!(
|
||||
a.kind,
|
||||
ArtifactKind::PlcProject | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
|
||||
)
|
||||
})
|
||||
.find_map(|a| {
|
||||
let path = ingest_set
|
||||
.get(&a.id)
|
||||
.and_then(|ia| ia.working_path.clone())?;
|
||||
werkbank_exec::plc::extract_program(&path)
|
||||
})
|
||||
.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
|
||||
|
||||
// Stash the program source so the runner can fetch it by hash.
|
||||
let base = std::path::Path::new(&agent.config.artifact_store_base_path);
|
||||
let hash =
|
||||
crate::ingest::blob::store_bytes(base, program.source.as_bytes()).map_err(internal)?;
|
||||
|
||||
let job_id = format!("job_{}", uuid::Uuid::new_v4().simple());
|
||||
let job = Job::plc_provision(
|
||||
&job_id,
|
||||
&req.tenant,
|
||||
&req.target_id,
|
||||
InputRef::blob(hash),
|
||||
agent.config.plc_runtime.max_lifetime_secs,
|
||||
);
|
||||
let enqueued = JobQueue::new(&db)
|
||||
.enqueue(job, chrono::Utc::now())
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
Ok(Json(EnqueueResponse { job_id, enqueued }))
|
||||
}
|
||||
|
||||
/// Persist a job result's findings against its target: general findings
|
||||
/// (dedup'd by fingerprint) and DAST findings. Best-effort — a persistence hiccup
|
||||
/// is logged, not surfaced to the runner (its result is already recorded).
|
||||
async fn persist_findings(db: &Database, target_id: &str, result: &JobResult) {
|
||||
for finding in &result.findings {
|
||||
let exists = db
|
||||
.findings()
|
||||
.find_one(doc! { "fingerprint": &finding.fingerprint })
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
if !exists {
|
||||
if let Err(e) = db.findings().insert_one(finding).await {
|
||||
tracing::warn!(target_id, error = %e, "werkbank: persist finding failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
for finding in &result.dast_findings {
|
||||
if let Err(e) = db.dast_findings().insert_one(finding).await {
|
||||
tracing::warn!(target_id, error = %e, "werkbank: persist DAST finding failed");
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
target_id,
|
||||
findings = result.findings.len(),
|
||||
dast = result.dast_findings.len(),
|
||||
"werkbank: persisted runner results"
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolve the tenant-scoped database for a request.
|
||||
async fn tenant_db(
|
||||
agent: &crate::agent::ComplianceAgent,
|
||||
tenant: &str,
|
||||
) -> Result<Database, StatusCode> {
|
||||
agent.db_pool.for_tenant_id(tenant).await.map_err(internal)
|
||||
}
|
||||
|
||||
/// Map any internal error to a 500.
|
||||
fn internal<E: std::fmt::Display>(e: E) -> StatusCode {
|
||||
tracing::error!("werkbank endpoint error: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
|
||||
/// Length-checked, constant-time-ish token comparison.
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.bytes().zip(b.bytes()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::constant_time_eq;
|
||||
|
||||
#[test]
|
||||
fn token_compare() {
|
||||
assert!(constant_time_eq("secret", "secret"));
|
||||
assert!(!constant_time_eq("secret", "secrex"));
|
||||
assert!(!constant_time_eq("secret", "secretx"));
|
||||
assert!(!constant_time_eq("", "x"));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth_middleware;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
|
||||
@@ -2,46 +2,29 @@ use axum::routing::{delete, get, patch, post};
|
||||
use axum::Router;
|
||||
|
||||
use crate::api::handlers;
|
||||
use crate::webhooks;
|
||||
|
||||
pub fn build_router() -> Router {
|
||||
Router::new()
|
||||
.route("/api/v1/health", get(handlers::health))
|
||||
.route("/api/v1/oscal/assess", post(handlers::oscal::assess_target))
|
||||
.route("/api/v1/stats/overview", get(handlers::stats_overview))
|
||||
.route(
|
||||
"/api/v1/settings/ssh-public-key",
|
||||
get(handlers::get_ssh_public_key),
|
||||
)
|
||||
// Unified onboarding targets (#131).
|
||||
.route("/api/v1/repositories", get(handlers::list_repositories))
|
||||
.route("/api/v1/repositories", post(handlers::add_repository))
|
||||
.route(
|
||||
"/api/v1/targets",
|
||||
get(handlers::onboarding::list_targets).post(handlers::onboarding::create_target),
|
||||
"/api/v1/repositories/{id}/scan",
|
||||
post(handlers::trigger_scan),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}",
|
||||
get(handlers::onboarding::get_target)
|
||||
.patch(handlers::onboarding::update_target)
|
||||
.delete(handlers::onboarding::delete_target),
|
||||
"/api/v1/repositories/{id}",
|
||||
delete(handlers::delete_repository).patch(handlers::update_repository),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}/artifacts",
|
||||
post(handlers::onboarding::add_artifact),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}/artifacts/upload",
|
||||
post(handlers::onboarding::upload_artifact),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}/applicable-scans",
|
||||
get(handlers::onboarding::applicable_scans_for_target),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}/detect",
|
||||
post(handlers::onboarding::detect_target),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/targets/{id}/scan",
|
||||
post(handlers::onboarding::trigger_target_scan),
|
||||
"/api/v1/repositories/{id}/webhook-config",
|
||||
get(handlers::get_webhook_config),
|
||||
)
|
||||
.route("/api/v1/findings", get(handlers::list_findings))
|
||||
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
||||
@@ -64,15 +47,6 @@ pub fn build_router() -> Router {
|
||||
.route("/api/v1/sbom/diff", get(handlers::sbom_diff))
|
||||
.route("/api/v1/issues", get(handlers::list_issues))
|
||||
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
||||
// MCP token management (per-tenant API tokens for the MCP server)
|
||||
.route(
|
||||
"/api/v1/mcp-tokens",
|
||||
get(handlers::mcp_tokens::list_mcp_tokens).post(handlers::mcp_tokens::create_mcp_token),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/mcp-tokens/{id}",
|
||||
delete(handlers::mcp_tokens::revoke_mcp_token),
|
||||
)
|
||||
// Graph API endpoints
|
||||
.route("/api/v1/graph/{repo_id}", get(handlers::graph::get_graph))
|
||||
.route(
|
||||
@@ -201,10 +175,17 @@ pub fn build_router() -> Router {
|
||||
"/api/v1/pentest/stats",
|
||||
get(handlers::pentest::pentest_stats),
|
||||
)
|
||||
// Webhook routes live on the separate webhook server (port 3002,
|
||||
// see crate::webhooks::server). The M7.2-C tenant-in-URL form is
|
||||
// `/webhook/{tenant_id}/{platform}/{repo_id}` and the handlers
|
||||
// expect a (tenant_id, repo_id) path tuple. Anything mounting
|
||||
// them here on the API server would mismatch the handler
|
||||
// signature, so the routes are not exported.
|
||||
// Webhook endpoints (proxied through dashboard)
|
||||
.route(
|
||||
"/webhook/github/{repo_id}",
|
||||
post(webhooks::github::handle_github_webhook),
|
||||
)
|
||||
.route(
|
||||
"/webhook/gitlab/{repo_id}",
|
||||
post(webhooks::gitlab::handle_gitlab_webhook),
|
||||
)
|
||||
.route(
|
||||
"/webhook/gitea/{repo_id}",
|
||||
post(webhooks::gitea::handle_gitea_webhook),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,117 +1,19 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{DefaultBodyLimit, Request};
|
||||
use axum::http::HeaderValue;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{middleware, Extension, Router};
|
||||
use axum::{middleware, Extension};
|
||||
use tokio::sync::RwLock;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use compliance_core::auth::{require_jwt_auth, require_tenant_status, JwksState};
|
||||
use compliance_core::{TenantContext, TenantStatus};
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
use crate::api::handlers;
|
||||
use crate::api::auth_middleware::{require_jwt_auth, JwksState};
|
||||
use crate::api::routes;
|
||||
use crate::error::AgentError;
|
||||
|
||||
/// Synthetic tenant id used when Keycloak isn't configured (local dev,
|
||||
/// `cargo run` against a bare Mongo). Lets the handler stack stay
|
||||
/// uniformly tenant-scoped without the operator having to spin up KC
|
||||
/// just to poke at the API. Override via `DEV_TENANT_ID`.
|
||||
const DEFAULT_DEV_TENANT_ID: &str = "dev";
|
||||
|
||||
/// Inject a synthetic [`TenantContext`] for any request that lacks one.
|
||||
/// Only mounted when Keycloak is NOT configured; with KC, the real
|
||||
/// `require_jwt_auth` middleware owns this and we never reach here
|
||||
/// without a context.
|
||||
///
|
||||
/// Public so the integration-test harness can mount it without
|
||||
/// duplicating the synthetic-context shape.
|
||||
pub async fn inject_dev_tenant(mut request: Request, next: Next) -> Response {
|
||||
if request.extensions().get::<TenantContext>().is_none() {
|
||||
let tenant_id =
|
||||
std::env::var("DEV_TENANT_ID").unwrap_or_else(|_| DEFAULT_DEV_TENANT_ID.to_string());
|
||||
let ctx = TenantContext {
|
||||
tenant_slug: tenant_id.clone(),
|
||||
tenant_id,
|
||||
org_roles: vec![],
|
||||
products: vec![],
|
||||
plan: "dev".to_string(),
|
||||
status: TenantStatus::Active,
|
||||
user_id: "dev-user".to_string(),
|
||||
user_name: None,
|
||||
};
|
||||
request.extensions_mut().insert(ctx);
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
|
||||
// Admin sub-router. Routes are only mounted when ADMIN_API_TOKEN is
|
||||
// configured — without it, the paths don't exist at all (404 rather
|
||||
// than 401), so an operator who hasn't opted in can't fingerprint
|
||||
// the surface area.
|
||||
let admin_router: Router = if agent.config.admin_api_token.is_some() {
|
||||
tracing::info!("Admin API enabled — /api/v1/admin/* mounted behind ADMIN_API_TOKEN bearer");
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/admin/tenants",
|
||||
get(handlers::admin::list_tenant_dbs),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/tenants/{tenant_id}",
|
||||
delete(handlers::admin::drop_tenant_db),
|
||||
)
|
||||
.layer(middleware::from_fn(handlers::admin::require_admin_token))
|
||||
} else {
|
||||
Router::new()
|
||||
};
|
||||
|
||||
// Werkbank runner API. Like admin, only mounted when its bearer token is
|
||||
// configured; runners authenticate with WERKBANK_RUNNER_TOKEN (not a JWT).
|
||||
let werkbank_router: Router = if agent.config.werkbank_runner_token.is_some() {
|
||||
tracing::info!(
|
||||
"Werkbank runner API enabled — /api/v1/werkbank/jobs/* behind WERKBANK_RUNNER_TOKEN"
|
||||
);
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/werkbank/jobs/lease",
|
||||
post(handlers::werkbank_jobs::lease),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/werkbank/jobs/heartbeat",
|
||||
post(handlers::werkbank_jobs::heartbeat),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/werkbank/jobs/complete",
|
||||
post(handlers::werkbank_jobs::complete),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/werkbank/jobs/enqueue",
|
||||
post(handlers::werkbank_jobs::enqueue),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/werkbank/artifacts/{hash}",
|
||||
get(handlers::werkbank_jobs::serve_artifact),
|
||||
)
|
||||
.layer(middleware::from_fn(
|
||||
handlers::werkbank_jobs::require_runner_token,
|
||||
))
|
||||
} else {
|
||||
Router::new()
|
||||
};
|
||||
|
||||
let mut app = routes::build_router()
|
||||
.merge(admin_router)
|
||||
.merge(werkbank_router)
|
||||
// Allow large artifact uploads (PLC .projectarchive, firmware images,
|
||||
// mobile packages) — axum's default request-body limit is only 2 MiB.
|
||||
.layer(DefaultBodyLimit::max(512 * 1024 * 1024))
|
||||
.layer(Extension(Arc::new(agent.clone())))
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
@@ -142,22 +44,11 @@ pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), A
|
||||
jwks_url,
|
||||
};
|
||||
tracing::info!("Keycloak JWT auth enabled for realm '{kc_realm}'");
|
||||
// Layers execute outermost-first. Extension(jwks_state) must run
|
||||
// before require_jwt_auth so the middleware can read it; the
|
||||
// status gate runs after JWT so TenantContext is in extensions.
|
||||
app = app
|
||||
.layer(middleware::from_fn(require_tenant_status))
|
||||
.layer(middleware::from_fn(require_jwt_auth))
|
||||
.layer(Extension(jwks_state));
|
||||
.layer(Extension(jwks_state))
|
||||
.layer(middleware::from_fn(require_jwt_auth));
|
||||
} else {
|
||||
let tenant_id =
|
||||
std::env::var("DEV_TENANT_ID").unwrap_or_else(|_| DEFAULT_DEV_TENANT_ID.to_string());
|
||||
tracing::warn!(
|
||||
tenant_id = %tenant_id,
|
||||
"Keycloak not configured — running unauthenticated against the dev tenant. \
|
||||
DO NOT use in any environment with real customer data."
|
||||
);
|
||||
app = app.layer(middleware::from_fn(inject_dev_tenant));
|
||||
tracing::warn!("Keycloak not configured - API endpoints are unprotected");
|
||||
}
|
||||
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
//! Firmware classification via tramiton.
|
||||
//!
|
||||
//! tramiton is the company's firmware build/repro engine; we do not re-implement
|
||||
//! its detection. We depend on `tramiton-core` directly (same-company IP) and run
|
||||
//! its provider analysis in-process behind a [`FirmwareDetector`] port, mapping
|
||||
//! tramiton's `BuildPlan` onto a [`TargetType`]. A deterministic
|
||||
//! [`MockFirmwareDetector`] backs the tests so CI unit tests need neither the
|
||||
//! tramiton sources nor a real firmware tree.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{DetectedFact, TargetType};
|
||||
use compliance_core::traits::ClassifierVerdict;
|
||||
|
||||
/// A minimal firmware-detection summary, mapped from tramiton's `BuildPlan`.
|
||||
/// Kept small and tramiton-independent so the classifier and the test mock don't
|
||||
/// need to construct a full tramiton plan.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FirmwareDetection {
|
||||
/// The detecting provider (e.g. `zephyr`, `cmake`, `source-archaeology`).
|
||||
pub provider: String,
|
||||
/// Detection confidence: `low` | `medium` | `high`.
|
||||
pub confidence: String,
|
||||
/// Build-system label (e.g. `Zephyr`, `ESP-IDF`, `CMake`).
|
||||
pub build_system: String,
|
||||
/// Framework, when known (`zephyr`, `esp-idf`, `bare-metal`, ...).
|
||||
pub framework: Option<String>,
|
||||
/// Target board / MCU / arch.
|
||||
pub target: FirmwareTarget,
|
||||
/// Unresolved gaps in the plan.
|
||||
pub gaps: Vec<String>,
|
||||
}
|
||||
|
||||
/// The detected firmware target (board / MCU / arch).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FirmwareTarget {
|
||||
/// Board name.
|
||||
pub board: Option<String>,
|
||||
/// MCU part.
|
||||
pub mcu: Option<String>,
|
||||
/// Architecture.
|
||||
pub arch: Option<String>,
|
||||
}
|
||||
|
||||
/// A source of tramiton firmware detection.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait FirmwareDetector: Send + Sync {
|
||||
/// Run detection over a path, returning a firmware detection if tramiton
|
||||
/// could form a build plan.
|
||||
async fn detect(&self, path: &Path) -> Result<Option<FirmwareDetection>, CoreError>;
|
||||
}
|
||||
|
||||
/// Uses `tramiton-core` in-process. The analysis is blocking (filesystem walk),
|
||||
/// so it runs on a blocking thread to avoid stalling the async runtime. A path
|
||||
/// with no recognizable build system yields `Ok(None)`.
|
||||
pub struct TramitonNative;
|
||||
|
||||
impl FirmwareDetector for TramitonNative {
|
||||
async fn detect(&self, path: &Path) -> Result<Option<FirmwareDetection>, CoreError> {
|
||||
let path = path.to_path_buf();
|
||||
let plan = tokio::task::spawn_blocking(move || {
|
||||
let repo = tramiton_core::Repo::new(&path);
|
||||
tramiton_core::provider::analyze(&repo)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CoreError::Other(format!("tramiton detect task join error: {e}")))?
|
||||
.map_err(|e| CoreError::Other(format!("tramiton analyze error: {e}")))?;
|
||||
Ok(plan.map(|bp| detection_from_build_plan(&bp)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map tramiton's `BuildPlan` onto our minimal detection summary.
|
||||
fn detection_from_build_plan(bp: &tramiton_core::BuildPlan) -> FirmwareDetection {
|
||||
FirmwareDetection {
|
||||
provider: bp.provider.clone(),
|
||||
confidence: bp.confidence.to_string(),
|
||||
build_system: bp.build_system.label().to_string(),
|
||||
framework: bp.framework.clone(),
|
||||
target: FirmwareTarget {
|
||||
board: bp.target.board.clone(),
|
||||
mcu: bp.target.mcu.clone(),
|
||||
arch: bp.target.arch.clone(),
|
||||
},
|
||||
gaps: bp.gaps.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a firmware detection to a target type. Framework/build-system signals
|
||||
/// distinguish RTOS from bare-metal from Yocto.
|
||||
pub fn detection_to_target_type(det: &FirmwareDetection) -> TargetType {
|
||||
let framework = det.framework.as_deref().unwrap_or("").to_lowercase();
|
||||
let build_system = det.build_system.to_lowercase();
|
||||
let signal = format!("{framework} {build_system} {}", det.provider.to_lowercase());
|
||||
|
||||
const RTOS: [&str; 6] = ["zephyr", "esp-idf", "freertos", "nuttx", "riot", "chibios"];
|
||||
if signal.contains("bitbake") || signal.contains("yocto") || signal.contains("openembedded") {
|
||||
TargetType::EmbeddedLinuxYocto
|
||||
} else if RTOS.iter().any(|k| signal.contains(k)) {
|
||||
TargetType::FirmwareRtos
|
||||
} else {
|
||||
TargetType::FirmwareBareMetal
|
||||
}
|
||||
}
|
||||
|
||||
/// Map tramiton's confidence label to a `[0,1]` score.
|
||||
fn confidence_score(label: &str) -> f32 {
|
||||
match label.to_lowercase().as_str() {
|
||||
"high" => 0.9,
|
||||
"medium" => 0.6,
|
||||
"low" => 0.3,
|
||||
_ => 0.4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a firmware detection into a classifier verdict, carrying the MCU / board
|
||||
/// / build-system as facts.
|
||||
pub fn detection_to_verdict(det: &FirmwareDetection) -> ClassifierVerdict {
|
||||
let target_type = detection_to_target_type(det);
|
||||
let mut facts = vec![DetectedFact::new(
|
||||
"build_system",
|
||||
det.build_system.clone(),
|
||||
"tramiton",
|
||||
)];
|
||||
if let Some(fw) = &det.framework {
|
||||
facts.push(DetectedFact::new("framework", fw.clone(), "tramiton"));
|
||||
}
|
||||
if let Some(mcu) = &det.target.mcu {
|
||||
facts.push(DetectedFact::new("mcu", mcu.clone(), "tramiton"));
|
||||
}
|
||||
if let Some(board) = &det.target.board {
|
||||
facts.push(DetectedFact::new("board", board.clone(), "tramiton"));
|
||||
}
|
||||
if let Some(arch) = &det.target.arch {
|
||||
facts.push(DetectedFact::new("arch", arch.clone(), "tramiton"));
|
||||
}
|
||||
ClassifierVerdict {
|
||||
target_type,
|
||||
confidence: confidence_score(&det.confidence),
|
||||
facts,
|
||||
rationale: format!(
|
||||
"tramiton detected build system '{}'{}",
|
||||
det.build_system,
|
||||
det.framework
|
||||
.as_ref()
|
||||
.map(|f| format!(" (framework {f})"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic [`FirmwareDetector`] for tests — returns a preset detection.
|
||||
pub struct MockFirmwareDetector {
|
||||
/// The detection to return (or `None` for "no detection").
|
||||
pub detection: Option<FirmwareDetection>,
|
||||
}
|
||||
|
||||
impl FirmwareDetector for MockFirmwareDetector {
|
||||
async fn detect(&self, _path: &Path) -> Result<Option<FirmwareDetection>, CoreError> {
|
||||
Ok(self.detection.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn detection(build_system: &str, framework: Option<&str>) -> FirmwareDetection {
|
||||
FirmwareDetection {
|
||||
provider: build_system.to_string(),
|
||||
confidence: "high".to_string(),
|
||||
build_system: build_system.to_string(),
|
||||
framework: framework.map(|s| s.to_string()),
|
||||
target: FirmwareTarget {
|
||||
mcu: Some("stm32f429".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
gaps: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zephyr_maps_to_rtos() {
|
||||
assert_eq!(
|
||||
detection_to_target_type(&detection("zephyr", Some("zephyr"))),
|
||||
TargetType::FirmwareRtos
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_cmake_maps_to_bare_metal() {
|
||||
assert_eq!(
|
||||
detection_to_target_type(&detection("cmake", Some("bare-metal"))),
|
||||
TargetType::FirmwareBareMetal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitbake_maps_to_yocto() {
|
||||
assert_eq!(
|
||||
detection_to_target_type(&detection("bitbake", None)),
|
||||
TargetType::EmbeddedLinuxYocto
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_carries_mcu_fact_and_confidence() {
|
||||
let v = detection_to_verdict(&detection("esp-idf", Some("esp-idf")));
|
||||
assert_eq!(v.target_type, TargetType::FirmwareRtos);
|
||||
assert!((v.confidence - 0.9).abs() < f32::EPSILON);
|
||||
assert!(v
|
||||
.facts
|
||||
.iter()
|
||||
.any(|f| f.key == "mcu" && f.value == "stm32f429"));
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
//! Heuristic target-type classification from artifact kinds and source markers.
|
||||
//!
|
||||
//! Complements the tramiton firmware detector: this handles web / backend /
|
||||
//! mobile / desktop / PLC by sniffing manifest files and file extensions in the
|
||||
//! ingested code trees, plus strong priors from the artifact kinds themselves
|
||||
//! (a PLC-project artifact is a PLC target; an `.ipa` is an iOS app).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{ArtifactKind, DetectedFact, TargetType};
|
||||
use compliance_core::traits::{ClassificationInput, ClassifierVerdict, TargetClassifier};
|
||||
|
||||
/// Max directory depth scanned for marker files.
|
||||
const SCAN_DEPTH: usize = 2;
|
||||
|
||||
/// Markers collected from a code tree.
|
||||
#[derive(Default)]
|
||||
struct Markers {
|
||||
files: HashSet<String>,
|
||||
dirs: HashSet<String>,
|
||||
exts: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Markers {
|
||||
fn has_file(&self, name: &str) -> bool {
|
||||
self.files.contains(name)
|
||||
}
|
||||
fn has_ext(&self, ext: &str) -> bool {
|
||||
self.exts.contains(ext)
|
||||
}
|
||||
fn any_dir_ends_with(&self, suffix: &str) -> bool {
|
||||
self.dirs.iter().any(|d| d.ends_with(suffix))
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively collect marker file/dir/extension names up to [`SCAN_DEPTH`].
|
||||
fn collect_markers(root: &Path) -> Markers {
|
||||
let mut m = Markers::default();
|
||||
scan_dir(root, 0, &mut m);
|
||||
m
|
||||
}
|
||||
|
||||
fn scan_dir(dir: &Path, depth: usize, m: &mut Markers) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_lowercase();
|
||||
if path.is_dir() {
|
||||
m.dirs.insert(name);
|
||||
if depth < SCAN_DEPTH {
|
||||
scan_dir(&path, depth + 1, m);
|
||||
}
|
||||
} else {
|
||||
if let Some(ext) = path.extension() {
|
||||
m.exts.insert(ext.to_string_lossy().to_lowercase());
|
||||
}
|
||||
m.files.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `package.json` at `root` looks like a front-end app.
|
||||
fn package_json_is_frontend(root: &Path) -> bool {
|
||||
let Ok(content) = fs::read_to_string(root.join("package.json")) else {
|
||||
return false;
|
||||
};
|
||||
let c = content.to_lowercase();
|
||||
["react", "next", "vue", "@angular", "svelte", "vite"]
|
||||
.iter()
|
||||
.any(|f| c.contains(f))
|
||||
}
|
||||
|
||||
/// The heuristic classifier: artifact-kind priors + source-tree markers.
|
||||
pub struct HeuristicClassifier;
|
||||
|
||||
impl HeuristicClassifier {
|
||||
/// Verdicts from the artifact kinds alone (no filesystem needed).
|
||||
fn kind_priors(&self, input: &ClassificationInput<'_>) -> Vec<ClassifierVerdict> {
|
||||
let mut out = Vec::new();
|
||||
for a in input.artifacts {
|
||||
let lower = a.source_ref.to_lowercase();
|
||||
match a.kind {
|
||||
ArtifactKind::PlcProject => out.push(verdict(
|
||||
TargetType::PlcSps,
|
||||
0.85,
|
||||
"PLC project artifact",
|
||||
vec![],
|
||||
)),
|
||||
ArtifactKind::MobilePackage => {
|
||||
let (tt, why) = if lower.ends_with(".ipa") {
|
||||
(TargetType::IosApp, "iOS package (.ipa)")
|
||||
} else {
|
||||
(TargetType::AndroidApp, "Android package (.apk/.aab)")
|
||||
};
|
||||
out.push(verdict(tt, 0.85, why, vec![]));
|
||||
}
|
||||
ArtifactKind::ContainerImage => out.push(verdict(
|
||||
TargetType::BackendService,
|
||||
0.4,
|
||||
"container image",
|
||||
vec![],
|
||||
)),
|
||||
ArtifactKind::FirmwareImage => out.push(verdict(
|
||||
TargetType::FirmwareBareMetal,
|
||||
0.35,
|
||||
"firmware image (pending tramiton detection)",
|
||||
vec![],
|
||||
)),
|
||||
ArtifactKind::LiveUrl if input.artifacts.len() == 1 => {
|
||||
out.push(verdict(TargetType::WebApp, 0.3, "live URL only", vec![]))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Verdicts from scanning the ingested code trees for manifest markers.
|
||||
fn source_verdicts(&self, input: &ClassificationInput<'_>) -> Vec<ClassifierVerdict> {
|
||||
let mut out = Vec::new();
|
||||
for a in input.artifacts {
|
||||
if !matches!(a.kind, ArtifactKind::GitRepo | ArtifactKind::SourceArchive) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = input.working_paths.get(&a.id) else {
|
||||
continue;
|
||||
};
|
||||
let m = collect_markers(path);
|
||||
|
||||
// Mobile (checked first — strongest signal).
|
||||
if m.has_file("androidmanifest.xml") || m.has_ext("apk") || m.has_ext("aab") {
|
||||
out.push(verdict(
|
||||
TargetType::AndroidApp,
|
||||
0.8,
|
||||
"Android manifest / gradle",
|
||||
facts_lang("kotlin/java"),
|
||||
));
|
||||
}
|
||||
if m.any_dir_ends_with(".xcodeproj")
|
||||
|| m.has_file("info.plist")
|
||||
|| m.has_file("podfile")
|
||||
|| m.has_ext("ipa")
|
||||
{
|
||||
out.push(verdict(
|
||||
TargetType::IosApp,
|
||||
0.8,
|
||||
"Xcode project / Info.plist",
|
||||
facts_lang("swift/objc"),
|
||||
));
|
||||
}
|
||||
// Desktop.
|
||||
if m.has_ext("sln")
|
||||
|| m.has_ext("csproj")
|
||||
|| m.has_ext("vcxproj")
|
||||
|| m.has_ext("desktop")
|
||||
{
|
||||
out.push(verdict(
|
||||
TargetType::DesktopApp,
|
||||
0.7,
|
||||
"desktop project files",
|
||||
facts_lang("dotnet/native"),
|
||||
));
|
||||
}
|
||||
// PLC.
|
||||
if m.has_ext("st") {
|
||||
out.push(verdict(
|
||||
TargetType::PlcSps,
|
||||
0.8,
|
||||
"Structured Text sources",
|
||||
facts_lang("iec-61131-3"),
|
||||
));
|
||||
}
|
||||
// Web vs backend from package.json.
|
||||
if m.has_file("package.json") {
|
||||
if package_json_is_frontend(path) {
|
||||
out.push(verdict(
|
||||
TargetType::WebApp,
|
||||
0.65,
|
||||
"package.json with a front-end framework",
|
||||
facts_lang("javascript"),
|
||||
));
|
||||
} else {
|
||||
out.push(verdict(
|
||||
TargetType::BackendService,
|
||||
0.55,
|
||||
"package.json (no front-end framework)",
|
||||
facts_lang("javascript"),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Backend languages.
|
||||
for (file, lang) in [
|
||||
("cargo.toml", "rust"),
|
||||
("go.mod", "go"),
|
||||
("pom.xml", "java"),
|
||||
("requirements.txt", "python"),
|
||||
("pyproject.toml", "python"),
|
||||
] {
|
||||
if m.has_file(file) {
|
||||
out.push(verdict(
|
||||
TargetType::BackendService,
|
||||
0.6,
|
||||
"backend build manifest",
|
||||
facts_lang(lang),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Container-only.
|
||||
if m.has_file("dockerfile") && out.is_empty() {
|
||||
out.push(verdict(
|
||||
TargetType::BackendService,
|
||||
0.4,
|
||||
"Dockerfile",
|
||||
facts_lang("container"),
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetClassifier for HeuristicClassifier {
|
||||
fn name(&self) -> &str {
|
||||
"heuristic"
|
||||
}
|
||||
|
||||
async fn classify(
|
||||
&self,
|
||||
input: &ClassificationInput<'_>,
|
||||
) -> Result<Vec<ClassifierVerdict>, CoreError> {
|
||||
let mut out = self.kind_priors(input);
|
||||
out.extend(self.source_verdicts(input));
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn verdict(
|
||||
target_type: TargetType,
|
||||
confidence: f32,
|
||||
rationale: &str,
|
||||
facts: Vec<DetectedFact>,
|
||||
) -> ClassifierVerdict {
|
||||
ClassifierVerdict {
|
||||
target_type,
|
||||
confidence,
|
||||
facts,
|
||||
rationale: rationale.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn facts_lang(lang: &str) -> Vec<DetectedFact> {
|
||||
vec![DetectedFact::new("language", lang, "heuristic")]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::Artifact;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("cs-classify-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&p).expect("mkdir");
|
||||
Self(p)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn classify_tree(setup: impl FnOnce(&Path)) -> Vec<ClassifierVerdict> {
|
||||
let scratch = Scratch::new();
|
||||
setup(&scratch.0);
|
||||
let artifact = Artifact::git_repo("https://git/x", "main");
|
||||
let mut wp = HashMap::new();
|
||||
wp.insert(artifact.id.clone(), scratch.0.clone());
|
||||
let artifacts = vec![artifact];
|
||||
let input = ClassificationInput {
|
||||
artifacts: &artifacts,
|
||||
working_paths: &wp,
|
||||
description: None,
|
||||
};
|
||||
HeuristicClassifier
|
||||
.classify(&input)
|
||||
.await
|
||||
.expect("classify")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frontend_package_json_is_webapp() {
|
||||
let v = classify_tree(|root| {
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"dependencies":{"react":"18"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
})
|
||||
.await;
|
||||
assert!(v.iter().any(|x| x.target_type == TargetType::WebApp));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cargo_toml_is_backend() {
|
||||
let v = classify_tree(|root| {
|
||||
fs::write(root.join("Cargo.toml"), "[package]\nname='x'").unwrap();
|
||||
})
|
||||
.await;
|
||||
assert!(v
|
||||
.iter()
|
||||
.any(|x| x.target_type == TargetType::BackendService));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn android_manifest_is_android() {
|
||||
let v = classify_tree(|root| {
|
||||
fs::write(root.join("AndroidManifest.xml"), "<manifest/>").unwrap();
|
||||
})
|
||||
.await;
|
||||
assert!(v.iter().any(|x| x.target_type == TargetType::AndroidApp));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structured_text_is_plc() {
|
||||
let v = classify_tree(|root| {
|
||||
fs::write(root.join("main.st"), "PROGRAM main END_PROGRAM").unwrap();
|
||||
})
|
||||
.await;
|
||||
assert!(v.iter().any(|x| x.target_type == TargetType::PlcSps));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ipa_artifact_prior_is_ios() {
|
||||
let artifacts = vec![Artifact::mobile_package("app.ipa")];
|
||||
let wp = HashMap::new();
|
||||
let input = ClassificationInput {
|
||||
artifacts: &artifacts,
|
||||
working_paths: &wp,
|
||||
description: None,
|
||||
};
|
||||
let v = HeuristicClassifier
|
||||
.classify(&input)
|
||||
.await
|
||||
.expect("classify");
|
||||
assert!(v.iter().any(|x| x.target_type == TargetType::IosApp));
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
//! Target classification.
|
||||
//!
|
||||
//! Runs the classifier registry over a target's artifacts and their ingested
|
||||
//! working paths, then merges and ranks the verdicts into a [`Classification`].
|
||||
//! The registry is the heuristic classifier (artifact kinds + source markers)
|
||||
//! plus the tramiton firmware detector (behind a [`FirmwareDetector`] port).
|
||||
|
||||
mod firmware;
|
||||
mod language;
|
||||
|
||||
pub use firmware::{
|
||||
FirmwareDetection, FirmwareDetector, FirmwareTarget, MockFirmwareDetector, TramitonNative,
|
||||
};
|
||||
pub use language::HeuristicClassifier;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{
|
||||
ArtifactKind, Classification, DetectedFact, OnboardedTarget, TargetType, TargetTypeCandidate,
|
||||
};
|
||||
use compliance_core::traits::{ClassificationInput, ClassifierVerdict, TargetClassifier};
|
||||
|
||||
use firmware::detection_to_verdict;
|
||||
|
||||
/// Classify a target from its artifacts and their ingested working paths, using
|
||||
/// the heuristic classifier plus the tramiton firmware detector. Verdicts are
|
||||
/// merged (max confidence per target type) and ranked into a [`Classification`].
|
||||
pub async fn classify_target<D: FirmwareDetector>(
|
||||
target: &OnboardedTarget,
|
||||
working_paths: &HashMap<String, PathBuf>,
|
||||
firmware_detector: &D,
|
||||
) -> Result<Classification, CoreError> {
|
||||
let input = ClassificationInput {
|
||||
artifacts: &target.artifacts,
|
||||
working_paths,
|
||||
description: target.description.as_deref(),
|
||||
};
|
||||
|
||||
let mut verdicts = Vec::new();
|
||||
let mut detected_by = Vec::new();
|
||||
|
||||
let heuristic = HeuristicClassifier.classify(&input).await?;
|
||||
if !heuristic.is_empty() {
|
||||
detected_by.push("heuristic".to_string());
|
||||
}
|
||||
verdicts.extend(heuristic);
|
||||
|
||||
// Tramiton firmware detection over firmware / code working paths.
|
||||
let mut tramiton_used = false;
|
||||
for artifact in &target.artifacts {
|
||||
if !matches!(
|
||||
artifact.kind,
|
||||
ArtifactKind::FirmwareImage | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = working_paths.get(&artifact.id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(detection) = firmware_detector.detect(path).await? {
|
||||
verdicts.push(detection_to_verdict(&detection));
|
||||
tramiton_used = true;
|
||||
}
|
||||
}
|
||||
if tramiton_used {
|
||||
detected_by.push("tramiton".to_string());
|
||||
}
|
||||
|
||||
Ok(rank(verdicts, detected_by, target.target_type))
|
||||
}
|
||||
|
||||
/// Merge verdicts by target type (keeping the max confidence and its rationale),
|
||||
/// dedupe facts, rank by descending confidence, and assemble a [`Classification`].
|
||||
/// Falls back to the declared type when no verdict is produced.
|
||||
fn rank(
|
||||
verdicts: Vec<ClassifierVerdict>,
|
||||
detected_by: Vec<String>,
|
||||
fallback: TargetType,
|
||||
) -> Classification {
|
||||
let mut best: HashMap<TargetType, (f32, String)> = HashMap::new();
|
||||
let mut facts: Vec<DetectedFact> = Vec::new();
|
||||
for verdict in verdicts {
|
||||
for fact in verdict.facts {
|
||||
if !facts
|
||||
.iter()
|
||||
.any(|e| e.key == fact.key && e.value == fact.value)
|
||||
{
|
||||
facts.push(fact);
|
||||
}
|
||||
}
|
||||
let entry = best
|
||||
.entry(verdict.target_type)
|
||||
.or_insert((0.0, String::new()));
|
||||
if verdict.confidence > entry.0 {
|
||||
*entry = (verdict.confidence, verdict.rationale);
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates: Vec<TargetTypeCandidate> = best
|
||||
.into_iter()
|
||||
.map(
|
||||
|(target_type, (confidence, rationale))| TargetTypeCandidate {
|
||||
target_type,
|
||||
confidence,
|
||||
rationale,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
// Descending confidence; ties broken by type name for deterministic ordering.
|
||||
candidates.sort_by(|a, b| {
|
||||
b.confidence
|
||||
.partial_cmp(&a.confidence)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.target_type.to_string().cmp(&b.target_type.to_string()))
|
||||
});
|
||||
|
||||
let suggested = candidates
|
||||
.first()
|
||||
.map(|c| c.target_type)
|
||||
.unwrap_or(fallback);
|
||||
|
||||
Classification {
|
||||
suggested,
|
||||
candidates,
|
||||
facts,
|
||||
detected_by,
|
||||
detected_at: chrono::Utc::now(),
|
||||
confirmed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::Artifact;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("cs-classify-mod-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&p).expect("mkdir");
|
||||
Self(p)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn no_firmware() -> MockFirmwareDetector {
|
||||
MockFirmwareDetector { detection: None }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_repo_classifies_as_backend() {
|
||||
let scratch = Scratch::new();
|
||||
fs::write(scratch.0.join("go.mod"), "module x").unwrap();
|
||||
|
||||
let artifact = Artifact::git_repo("https://git/x", "main");
|
||||
let mut wp = HashMap::new();
|
||||
wp.insert(artifact.id.clone(), scratch.0.clone());
|
||||
let mut target = OnboardedTarget::new("x".to_string(), TargetType::WebApp);
|
||||
target.artifacts.push(artifact);
|
||||
|
||||
let c = classify_target(&target, &wp, &no_firmware())
|
||||
.await
|
||||
.expect("classify");
|
||||
assert_eq!(c.suggested, TargetType::BackendService);
|
||||
assert!(c.detected_by.contains(&"heuristic".to_string()));
|
||||
assert!(!c.confirmed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn firmware_detector_verdict_ranks_top() {
|
||||
let scratch = Scratch::new();
|
||||
fs::write(scratch.0.join("fw.bin"), b"x").unwrap();
|
||||
|
||||
let artifact =
|
||||
Artifact::firmware_image(scratch.0.join("fw.bin").to_string_lossy().to_string());
|
||||
let mut wp = HashMap::new();
|
||||
wp.insert(artifact.id.clone(), scratch.0.clone());
|
||||
let mut target = OnboardedTarget::new("fw".to_string(), TargetType::FirmwareBareMetal);
|
||||
target.artifacts.push(artifact);
|
||||
|
||||
let detector = MockFirmwareDetector {
|
||||
detection: Some(FirmwareDetection {
|
||||
provider: "zephyr".to_string(),
|
||||
confidence: "high".to_string(),
|
||||
build_system: "zephyr".to_string(),
|
||||
framework: Some("zephyr".to_string()),
|
||||
target: FirmwareTarget {
|
||||
mcu: Some("nrf52840".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
gaps: vec![],
|
||||
}),
|
||||
};
|
||||
|
||||
let c = classify_target(&target, &wp, &detector)
|
||||
.await
|
||||
.expect("classify");
|
||||
// tramiton's high-confidence RTOS verdict beats the weak firmware prior.
|
||||
assert_eq!(c.suggested, TargetType::FirmwareRtos);
|
||||
assert!(c.detected_by.contains(&"tramiton".to_string()));
|
||||
assert!(c.facts.iter().any(|f| f.key == "mcu"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_signal_falls_back_to_declared_type() {
|
||||
let scratch = Scratch::new();
|
||||
let _ = Path::new(&scratch.0);
|
||||
let target = OnboardedTarget::new("empty".to_string(), TargetType::DesktopApp);
|
||||
let wp = HashMap::new();
|
||||
let c = classify_target(&target, &wp, &no_firmware())
|
||||
.await
|
||||
.expect("classify");
|
||||
assert_eq!(c.suggested, TargetType::DesktopApp);
|
||||
assert!(c.candidates.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use compliance_core::config::{BreakpilotConfig, PlcRuntimeConfig};
|
||||
use compliance_core::AgentConfig;
|
||||
use secrecy::SecretString;
|
||||
|
||||
@@ -46,8 +45,6 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
||||
.unwrap_or_else(|| "0 0 * * * *".to_string()),
|
||||
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
|
||||
.unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()),
|
||||
artifact_store_base_path: env_var_opt("ARTIFACT_STORE_BASE_PATH")
|
||||
.unwrap_or_else(|| "/data/compliance-scanner/artifacts".to_string()),
|
||||
ssh_key_path: env_var_opt("SSH_KEY_PATH")
|
||||
.unwrap_or_else(|| "/data/compliance-scanner/ssh/id_ed25519".to_string()),
|
||||
keycloak_url: env_var_opt("KEYCLOAK_URL"),
|
||||
@@ -62,43 +59,5 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
||||
.unwrap_or(true),
|
||||
pentest_imap_username: env_var_opt("PENTEST_IMAP_USERNAME"),
|
||||
pentest_imap_password: env_secret_opt("PENTEST_IMAP_PASSWORD"),
|
||||
admin_api_token: env_secret_opt("ADMIN_API_TOKEN"),
|
||||
tenant_registry_url: env_var_opt("TENANT_REGISTRY_URL"),
|
||||
plc_runtime: load_plc_runtime_config(),
|
||||
werkbank_runner_token: env_secret_opt("WERKBANK_RUNNER_TOKEN"),
|
||||
breakpilot: load_breakpilot_config(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the ephemeral soft-PLC provisioning config from the environment,
|
||||
/// falling back to [`PlcRuntimeConfig::default`] for any unset knob. Disabled
|
||||
/// unless `PLC_RUNTIME_ENABLED` is truthy — it requires Docker access.
|
||||
fn load_plc_runtime_config() -> PlcRuntimeConfig {
|
||||
let d = PlcRuntimeConfig::default();
|
||||
PlcRuntimeConfig {
|
||||
enabled: env_var_opt("PLC_RUNTIME_ENABLED")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(d.enabled),
|
||||
image: env_var_opt("PLC_RUNTIME_IMAGE").unwrap_or(d.image),
|
||||
network: env_var_opt("PLC_RUNTIME_NETWORK").unwrap_or(d.network),
|
||||
memory: env_var_opt("PLC_RUNTIME_MEMORY").unwrap_or(d.memory),
|
||||
cpus: env_var_opt("PLC_RUNTIME_CPUS").unwrap_or(d.cpus),
|
||||
max_lifetime_secs: env_var_opt("PLC_RUNTIME_MAX_LIFETIME_SECS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.max_lifetime_secs),
|
||||
openplc_user: env_var_opt("PLC_RUNTIME_OPENPLC_USER").unwrap_or(d.openplc_user),
|
||||
openplc_password: env_secret_opt("PLC_RUNTIME_OPENPLC_PASSWORD")
|
||||
.unwrap_or(d.openplc_password),
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the breakpilot OSCAL-catalog source from env, defaulting the snapshot
|
||||
/// directory. A missing `BREAKPILOT_BASE_URL` leaves the controls provider off.
|
||||
fn load_breakpilot_config() -> BreakpilotConfig {
|
||||
let d = BreakpilotConfig::default();
|
||||
BreakpilotConfig {
|
||||
base_url: env_var_opt("BREAKPILOT_BASE_URL"),
|
||||
token: env_secret_opt("BREAKPILOT_TOKEN"),
|
||||
snapshot_dir: env_var_opt("BREAKPILOT_SNAPSHOT_DIR").unwrap_or(d.snapshot_dir),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
//! Controls corpus providers.
|
||||
//!
|
||||
//! Implementations of [`compliance_core::traits::ControlsProvider`] that supply
|
||||
//! the control corpus the mapping engine assesses findings against. Currently:
|
||||
//! [`OscalControlsProvider`], which pulls breakpilot-compliance's OSCAL catalog
|
||||
//! and snapshots it locally.
|
||||
|
||||
mod oscal_provider;
|
||||
|
||||
pub use oscal_provider::OscalControlsProvider;
|
||||
@@ -1,233 +0,0 @@
|
||||
//! Pull + snapshot [`ControlsProvider`] backed by breakpilot-compliance's OSCAL
|
||||
//! catalog export.
|
||||
//!
|
||||
//! Fetches `GET {base}/api/compliance/v1/oscal/catalog?framework=<fw>`, snapshots
|
||||
//! the exact bytes to disk (so scans are deterministic and keep working offline /
|
||||
//! on-prem), and maps the catalog into the corpus controls the mapping engine
|
||||
//! consumes. The producer owns the catalog; we own the assessment — this is the
|
||||
//! ingest half of the loop.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::onboarding::ComplianceFramework;
|
||||
use compliance_core::models::oscal::OscalDocument;
|
||||
use compliance_core::traits::{Control, ControlQuery, ControlsProvider};
|
||||
|
||||
/// A [`ControlsProvider`] that pulls the OSCAL catalog from breakpilot-compliance
|
||||
/// and snapshots it locally for deterministic / offline reuse.
|
||||
pub struct OscalControlsProvider {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
token: Option<SecretString>,
|
||||
snapshot_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl OscalControlsProvider {
|
||||
/// Create a provider. `base_url` is the breakpilot-compliance root (e.g.
|
||||
/// `http://backend-compliance:8002`); `snapshot_dir` is where catalog
|
||||
/// snapshots are written so a later scan can reuse them without the network.
|
||||
pub fn new(
|
||||
http: reqwest::Client,
|
||||
base_url: impl Into<String>,
|
||||
token: Option<SecretString>,
|
||||
snapshot_dir: impl Into<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
http,
|
||||
base_url: base_url.into(),
|
||||
token,
|
||||
snapshot_dir: snapshot_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_url(&self, framework: ComplianceFramework) -> String {
|
||||
format!(
|
||||
"{}/api/compliance/v1/oscal/catalog?framework={framework}",
|
||||
self.base_url.trim_end_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
fn snapshot_path(&self, framework: ComplianceFramework) -> PathBuf {
|
||||
self.snapshot_dir
|
||||
.join(format!("oscal-catalog-{framework}.json"))
|
||||
}
|
||||
|
||||
/// Fetch the raw catalog bytes for a framework over HTTP.
|
||||
async fn fetch_raw(&self, framework: ComplianceFramework) -> Result<Vec<u8>, CoreError> {
|
||||
let mut req = self.http.get(self.catalog_url(framework));
|
||||
if let Some(token) = &self.token {
|
||||
req = req.bearer_auth(token.expose_secret());
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| CoreError::Http(e.to_string()))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(CoreError::Http(format!(
|
||||
"catalog fetch for {framework} returned HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
resp.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| CoreError::Http(e.to_string()))
|
||||
}
|
||||
|
||||
/// Write a catalog snapshot atomically (temp file + rename).
|
||||
async fn write_snapshot(
|
||||
&self,
|
||||
framework: ComplianceFramework,
|
||||
raw: &[u8],
|
||||
) -> Result<(), CoreError> {
|
||||
tokio::fs::create_dir_all(&self.snapshot_dir).await?;
|
||||
let path = self.snapshot_path(framework);
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, raw).await?;
|
||||
tokio::fs::rename(&tmp, &path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a previously written snapshot, if one exists.
|
||||
async fn read_snapshot(
|
||||
&self,
|
||||
framework: ComplianceFramework,
|
||||
) -> Result<Option<OscalDocument>, CoreError> {
|
||||
match tokio::fs::read(self.snapshot_path(framework)).await {
|
||||
Ok(raw) => Ok(Some(serde_json::from_slice(&raw)?)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the catalog for a framework: fetch fresh + snapshot the exact bytes;
|
||||
/// on network failure, fall back to the last snapshot so scans still run.
|
||||
pub async fn load(&self, framework: ComplianceFramework) -> Result<OscalDocument, CoreError> {
|
||||
match self.fetch_raw(framework).await {
|
||||
Ok(raw) => {
|
||||
let doc: OscalDocument = serde_json::from_slice(&raw)?;
|
||||
if let Err(e) = self.write_snapshot(framework, &raw).await {
|
||||
tracing::warn!(%framework, error = %e, "failed to write OSCAL snapshot");
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
Err(fetch_err) => match self.read_snapshot(framework).await? {
|
||||
Some(doc) => {
|
||||
tracing::warn!(
|
||||
%framework, error = %fetch_err,
|
||||
"OSCAL catalog fetch failed; falling back to snapshot"
|
||||
);
|
||||
Ok(doc)
|
||||
}
|
||||
None => Err(fetch_err),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Order controls whose title/text mention the query context first (stable), then
|
||||
/// truncate to the requested limit. Naive relevance — refined when the assessment
|
||||
/// layer lands.
|
||||
fn rank_and_truncate(mut controls: Vec<Control>, context: &str, limit: usize) -> Vec<Control> {
|
||||
if !context.is_empty() {
|
||||
let needle = context.to_lowercase();
|
||||
controls.sort_by_key(|c| {
|
||||
let hit =
|
||||
c.title.to_lowercase().contains(&needle) || c.text.to_lowercase().contains(&needle);
|
||||
u8::from(!hit)
|
||||
});
|
||||
}
|
||||
controls.truncate(limit);
|
||||
controls
|
||||
}
|
||||
|
||||
impl ControlsProvider for OscalControlsProvider {
|
||||
fn name(&self) -> &str {
|
||||
"breakpilot-oscal"
|
||||
}
|
||||
|
||||
async fn controls(&self, query: &ControlQuery<'_>) -> Result<Vec<Control>, CoreError> {
|
||||
let mut out: Vec<Control> = Vec::new();
|
||||
for &framework in query.frameworks {
|
||||
match self.load(framework).await {
|
||||
Ok(doc) => out.extend(doc.to_controls()),
|
||||
Err(e) => {
|
||||
tracing::warn!(%framework, error = %e, "skipping framework: catalog unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(rank_and_truncate(out, query.context, query.limit))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MINI_CATALOG: &str = r#"{"catalog":{"uuid":"u","metadata":{"title":"T",
|
||||
"version":"1.0.0","oscal-version":"1.1.2","props":[{"name":"framework","value":"cra"}]},
|
||||
"groups":[{"id":"g","title":"G","controls":[{"id":"cra-ai-1","title":"MFA",
|
||||
"props":[],"parts":[{"name":"statement","prose":"require mfa"}]}]}]}}"#;
|
||||
|
||||
fn provider(dir: &std::path::Path) -> OscalControlsProvider {
|
||||
OscalControlsProvider::new(reqwest::Client::new(), "http://unused/", None, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_catalog_url_and_snapshot_path() {
|
||||
let p = provider(std::path::Path::new("/snap"));
|
||||
assert_eq!(
|
||||
p.catalog_url(ComplianceFramework::Cra),
|
||||
"http://unused/api/compliance/v1/oscal/catalog?framework=cra"
|
||||
);
|
||||
assert_eq!(
|
||||
p.snapshot_path(ComplianceFramework::Cra),
|
||||
std::path::Path::new("/snap/oscal-catalog-cra.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranks_context_hits_first_then_truncates() {
|
||||
let mk = |id: &str, title: &str| Control {
|
||||
id: id.into(),
|
||||
framework: ComplianceFramework::Cra,
|
||||
title: title.into(),
|
||||
text: String::new(),
|
||||
source: None,
|
||||
};
|
||||
let controls = vec![
|
||||
mk("a", "logging policy"),
|
||||
mk("b", "multi-factor auth"),
|
||||
mk("c", "backup"),
|
||||
];
|
||||
let ranked = rank_and_truncate(controls, "auth", 2);
|
||||
assert_eq!(ranked.len(), 2);
|
||||
assert_eq!(ranked[0].id, "b"); // the "auth" hit floats to the top
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_round_trip_and_offline_fallback() {
|
||||
let dir = std::env::temp_dir().join(format!("oscal-test-{}", uuid::Uuid::new_v4()));
|
||||
let p = provider(&dir);
|
||||
assert!(p
|
||||
.read_snapshot(ComplianceFramework::Cra)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
p.write_snapshot(ComplianceFramework::Cra, MINI_CATALOG.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let doc = p
|
||||
.read_snapshot(ComplianceFramework::Cra)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(doc.to_controls().len(), 1);
|
||||
assert_eq!(doc.framework(), Some(ComplianceFramework::Cra));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,233 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dashmap::DashMap;
|
||||
use mongodb::bson::doc;
|
||||
use mongodb::options::IndexOptions;
|
||||
use mongodb::{Client, Collection, IndexModel};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use compliance_core::models::*;
|
||||
use compliance_core::TenantContext;
|
||||
|
||||
use crate::error::AgentError;
|
||||
|
||||
/// Mongo enforces a 63-byte cap on database names (older clusters: 64
|
||||
/// on Linux, 63 on Windows; we target the conservative limit).
|
||||
const MAX_DB_NAME_LEN: usize = 63;
|
||||
|
||||
/// Hex length of the SHA-256 truncation used for the hash fallback
|
||||
/// tenant DB name (16 bytes → 32 hex chars). 16 bytes gives ~2^64
|
||||
/// birthday-collision resistance — at our 10s-100s tenant scale this
|
||||
/// is effectively impossible to hit.
|
||||
const HASH_HEX_LEN: usize = 32;
|
||||
|
||||
/// Largest `db_prefix` that still guarantees the hash-fallback name
|
||||
/// fits in the 63-byte cap: `prefix + "_" + 32 hex chars`.
|
||||
const MAX_PREFIX_LEN: usize = MAX_DB_NAME_LEN - 1 - HASH_HEX_LEN;
|
||||
|
||||
/// Per-tenant Mongo connection broker (M7.2 isolation model).
|
||||
///
|
||||
/// Holds one [`Client`] and hands out [`Database`] handles physically
|
||||
/// scoped to `<db_prefix>_<tenant_id>`. The driver is the isolation
|
||||
/// boundary — a handle for tenant A cannot see tenant B's documents
|
||||
/// because it is connected to a different database, not because of an
|
||||
/// application-level filter.
|
||||
///
|
||||
/// Index creation runs idempotently the first time each tenant is seen
|
||||
/// in the process's lifetime. Mongo's `createIndex` is itself idempotent
|
||||
/// by index name; the in-memory `ensured` set just skips the round-trip.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DatabasePool {
|
||||
client: Client,
|
||||
db_prefix: String,
|
||||
ensured: Arc<DashMap<String, ()>>,
|
||||
}
|
||||
|
||||
impl DatabasePool {
|
||||
/// Connect to the cluster and prepare to hand out tenant databases
|
||||
/// named `<db_prefix>_<tenant_id>`.
|
||||
///
|
||||
/// Validates `db_prefix.len() <= MAX_PREFIX_LEN` so the
|
||||
/// hash-fallback path is provably within Mongo's 63-byte db-name
|
||||
/// cap. Refuses to construct a pool that could ever produce an
|
||||
/// over-long name.
|
||||
pub async fn connect(uri: &str, db_prefix: &str) -> Result<Self, AgentError> {
|
||||
if db_prefix.len() > MAX_PREFIX_LEN {
|
||||
return Err(AgentError::Other(format!(
|
||||
"db_prefix '{db_prefix}' is {} chars; max is {MAX_PREFIX_LEN} so the \
|
||||
hash-fallback tenant DB name fits Mongo's {MAX_DB_NAME_LEN}-byte cap",
|
||||
db_prefix.len()
|
||||
)));
|
||||
}
|
||||
let client = Client::with_uri_str(uri).await?;
|
||||
client
|
||||
.database("admin")
|
||||
.run_command(doc! { "ping": 1 })
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"MongoDB cluster reachable; per-tenant pool ready (db prefix '{db_prefix}')"
|
||||
);
|
||||
Ok(Self {
|
||||
client,
|
||||
db_prefix: db_prefix.to_string(),
|
||||
ensured: Arc::new(DashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Return a [`Database`] scoped to this tenant. Ensures indexes on
|
||||
/// first call per tenant (per process). Cheap on the hot path —
|
||||
/// subsequent calls skip the round-trip.
|
||||
pub async fn for_tenant(&self, ctx: &TenantContext) -> Result<Database, AgentError> {
|
||||
self.for_tenant_id(&ctx.tenant_id).await
|
||||
}
|
||||
|
||||
/// Like [`Self::for_tenant`] but accepts a bare tenant_id.
|
||||
/// For background paths (scheduler, webhooks, pipeline orchestrators)
|
||||
/// that don't have a full [`TenantContext`] but know which tenant
|
||||
/// they're operating on (typically resolved from a URL path, a job
|
||||
/// argument, or the registry).
|
||||
pub async fn for_tenant_id(&self, tenant_id: &str) -> Result<Database, AgentError> {
|
||||
let db_name = self.tenant_db_name(tenant_id);
|
||||
let db = Database::from_database(self.client.database(&db_name));
|
||||
// `DashMap::insert` returns the previous value; `None` means we
|
||||
// were the first writer for this tenant_id and own the
|
||||
// index-ensure work.
|
||||
if self.ensured.insert(tenant_id.to_string(), ()).is_none() {
|
||||
if let Err(e) = db.ensure_indexes().await {
|
||||
// Roll the marker back so the next request retries.
|
||||
self.ensured.remove(tenant_id);
|
||||
return Err(e);
|
||||
}
|
||||
tracing::debug!(
|
||||
tenant_id = %tenant_id,
|
||||
db_name = %db_name,
|
||||
"Indexes ensured for tenant database"
|
||||
);
|
||||
}
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Compute the Mongo database name for a tenant. Public for tests
|
||||
/// and tenant offboarding (`pool.client().database(name).drop()`).
|
||||
///
|
||||
/// Format: `<prefix>_<sanitized_tenant_id>` if it fits the 63-byte
|
||||
/// cap, else `<prefix>_<sha256-16-byte-hex-of-tenant_id>`. The
|
||||
/// `db_prefix` length invariant established at [`Self::connect`]
|
||||
/// guarantees the hash-fallback name always fits — no runtime
|
||||
/// assertion needed.
|
||||
///
|
||||
/// Collision resistance: the hash fallback is a 16-byte SHA-256
|
||||
/// truncation, which gives ~2^64 birthday-collision resistance. At
|
||||
/// our 10s–100s tenant scale the probability of two tenant_ids
|
||||
/// colliding is effectively zero. (8-byte truncation would have
|
||||
/// been ~2^32 — too close for comfort on a regulated product.)
|
||||
pub fn tenant_db_name(&self, tenant_id: &str) -> String {
|
||||
let sanitized = sanitize_tenant_id(tenant_id);
|
||||
let natural = format!("{}_{}", self.db_prefix, sanitized);
|
||||
if natural.len() <= MAX_DB_NAME_LEN {
|
||||
natural
|
||||
} else {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(tenant_id.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
let suffix = hex::encode(&digest[..HASH_HEX_LEN / 2]);
|
||||
format!("{}_{}", self.db_prefix, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw client handle. Reserved for cross-tenant admin flows that
|
||||
/// must opt in explicitly (tenant listing, drop-on-offboard).
|
||||
pub fn client(&self) -> &Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Cross-tenant admin database used by features that intentionally
|
||||
/// span tenants (today: MCP bearer tokens — each token row carries
|
||||
/// a `tenant_id` and the MCP server reads them to route requests).
|
||||
///
|
||||
/// The name `<db_prefix>__admin` (double underscore) is reserved —
|
||||
/// the sanitizer never produces it for a normal tenant DB because
|
||||
/// the natural format is `<db_prefix>_<sanitized_tenant_id>` (one
|
||||
/// underscore) and tenant_ids would have to start with `_admin` to
|
||||
/// collide. New tenant provisioning should reject such ids.
|
||||
pub fn admin_db(&self) -> mongodb::Database {
|
||||
self.client.database(&self.admin_db_name())
|
||||
}
|
||||
|
||||
/// Name of the admin database — public so tests / operators can
|
||||
/// drop it via the raw client.
|
||||
pub fn admin_db_name(&self) -> String {
|
||||
format!("{}__admin", self.db_prefix)
|
||||
}
|
||||
|
||||
/// List every Mongo database currently belonging to this pool,
|
||||
/// identified by the `<db_prefix>_` prefix. The result is the raw
|
||||
/// database names — opening one for offboarding/cleanup goes
|
||||
/// through [`Self::client`].
|
||||
///
|
||||
/// Note: hashed-fallback names (very long tenant_ids) lose the
|
||||
/// original tenant_id at the cluster level — we know a database
|
||||
/// exists for *some* tenant but not which one. In practice
|
||||
/// tenant_ids are UUIDs (36 chars) and never hit the fallback,
|
||||
/// so this is a theoretical concern, not an operational one.
|
||||
pub async fn list_tenant_db_names(&self) -> Result<Vec<String>, AgentError> {
|
||||
let prefix = format!("{}_", self.db_prefix);
|
||||
let names = self.client.list_database_names().await?;
|
||||
Ok(names
|
||||
.into_iter()
|
||||
.filter(|n| n.starts_with(&prefix))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Tenant ids for every provisioned tenant database, derived by stripping
|
||||
/// the `<prefix>_` from the database names. Skips the admin database
|
||||
/// (`<prefix>__admin`). Hash-fallback names (very long tenant_ids) are lost
|
||||
/// at the cluster level and cannot be recovered here — in practice tenant
|
||||
/// ids are UUIDs and never hit that path. Used by the migration CLI's
|
||||
/// `--all` mode.
|
||||
pub async fn list_tenant_ids(&self) -> Result<Vec<String>, AgentError> {
|
||||
let prefix = format!("{}_", self.db_prefix);
|
||||
Ok(self
|
||||
.list_tenant_db_names()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|n| n.strip_prefix(&prefix).map(str::to_string))
|
||||
.filter(|id| !id.starts_with('_'))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Drop the database for a specific tenant. Used by GDPR delete
|
||||
/// and tenant offboarding. Idempotent — dropping a non-existent
|
||||
/// database is a no-op at the driver level.
|
||||
///
|
||||
/// Also evicts the tenant from the in-memory `ensured` set so a
|
||||
/// later re-provision triggers fresh `ensure_indexes`.
|
||||
pub async fn drop_tenant(&self, tenant_id: &str) -> Result<(), AgentError> {
|
||||
let db_name = self.tenant_db_name(tenant_id);
|
||||
self.client.database(&db_name).drop().await?;
|
||||
self.ensured.remove(tenant_id);
|
||||
tracing::info!(
|
||||
tenant_id = %tenant_id,
|
||||
db_name = %db_name,
|
||||
"Dropped tenant database"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Mongo database names disallow `/`, `\`, `.`, `"`, `$`, ` `, and NUL.
|
||||
/// breakpilot-dev tenant_ids are UUIDs so this is belt-and-braces, but
|
||||
/// it lets the pool tolerate any future tenant_id shape without surprise.
|
||||
fn sanitize_tenant_id(tenant_id: &str) -> String {
|
||||
tenant_id
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | '.' | '"' | '$' | ' ' | '\0' => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Database {
|
||||
inner: mongodb::Database,
|
||||
@@ -242,13 +20,17 @@ impl Database {
|
||||
Ok(Self { inner: db })
|
||||
}
|
||||
|
||||
/// Wrap an already-resolved Mongo database. Used by [`DatabasePool`]
|
||||
/// to hand out tenant-scoped handles without a fresh client per tenant.
|
||||
pub(crate) fn from_database(inner: mongodb::Database) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub async fn ensure_indexes(&self) -> Result<(), AgentError> {
|
||||
// repositories: unique git_url
|
||||
self.repositories()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "git_url": 1 })
|
||||
.options(IndexOptions::builder().unique(true).build())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// findings: unique fingerprint
|
||||
self.findings()
|
||||
.create_index(
|
||||
@@ -435,68 +217,14 @@ impl Database {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// onboarded_targets: multikey on artifact source ref (webhook + dedupe
|
||||
// lookup). Non-unique — "one git URL per tenant" is enforced in the
|
||||
// create handler, since a unique multikey index on an array field has
|
||||
// null-collision caveats.
|
||||
self.onboarded_targets()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "artifacts.source_ref": 1 })
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// onboarded_targets: multikey on artifact kind
|
||||
self.onboarded_targets()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "artifacts.kind": 1 })
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// onboarded_targets: target_type filter
|
||||
self.onboarded_targets()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "target_type": 1 })
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// werkbank_jobs: unique job id (idempotent enqueue by job id)
|
||||
self.werkbank_jobs()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "job.id": 1 })
|
||||
.options(IndexOptions::builder().unique(true).build())
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// werkbank_jobs: lease query — oldest queued job for an executor
|
||||
self.werkbank_jobs()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "status": 1, "job.executor": 1, "created_at": 1 })
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// werkbank_jobs: visibility-timeout sweep of expired leases
|
||||
self.werkbank_jobs()
|
||||
.create_index(
|
||||
IndexModel::builder()
|
||||
.keys(doc! { "status": 1, "lease_expires_at": 1 })
|
||||
.build(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Database indexes ensured");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn repositories(&self) -> Collection<TrackedRepository> {
|
||||
self.inner.collection("repositories")
|
||||
}
|
||||
|
||||
pub fn findings(&self) -> Collection<Finding> {
|
||||
self.inner.collection("findings")
|
||||
}
|
||||
@@ -545,20 +273,6 @@ impl Database {
|
||||
self.inner.collection("dast_targets")
|
||||
}
|
||||
|
||||
/// The unified onboarding targets that replace `repositories` and
|
||||
/// `dast_targets`. Ids are preserved from the legacy collections during
|
||||
/// migration so downstream `repo_id` / `target_id` references keep resolving.
|
||||
pub fn onboarded_targets(&self) -> Collection<OnboardedTarget> {
|
||||
self.inner.collection("onboarded_targets")
|
||||
}
|
||||
|
||||
/// A typed handle to an arbitrary collection by name. For bookkeeping
|
||||
/// collections without a dedicated model (e.g. `schema_migrations`,
|
||||
/// `onboarding_migration_log`).
|
||||
pub fn collection_named<T: Send + Sync>(&self, name: &str) -> Collection<T> {
|
||||
self.inner.collection(name)
|
||||
}
|
||||
|
||||
pub fn dast_scan_runs(&self) -> Collection<DastScanRun> {
|
||||
self.inner.collection("dast_scan_runs")
|
||||
}
|
||||
@@ -591,12 +305,6 @@ impl Database {
|
||||
self.inner.collection("pentest_messages")
|
||||
}
|
||||
|
||||
/// The Werkbank job queue (WB-02): declarative dynamic-execution jobs the
|
||||
/// control plane enqueues and runners lease.
|
||||
pub fn werkbank_jobs(&self) -> Collection<compliance_core::models::werkbank::JobRecord> {
|
||||
self.inner.collection("werkbank_jobs")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
|
||||
self.inner.collection(name)
|
||||
|
||||
@@ -27,9 +27,6 @@ pub enum AgentError {
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Dynamic-execution error: {0}")]
|
||||
Exec(#[from] werkbank_exec::ExecError),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
//! Content-addressed blob storage and archive extraction for ingest.
|
||||
//!
|
||||
//! Blobs are stored at `<base>/blobs/<sha[0:2]>/<sha>` and deduplicated by
|
||||
//! digest; per-run working directories live under `<base>/work/`.
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::error::AgentError;
|
||||
|
||||
/// Read buffer size for streaming hashes/copies (64 KiB).
|
||||
const BUF_LEN: usize = 64 * 1024;
|
||||
|
||||
/// Stream-hash a file with SHA-256, returning the lowercase-hex digest and the
|
||||
/// byte length. Streams so large firmware images never load fully into memory.
|
||||
pub fn hash_file(path: &Path) -> Result<(String, u64), AgentError> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = [0u8; BUF_LEN];
|
||||
let mut total: u64 = 0;
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
total += n as u64;
|
||||
}
|
||||
Ok((hex::encode(hasher.finalize()), total))
|
||||
}
|
||||
|
||||
/// Store raw bytes in the content-addressed blob store under `base`, returning
|
||||
/// the SHA-256 digest. Used to stash a small derived artifact (e.g. the extracted
|
||||
/// PLC program source) so a Werkbank runner can fetch it by hash. Idempotent.
|
||||
pub fn store_bytes(base: &Path, bytes: &[u8]) -> Result<String, AgentError> {
|
||||
let sha = hex::encode(Sha256::digest(bytes));
|
||||
let dir = base.join("blobs").join(&sha[0..2]);
|
||||
fs::create_dir_all(&dir)?;
|
||||
let dest = dir.join(&sha);
|
||||
if !dest.exists() {
|
||||
fs::write(&dest, bytes)?;
|
||||
}
|
||||
Ok(sha)
|
||||
}
|
||||
|
||||
/// Read a blob's bytes by its SHA-256 digest. Rejects a non-hex/wrong-length hash
|
||||
/// so a request can't traverse outside the blob store.
|
||||
pub fn read_blob(base: &Path, sha: &str) -> Result<Vec<u8>, AgentError> {
|
||||
if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(AgentError::Other(format!("invalid content hash '{sha}'")));
|
||||
}
|
||||
let path = base.join("blobs").join(&sha[0..2]).join(sha);
|
||||
Ok(fs::read(path)?)
|
||||
}
|
||||
|
||||
/// Copy `src` into the content-addressed blob store under `base`, returning the
|
||||
/// stored path. Idempotent: an already-present blob is not rewritten.
|
||||
pub fn store_file(base: &Path, src: &Path, sha: &str) -> Result<PathBuf, AgentError> {
|
||||
if sha.len() < 2 {
|
||||
return Err(AgentError::Other(format!("invalid content hash '{sha}'")));
|
||||
}
|
||||
let dir = base.join("blobs").join(&sha[0..2]);
|
||||
fs::create_dir_all(&dir)?;
|
||||
let dest = dir.join(sha);
|
||||
if !dest.exists() {
|
||||
fs::copy(src, &dest)?;
|
||||
}
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Extract a zip archive into `dest` (created if needed). `enclosed_name`
|
||||
/// sanitizes each entry path, so this is safe against zip-slip traversal.
|
||||
pub fn extract_zip(archive: &Path, dest: &Path) -> Result<(), AgentError> {
|
||||
let file = File::open(archive)?;
|
||||
let mut zip =
|
||||
zip::ZipArchive::new(file).map_err(|e| AgentError::Other(format!("open zip: {e}")))?;
|
||||
fs::create_dir_all(dest)?;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip
|
||||
.by_index(i)
|
||||
.map_err(|e| AgentError::Other(format!("read zip entry: {e}")))?;
|
||||
// `enclosed_name` returns `None` for traversal-unsafe paths — skip them.
|
||||
let Some(rel) = entry.enclosed_name() else {
|
||||
continue;
|
||||
};
|
||||
let out = dest.join(rel);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&out)?;
|
||||
} else {
|
||||
if let Some(parent) = out.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut outfile = File::create(&out)?;
|
||||
io::copy(&mut entry, &mut outfile)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The working directory for one artifact of a target: `<base>/work/<target>/<artifact>`.
|
||||
pub fn work_dir(base: &Path, target_id: &str, artifact_id: &str) -> PathBuf {
|
||||
base.join("work").join(target_id).join(artifact_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A unique scratch directory, removed on drop.
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("cs-ingest-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&p).expect("mkdir scratch");
|
||||
Self(p)
|
||||
}
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_stable_and_reports_size() {
|
||||
let dir = Scratch::new();
|
||||
let f = dir.path().join("a.bin");
|
||||
fs::write(&f, b"hello world").expect("write");
|
||||
let (sha, size) = hash_file(&f).expect("hash");
|
||||
assert_eq!(size, 11);
|
||||
// Known SHA-256 of "hello world".
|
||||
assert_eq!(
|
||||
sha,
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_is_content_addressed_and_idempotent() {
|
||||
let base = Scratch::new();
|
||||
let src = base.path().join("src.bin");
|
||||
fs::write(&src, b"payload").expect("write");
|
||||
let (sha, _) = hash_file(&src).expect("hash");
|
||||
let p1 = store_file(base.path(), &src, &sha).expect("store");
|
||||
let p2 = store_file(base.path(), &src, &sha).expect("store again");
|
||||
assert_eq!(p1, p2);
|
||||
assert!(p1.ends_with(&sha));
|
||||
assert!(p1.starts_with(base.path().join("blobs").join(&sha[0..2])));
|
||||
assert_eq!(fs::read(&p1).expect("read"), b"payload");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_zip_writes_entries() {
|
||||
let base = Scratch::new();
|
||||
let archive = base.path().join("a.zip");
|
||||
{
|
||||
let file = File::create(&archive).expect("create");
|
||||
let mut w = zip::ZipWriter::new(file);
|
||||
let opts: zip::write::SimpleFileOptions = Default::default();
|
||||
w.start_file("dir/hello.txt", opts).expect("start");
|
||||
io::Write::write_all(&mut w, b"hi").expect("write");
|
||||
w.finish().expect("finish");
|
||||
}
|
||||
let dest = base.path().join("out");
|
||||
extract_zip(&archive, &dest).expect("extract");
|
||||
assert_eq!(
|
||||
fs::read_to_string(dest.join("dir/hello.txt")).expect("read"),
|
||||
"hi"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
//! Artifact ingest.
|
||||
//!
|
||||
//! Normalizes each [`Artifact`] on an [`OnboardedTarget`] into a local working
|
||||
//! path plus recorded metadata (content hash, size, discovered facts) that the
|
||||
//! classifier and scanners consume. Every blob is SHA-256 hashed — that digest
|
||||
//! is also the reconciliation key against sibling products (a firmware sha256
|
||||
//! matches tramiton's `Artifact.sha256`).
|
||||
|
||||
pub(crate) mod blob;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use compliance_core::models::{Artifact, ArtifactKind, DetectedFact, OnboardedTarget};
|
||||
use compliance_core::AgentConfig;
|
||||
|
||||
use crate::error::AgentError;
|
||||
use crate::pipeline::git::{GitOps, RepoCredentials};
|
||||
|
||||
/// The paths and identifiers an ingest needs. Decoupled from the full
|
||||
/// [`AgentConfig`] so ingest is testable without a complete config.
|
||||
pub struct IngestContext<'a> {
|
||||
/// Base directory for content-addressed blobs and working dirs.
|
||||
pub artifact_store_base: &'a Path,
|
||||
/// Base directory for git clones.
|
||||
pub git_clone_base: &'a str,
|
||||
/// Default SSH key path (used when an artifact provides none).
|
||||
pub ssh_key_path: &'a str,
|
||||
/// The id of the target these artifacts belong to (namespaces working dirs).
|
||||
pub target_id: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> IngestContext<'a> {
|
||||
/// Build an ingest context from the agent config for a given target.
|
||||
pub fn from_config(config: &'a AgentConfig, target_id: &'a str) -> Self {
|
||||
Self {
|
||||
artifact_store_base: Path::new(&config.artifact_store_base_path),
|
||||
git_clone_base: &config.git_clone_base_path,
|
||||
ssh_key_path: &config.ssh_key_path,
|
||||
target_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of ingesting one artifact.
|
||||
pub struct IngestedArtifact {
|
||||
/// The artifact this corresponds to ([`Artifact::id`]).
|
||||
pub artifact_id: String,
|
||||
/// The artifact kind.
|
||||
pub kind: ArtifactKind,
|
||||
/// Local working path (clone dir, extracted dir, or blob file). `None` for
|
||||
/// artifacts with no on-disk form (live URL, plaintext, container ref).
|
||||
pub working_path: Option<PathBuf>,
|
||||
/// SHA-256 of the content (blobs) or git head SHA (git repos).
|
||||
pub content_hash: Option<String>,
|
||||
/// Stored blob size in bytes, when applicable.
|
||||
pub size_bytes: Option<u64>,
|
||||
/// Facts discovered during ingest.
|
||||
pub facts: Vec<DetectedFact>,
|
||||
}
|
||||
|
||||
/// All ingested artifacts for a target, keyed by artifact id.
|
||||
pub struct IngestSet {
|
||||
/// The ingested artifacts, keyed by [`Artifact::id`].
|
||||
pub by_artifact: HashMap<String, IngestedArtifact>,
|
||||
}
|
||||
|
||||
impl IngestSet {
|
||||
/// The working paths of every ingested artifact that has one — the input the
|
||||
/// classifier expects.
|
||||
pub fn working_paths(&self) -> HashMap<String, PathBuf> {
|
||||
self.by_artifact
|
||||
.iter()
|
||||
.filter_map(|(id, a)| a.working_path.clone().map(|p| (id.clone(), p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The ingest result for a specific artifact.
|
||||
pub fn get(&self, artifact_id: &str) -> Option<&IngestedArtifact> {
|
||||
self.by_artifact.get(artifact_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ingest every artifact on a target.
|
||||
pub fn ingest_all(
|
||||
target: &OnboardedTarget,
|
||||
ctx: &IngestContext<'_>,
|
||||
) -> Result<IngestSet, AgentError> {
|
||||
let mut by_artifact = HashMap::new();
|
||||
for artifact in &target.artifacts {
|
||||
let ingested = ingest_artifact(artifact, ctx)?;
|
||||
by_artifact.insert(artifact.id.clone(), ingested);
|
||||
}
|
||||
Ok(IngestSet { by_artifact })
|
||||
}
|
||||
|
||||
/// Ingest a single artifact, dispatching on its kind.
|
||||
pub fn ingest_artifact(
|
||||
artifact: &Artifact,
|
||||
ctx: &IngestContext<'_>,
|
||||
) -> Result<IngestedArtifact, AgentError> {
|
||||
match artifact.kind {
|
||||
ArtifactKind::GitRepo => ingest_git(artifact, ctx),
|
||||
ArtifactKind::SourceArchive | ArtifactKind::MobilePackage | ArtifactKind::PlcProject => {
|
||||
ingest_blob(artifact, ctx, true)
|
||||
}
|
||||
ArtifactKind::FirmwareImage => ingest_blob(artifact, ctx, false),
|
||||
ArtifactKind::ContainerImage => Ok(metadata_only(
|
||||
artifact,
|
||||
DetectedFact::new("container_ref", artifact.source_ref.as_str(), "ingest"),
|
||||
)),
|
||||
ArtifactKind::LiveUrl => Ok(metadata_only(
|
||||
artifact,
|
||||
DetectedFact::new("live_url", artifact.source_ref.as_str(), "ingest"),
|
||||
)),
|
||||
ArtifactKind::PlaintextDescription => Ok(metadata_only(
|
||||
artifact,
|
||||
DetectedFact::new(
|
||||
"description_len",
|
||||
artifact.source_ref.len().to_string(),
|
||||
"ingest",
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone (or fetch) a git artifact, recording the head SHA as the content hash.
|
||||
fn ingest_git(
|
||||
artifact: &Artifact,
|
||||
ctx: &IngestContext<'_>,
|
||||
) -> Result<IngestedArtifact, AgentError> {
|
||||
let creds = credentials_for(artifact, ctx.ssh_key_path);
|
||||
let git_ops = GitOps::new(ctx.git_clone_base, creds);
|
||||
let repo_path = git_ops.clone_or_fetch(&artifact.source_ref, &artifact.id)?;
|
||||
let head = GitOps::get_head_sha(&repo_path).ok();
|
||||
Ok(IngestedArtifact {
|
||||
artifact_id: artifact.id.clone(),
|
||||
kind: artifact.kind,
|
||||
working_path: Some(repo_path),
|
||||
content_hash: head,
|
||||
size_bytes: None,
|
||||
facts: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a blob artifact content-addressed. When `extract` is set and the blob
|
||||
/// is a zip container (source archive, APK/AAB/IPA), also unpack it into a
|
||||
/// working directory; otherwise the working path is the stored blob.
|
||||
fn ingest_blob(
|
||||
artifact: &Artifact,
|
||||
ctx: &IngestContext<'_>,
|
||||
extract: bool,
|
||||
) -> Result<IngestedArtifact, AgentError> {
|
||||
let base = ctx.artifact_store_base;
|
||||
let src = local_source(artifact)?;
|
||||
let (sha, size) = blob::hash_file(&src)?;
|
||||
let stored = blob::store_file(base, &src, &sha)?;
|
||||
|
||||
let mut facts = Vec::new();
|
||||
let working_path = if extract {
|
||||
let dest = blob::work_dir(base, ctx.target_id, &artifact.id);
|
||||
match blob::extract_zip(&stored, &dest) {
|
||||
Ok(()) => dest,
|
||||
Err(e) => {
|
||||
// Not a zip container — this is a single uploaded file (e.g. a
|
||||
// `.st`/`.xml` PLC project or a `.tar.gz`). The content-addressed
|
||||
// blob has no extension, so materialize it into a working dir
|
||||
// under its original name; extension-based scanners (PLC) can then
|
||||
// discover it and report a readable path.
|
||||
facts.push(DetectedFact::new(
|
||||
"archive_unextracted",
|
||||
e.to_string(),
|
||||
"ingest",
|
||||
));
|
||||
match materialize_single(&stored, &dest, &blob_file_name(artifact)) {
|
||||
Ok(dir) => dir,
|
||||
Err(copy_err) => {
|
||||
facts.push(DetectedFact::new(
|
||||
"materialize_failed",
|
||||
copy_err.to_string(),
|
||||
"ingest",
|
||||
));
|
||||
stored.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stored.clone()
|
||||
};
|
||||
|
||||
Ok(IngestedArtifact {
|
||||
artifact_id: artifact.id.clone(),
|
||||
kind: artifact.kind,
|
||||
working_path: Some(working_path),
|
||||
content_hash: Some(sha),
|
||||
size_bytes: Some(size),
|
||||
facts,
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy a stored blob into `dest`/`name`, returning `dest`. Used when an
|
||||
/// "extractable" artifact turns out to be a single file rather than an archive.
|
||||
fn materialize_single(stored: &Path, dest: &Path, name: &str) -> Result<PathBuf, AgentError> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
std::fs::copy(stored, dest.join(name))?;
|
||||
Ok(dest.to_path_buf())
|
||||
}
|
||||
|
||||
/// A safe, single-segment file name for an artifact, preserving the original
|
||||
/// extension so scanners can identify it. Derives from `source_ref` (the
|
||||
/// uploaded/original file name); `file_name` strips any directory components,
|
||||
/// so this is traversal-safe. Falls back to the artifact id.
|
||||
fn blob_file_name(artifact: &Artifact) -> String {
|
||||
Path::new(&artifact.source_ref)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| format!("artifact-{}", artifact.id))
|
||||
}
|
||||
|
||||
/// An artifact with no on-disk form: record a single fact, no hash/path.
|
||||
fn metadata_only(artifact: &Artifact, fact: DetectedFact) -> IngestedArtifact {
|
||||
IngestedArtifact {
|
||||
artifact_id: artifact.id.clone(),
|
||||
kind: artifact.kind,
|
||||
working_path: None,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
facts: vec![fact],
|
||||
}
|
||||
}
|
||||
|
||||
/// The local file backing a blob artifact: its `stored_path` if already
|
||||
/// uploaded, else its `source_ref` interpreted as a filesystem path.
|
||||
fn local_source(artifact: &Artifact) -> Result<PathBuf, AgentError> {
|
||||
let path = artifact
|
||||
.stored_path
|
||||
.as_deref()
|
||||
.unwrap_or(artifact.source_ref.as_str());
|
||||
let path = PathBuf::from(path);
|
||||
if !path.exists() {
|
||||
return Err(AgentError::Other(format!(
|
||||
"artifact {} source not found at {}",
|
||||
artifact.id,
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Build git credentials from an artifact's auth plus a default SSH key path.
|
||||
fn credentials_for(artifact: &Artifact, default_ssh_key_path: &str) -> RepoCredentials {
|
||||
let auth = artifact.auth.as_ref();
|
||||
RepoCredentials {
|
||||
ssh_key_path: auth
|
||||
.and_then(|a| a.ssh_key_path.clone())
|
||||
.or_else(|| Some(default_ssh_key_path.to_string())),
|
||||
auth_token: auth.and_then(|a| a.secret.clone()),
|
||||
auth_username: auth.and_then(|a| a.username.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::{ArtifactAuth, TargetType};
|
||||
|
||||
/// A unique scratch directory, removed on drop.
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("cs-ingest-mod-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).expect("mkdir scratch");
|
||||
Self(p)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_for<'a>(store: &'a Path, target_id: &'a str) -> IngestContext<'a> {
|
||||
IngestContext {
|
||||
artifact_store_base: store,
|
||||
git_clone_base: "/tmp/cs-ingest-test-repos",
|
||||
ssh_key_path: "/tmp/cs-ingest-test-ssh",
|
||||
target_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_blob_is_hashed_and_stored() {
|
||||
let scratch = Scratch::new();
|
||||
let store = scratch.0.join("store");
|
||||
let fw = scratch.0.join("fw.bin");
|
||||
std::fs::write(&fw, b"firmware-bytes").expect("write");
|
||||
|
||||
let ctx = ctx_for(&store, "t1");
|
||||
let artifact = Artifact::firmware_image(fw.to_string_lossy().to_string());
|
||||
let out = ingest_artifact(&artifact, &ctx).expect("ingest");
|
||||
|
||||
assert_eq!(out.kind, ArtifactKind::FirmwareImage);
|
||||
assert_eq!(out.size_bytes, Some(14));
|
||||
let sha = out.content_hash.expect("hash");
|
||||
assert_eq!(sha.len(), 64);
|
||||
// working path is the content-addressed blob
|
||||
let wp = out.working_path.expect("working path");
|
||||
assert!(wp.starts_with(store.join("blobs")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_url_has_no_blob() {
|
||||
let scratch = Scratch::new();
|
||||
let store = scratch.0.join("store");
|
||||
let ctx = ctx_for(&store, "t1");
|
||||
let artifact = Artifact::live_url("https://example.com");
|
||||
let out = ingest_artifact(&artifact, &ctx).expect("ingest");
|
||||
assert!(out.working_path.is_none());
|
||||
assert!(out.content_hash.is_none());
|
||||
assert!(out.facts.iter().any(|f| f.key == "live_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingest_all_collects_working_paths() {
|
||||
let scratch = Scratch::new();
|
||||
let store = scratch.0.join("store");
|
||||
let fw = scratch.0.join("fw.bin");
|
||||
std::fs::write(&fw, b"abc").expect("write");
|
||||
let ctx = ctx_for(&store, "t1");
|
||||
|
||||
let mut target = OnboardedTarget::new("t".to_string(), TargetType::FirmwareBareMetal);
|
||||
target
|
||||
.artifacts
|
||||
.push(Artifact::firmware_image(fw.to_string_lossy().to_string()));
|
||||
target.artifacts.push(Artifact::live_url("https://x"));
|
||||
|
||||
let set = ingest_all(&target, &ctx).expect("ingest all");
|
||||
assert_eq!(set.by_artifact.len(), 2);
|
||||
// Only the firmware artifact yields a working path.
|
||||
assert_eq!(set.working_paths().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_prefer_artifact_auth() {
|
||||
let mut artifact = Artifact::git_repo("https://git/x", "main");
|
||||
artifact.auth = Some(ArtifactAuth {
|
||||
method: "token".to_string(),
|
||||
username: Some("bob".to_string()),
|
||||
secret: Some("pat".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
let creds = credentials_for(&artifact, "/default/ssh/key");
|
||||
assert_eq!(creds.auth_token.as_deref(), Some("pat"));
|
||||
assert_eq!(creds.auth_username.as_deref(), Some("bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_fall_back_to_default_ssh_key() {
|
||||
let artifact = Artifact::git_repo("git@host:x.git", "main");
|
||||
let creds = credentials_for(&artifact, "/default/ssh/key");
|
||||
assert_eq!(creds.ssh_key_path.as_deref(), Some("/default/ssh/key"));
|
||||
assert!(creds.auth_token.is_none());
|
||||
}
|
||||
|
||||
/// A single uploaded PLC file (not an archive) must land in a working dir
|
||||
/// under its original name so the PLC scanner can discover it by extension
|
||||
/// and report a readable path — the demo's upload → scan path.
|
||||
#[test]
|
||||
fn single_uploaded_plc_file_is_materialized_and_scannable() {
|
||||
use compliance_core::models::PlcFormat;
|
||||
|
||||
let scratch = Scratch::new();
|
||||
let store = scratch.0.join("store");
|
||||
// Simulate the upload handler: bytes written to an `uploads/` path,
|
||||
// `source_ref` carrying the original (clean) file name.
|
||||
let uploads = scratch.0.join("uploads");
|
||||
std::fs::create_dir_all(&uploads).expect("mkdir uploads");
|
||||
let uploaded = uploads.join("a1b2c3_pump_station.st");
|
||||
std::fs::write(
|
||||
&uploaded,
|
||||
"PROGRAM P\nVAR\n ApiKey : STRING := 'sk-live-1234';\nEND_VAR\nEND_PROGRAM\n",
|
||||
)
|
||||
.expect("write st");
|
||||
|
||||
let mut artifact = Artifact::plc_project("pump_station.st", PlcFormat::StructuredText);
|
||||
artifact.stored_path = Some(uploaded.to_string_lossy().to_string());
|
||||
|
||||
let ctx = ctx_for(&store, "t-plc");
|
||||
let out = ingest_artifact(&artifact, &ctx).expect("ingest");
|
||||
|
||||
// Working path is a directory (not the extensionless blob) holding the
|
||||
// file under its original name.
|
||||
let wp = out.working_path.expect("working path");
|
||||
assert!(wp.is_dir(), "expected a working dir, got {wp:?}");
|
||||
assert!(wp.join("pump_station.st").is_file());
|
||||
|
||||
// The PLC scanner finds the hardcoded credential and reports a clean path.
|
||||
let findings = crate::pipeline::plc::analyze_tree(&wp, "t-plc");
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"scanner should flag the uploaded file"
|
||||
);
|
||||
assert!(findings
|
||||
.iter()
|
||||
.any(|f| f.rule_id.as_deref() == Some("plc-hardcoded-credential")));
|
||||
assert_eq!(
|
||||
findings[0].file_path.as_deref(),
|
||||
Some("pump_station.st"),
|
||||
"finding should reference the original file name"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,9 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod api;
|
||||
pub mod classify;
|
||||
pub mod config;
|
||||
pub mod controls;
|
||||
pub mod database;
|
||||
pub mod error;
|
||||
pub mod ingest;
|
||||
pub mod llm;
|
||||
pub mod pentest;
|
||||
pub mod pipeline;
|
||||
@@ -17,4 +14,3 @@ pub mod ssh;
|
||||
#[allow(dead_code)]
|
||||
pub mod trackers;
|
||||
pub mod webhooks;
|
||||
pub mod werkbank;
|
||||
|
||||
@@ -25,13 +25,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
tracing::info!("Connecting to MongoDB...");
|
||||
// Per-tenant pool only — the agent has no shared "default" database
|
||||
// after M7.2-D. `mongodb_database` is now the db-name prefix used
|
||||
// for tenant databases (`<prefix>_<tenant_id>`).
|
||||
let db_pool =
|
||||
database::DatabasePool::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||
let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||
db.ensure_indexes().await?;
|
||||
|
||||
let agent = agent::ComplianceAgent::new(config.clone(), db_pool);
|
||||
let agent = agent::ComplianceAgent::new(config.clone(), db.clone());
|
||||
|
||||
tracing::info!("Starting scheduler...");
|
||||
let scheduler_agent = agent.clone();
|
||||
|
||||
@@ -328,7 +328,6 @@ mod tests {
|
||||
scan_schedule: String::new(),
|
||||
cve_monitor_schedule: String::new(),
|
||||
git_clone_base_path: String::new(),
|
||||
artifact_store_base_path: String::new(),
|
||||
ssh_key_path: String::new(),
|
||||
keycloak_url: None,
|
||||
keycloak_realm: None,
|
||||
@@ -340,11 +339,6 @@ mod tests {
|
||||
pentest_imap_tls: true,
|
||||
pentest_imap_username: None,
|
||||
pentest_imap_password: None,
|
||||
admin_api_token: None,
|
||||
tenant_registry_url: None,
|
||||
plc_runtime: compliance_core::PlcRuntimeConfig::default(),
|
||||
werkbank_runner_token: None,
|
||||
breakpilot: compliance_core::config::BreakpilotConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,202 +204,6 @@ impl CveScanner {
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Match the CODESYS **runtime** component against NVD by CPE.
|
||||
///
|
||||
/// CODESYS advisories (the CoDe16 cluster and friends) are indexed in NVD by
|
||||
/// CPE (`cpe:2.3:a:codesys:control*`) keyed off the *runtime* version — not by
|
||||
/// the internal `Cmp*`/`Sys*` library names OSV-by-purl would look up. So we
|
||||
/// find the runtime SBOM entry, pull every `cpe:2.3:a:codesys:*` CVE from NVD,
|
||||
/// and keep the ones whose affected-version range covers our runtime version.
|
||||
/// Best-effort: returns empty without an NVD key, on a network error, or when
|
||||
/// no CODESYS runtime component is present.
|
||||
pub async fn scan_codesys(&self, repo_id: &str, entries: &mut [SbomEntry]) -> Vec<CveAlert> {
|
||||
let Some((name, version)) = codesys_runtime(entries) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let url = "https://services.nvd.nist.gov/rest/json/cves/2.0\
|
||||
?virtualMatchString=cpe:2.3:a:codesys";
|
||||
let mut req = self.http.get(url);
|
||||
if let Some(key) = &self.nvd_api_key {
|
||||
req = req.header("apiKey", key.as_str());
|
||||
}
|
||||
let body: serde_json::Value = match req.send().await {
|
||||
Ok(r) if r.status().is_success() => match r.json().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!("CODESYS NVD parse failed: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
},
|
||||
Ok(r) => {
|
||||
tracing::warn!("CODESYS NVD returned {}", r.status());
|
||||
return Vec::new();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("CODESYS NVD request failed: {e}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let matched = parse_codesys_nvd(&body, &version);
|
||||
let mut alerts = Vec::new();
|
||||
for cve in matched {
|
||||
if let Some(e) = entries
|
||||
.iter_mut()
|
||||
.find(|e| e.name == name && e.version == version)
|
||||
{
|
||||
e.known_vulnerabilities.push(VulnRef {
|
||||
id: cve.id.clone(),
|
||||
source: "nvd".to_string(),
|
||||
severity: None,
|
||||
url: Some(format!("https://nvd.nist.gov/vuln/detail/{}", cve.id)),
|
||||
});
|
||||
}
|
||||
let mut alert = CveAlert::new(
|
||||
cve.id,
|
||||
repo_id.to_string(),
|
||||
name.clone(),
|
||||
version.clone(),
|
||||
CveSource::Nvd,
|
||||
);
|
||||
alert.summary = cve.summary;
|
||||
alert.cvss_score = cve.cvss;
|
||||
alerts.push(alert);
|
||||
}
|
||||
tracing::info!(runtime = %name, version = %version, cves = alerts.len(), "CODESYS CVE match");
|
||||
alerts
|
||||
}
|
||||
}
|
||||
|
||||
/// The CODESYS runtime component (name + version) from an SBOM, if present. The
|
||||
/// runtime carries the version CODESYS advisories key off; the internal library
|
||||
/// components do not.
|
||||
fn codesys_runtime(entries: &[SbomEntry]) -> Option<(String, String)> {
|
||||
entries
|
||||
.iter()
|
||||
.find(|e| e.package_manager == "codesys" && e.name.starts_with("CODESYS Control"))
|
||||
.map(|e| (e.name.clone(), e.version.clone()))
|
||||
}
|
||||
|
||||
/// A parsed NVD CVE that affects the CODESYS runtime.
|
||||
struct CodesysCve {
|
||||
id: String,
|
||||
summary: Option<String>,
|
||||
cvss: Option<f64>,
|
||||
}
|
||||
|
||||
/// Version constraints from an NVD `cpeMatch` node.
|
||||
#[derive(Default)]
|
||||
struct CpeRange {
|
||||
exact: Option<String>,
|
||||
start_incl: Option<String>,
|
||||
start_excl: Option<String>,
|
||||
end_incl: Option<String>,
|
||||
end_excl: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse an NVD CVE-list response and keep the CVEs whose CODESYS CPE match covers
|
||||
/// `runtime_version`.
|
||||
fn parse_codesys_nvd(body: &serde_json::Value, runtime_version: &str) -> Vec<CodesysCve> {
|
||||
let mut out = Vec::new();
|
||||
let Some(vulns) = body["vulnerabilities"].as_array() else {
|
||||
return out;
|
||||
};
|
||||
for v in vulns {
|
||||
let cve = &v["cve"];
|
||||
let Some(id) = cve["id"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
let covered = cve["configurations"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|c| c["nodes"].as_array().into_iter().flatten())
|
||||
.flat_map(|n| n["cpeMatch"].as_array().into_iter().flatten())
|
||||
.any(|cm| {
|
||||
cm["vulnerable"].as_bool() == Some(true)
|
||||
&& cm["criteria"]
|
||||
.as_str()
|
||||
.is_some_and(|c| c.contains(":codesys:"))
|
||||
&& version_matches(runtime_version, &cpe_range(cm))
|
||||
});
|
||||
if covered {
|
||||
let summary = cve["descriptions"]
|
||||
.as_array()
|
||||
.and_then(|d| d.iter().find(|x| x["lang"].as_str() == Some("en")))
|
||||
.and_then(|x| x["value"].as_str())
|
||||
.map(String::from);
|
||||
let cvss = cve["metrics"]["cvssMetricV31"]
|
||||
.as_array()
|
||||
.and_then(|m| m.first())
|
||||
.and_then(|m| m["cvssData"]["baseScore"].as_f64());
|
||||
out.push(CodesysCve {
|
||||
id: id.to_string(),
|
||||
summary,
|
||||
cvss,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a [`CpeRange`] from an NVD `cpeMatch` object.
|
||||
fn cpe_range(cm: &serde_json::Value) -> CpeRange {
|
||||
let exact = cm["criteria"]
|
||||
.as_str()
|
||||
.and_then(cpe_version)
|
||||
.filter(|v| v != "*" && v != "-" && !v.is_empty());
|
||||
CpeRange {
|
||||
exact,
|
||||
start_incl: cm["versionStartIncluding"].as_str().map(String::from),
|
||||
start_excl: cm["versionStartExcluding"].as_str().map(String::from),
|
||||
end_incl: cm["versionEndIncluding"].as_str().map(String::from),
|
||||
end_excl: cm["versionEndExcluding"].as_str().map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// The version field (6th component) of a CPE 2.3 string.
|
||||
fn cpe_version(criteria: &str) -> Option<String> {
|
||||
criteria.split(':').nth(5).map(String::from)
|
||||
}
|
||||
|
||||
/// Whether `v` satisfies a CPE version range.
|
||||
fn version_matches(v: &str, r: &CpeRange) -> bool {
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
if let Some(exact) = &r.exact {
|
||||
return cmp_dotted(v, exact) == Equal;
|
||||
}
|
||||
let mut ok = true;
|
||||
if let Some(s) = &r.start_incl {
|
||||
ok &= cmp_dotted(v, s) != Less;
|
||||
}
|
||||
if let Some(s) = &r.start_excl {
|
||||
ok &= cmp_dotted(v, s) == Greater;
|
||||
}
|
||||
if let Some(e) = &r.end_incl {
|
||||
ok &= cmp_dotted(v, e) != Greater;
|
||||
}
|
||||
if let Some(e) = &r.end_excl {
|
||||
ok &= cmp_dotted(v, e) == Less;
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Compare two dotted numeric versions (`4.17.0.0` vs `4.9.0.0`); missing
|
||||
/// components count as 0, non-numeric components as 0.
|
||||
fn cmp_dotted(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let pa: Vec<u64> = a.split('.').map(|x| x.parse().unwrap_or(0)).collect();
|
||||
let pb: Vec<u64> = b.split('.').map(|x| x.parse().unwrap_or(0)).collect();
|
||||
for i in 0..pa.len().max(pb.len()) {
|
||||
let x = pa.get(i).copied().unwrap_or(0);
|
||||
let y = pb.get(i).copied().unwrap_or(0);
|
||||
match x.cmp(&y) {
|
||||
std::cmp::Ordering::Equal => continue,
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -424,90 +228,3 @@ struct OsvVuln {
|
||||
summary: Option<String>,
|
||||
severity: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cmp::Ordering::{Equal, Greater, Less};
|
||||
|
||||
fn entry(name: &str, ver: &str, pm: &str) -> SbomEntry {
|
||||
SbomEntry::new("t".into(), name.into(), ver.into(), pm.into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_codesys_runtime_component() {
|
||||
let entries = vec![
|
||||
entry("Standard", "3.5.18.0", "codesys"),
|
||||
entry("CODESYS Control for Linux ARM SL", "4.17.0.0", "codesys"),
|
||||
];
|
||||
assert_eq!(
|
||||
codesys_runtime(&entries),
|
||||
Some(("CODESYS Control for Linux ARM SL".into(), "4.17.0.0".into()))
|
||||
);
|
||||
// Internal library components are not the runtime.
|
||||
assert!(codesys_runtime(&[entry("Util", "3.5.21.0", "codesys")]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_version_comparison() {
|
||||
assert_eq!(cmp_dotted("4.17.0.0", "4.9.0.0"), Greater);
|
||||
assert_eq!(cmp_dotted("4.9.0.0", "4.17.0.0"), Less);
|
||||
assert_eq!(cmp_dotted("3.5.18.0", "3.5.18.0"), Equal);
|
||||
assert_eq!(cmp_dotted("4.2", "4.2.0.0"), Equal); // missing components = 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_range_matching() {
|
||||
let end_excl = CpeRange {
|
||||
end_excl: Some("4.9.0.0".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!version_matches("4.17.0.0", &end_excl)); // patched
|
||||
assert!(version_matches("4.5.0.0", &end_excl)); // affected
|
||||
|
||||
let exact = CpeRange {
|
||||
exact: Some("3.5.16.0".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(version_matches("3.5.16.0", &exact));
|
||||
assert!(!version_matches("3.5.17.0", &exact));
|
||||
|
||||
let span = CpeRange {
|
||||
start_incl: Some("3.0.0.0".into()),
|
||||
end_incl: Some("3.5.16.0".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(version_matches("3.5.16.0", &span));
|
||||
assert!(!version_matches("3.5.17.0", &span));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_nvd_and_matches_by_runtime_version() {
|
||||
// Two CODESYS CVEs: one affects < 4.9 (our 4.17 is patched), one affects
|
||||
// <= 4.20 (our 4.17 is affected). Only the latter should match.
|
||||
let body = serde_json::json!({
|
||||
"vulnerabilities": [
|
||||
{"cve": {"id":"CVE-2023-0001",
|
||||
"descriptions":[{"lang":"en","value":"old CmpBlkDrvTcp bug"}],
|
||||
"metrics":{"cvssMetricV31":[{"cvssData":{"baseScore":7.5}}]},
|
||||
"configurations":[{"nodes":[{"cpeMatch":[
|
||||
{"vulnerable":true,
|
||||
"criteria":"cpe:2.3:a:codesys:control_for_linux_sl:*:*:*:*:*:*:*:*",
|
||||
"versionEndExcluding":"4.9.0.0"}
|
||||
]}]}]}},
|
||||
{"cve": {"id":"CVE-2024-0002",
|
||||
"descriptions":[{"lang":"en","value":"recent runtime bug"}],
|
||||
"metrics":{"cvssMetricV31":[{"cvssData":{"baseScore":9.8}}]},
|
||||
"configurations":[{"nodes":[{"cpeMatch":[
|
||||
{"vulnerable":true,
|
||||
"criteria":"cpe:2.3:a:codesys:control_for_linux_sl:*:*:*:*:*:*:*:*",
|
||||
"versionEndIncluding":"4.20.0.0"}
|
||||
]}]}]}}
|
||||
]
|
||||
});
|
||||
let matched = parse_codesys_nvd(&body, "4.17.0.0");
|
||||
let ids: Vec<&str> = matched.iter().map(|c| c.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["CVE-2024-0002"]);
|
||||
assert_eq!(matched[0].cvss, Some(9.8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
//! Firmware SBOM via tramiton.
|
||||
//!
|
||||
//! Phase 2 (full, the default): drive a **reproducible build** with tramiton's
|
||||
//! `NixBackend` — `analyze` → `seal_and_build` → a sealed lock whose libraries
|
||||
//! are pinned and whose firmware artifact carries a content hash — then render
|
||||
//! the SBOM from the lock plus deep binary SCA of pre-compiled inputs. This is
|
||||
//! the complete bill of materials (toolchain + every fetched library + the
|
||||
//! firmware image), the same one `tramiton sbom` produces.
|
||||
//!
|
||||
//! Phase 1 fallback (analysis-only): when no nix backend is available or the
|
||||
//! build fails, fall back to the resolvable libraries + toolchain from the build
|
||||
//! plan alone (no build). A scan therefore always yields *something*, and a nix
|
||||
//! that can't run in the deployment never breaks a scan.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::models::{SbomEntry, TargetType};
|
||||
use tramiton_repro::ReproBackend;
|
||||
use tramiton_sbom::ComponentKind;
|
||||
|
||||
/// Whether firmware SBOM applies to this target family.
|
||||
pub fn is_firmware_target(target_type: TargetType) -> bool {
|
||||
matches!(
|
||||
target_type,
|
||||
TargetType::FirmwareBareMetal | TargetType::FirmwareRtos | TargetType::EmbeddedLinuxYocto
|
||||
)
|
||||
}
|
||||
|
||||
/// Build SBOM entries for a firmware target from its source tree. Prefers a full
|
||||
/// reproducible build (sealed lock); falls back to analysis-only. Returns an
|
||||
/// empty vector when tramiton cannot even form a build plan.
|
||||
pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec<SbomEntry> {
|
||||
let p = path.to_path_buf();
|
||||
let repo = repo_id.to_string();
|
||||
// The whole analyze → seal → build → render sequence is blocking (it shells
|
||||
// out to nix), so keep it off the async runtime. Bound it: a firmware build
|
||||
// that hangs must not wedge the scan (the orphaned task is abandoned).
|
||||
let handle = tokio::task::spawn_blocking(move || build_sbom_blocking(&p, &repo));
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(900), handle).await {
|
||||
Ok(Ok(entries)) => entries,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(repo_id, error = %e, "Firmware SBOM: task join error");
|
||||
Vec::new()
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(repo_id, "Firmware SBOM: build exceeded 15m; skipping");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_sbom_blocking(path: &Path, repo_id: &str) -> Vec<SbomEntry> {
|
||||
let repo = tramiton_core::Repo::new(path);
|
||||
let plan = match tramiton_core::provider::analyze(&repo) {
|
||||
Ok(Some(bp)) => bp,
|
||||
Ok(None) => return Vec::new(),
|
||||
Err(e) => {
|
||||
tracing::warn!(repo_id, error = %e, "Firmware SBOM: tramiton analyze failed");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 2: reproducible build → sealed lock → complete SBOM.
|
||||
if let Some(backend) = tramiton_repro::NixBackend::detect() {
|
||||
match tramiton_repro::seal_and_build(&backend, &plan, path) {
|
||||
Ok(lock) => {
|
||||
let mut sbom = tramiton_sbom::Sbom::from_lock(&lock, repo_id);
|
||||
// Deep binary SCA of any pre-compiled inputs in the tree.
|
||||
sbom.components.extend(tramiton_sbom::binary::scan(path));
|
||||
let entries = sbom_to_entries(&sbom, repo_id);
|
||||
tracing::info!(
|
||||
repo_id,
|
||||
backend = backend.name(),
|
||||
count = entries.len(),
|
||||
"Firmware SBOM: sealed reproducible build"
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(repo_id, error = %e, "Firmware SBOM: reproducible build failed; falling back to analysis-only")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::info!(
|
||||
repo_id,
|
||||
"Firmware SBOM: no nix backend available; analysis-only SBOM"
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 1 fallback: analysis-only (toolchain + resolvable libraries).
|
||||
analysis_entries(&plan, repo_id)
|
||||
}
|
||||
|
||||
/// Map a rendered [`tramiton_sbom::Sbom`] (primary firmware + components) into
|
||||
/// our [`SbomEntry`] rows. Source-file (`File`) components are dropped — they are
|
||||
/// build inputs, not a dependency inventory.
|
||||
fn sbom_to_entries(sbom: &tramiton_sbom::Sbom, repo_id: &str) -> Vec<SbomEntry> {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(primary) = &sbom.primary {
|
||||
entries.push(component_to_entry(primary, repo_id));
|
||||
}
|
||||
for c in &sbom.components {
|
||||
if matches!(c.kind, ComponentKind::File) {
|
||||
continue;
|
||||
}
|
||||
entries.push(component_to_entry(c, repo_id));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn component_to_entry(c: &tramiton_sbom::Component, repo_id: &str) -> SbomEntry {
|
||||
let manager = match c.kind {
|
||||
ComponentKind::Firmware => "firmware",
|
||||
ComponentKind::Library => "library",
|
||||
ComponentKind::Toolchain => "toolchain",
|
||||
ComponentKind::File => "file",
|
||||
};
|
||||
let mut entry = SbomEntry::new(
|
||||
repo_id.to_string(),
|
||||
c.name.clone(),
|
||||
c.version.clone().unwrap_or_default(),
|
||||
manager.to_string(),
|
||||
);
|
||||
entry.purl = c.source.clone();
|
||||
entry
|
||||
}
|
||||
|
||||
/// Analysis-only components from the build plan: the cross-toolchain plus the
|
||||
/// resolvable fetched libraries, without a build.
|
||||
fn analysis_entries(bp: &tramiton_core::BuildPlan, repo_id: &str) -> Vec<SbomEntry> {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(id) = bp.toolchain.id.clone() {
|
||||
let version = bp.toolchain.version.clone().unwrap_or_default();
|
||||
entries.push(SbomEntry::new(
|
||||
repo_id.to_string(),
|
||||
id,
|
||||
version,
|
||||
"toolchain".to_string(),
|
||||
));
|
||||
}
|
||||
for lib in tramiton_repro::lock::libraries_from_inputs(&bp.inputs) {
|
||||
let mut entry = SbomEntry::new(
|
||||
repo_id.to_string(),
|
||||
lib.name,
|
||||
lib.revision,
|
||||
"library".to_string(),
|
||||
);
|
||||
entry.purl = lib.source;
|
||||
entries.push(entry);
|
||||
}
|
||||
entries
|
||||
}
|
||||
@@ -80,10 +80,7 @@ impl GitOps {
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_name = %repo_name))]
|
||||
pub fn clone_or_fetch(&self, git_url: &str, repo_name: &str) -> Result<PathBuf, AgentError> {
|
||||
// Names can contain slashes or other path-hostile characters (a target
|
||||
// named after a repo path, say); collapse to one safe directory segment
|
||||
// so the clone path never nests or breaks.
|
||||
let repo_path = self.base_path.join(sanitize_repo_dir(repo_name));
|
||||
let repo_path = self.base_path.join(repo_name);
|
||||
|
||||
if repo_path.exists() {
|
||||
tracing::info!("fetching updates for existing repo");
|
||||
@@ -138,7 +135,7 @@ impl GitOps {
|
||||
/// Build credentials from agent config + per-repo overrides
|
||||
pub fn make_repo_credentials(
|
||||
config: &compliance_core::AgentConfig,
|
||||
repo: &crate::pipeline::repo_view::RepoView,
|
||||
repo: &compliance_core::models::TrackedRepository,
|
||||
) -> RepoCredentials {
|
||||
RepoCredentials {
|
||||
ssh_key_path: Some(config.ssh_key_path.clone()),
|
||||
@@ -256,46 +253,3 @@ pub struct DiffFile {
|
||||
pub path: String,
|
||||
pub hunks: String,
|
||||
}
|
||||
|
||||
/// Collapse a repository name into a single filesystem-safe directory segment.
|
||||
/// Names may carry slashes or other path-hostile characters (a target named
|
||||
/// after a repo path, for instance); those would otherwise nest or break the
|
||||
/// clone path, so map anything outside `[A-Za-z0-9._-]` to `_`.
|
||||
fn sanitize_repo_dir(name: &str) -> String {
|
||||
let mapped: String = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let trimmed = mapped.trim_matches(|c| c == '.' || c == '_');
|
||||
if trimmed.is_empty() {
|
||||
"repo".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_repo_dir;
|
||||
|
||||
#[test]
|
||||
fn sanitizes_path_hostile_names() {
|
||||
assert_eq!(
|
||||
sanitize_repo_dir("zephyr-example-app"),
|
||||
"zephyr-example-app"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_repo_dir("ChristianRinn/bare_metal_stm32f411xe"),
|
||||
"ChristianRinn_bare_metal_stm32f411xe"
|
||||
);
|
||||
assert_eq!(sanitize_repo_dir("../../etc/passwd"), "etc_passwd");
|
||||
assert_eq!(sanitize_repo_dir("a b:c"), "a_b_c");
|
||||
assert_eq!(sanitize_repo_dir("///"), "repo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use mongodb::bson::doc;
|
||||
|
||||
use crate::pipeline::repo_view::RepoView;
|
||||
use compliance_core::models::*;
|
||||
|
||||
use super::orchestrator::{extract_base_url, PipelineOrchestrator};
|
||||
@@ -11,7 +10,7 @@ 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: &RepoView) -> Option<TrackerDispatch> {
|
||||
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 {
|
||||
@@ -82,7 +81,7 @@ impl PipelineOrchestrator {
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
|
||||
pub(super) async fn create_tracker_issues(
|
||||
&self,
|
||||
repo: &RepoView,
|
||||
repo: &TrackedRepository,
|
||||
repo_id: &str,
|
||||
new_findings: &[Finding],
|
||||
) -> Result<(), AgentError> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod code_review;
|
||||
pub mod cve;
|
||||
pub mod dedup;
|
||||
pub mod firmware_sbom;
|
||||
pub mod git;
|
||||
pub mod gitleaks;
|
||||
mod graph_build;
|
||||
@@ -9,10 +8,7 @@ mod issue_creation;
|
||||
pub mod lint;
|
||||
pub mod orchestrator;
|
||||
pub mod patterns;
|
||||
pub mod plan;
|
||||
pub mod plc;
|
||||
mod pr_review;
|
||||
pub mod repo_view;
|
||||
pub mod sbom;
|
||||
pub mod semgrep;
|
||||
mod tracker_dispatch;
|
||||
|
||||
@@ -15,8 +15,6 @@ use crate::pipeline::git::GitOps;
|
||||
use crate::pipeline::gitleaks::GitleaksScanner;
|
||||
use crate::pipeline::lint::LintScanner;
|
||||
use crate::pipeline::patterns::{GdprPatternScanner, OAuthPatternScanner};
|
||||
use crate::pipeline::plan::build_scan_plan;
|
||||
use crate::pipeline::repo_view::RepoView;
|
||||
use crate::pipeline::sbom::SbomScanner;
|
||||
use crate::pipeline::semgrep::SemgrepScanner;
|
||||
|
||||
@@ -52,8 +50,72 @@ impl PipelineOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, trigger = ?trigger))]
|
||||
pub async fn run(&self, repo_id: &str, trigger: ScanTrigger) -> Result<(), AgentError> {
|
||||
// Look up the repository
|
||||
let repo = self
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(repo_id).map_err(|e| AgentError::Other(e.to_string()))? })
|
||||
.await?
|
||||
.ok_or_else(|| AgentError::Other(format!("Repository {repo_id} not found")))?;
|
||||
|
||||
// Create scan run
|
||||
let scan_run = ScanRun::new(repo_id.to_string(), trigger);
|
||||
let insert = self.db.scan_runs().insert_one(&scan_run).await?;
|
||||
let scan_run_id = insert
|
||||
.inserted_id
|
||||
.as_object_id()
|
||||
.map(|id| id.to_hex())
|
||||
.unwrap_or_default();
|
||||
|
||||
let result = self.run_pipeline(&repo, &scan_run_id).await;
|
||||
|
||||
// Update scan run status
|
||||
match &result {
|
||||
Ok(count) => {
|
||||
self.db
|
||||
.scan_runs()
|
||||
.update_one(
|
||||
doc! { "_id": &insert.inserted_id },
|
||||
doc! {
|
||||
"$set": {
|
||||
"status": "completed",
|
||||
"current_phase": "completed",
|
||||
"new_findings_count": *count as i64,
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(repo_id, error = %e, "Scan pipeline failed");
|
||||
self.db
|
||||
.scan_runs()
|
||||
.update_one(
|
||||
doc! { "_id": &insert.inserted_id },
|
||||
doc! {
|
||||
"$set": {
|
||||
"status": "failed",
|
||||
"error_message": e.to_string(),
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, fields(repo_id = repo.name.as_str()))]
|
||||
async fn run_pipeline(&self, repo: &RepoView, scan_run_id: &str) -> Result<u32, AgentError> {
|
||||
async fn run_pipeline(
|
||||
&self,
|
||||
repo: &TrackedRepository,
|
||||
scan_run_id: &str,
|
||||
) -> Result<u32, AgentError> {
|
||||
let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
||||
|
||||
// Stage 0: Change detection
|
||||
@@ -67,6 +129,7 @@ impl PipelineOrchestrator {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let current_sha = GitOps::get_head_sha(&repo_path)?;
|
||||
let mut all_findings: Vec<Finding> = Vec::new();
|
||||
|
||||
// Stage 1: Semgrep SAST
|
||||
@@ -259,12 +322,67 @@ impl PipelineOrchestrator {
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Persist CVE alerts and create notifications (shared with the PLC path).
|
||||
let new_notif_count = self
|
||||
.persist_cve_alerts(&repo_id, &repo.name, &cve_alerts)
|
||||
.await?;
|
||||
if new_notif_count > 0 {
|
||||
tracing::info!("[{repo_id}] Created {new_notif_count} CVE notification(s)");
|
||||
// Persist CVE alerts and create notifications
|
||||
{
|
||||
use compliance_core::models::notification::{parse_severity, CveNotification};
|
||||
|
||||
let repo_name = repo.name.clone();
|
||||
let mut new_notif_count = 0u32;
|
||||
|
||||
for alert in &cve_alerts {
|
||||
// Upsert the alert
|
||||
let filter = doc! {
|
||||
"cve_id": &alert.cve_id,
|
||||
"repo_id": &alert.repo_id,
|
||||
};
|
||||
let update = mongodb::bson::to_document(alert)
|
||||
.map(|d| doc! { "$set": d })
|
||||
.unwrap_or_else(|_| doc! {});
|
||||
self.db
|
||||
.cve_alerts()
|
||||
.update_one(filter, update)
|
||||
.upsert(true)
|
||||
.await?;
|
||||
|
||||
// Create notification (dedup by cve_id + repo + package + version)
|
||||
let notif_filter = doc! {
|
||||
"cve_id": &alert.cve_id,
|
||||
"repo_id": &alert.repo_id,
|
||||
"package_name": &alert.affected_package,
|
||||
"package_version": &alert.affected_version,
|
||||
};
|
||||
let severity = parse_severity(alert.severity.as_deref(), alert.cvss_score);
|
||||
let mut notification = CveNotification::new(
|
||||
alert.cve_id.clone(),
|
||||
repo_id.clone(),
|
||||
repo_name.clone(),
|
||||
alert.affected_package.clone(),
|
||||
alert.affected_version.clone(),
|
||||
severity,
|
||||
);
|
||||
notification.cvss_score = alert.cvss_score;
|
||||
notification.summary = alert.summary.clone();
|
||||
notification.url = Some(format!("https://osv.dev/vulnerability/{}", alert.cve_id));
|
||||
|
||||
let notif_update = doc! {
|
||||
"$setOnInsert": mongodb::bson::to_bson(¬ification).unwrap_or_default()
|
||||
};
|
||||
if let Ok(result) = self
|
||||
.db
|
||||
.cve_notifications()
|
||||
.update_one(notif_filter, notif_update)
|
||||
.upsert(true)
|
||||
.await
|
||||
{
|
||||
if result.upserted_id.is_some() {
|
||||
new_notif_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if new_notif_count > 0 {
|
||||
tracing::info!("[{repo_id}] Created {new_notif_count} CVE notification(s)");
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 6: Issue Creation
|
||||
@@ -277,9 +395,20 @@ impl PipelineOrchestrator {
|
||||
tracing::warn!("[{repo_id}] Issue creation failed: {e}");
|
||||
}
|
||||
|
||||
// The onboarded target's findings_count and the git artifact's
|
||||
// last_scanned_commit watermark are persisted by `finalize_target` after
|
||||
// `run_pipeline` returns.
|
||||
// Stage 7: Update repository
|
||||
self.db
|
||||
.repositories()
|
||||
.update_one(
|
||||
doc! { "_id": repo.id },
|
||||
doc! {
|
||||
"$set": {
|
||||
"last_scanned_commit": ¤t_sha,
|
||||
"updated_at": mongodb::bson::DateTime::now(),
|
||||
},
|
||||
"$inc": { "findings_count": new_count as i64 },
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Stage 8: DAST (async, optional — only if a DastTarget is configured)
|
||||
tracing::info!("[{repo_id}] Stage 8: Checking for DAST targets");
|
||||
@@ -290,727 +419,6 @@ impl PipelineOrchestrator {
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Unified entry point (behind `UNIFIED_PIPELINE`): run a scan for an
|
||||
/// `OnboardedTarget`. Mirrors [`Self::run`] but sources the target from
|
||||
/// `onboarded_targets` and dispatches by the scan plan.
|
||||
#[tracing::instrument(skip_all, fields(target_id = %target_id, trigger = ?trigger))]
|
||||
pub async fn run_target(
|
||||
&self,
|
||||
target_id: &str,
|
||||
trigger: ScanTrigger,
|
||||
) -> Result<(), AgentError> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(target_id)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let target = self
|
||||
.db
|
||||
.onboarded_targets()
|
||||
.find_one(doc! { "_id": oid })
|
||||
.await?
|
||||
.ok_or_else(|| AgentError::Other(format!("Onboarded target {target_id} not found")))?;
|
||||
|
||||
let scan_run = ScanRun::new(target_id.to_string(), trigger);
|
||||
let insert = self.db.scan_runs().insert_one(&scan_run).await?;
|
||||
let scan_run_id = insert
|
||||
.inserted_id
|
||||
.as_object_id()
|
||||
.map(|id| id.to_hex())
|
||||
.unwrap_or_default();
|
||||
|
||||
let result = self.run_target_pipeline(&target, &scan_run_id).await;
|
||||
match &result {
|
||||
Ok(count) => {
|
||||
self.db
|
||||
.scan_runs()
|
||||
.update_one(
|
||||
doc! { "_id": &insert.inserted_id },
|
||||
doc! { "$set": {
|
||||
"status": "completed",
|
||||
"current_phase": "completed",
|
||||
"new_findings_count": *count as i64,
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
} },
|
||||
)
|
||||
.await?;
|
||||
// Refresh the target's cached findings count. The shared pipeline
|
||||
// (Stage 7) increments `repositories`, which the unified path does
|
||||
// not use, so set the accurate total on the target itself.
|
||||
let total = self
|
||||
.db
|
||||
.findings()
|
||||
.count_documents(doc! { "repo_id": target_id })
|
||||
.await
|
||||
.unwrap_or(*count as u64);
|
||||
self.db
|
||||
.onboarded_targets()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$set": {
|
||||
"findings_count": total as i64,
|
||||
"updated_at": mongodb::bson::DateTime::now(),
|
||||
} },
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(target_id, error = %e, "Unified scan pipeline failed");
|
||||
self.db
|
||||
.scan_runs()
|
||||
.update_one(
|
||||
doc! { "_id": &insert.inserted_id },
|
||||
doc! { "$set": {
|
||||
"status": "failed",
|
||||
"error_message": e.to_string(),
|
||||
"completed_at": mongodb::bson::DateTime::now(),
|
||||
} },
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
/// Run the applicable scans for a target. For a code target this reuses the
|
||||
/// full legacy pipeline over the code artifact (clone → SAST umbrella →
|
||||
/// triage → persist → issues → DAST); firmware/PLC/mobile scanners are
|
||||
/// follow-ups (#128/#129/#130). Returns the number of new findings.
|
||||
async fn run_target_pipeline(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
scan_run_id: &str,
|
||||
) -> Result<u32, AgentError> {
|
||||
let target_id = target.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
let plan = build_scan_plan(target);
|
||||
tracing::info!(
|
||||
target_id = %target_id,
|
||||
target_type = %target.target_type,
|
||||
planned_steps = plan.steps.len(),
|
||||
"Unified pipeline: scan plan built"
|
||||
);
|
||||
|
||||
// Ingest + classify (tramiton for firmware) and store the detected type.
|
||||
self.classify_and_store(target, &target_id, scan_run_id)
|
||||
.await;
|
||||
// Provision a DAST target from a LiveUrl artifact so DAST fires for
|
||||
// wizard-created targets, not just migrated ones.
|
||||
self.ensure_dast_target(target, &plan).await;
|
||||
|
||||
// PLC/SPS targets: the control-logic scan consumes the PLC source (an
|
||||
// uploaded PlcProject *or* a git repo / source archive of PLCopen XML / ST
|
||||
// exports), so it takes over the code artifact — we don't also run the
|
||||
// SAST pipeline over it. A PLC device is reachable, so DAST still runs
|
||||
// against a WebVisu / exposed endpoint when one is provisioned.
|
||||
let mut new_count = 0u32;
|
||||
let plc = plan.has(ScanType::PlcControlLogic);
|
||||
let ics = plan.has(ScanType::IcsProbe);
|
||||
if plc {
|
||||
new_count += self.run_plc_scan(target, &target_id, scan_run_id).await?;
|
||||
// Provision-and-test (#183): with the control logic but no reachable
|
||||
// device, instantiate it on an ephemeral soft-PLC and probe that
|
||||
// instead of the customer's OT network. Opt-in (needs Docker) and only
|
||||
// when there is no live URL to probe directly. Never fails the scan.
|
||||
if self.config.plc_runtime.enabled && target.live_url().is_none() {
|
||||
match self
|
||||
.run_provisioned_plc_test(target, &target_id, scan_run_id)
|
||||
.await
|
||||
{
|
||||
Ok(n) => new_count += n,
|
||||
Err(e) => {
|
||||
tracing::warn!(target_id = %target_id, error = %e, "provision-and-test failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ics {
|
||||
new_count += self.run_ics_probe(target, &target_id, scan_run_id).await?;
|
||||
}
|
||||
if plc || ics {
|
||||
// PLC/SPS device: also DAST against a WebVisu / exposed endpoint, but
|
||||
// only when DAST is actually planned — a device reachable only over an
|
||||
// industrial protocol (e.g. modbus://) has no web surface to crawl, and
|
||||
// running DAST there just fails at reconnaissance. Gating here (not only
|
||||
// at provisioning) also stops a DAST target left over from an earlier
|
||||
// run from re-triggering. The control-logic scan already consumed the
|
||||
// code artifact, so the SAST pipeline is not re-run.
|
||||
if plan.has(ScanType::Dast) {
|
||||
self.update_phase(scan_run_id, "dast_scanning").await;
|
||||
self.maybe_trigger_dast(&target_id, scan_run_id).await;
|
||||
}
|
||||
return Ok(new_count);
|
||||
}
|
||||
|
||||
match target.code_artifact() {
|
||||
Some(code) if code.kind == ArtifactKind::GitRepo => {
|
||||
let repo = RepoView::from_target(target, code);
|
||||
let n = self.run_pipeline(&repo, scan_run_id).await?;
|
||||
self.finalize_target(target, &repo, n).await?;
|
||||
new_count += n;
|
||||
}
|
||||
Some(_) => {
|
||||
tracing::warn!(
|
||||
target_id = %target_id,
|
||||
"Unified pipeline: source-archive scanning not yet wired; skipping"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
// No code to scan (a migrated DAST target). Firmware/mobile static
|
||||
// scanners land in #128/#129; DAST for a running URL works when a
|
||||
// DastTarget row exists (provisioned above from a LiveUrl, or from
|
||||
// a migrated target).
|
||||
tracing::info!(
|
||||
target_id = %target_id,
|
||||
"Unified pipeline: no code artifact; attempting DAST"
|
||||
);
|
||||
self.update_phase(scan_run_id, "dast_scanning").await;
|
||||
self.maybe_trigger_dast(&target_id, scan_run_id).await;
|
||||
}
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Analyze a PLC/SPS project (Structured Text / PLCopen XML) for
|
||||
/// control-logic security issues and persist the new findings.
|
||||
async fn run_plc_scan(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
target_id: &str,
|
||||
scan_run_id: &str,
|
||||
) -> Result<u32, AgentError> {
|
||||
tracing::info!(target_id, "[{target_id}] PLC control-logic analysis");
|
||||
self.update_phase(scan_run_id, "plc_analysis").await;
|
||||
|
||||
let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id);
|
||||
let ingest_set = crate::ingest::ingest_all(target, &ctx)?;
|
||||
// Every PLC-source artifact on the target: dedicated PLC projects plus any
|
||||
// code artifacts (git repo / source archive) holding PLCopen XML / ST
|
||||
// exports. A target can carry several (e.g. one POU export per file).
|
||||
let sources: Vec<&Artifact> = target
|
||||
.artifacts
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
matches!(
|
||||
a.kind,
|
||||
ArtifactKind::PlcProject | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if sources.is_empty() {
|
||||
tracing::warn!(target_id, "PLC scan: no PLC source artifact");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut all_findings = Vec::new();
|
||||
let mut all_sbom: Vec<SbomEntry> = Vec::new();
|
||||
let mut sbom_seen = std::collections::BTreeSet::new();
|
||||
for a in &sources {
|
||||
let Some(path) = ingest_set.get(&a.id).and_then(|ia| ia.working_path.clone()) else {
|
||||
continue;
|
||||
};
|
||||
all_findings.extend(crate::pipeline::plc::analyze_tree(&path, target_id));
|
||||
// Control-application SBOM: CODESYS libraries + runtime from a
|
||||
// `.projectarchive` (uploaded, or committed in the working tree).
|
||||
let archive = a
|
||||
.stored_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| a.source_ref.clone());
|
||||
for e in crate::pipeline::plc::sbom::collect_sbom(
|
||||
std::path::Path::new(&archive),
|
||||
&path,
|
||||
target_id,
|
||||
) {
|
||||
if sbom_seen.insert((e.name.clone(), e.version.clone())) {
|
||||
all_sbom.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
target_id,
|
||||
artifacts = sources.len(),
|
||||
found = all_findings.len(),
|
||||
"PLC control-logic analysis complete"
|
||||
);
|
||||
|
||||
let mut new_count = 0u32;
|
||||
for mut finding in all_findings {
|
||||
finding.scan_run_id = Some(scan_run_id.to_string());
|
||||
if self
|
||||
.db
|
||||
.findings()
|
||||
.find_one(doc! { "fingerprint": &finding.fingerprint })
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
self.db.findings().insert_one(&finding).await?;
|
||||
new_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !all_sbom.is_empty() {
|
||||
if let Err(e) = self
|
||||
.persist_control_app_sbom(target_id, &target.name, all_sbom)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(target_id, error = %e, "control-app SBOM persist failed");
|
||||
}
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Provision-and-test (#183): instantiate the target's control logic on an
|
||||
/// ephemeral soft-PLC (OpenPLC), start it, probe the provisioned Modbus
|
||||
/// endpoint, and tear the instance down. Used when a PLC/SPS target has the
|
||||
/// control logic but no reachable live device to probe directly. Guarded by
|
||||
/// `plc_runtime.enabled` (needs Docker); persists the same [`ScanType::IcsProbe`]
|
||||
/// findings as a live probe.
|
||||
async fn run_provisioned_plc_test(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
target_id: &str,
|
||||
scan_run_id: &str,
|
||||
) -> Result<u32, AgentError> {
|
||||
self.update_phase(scan_run_id, "plc_provision").await;
|
||||
|
||||
// Locate a loadable control-logic program among the PLC-source artifacts
|
||||
// (same selection as the static PLC scan: dedicated PLC projects plus code
|
||||
// artifacts holding PLCopen XML / ST exports).
|
||||
let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id);
|
||||
let ingest_set = crate::ingest::ingest_all(target, &ctx)?;
|
||||
let program = target
|
||||
.artifacts
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
matches!(
|
||||
a.kind,
|
||||
ArtifactKind::PlcProject | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
|
||||
)
|
||||
})
|
||||
.find_map(|a| {
|
||||
let path = ingest_set
|
||||
.get(&a.id)
|
||||
.and_then(|ia| ia.working_path.clone())?;
|
||||
werkbank_exec::plc::extract_program(&path)
|
||||
});
|
||||
let Some(program) = program else {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
"provision-and-test: no loadable control-logic program"
|
||||
);
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let http = werkbank_exec::plc::http_client()?;
|
||||
let provisioner = werkbank_exec::plc::DockerSoftPlc::new(self.config.plc_runtime.clone());
|
||||
let outcome = werkbank_exec::plc::provision_and_test(
|
||||
&provisioner,
|
||||
&http,
|
||||
&self.config.plc_runtime,
|
||||
&program,
|
||||
target_id,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
found = outcome.findings.len(),
|
||||
dast = outcome.dast.is_some(),
|
||||
"provision-and-test complete"
|
||||
);
|
||||
|
||||
let mut new_count = 0u32;
|
||||
for mut finding in outcome.findings {
|
||||
finding.scan_run_id = Some(scan_run_id.to_string());
|
||||
if self
|
||||
.db
|
||||
.findings()
|
||||
.find_one(doc! { "fingerprint": &finding.fingerprint })
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
self.db.findings().insert_one(&finding).await?;
|
||||
new_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the DAST scan of the provisioned web endpoint, linked to this
|
||||
// scan run (mirrors `maybe_trigger_dast`).
|
||||
if let Some(dast) = outcome.dast {
|
||||
let mut scan_run = dast.scan_run;
|
||||
scan_run.sast_scan_run_id = Some(scan_run_id.to_string());
|
||||
if let Err(e) = self.db.dast_scan_runs().insert_one(&scan_run).await {
|
||||
tracing::warn!(target_id, error = %e, "failed to store provisioned DAST scan run");
|
||||
}
|
||||
for finding in &dast.findings {
|
||||
if let Err(e) = self.db.dast_findings().insert_one(finding).await {
|
||||
tracing::warn!(target_id, error = %e, "failed to store provisioned DAST finding");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Probe a running PLC/SPS device over industrial protocols (Modbus/TCP, …)
|
||||
/// and persist findings for exposed / unauthenticated control access. The
|
||||
/// probe is read-only; it targets the Modbus port of the target's live URL.
|
||||
async fn run_ics_probe(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
target_id: &str,
|
||||
scan_run_id: &str,
|
||||
) -> Result<u32, AgentError> {
|
||||
self.update_phase(scan_run_id, "ics_probe").await;
|
||||
let Some(endpoint) = target.live_url().map(|a| a.source_ref.clone()) else {
|
||||
tracing::warn!(target_id, "ICS probe: no live URL");
|
||||
return Ok(0);
|
||||
};
|
||||
// Short per-request budget so an unreachable device doesn't stall the scan.
|
||||
let budget = std::time::Duration::from_secs(5);
|
||||
let findings = werkbank_exec::ics::probe_target(&endpoint, target_id, budget).await;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
endpoint = %endpoint,
|
||||
found = findings.len(),
|
||||
"ICS probe complete"
|
||||
);
|
||||
let mut new_count = 0u32;
|
||||
for mut finding in findings {
|
||||
finding.scan_run_id = Some(scan_run_id.to_string());
|
||||
if self
|
||||
.db
|
||||
.findings()
|
||||
.find_one(doc! { "fingerprint": &finding.fingerprint })
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
self.db.findings().insert_one(&finding).await?;
|
||||
new_count += 1;
|
||||
}
|
||||
}
|
||||
Ok(new_count)
|
||||
}
|
||||
|
||||
/// Store a control-application SBOM (CODESYS libraries + runtime) for a target
|
||||
/// and match it against known CVEs. Scoped to `package_manager = "codesys"` so
|
||||
/// it refreshes on re-scan and coexists with any firmware/source SBOM. The
|
||||
/// runtime `Cmp*` / `3SLicense` components carry real CODESYS advisories, so
|
||||
/// this is where PLC-device CVE coverage comes from.
|
||||
async fn persist_control_app_sbom(
|
||||
&self,
|
||||
target_id: &str,
|
||||
target_name: &str,
|
||||
mut entries: Vec<SbomEntry>,
|
||||
) -> Result<(), AgentError> {
|
||||
if entries.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.db
|
||||
.sbom_entries()
|
||||
.delete_many(doc! { "repo_id": target_id, "package_manager": "codesys" })
|
||||
.await?;
|
||||
|
||||
let cve_scanner = CveScanner::new(
|
||||
self.http.clone(),
|
||||
self.config.searxng_url.clone(),
|
||||
self.config.nvd_api_key.as_ref().map(|k| {
|
||||
use secrecy::ExposeSecret;
|
||||
k.expose_secret().to_string()
|
||||
}),
|
||||
);
|
||||
let mut alerts = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(600),
|
||||
cve_scanner.scan_dependencies(target_id, &mut entries),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(a)) => a,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(target_id, error = %e, "control-app CVE scan failed");
|
||||
Vec::new()
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(target_id, "control-app CVE scan timed out");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
// OSV can't match `pkg:codesys/*` (no such ecosystem); CODESYS advisories
|
||||
// live in NVD keyed by CPE + runtime version. Add those (best-effort).
|
||||
if let Ok(codesys) = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
cve_scanner.scan_codesys(target_id, &mut entries),
|
||||
)
|
||||
.await
|
||||
{
|
||||
alerts.extend(codesys);
|
||||
} else {
|
||||
tracing::warn!(target_id, "CODESYS CVE match timed out");
|
||||
}
|
||||
|
||||
for entry in &entries {
|
||||
let filter = doc! {
|
||||
"repo_id": &entry.repo_id,
|
||||
"name": &entry.name,
|
||||
"version": &entry.version,
|
||||
};
|
||||
if let Ok(d) = mongodb::bson::to_document(entry) {
|
||||
self.db
|
||||
.sbom_entries()
|
||||
.update_one(filter, doc! { "$set": d })
|
||||
.upsert(true)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let new_notifs = self
|
||||
.persist_cve_alerts(target_id, target_name, &alerts)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
target_id,
|
||||
components = entries.len(),
|
||||
alerts = alerts.len(),
|
||||
notifications = new_notifs,
|
||||
"control-app SBOM stored"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Upsert CVE alerts for a target and create dedup'd CVE notifications;
|
||||
/// returns the number of newly-created notifications. Shared by the SAST
|
||||
/// pipeline and the PLC control-app SBOM path, so every SBOM source (source,
|
||||
/// firmware, CODESYS libraries/runtime) raises the same notifications.
|
||||
async fn persist_cve_alerts(
|
||||
&self,
|
||||
repo_id: &str,
|
||||
repo_name: &str,
|
||||
alerts: &[CveAlert],
|
||||
) -> Result<u32, AgentError> {
|
||||
use compliance_core::models::notification::{parse_severity, CveNotification};
|
||||
|
||||
let mut new_notif = 0u32;
|
||||
for alert in alerts {
|
||||
let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id };
|
||||
let update = mongodb::bson::to_document(alert)
|
||||
.map(|d| doc! { "$set": d })
|
||||
.unwrap_or_else(|_| doc! {});
|
||||
self.db
|
||||
.cve_alerts()
|
||||
.update_one(filter, update)
|
||||
.upsert(true)
|
||||
.await?;
|
||||
|
||||
// Dedup notifications by cve + repo + package + version.
|
||||
let notif_filter = doc! {
|
||||
"cve_id": &alert.cve_id,
|
||||
"repo_id": &alert.repo_id,
|
||||
"package_name": &alert.affected_package,
|
||||
"package_version": &alert.affected_version,
|
||||
};
|
||||
let severity = parse_severity(alert.severity.as_deref(), alert.cvss_score);
|
||||
let mut notification = CveNotification::new(
|
||||
alert.cve_id.clone(),
|
||||
repo_id.to_string(),
|
||||
repo_name.to_string(),
|
||||
alert.affected_package.clone(),
|
||||
alert.affected_version.clone(),
|
||||
severity,
|
||||
);
|
||||
notification.cvss_score = alert.cvss_score;
|
||||
notification.summary = alert.summary.clone();
|
||||
notification.url = Some(format!("https://osv.dev/vulnerability/{}", alert.cve_id));
|
||||
let notif_update = doc! {
|
||||
"$setOnInsert": mongodb::bson::to_bson(¬ification).unwrap_or_default()
|
||||
};
|
||||
if let Ok(result) = self
|
||||
.db
|
||||
.cve_notifications()
|
||||
.update_one(notif_filter, notif_update)
|
||||
.upsert(true)
|
||||
.await
|
||||
{
|
||||
if result.upserted_id.is_some() {
|
||||
new_notif += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(new_notif)
|
||||
}
|
||||
|
||||
/// Ingest the target's artifacts, classify (tramiton for firmware/RTOS/Yocto,
|
||||
/// heuristics otherwise), and store the detected classification on the target.
|
||||
/// Best-effort — never fails the scan.
|
||||
async fn classify_and_store(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
target_id: &str,
|
||||
scan_run_id: &str,
|
||||
) {
|
||||
self.update_phase(scan_run_id, "classification").await;
|
||||
let ctx = crate::ingest::IngestContext::from_config(&self.config, target_id);
|
||||
let ingest_set = match crate::ingest::ingest_all(target, &ctx) {
|
||||
Ok(set) => set,
|
||||
Err(e) => {
|
||||
tracing::warn!(target_id, error = %e, "Unified pipeline: ingest for classification failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let working_paths = ingest_set.working_paths();
|
||||
match crate::classify::classify_target(
|
||||
target,
|
||||
&working_paths,
|
||||
&crate::classify::TramitonNative,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(classification) => {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
suggested = %classification.suggested,
|
||||
"Unified pipeline: classified target"
|
||||
);
|
||||
if let (Some(oid), Ok(bson)) = (target.id, mongodb::bson::to_bson(&classification))
|
||||
{
|
||||
let _ = self
|
||||
.db
|
||||
.onboarded_targets()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! { "$set": { "classification": bson } },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(target_id, error = %e, "Unified pipeline: classification failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Analysis-based firmware SBOM: for embedded targets, derive components
|
||||
// (resolved libraries + cross-toolchain) from tramiton's build-plan
|
||||
// analysis over the already-ingested source — no build, no binary
|
||||
// upload. Best-effort; empty when no build plan forms.
|
||||
if crate::pipeline::firmware_sbom::is_firmware_target(target.target_type) {
|
||||
if let Some(code) = target.code_artifact() {
|
||||
if let Some(path) = working_paths.get(&code.id) {
|
||||
let entries =
|
||||
crate::pipeline::firmware_sbom::firmware_sbom_entries(path, target_id)
|
||||
.await;
|
||||
if !entries.is_empty() {
|
||||
let _ = self
|
||||
.db
|
||||
.sbom_entries()
|
||||
.delete_many(doc! { "repo_id": target_id })
|
||||
.await;
|
||||
for entry in &entries {
|
||||
let filter = doc! {
|
||||
"repo_id": &entry.repo_id,
|
||||
"name": &entry.name,
|
||||
"version": &entry.version,
|
||||
};
|
||||
if let Ok(d) = mongodb::bson::to_document(entry) {
|
||||
let _ = self
|
||||
.db
|
||||
.sbom_entries()
|
||||
.update_one(filter, doc! { "$set": d })
|
||||
.upsert(true)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
target_id,
|
||||
count = entries.len(),
|
||||
"Firmware SBOM: stored components from tramiton analysis"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If the target has a `LiveUrl` artifact and DAST is planned, provision a
|
||||
/// `DastTarget` (keyed by `repo_id` = target id) so the existing DAST trigger
|
||||
/// fires for wizard-created targets. Idempotent.
|
||||
async fn ensure_dast_target(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
plan: &crate::pipeline::plan::ScanPlan,
|
||||
) {
|
||||
if !plan.has(ScanType::Dast) {
|
||||
return;
|
||||
}
|
||||
let (Some(url), Some(oid)) = (target.live_url(), target.id) else {
|
||||
return;
|
||||
};
|
||||
let target_id = oid.to_hex();
|
||||
if self
|
||||
.db
|
||||
.dast_targets()
|
||||
.find_one(doc! { "repo_id": &target_id })
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return; // already provisioned
|
||||
}
|
||||
let kind = url
|
||||
.web
|
||||
.as_ref()
|
||||
.map(|w| w.target_kind.clone())
|
||||
.unwrap_or(DastTargetType::WebApp);
|
||||
let mut dast = DastTarget::new(target.name.clone(), url.source_ref.clone(), kind);
|
||||
dast.repo_id = Some(target_id);
|
||||
if let Some(web) = &url.web {
|
||||
dast.excluded_paths = web.excluded_paths.clone();
|
||||
dast.max_crawl_depth = web.max_crawl_depth;
|
||||
dast.rate_limit = web.rate_limit;
|
||||
dast.allow_destructive = web.allow_destructive;
|
||||
}
|
||||
if let Some(auth) = &url.auth {
|
||||
dast.auth_config = Some(DastAuthConfig {
|
||||
method: auth.method.clone(),
|
||||
login_url: auth.login_url.clone(),
|
||||
username: auth.username.clone(),
|
||||
password: None,
|
||||
token: auth.secret.clone(),
|
||||
headers: auth.headers.clone(),
|
||||
});
|
||||
}
|
||||
if let Err(e) = self.db.dast_targets().insert_one(&dast).await {
|
||||
tracing::warn!(error = %e, "Unified pipeline: failed to provision DAST target");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync the onboarded-target document after a scan: bump `findings_count`
|
||||
/// and advance the git artifact's `last_scanned_commit` watermark.
|
||||
async fn finalize_target(
|
||||
&self,
|
||||
target: &OnboardedTarget,
|
||||
repo: &RepoView,
|
||||
new_count: u32,
|
||||
) -> Result<(), AgentError> {
|
||||
let oid = match target.id {
|
||||
Some(id) => id,
|
||||
None => return Ok(()),
|
||||
};
|
||||
self.db
|
||||
.onboarded_targets()
|
||||
.update_one(
|
||||
doc! { "_id": oid },
|
||||
doc! {
|
||||
"$inc": { "findings_count": new_count as i64 },
|
||||
"$set": { "updated_at": mongodb::bson::DateTime::now() },
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let repo_path = std::path::Path::new(&self.config.git_clone_base_path).join(&repo.name);
|
||||
if let (Ok(sha), Some(code)) = (GitOps::get_head_sha(&repo_path), target.code_artifact()) {
|
||||
self.db
|
||||
.onboarded_targets()
|
||||
.update_one(
|
||||
doc! { "_id": oid, "artifacts.id": &code.id },
|
||||
doc! { "$set": { "artifacts.$.git.last_scanned_commit": sha } },
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1052,50 +460,3 @@ pub(super) fn extract_base_url(git_url: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::{
|
||||
Artifact, ArtifactAuth, IssueTrackerConfig, TargetType, TrackerType,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn repo_view_preserves_id_git_auth_and_tracker() {
|
||||
let mut target = OnboardedTarget::new("acme".to_string(), TargetType::WebApp);
|
||||
target.id = Some(mongodb::bson::oid::ObjectId::new());
|
||||
target.findings_count = 3;
|
||||
target.scan_config.issue_tracker = Some(IssueTrackerConfig {
|
||||
tracker_type: Some(TrackerType::Gitea),
|
||||
owner: Some("acme".to_string()),
|
||||
repo: Some("web".to_string()),
|
||||
token: Some("tt".to_string()),
|
||||
});
|
||||
|
||||
let mut artifact = Artifact::git_repo("https://git/acme.git", "develop");
|
||||
if let Some(git) = artifact.git.as_mut() {
|
||||
git.last_scanned_commit = Some("abc123".to_string());
|
||||
}
|
||||
artifact.auth = Some(ArtifactAuth {
|
||||
method: "token".to_string(),
|
||||
username: Some("bob".to_string()),
|
||||
secret: Some("pat".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
target.artifacts.push(artifact);
|
||||
|
||||
let code = target.code_artifact().expect("code artifact");
|
||||
let repo = RepoView::from_target(&target, code);
|
||||
|
||||
assert_eq!(repo.id, target.id); // preserved
|
||||
assert_eq!(repo.git_url, "https://git/acme.git");
|
||||
assert_eq!(repo.default_branch, "develop");
|
||||
assert_eq!(repo.last_scanned_commit.as_deref(), Some("abc123"));
|
||||
assert_eq!(repo.auth_token.as_deref(), Some("pat"));
|
||||
assert_eq!(repo.auth_username.as_deref(), Some("bob"));
|
||||
assert_eq!(repo.tracker_type, Some(TrackerType::Gitea));
|
||||
assert_eq!(repo.tracker_owner.as_deref(), Some("acme"));
|
||||
assert_eq!(repo.findings_count, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ fn scan_with_patterns(
|
||||
repo_id.to_string(),
|
||||
fingerprint,
|
||||
scanner_name.to_string(),
|
||||
scan_type,
|
||||
scan_type.clone(),
|
||||
pattern.title.clone(),
|
||||
pattern.description.clone(),
|
||||
pattern.severity.clone(),
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
//! The scan plan.
|
||||
//!
|
||||
//! [`build_scan_plan`] turns an [`OnboardedTarget`] into the concrete ordered
|
||||
//! list of scans to run, each bound to the artifact it consumes. It intersects
|
||||
//! the scan-applicability matrix ([`applicable_scans`]) with the target's
|
||||
//! `scan_config` overrides: a scan runs when its required artifact is present and
|
||||
//! it is either on by default or explicitly enabled, and is not explicitly
|
||||
//! disabled. This is the decision engine the unified pipeline (`run_target`)
|
||||
//! executes.
|
||||
|
||||
use compliance_core::models::{Artifact, ArtifactKind, OnboardedTarget, ScanPhase, ScanType};
|
||||
use compliance_core::scan_matrix::applicable_scans;
|
||||
|
||||
/// One scan to run, bound to the artifact it operates on.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ScanStep {
|
||||
/// The scan to run.
|
||||
pub scan_type: ScanType,
|
||||
/// The pipeline phase to report while it runs.
|
||||
pub phase: ScanPhase,
|
||||
/// The id of the artifact this scan consumes ([`Artifact::id`]).
|
||||
pub artifact_id: String,
|
||||
}
|
||||
|
||||
/// The ordered set of scans to run for a target.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ScanPlan {
|
||||
/// The scans, in matrix order.
|
||||
pub steps: Vec<ScanStep>,
|
||||
}
|
||||
|
||||
impl ScanPlan {
|
||||
/// Whether the plan contains a step for the given scan type.
|
||||
pub fn has(&self, scan: ScanType) -> bool {
|
||||
self.steps.iter().any(|s| s.scan_type == scan)
|
||||
}
|
||||
|
||||
/// Whether the plan is empty (nothing to run).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.steps.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the scan plan for a target: matrix defaults ∩ `scan_config`, each scan
|
||||
/// bound to the artifact it consumes. Scans whose required artifact is absent, or
|
||||
/// that are disabled, or off-by-default and not explicitly enabled, are dropped.
|
||||
pub fn build_scan_plan(target: &OnboardedTarget) -> ScanPlan {
|
||||
let enabled = &target.scan_config.enabled_scans;
|
||||
let disabled = &target.scan_config.disabled_scans;
|
||||
|
||||
let mut steps = Vec::new();
|
||||
for option in applicable_scans(target) {
|
||||
// Required artifact missing → not runnable.
|
||||
if option.blocked_reason.is_some() {
|
||||
continue;
|
||||
}
|
||||
// Explicit opt-out wins.
|
||||
if disabled.contains(&option.scan) {
|
||||
continue;
|
||||
}
|
||||
// Run if on by default, or explicitly enabled.
|
||||
if !option.default_on && !enabled.contains(&option.scan) {
|
||||
continue;
|
||||
}
|
||||
let Some(artifact) = resolve_artifact(target, option.required_artifact) else {
|
||||
continue;
|
||||
};
|
||||
steps.push(ScanStep {
|
||||
scan_type: option.scan,
|
||||
phase: phase_for(option.scan),
|
||||
artifact_id: artifact.id.clone(),
|
||||
});
|
||||
}
|
||||
ScanPlan { steps }
|
||||
}
|
||||
|
||||
/// Resolve the artifact a scan consumes. A "code" requirement (represented by
|
||||
/// `GitRepo`) is satisfied by a git repo *or* a source archive. The PLC
|
||||
/// control-logic requirement (represented by `PlcProject`) prefers an uploaded
|
||||
/// PLC project but also accepts a code artifact — a git repo / source archive
|
||||
/// holding PLCopen XML / ST exports.
|
||||
fn resolve_artifact(target: &OnboardedTarget, required: Option<ArtifactKind>) -> Option<&Artifact> {
|
||||
match required {
|
||||
Some(ArtifactKind::GitRepo) => target.code_artifact(),
|
||||
Some(ArtifactKind::PlcProject) => target
|
||||
.first_of(ArtifactKind::PlcProject)
|
||||
.or_else(|| target.code_artifact()),
|
||||
Some(kind) => target.first_of(kind),
|
||||
None => target.code_artifact().or_else(|| target.artifacts.first()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pipeline phase reported while a given scan runs.
|
||||
fn phase_for(scan: ScanType) -> ScanPhase {
|
||||
match scan {
|
||||
ScanType::Sast => ScanPhase::Sast,
|
||||
ScanType::Sbom => ScanPhase::SbomGeneration,
|
||||
ScanType::Cve => ScanPhase::CveScanning,
|
||||
ScanType::Gdpr | ScanType::OAuth => ScanPhase::PatternScanning,
|
||||
ScanType::SecretDetection => ScanPhase::SecretDetection,
|
||||
ScanType::Lint => ScanPhase::LintScanning,
|
||||
ScanType::CodeReview => ScanPhase::CodeReview,
|
||||
ScanType::Graph => ScanPhase::GraphBuilding,
|
||||
ScanType::Dast => ScanPhase::DastScanning,
|
||||
ScanType::FirmwareStatic => ScanPhase::FirmwareStatic,
|
||||
ScanType::PlcControlLogic => ScanPhase::PlcAnalysis,
|
||||
ScanType::MobileStatic => ScanPhase::MobileStatic,
|
||||
ScanType::ContainerScan => ScanPhase::ContainerScan,
|
||||
ScanType::IcsProbe => ScanPhase::IcsProbe,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use compliance_core::models::{PlcFormat, TargetType};
|
||||
|
||||
fn target(target_type: TargetType, artifacts: Vec<Artifact>) -> OnboardedTarget {
|
||||
let mut t = OnboardedTarget::new("t".to_string(), target_type);
|
||||
t.artifacts = artifacts;
|
||||
t
|
||||
}
|
||||
|
||||
fn step_for<'a>(plan: &'a ScanPlan, scan: ScanType) -> Option<&'a ScanStep> {
|
||||
plan.steps.iter().find(|s| s.scan_type == scan)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webapp_with_code_and_url_runs_sast_and_dast_bound_to_the_right_artifacts() {
|
||||
let code = Artifact::git_repo("https://git/x", "main");
|
||||
let url = Artifact::live_url("https://x");
|
||||
let (code_id, url_id) = (code.id.clone(), url.id.clone());
|
||||
let t = target(TargetType::WebApp, vec![code, url]);
|
||||
|
||||
let plan = build_scan_plan(&t);
|
||||
let sast = step_for(&plan, ScanType::Sast).expect("sast planned");
|
||||
assert_eq!(sast.artifact_id, code_id);
|
||||
assert_eq!(sast.phase, ScanPhase::Sast);
|
||||
let dast = step_for(&plan, ScanType::Dast).expect("dast planned");
|
||||
assert_eq!(dast.artifact_id, url_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webapp_without_url_omits_dast() {
|
||||
let t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
|
||||
let plan = build_scan_plan(&t);
|
||||
assert!(plan.has(ScanType::Sast));
|
||||
assert!(!plan.has(ScanType::Dast));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_scan_binds_to_source_archive_when_no_git_repo() {
|
||||
let arc = Artifact::source_archive("src.zip");
|
||||
let arc_id = arc.id.clone();
|
||||
let t = target(TargetType::BackendService, vec![arc]);
|
||||
let plan = build_scan_plan(&t);
|
||||
let sast = step_for(&plan, ScanType::Sast).expect("sast planned");
|
||||
assert_eq!(sast.artifact_id, arc_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_sbom_and_cve_bind_to_the_firmware_image() {
|
||||
let fw = Artifact::firmware_image("fw.bin");
|
||||
let fw_id = fw.id.clone();
|
||||
let t = target(TargetType::FirmwareBareMetal, vec![fw]);
|
||||
let plan = build_scan_plan(&t);
|
||||
let sbom = step_for(&plan, ScanType::Sbom).expect("sbom planned");
|
||||
assert_eq!(sbom.artifact_id, fw_id);
|
||||
assert!(step_for(&plan, ScanType::FirmwareStatic).is_some());
|
||||
assert!(!plan.has(ScanType::Dast));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_plans_only_control_logic() {
|
||||
let plc = Artifact::plc_project("p.xml", PlcFormat::PlcopenXml);
|
||||
let t = target(TargetType::PlcSps, vec![plc]);
|
||||
let plan = build_scan_plan(&t);
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.steps[0].scan_type, ScanType::PlcControlLogic);
|
||||
assert_eq!(plan.steps[0].phase, ScanPhase::PlcAnalysis);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_control_logic_binds_to_a_git_repo() {
|
||||
// A CODESYS project in git (PLCopen XML / ST exports) with no uploaded
|
||||
// PlcProject: control-logic still plans, bound to the git artifact.
|
||||
let git = Artifact::git_repo("https://git/plc", "main");
|
||||
let git_id = git.id.clone();
|
||||
let t = target(TargetType::PlcSps, vec![git]);
|
||||
let plan = build_scan_plan(&t);
|
||||
let step = step_for(&plan, ScanType::PlcControlLogic).expect("control-logic planned");
|
||||
assert_eq!(step.artifact_id, git_id, "PLC scan binds to the git repo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_scan_is_dropped_and_off_by_default_can_be_enabled() {
|
||||
let mut t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
|
||||
t.scan_config.disabled_scans = vec![ScanType::Lint];
|
||||
// CodeReview is off by default for web; enable it explicitly.
|
||||
t.scan_config.enabled_scans = vec![ScanType::CodeReview];
|
||||
let plan = build_scan_plan(&t);
|
||||
assert!(!plan.has(ScanType::Lint));
|
||||
assert!(plan.has(ScanType::CodeReview));
|
||||
assert!(plan.has(ScanType::Sast));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_code_artifact_yields_empty_plan_for_web() {
|
||||
let t = target(TargetType::WebApp, vec![]);
|
||||
let plan = build_scan_plan(&t);
|
||||
assert!(plan.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
//! Abstract syntax tree for IEC 61131-3 Structured Text (ST).
|
||||
//!
|
||||
//! This is the security-relevant subset: POUs with their variable declarations
|
||||
//! and statement bodies, enough to run semantic control-logic rules over. It is
|
||||
//! deliberately not a full language model — declarations we don't reason about
|
||||
//! (e.g. exotic type definitions) are parsed loosely and kept as raw text.
|
||||
|
||||
/// A Program Organization Unit: a PROGRAM, FUNCTION, or FUNCTION_BLOCK.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pou {
|
||||
pub name: String,
|
||||
pub kind: PouKind,
|
||||
/// The declared variables, across all VAR_* sections.
|
||||
pub vars: Vec<VarDecl>,
|
||||
/// The statement body.
|
||||
pub body: Vec<Stmt>,
|
||||
/// 1-based line where the POU header appears (in the source that was parsed).
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PouKind {
|
||||
Program,
|
||||
Function,
|
||||
FunctionBlock,
|
||||
}
|
||||
|
||||
impl PouKind {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PouKind::Program => "PROGRAM",
|
||||
PouKind::Function => "FUNCTION",
|
||||
PouKind::FunctionBlock => "FUNCTION_BLOCK",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single declared variable.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VarDecl {
|
||||
pub name: String,
|
||||
pub section: VarSection,
|
||||
/// The declared type as written (e.g. `BOOL`, `INT`, `ARRAY[0..9] OF INT`).
|
||||
pub type_name: String,
|
||||
/// Whether the type is an ARRAY, and its declared bounds `(lo, hi)` when
|
||||
/// they are literal integers — used by the array-bounds rule.
|
||||
pub array_bounds: Option<(i64, i64)>,
|
||||
/// The initializer expression, if any (`:= <expr>`).
|
||||
pub init: Option<Expr>,
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VarSection {
|
||||
Var,
|
||||
Input,
|
||||
Output,
|
||||
InOut,
|
||||
Global,
|
||||
Temp,
|
||||
External,
|
||||
}
|
||||
|
||||
/// A statement.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Stmt {
|
||||
Assign {
|
||||
target: Expr,
|
||||
value: Expr,
|
||||
line: u32,
|
||||
},
|
||||
If {
|
||||
/// (condition, body) for IF and each ELSIF, in order.
|
||||
branches: Vec<(Expr, Vec<Stmt>)>,
|
||||
else_body: Option<Vec<Stmt>>,
|
||||
line: u32,
|
||||
},
|
||||
Case {
|
||||
selector: Expr,
|
||||
/// (label expressions, body) per CASE arm.
|
||||
arms: Vec<(Vec<Expr>, Vec<Stmt>)>,
|
||||
else_body: Option<Vec<Stmt>>,
|
||||
line: u32,
|
||||
},
|
||||
For {
|
||||
var: String,
|
||||
from: Expr,
|
||||
to: Expr,
|
||||
by: Option<Expr>,
|
||||
body: Vec<Stmt>,
|
||||
line: u32,
|
||||
},
|
||||
While {
|
||||
cond: Expr,
|
||||
body: Vec<Stmt>,
|
||||
line: u32,
|
||||
},
|
||||
Repeat {
|
||||
body: Vec<Stmt>,
|
||||
until: Expr,
|
||||
line: u32,
|
||||
},
|
||||
/// A bare call statement, e.g. `TON1(IN := x, PT := T#5s);`.
|
||||
Call {
|
||||
callee: String,
|
||||
args: Vec<CallArg>,
|
||||
line: u32,
|
||||
},
|
||||
Return {
|
||||
line: u32,
|
||||
},
|
||||
Exit {
|
||||
line: u32,
|
||||
},
|
||||
/// `JMP label;` — an unstructured jump.
|
||||
Jump {
|
||||
label: String,
|
||||
line: u32,
|
||||
},
|
||||
/// `label:` — a jump target.
|
||||
Label {
|
||||
name: String,
|
||||
line: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// One argument in a call: positional (`name: None`) or named (`X := expr`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CallArg {
|
||||
pub name: Option<String>,
|
||||
pub value: Expr,
|
||||
}
|
||||
|
||||
/// An expression.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Expr {
|
||||
Int(i64, u32),
|
||||
Real(f64, u32),
|
||||
Bool(bool, u32),
|
||||
/// A string literal, with the unquoted contents.
|
||||
Str(String, u32),
|
||||
/// A duration / date / time literal, kept as raw text (`T#5s`, `DT#...`).
|
||||
Time(String, u32),
|
||||
Ident(String, u32),
|
||||
/// `base[index]`.
|
||||
Index {
|
||||
base: Box<Expr>,
|
||||
index: Box<Expr>,
|
||||
line: u32,
|
||||
},
|
||||
/// `base.field`.
|
||||
Member {
|
||||
base: Box<Expr>,
|
||||
field: String,
|
||||
line: u32,
|
||||
},
|
||||
Unary {
|
||||
op: UnOp,
|
||||
expr: Box<Expr>,
|
||||
line: u32,
|
||||
},
|
||||
Binary {
|
||||
op: BinOp,
|
||||
lhs: Box<Expr>,
|
||||
rhs: Box<Expr>,
|
||||
line: u32,
|
||||
},
|
||||
/// A function call used as an expression, e.g. `LIMIT(a, b, c)`.
|
||||
Call {
|
||||
callee: String,
|
||||
args: Vec<CallArg>,
|
||||
line: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Expr {
|
||||
/// The 1-based source line this expression starts on.
|
||||
pub fn line(&self) -> u32 {
|
||||
match self {
|
||||
Expr::Int(_, l)
|
||||
| Expr::Real(_, l)
|
||||
| Expr::Bool(_, l)
|
||||
| Expr::Str(_, l)
|
||||
| Expr::Time(_, l)
|
||||
| Expr::Ident(_, l)
|
||||
| Expr::Index { line: l, .. }
|
||||
| Expr::Member { line: l, .. }
|
||||
| Expr::Unary { line: l, .. }
|
||||
| Expr::Binary { line: l, .. }
|
||||
| Expr::Call { line: l, .. } => *l,
|
||||
}
|
||||
}
|
||||
|
||||
/// If this expression is a plain identifier, its name.
|
||||
pub fn as_ident(&self) -> Option<&str> {
|
||||
match self {
|
||||
Expr::Ident(name, _) => Some(name.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnOp {
|
||||
Not,
|
||||
Neg,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BinOp {
|
||||
Add,
|
||||
Sub,
|
||||
Mul,
|
||||
Div,
|
||||
Mod,
|
||||
Pow,
|
||||
Eq,
|
||||
Ne,
|
||||
Lt,
|
||||
Le,
|
||||
Gt,
|
||||
Ge,
|
||||
And,
|
||||
Or,
|
||||
Xor,
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
//! Lexer for IEC 61131-3 Structured Text.
|
||||
//!
|
||||
//! Tokenizes ST source into a flat token stream with 1-based line numbers.
|
||||
//! Keywords are case-insensitive. Handles `(* *)` and `//` comments, `'..'` and
|
||||
//! `".."` strings (with `''`/`""` escapes), based integers (`16#FF`, `2#1010`),
|
||||
//! and duration/date literals (`T#5s`, `DT#...`) kept as raw text.
|
||||
|
||||
/// A lexed token with its source line.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Token {
|
||||
pub kind: Tok,
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Tok {
|
||||
Int(i64),
|
||||
Real(f64),
|
||||
Str(String),
|
||||
Time(String),
|
||||
Bool(bool),
|
||||
Ident(String),
|
||||
Kw(Keyword),
|
||||
Assign, // :=
|
||||
Plus, // +
|
||||
Minus, // -
|
||||
Star, // *
|
||||
Slash, // /
|
||||
Power, // **
|
||||
LParen, // (
|
||||
RParen, // )
|
||||
LBrack, // [
|
||||
RBrack, // ]
|
||||
Dot, // .
|
||||
DotDot, // ..
|
||||
Comma, // ,
|
||||
Semi, // ;
|
||||
Colon, // :
|
||||
Lt, // <
|
||||
Le, // <=
|
||||
Gt, // >
|
||||
Ge, // >=
|
||||
Eq, // =
|
||||
Ne, // <>
|
||||
Amp, // &
|
||||
Eof,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Keyword {
|
||||
Program,
|
||||
EndProgram,
|
||||
Function,
|
||||
EndFunction,
|
||||
FunctionBlock,
|
||||
EndFunctionBlock,
|
||||
Var,
|
||||
VarInput,
|
||||
VarOutput,
|
||||
VarInOut,
|
||||
VarGlobal,
|
||||
VarTemp,
|
||||
VarExternal,
|
||||
Constant,
|
||||
EndVar,
|
||||
Array,
|
||||
Of,
|
||||
If,
|
||||
Then,
|
||||
Elsif,
|
||||
Else,
|
||||
EndIf,
|
||||
Case,
|
||||
EndCase,
|
||||
For,
|
||||
To,
|
||||
By,
|
||||
Do,
|
||||
EndFor,
|
||||
While,
|
||||
EndWhile,
|
||||
Repeat,
|
||||
Until,
|
||||
EndRepeat,
|
||||
Return,
|
||||
Exit,
|
||||
Jmp,
|
||||
Not,
|
||||
And,
|
||||
Or,
|
||||
Xor,
|
||||
Mod,
|
||||
Type,
|
||||
EndType,
|
||||
Struct,
|
||||
EndStruct,
|
||||
}
|
||||
|
||||
fn keyword_from(word: &str) -> Option<Keyword> {
|
||||
use Keyword::*;
|
||||
Some(match word.to_ascii_uppercase().as_str() {
|
||||
"PROGRAM" => Program,
|
||||
"END_PROGRAM" => EndProgram,
|
||||
"FUNCTION" => Function,
|
||||
"END_FUNCTION" => EndFunction,
|
||||
"FUNCTION_BLOCK" => FunctionBlock,
|
||||
"END_FUNCTION_BLOCK" => EndFunctionBlock,
|
||||
"VAR" => Var,
|
||||
"VAR_INPUT" => VarInput,
|
||||
"VAR_OUTPUT" => VarOutput,
|
||||
"VAR_IN_OUT" => VarInOut,
|
||||
"VAR_GLOBAL" => VarGlobal,
|
||||
"VAR_TEMP" => VarTemp,
|
||||
"VAR_EXTERNAL" => VarExternal,
|
||||
"CONSTANT" => Constant,
|
||||
"END_VAR" => EndVar,
|
||||
"ARRAY" => Array,
|
||||
"OF" => Of,
|
||||
"IF" => If,
|
||||
"THEN" => Then,
|
||||
"ELSIF" => Elsif,
|
||||
"ELSE" => Else,
|
||||
"END_IF" => EndIf,
|
||||
"CASE" => Case,
|
||||
"END_CASE" => EndCase,
|
||||
"FOR" => For,
|
||||
"TO" => To,
|
||||
"BY" => By,
|
||||
"DO" => Do,
|
||||
"END_FOR" => EndFor,
|
||||
"WHILE" => While,
|
||||
"END_WHILE" => EndWhile,
|
||||
"REPEAT" => Repeat,
|
||||
"UNTIL" => Until,
|
||||
"END_REPEAT" => EndRepeat,
|
||||
"RETURN" => Return,
|
||||
"EXIT" => Exit,
|
||||
"JMP" => Jmp,
|
||||
"NOT" => Not,
|
||||
"AND" => And,
|
||||
"OR" => Or,
|
||||
"XOR" => Xor,
|
||||
"MOD" => Mod,
|
||||
"TYPE" => Type,
|
||||
"END_TYPE" => EndType,
|
||||
"STRUCT" => Struct,
|
||||
"END_STRUCT" => EndStruct,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tokenize `src`. Unknown characters are skipped (best-effort — a scanner must
|
||||
/// not die on odd input).
|
||||
pub fn lex(src: &str) -> Vec<Token> {
|
||||
let chars: Vec<char> = src.chars().collect();
|
||||
let mut i = 0usize;
|
||||
let mut line = 1u32;
|
||||
let mut out = Vec::new();
|
||||
|
||||
let bump_line = |c: char, line: &mut u32| {
|
||||
if c == '\n' {
|
||||
*line += 1;
|
||||
}
|
||||
};
|
||||
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
|
||||
// Whitespace.
|
||||
if c.is_whitespace() {
|
||||
bump_line(c, &mut line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line comment: //
|
||||
if c == '/' && i + 1 < chars.len() && chars[i + 1] == '/' {
|
||||
while i < chars.len() && chars[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block comment: (* ... *)
|
||||
if c == '(' && i + 1 < chars.len() && chars[i + 1] == '*' {
|
||||
i += 2;
|
||||
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == ')') {
|
||||
bump_line(chars[i], &mut line);
|
||||
i += 1;
|
||||
}
|
||||
i = (i + 2).min(chars.len());
|
||||
continue;
|
||||
}
|
||||
|
||||
let tok_line = line;
|
||||
|
||||
// String literal: '...' or "..."
|
||||
if c == '\'' || c == '"' {
|
||||
let quote = c;
|
||||
i += 1;
|
||||
let mut s = String::new();
|
||||
while i < chars.len() {
|
||||
let ch = chars[i];
|
||||
if ch == quote {
|
||||
// Doubled quote is an escaped quote.
|
||||
if i + 1 < chars.len() && chars[i + 1] == quote {
|
||||
s.push(quote);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
bump_line(ch, &mut line);
|
||||
s.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
out.push(Token {
|
||||
kind: Tok::Str(s),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier / keyword / time literal / boolean.
|
||||
if c.is_ascii_alphabetic() || c == '_' {
|
||||
let start = i;
|
||||
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
let word: String = chars[start..i].iter().collect();
|
||||
|
||||
// Duration/date/time literal prefix: T#, TIME#, DT#, D#, TOD#, LT# ...
|
||||
if i < chars.len() && chars[i] == '#' {
|
||||
let up = word.to_ascii_uppercase();
|
||||
if matches!(
|
||||
up.as_str(),
|
||||
"T" | "TIME" | "DT" | "D" | "TOD" | "LT" | "DATE"
|
||||
) {
|
||||
let lit_start = start;
|
||||
i += 1; // consume '#'
|
||||
while i < chars.len()
|
||||
&& (chars[i].is_ascii_alphanumeric()
|
||||
|| chars[i] == '.'
|
||||
|| chars[i] == '_'
|
||||
|| chars[i] == ':')
|
||||
{
|
||||
i += 1;
|
||||
}
|
||||
let lit: String = chars[lit_start..i].iter().collect();
|
||||
out.push(Token {
|
||||
kind: Tok::Time(lit),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let kind = match word.to_ascii_uppercase().as_str() {
|
||||
"TRUE" => Tok::Bool(true),
|
||||
"FALSE" => Tok::Bool(false),
|
||||
_ => match keyword_from(&word) {
|
||||
Some(kw) => Tok::Kw(kw),
|
||||
None => Tok::Ident(word),
|
||||
},
|
||||
};
|
||||
out.push(Token {
|
||||
kind,
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number: decimal, real, or based (16#..., 2#...).
|
||||
if c.is_ascii_digit() {
|
||||
let start = i;
|
||||
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
// Based literal: <base>#<digits>
|
||||
if i < chars.len() && chars[i] == '#' {
|
||||
let base_str: String = chars[start..i].iter().filter(|c| **c != '_').collect();
|
||||
i += 1;
|
||||
let dstart = i;
|
||||
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
let digits: String = chars[dstart..i].iter().filter(|c| **c != '_').collect();
|
||||
let radix = base_str.parse::<u32>().unwrap_or(10);
|
||||
let val = i64::from_str_radix(&digits, radix.clamp(2, 36)).unwrap_or(0);
|
||||
out.push(Token {
|
||||
kind: Tok::Int(val),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Real: has a '.' (not '..') or exponent.
|
||||
let is_real =
|
||||
i < chars.len() && chars[i] == '.' && !(i + 1 < chars.len() && chars[i + 1] == '.');
|
||||
if is_real {
|
||||
i += 1;
|
||||
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '_') {
|
||||
i += 1;
|
||||
}
|
||||
let raw: String = chars[start..i].iter().filter(|c| **c != '_').collect();
|
||||
out.push(Token {
|
||||
kind: Tok::Real(raw.parse().unwrap_or(0.0)),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let raw: String = chars[start..i].iter().filter(|c| **c != '_').collect();
|
||||
out.push(Token {
|
||||
kind: Tok::Int(raw.parse().unwrap_or(0)),
|
||||
line: tok_line,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Operators / punctuation (longest match first).
|
||||
let two: String = chars[i..(i + 2).min(chars.len())].iter().collect();
|
||||
let kind = match two.as_str() {
|
||||
":=" => Some(Tok::Assign),
|
||||
"<=" => Some(Tok::Le),
|
||||
">=" => Some(Tok::Ge),
|
||||
"<>" => Some(Tok::Ne),
|
||||
".." => Some(Tok::DotDot),
|
||||
"**" => Some(Tok::Power),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(k) = kind {
|
||||
out.push(Token {
|
||||
kind: k,
|
||||
line: tok_line,
|
||||
});
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let one = match c {
|
||||
'+' => Some(Tok::Plus),
|
||||
'-' => Some(Tok::Minus),
|
||||
'*' => Some(Tok::Star),
|
||||
'/' => Some(Tok::Slash),
|
||||
'(' => Some(Tok::LParen),
|
||||
')' => Some(Tok::RParen),
|
||||
'[' => Some(Tok::LBrack),
|
||||
']' => Some(Tok::RBrack),
|
||||
'.' => Some(Tok::Dot),
|
||||
',' => Some(Tok::Comma),
|
||||
';' => Some(Tok::Semi),
|
||||
':' => Some(Tok::Colon),
|
||||
'<' => Some(Tok::Lt),
|
||||
'>' => Some(Tok::Gt),
|
||||
'=' => Some(Tok::Eq),
|
||||
'&' => Some(Tok::Amp),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(k) = one {
|
||||
out.push(Token {
|
||||
kind: k,
|
||||
line: tok_line,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out.push(Token {
|
||||
kind: Tok::Eof,
|
||||
line,
|
||||
});
|
||||
out
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
//! PLC control-logic security scanner for IEC 61131-3 targets.
|
||||
//!
|
||||
//! Parses Structured Text (raw `.st`/`.scl`/`.exp` files and PLCopen-XML
|
||||
//! projects) into an AST and runs semantic control-logic security rules over it.
|
||||
//! Implements [`ScanType::PlcControlLogic`].
|
||||
|
||||
pub mod ast;
|
||||
pub mod lexer;
|
||||
pub mod parser;
|
||||
pub mod plcopen;
|
||||
pub mod rules;
|
||||
pub mod sbom;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use compliance_core::error::CoreError;
|
||||
use compliance_core::models::{Finding, ScanType};
|
||||
use compliance_core::traits::{ScanOutput, Scanner};
|
||||
|
||||
use crate::pipeline::dedup;
|
||||
|
||||
/// Scanner for `ScanType::PlcControlLogic`.
|
||||
pub struct PlcControlLogicScanner;
|
||||
|
||||
impl Scanner for PlcControlLogicScanner {
|
||||
fn name(&self) -> &str {
|
||||
"plc-control-logic"
|
||||
}
|
||||
|
||||
fn scan_type(&self) -> ScanType {
|
||||
ScanType::PlcControlLogic
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
|
||||
let findings = analyze_tree(repo_path, repo_id);
|
||||
Ok(ScanOutput {
|
||||
findings,
|
||||
sbom_entries: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a PLC project tree and produce findings.
|
||||
pub(crate) fn analyze_tree(root: &Path, repo_id: &str) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
for entry in walkdir::WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let is_st = matches!(ext.as_str(), "st" | "iecst" | "scl" | "exp" | "il");
|
||||
let is_xml = matches!(ext.as_str(), "xml" | "plcopen" | "project");
|
||||
if !is_st && !is_xml {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = std::fs::read_to_string(path) else {
|
||||
continue;
|
||||
};
|
||||
let pous = if is_xml {
|
||||
plcopen::parse_plcopen(&content)
|
||||
} else {
|
||||
parser::parse(&content)
|
||||
};
|
||||
if pous.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
for pou in &pous {
|
||||
for hit in rules::analyze(pou) {
|
||||
let line_s = hit.line.to_string();
|
||||
let fingerprint =
|
||||
dedup::compute_fingerprint(&[repo_id, &rel, hit.rule_id, &pou.name, &line_s]);
|
||||
let mut f = Finding::new(
|
||||
repo_id.to_string(),
|
||||
fingerprint,
|
||||
"plc-control-logic".to_string(),
|
||||
ScanType::PlcControlLogic,
|
||||
hit.title,
|
||||
hit.description,
|
||||
hit.severity,
|
||||
);
|
||||
f.file_path = Some(rel.clone());
|
||||
f.line_number = Some(hit.line);
|
||||
f.rule_id = Some(hit.rule_id.to_string());
|
||||
f.cwe = hit.cwe.map(String::from);
|
||||
f.remediation = Some(hit.remediation.to_string());
|
||||
findings.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn demo_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.expect("workspace root")
|
||||
.join("examples/plc-demo")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scans_demo_project_end_to_end() {
|
||||
let findings = analyze_tree(&demo_dir(), "demo-target");
|
||||
assert!(!findings.is_empty(), "demo project should produce findings");
|
||||
|
||||
let rules: HashSet<&str> = findings
|
||||
.iter()
|
||||
.filter_map(|f| f.rule_id.as_deref())
|
||||
.collect();
|
||||
for r in [
|
||||
"plc-hardcoded-credential",
|
||||
"plc-default-password",
|
||||
"plc-safety-bypass",
|
||||
"plc-array-unchecked-index",
|
||||
"plc-insecure-comm",
|
||||
"plc-insecure-protocol-port",
|
||||
"plc-unstructured-jump",
|
||||
"plc-division-by-zero",
|
||||
] {
|
||||
assert!(rules.contains(r), "expected rule {r}; got {rules:?}");
|
||||
}
|
||||
|
||||
// Every finding is well-formed for storage.
|
||||
for f in &findings {
|
||||
assert_eq!(f.repo_id, "demo-target");
|
||||
assert!(f.file_path.is_some(), "finding needs a file");
|
||||
assert!(f.line_number.is_some(), "finding needs a line");
|
||||
}
|
||||
|
||||
// The guarded division (IF ScaleFactor <> 0.0) must not be double-counted:
|
||||
// exactly one division-by-zero (the unguarded MeasuredFlow divide).
|
||||
let div0 = findings
|
||||
.iter()
|
||||
.filter(|f| f.rule_id.as_deref() == Some("plc-division-by-zero"))
|
||||
.count();
|
||||
assert_eq!(div0, 1, "only the unguarded division should be flagged");
|
||||
}
|
||||
|
||||
/// The realistic OpenPLC-style traffic-light sample is mostly sound control
|
||||
/// logic: the scanner must surface its few genuine defects and stay quiet on
|
||||
/// the timed state machine and the guarded duty-cycle division.
|
||||
#[test]
|
||||
fn realistic_sample_flags_only_real_issues() {
|
||||
let all = analyze_tree(&demo_dir(), "demo-target");
|
||||
let tl: Vec<_> = all
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
f.file_path
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.ends_with("traffic_light.st"))
|
||||
})
|
||||
.collect();
|
||||
assert!(!tl.is_empty(), "traffic_light.st should produce findings");
|
||||
|
||||
let rules: HashSet<&str> = tl.iter().filter_map(|f| f.rule_id.as_deref()).collect();
|
||||
// The three planted defects: hardcoded SCADA password, cleartext Modbus
|
||||
// master (no auth), and a maintenance mode that drops the PedPermit.
|
||||
for r in [
|
||||
"plc-hardcoded-credential",
|
||||
"plc-insecure-comm",
|
||||
"plc-safety-bypass",
|
||||
] {
|
||||
assert!(rules.contains(r), "expected rule {r}; got {rules:?}");
|
||||
}
|
||||
// Modbus/TCP on 502 is also an insecure-protocol port.
|
||||
assert!(rules.contains("plc-insecure-protocol-port"));
|
||||
|
||||
// Low false positives: the guarded `IF LampCount <> 0` division and the
|
||||
// JMP-free state machine must not trip anything.
|
||||
assert_eq!(
|
||||
tl.iter()
|
||||
.filter(|f| f.rule_id.as_deref() == Some("plc-division-by-zero"))
|
||||
.count(),
|
||||
0,
|
||||
"the guarded duty-cycle division must not be flagged"
|
||||
);
|
||||
assert!(
|
||||
!rules.contains("plc-unstructured-jump"),
|
||||
"the CASE state machine uses no JMP"
|
||||
);
|
||||
}
|
||||
|
||||
/// Graphical logic must be analysed too: an FBD POU (blocks + in/out
|
||||
/// variables) is translated to synthetic ST, so the same rules fire on the
|
||||
/// cleartext Modbus block, the hardcoded HMI password and the safety write.
|
||||
#[test]
|
||||
fn fbd_graphical_body_is_analysed() {
|
||||
let all = analyze_tree(&demo_dir(), "demo-target");
|
||||
let fbd: Vec<_> = all
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
f.file_path
|
||||
.as_deref()
|
||||
.is_some_and(|p| p.ends_with("pump_fbd.xml"))
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
!fbd.is_empty(),
|
||||
"pump_fbd.xml (FBD) should produce findings"
|
||||
);
|
||||
|
||||
let rules: HashSet<&str> = fbd.iter().filter_map(|f| f.rule_id.as_deref()).collect();
|
||||
for r in [
|
||||
"plc-insecure-comm", // Modbus_TCP_Master(AUTH := FALSE)
|
||||
"plc-insecure-protocol-port", // PORT := 502
|
||||
"plc-hardcoded-credential", // HmiPassword := 'admin123'
|
||||
"plc-safety-bypass", // Safety_Enable := FALSE
|
||||
] {
|
||||
assert!(
|
||||
rules.contains(r),
|
||||
"expected rule {r} from FBD; got {rules:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,766 +0,0 @@
|
||||
//! Recursive-descent parser for the security-relevant subset of Structured Text.
|
||||
//!
|
||||
//! Tolerant by design: it parses the POUs, variable sections, and statement
|
||||
//! bodies it understands, and skips (with statement/POU-level recovery) anything
|
||||
//! it does not, so a single odd construct never sinks the whole file.
|
||||
|
||||
use super::ast::*;
|
||||
use super::lexer::{Keyword as K, Tok, Token};
|
||||
|
||||
pub struct Parser {
|
||||
toks: Vec<Token>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
pub fn new(toks: Vec<Token>) -> Self {
|
||||
Self { toks, pos: 0 }
|
||||
}
|
||||
|
||||
// ── token helpers ──────────────────────────────────────────────
|
||||
fn peek(&self) -> &Tok {
|
||||
&self.toks[self.pos.min(self.toks.len() - 1)].kind
|
||||
}
|
||||
fn line(&self) -> u32 {
|
||||
self.toks[self.pos.min(self.toks.len() - 1)].line
|
||||
}
|
||||
fn at_end(&self) -> bool {
|
||||
matches!(self.peek(), Tok::Eof)
|
||||
}
|
||||
fn advance(&mut self) -> Tok {
|
||||
let t = self.toks[self.pos.min(self.toks.len() - 1)].kind.clone();
|
||||
if self.pos < self.toks.len() - 1 {
|
||||
self.pos += 1;
|
||||
}
|
||||
t
|
||||
}
|
||||
fn eat(&mut self, t: &Tok) -> bool {
|
||||
if self.peek() == t {
|
||||
self.advance();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn eat_kw(&mut self, k: K) -> bool {
|
||||
if matches!(self.peek(), Tok::Kw(x) if *x == k) {
|
||||
self.advance();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn at_kw(&self, k: K) -> bool {
|
||||
matches!(self.peek(), Tok::Kw(x) if *x == k)
|
||||
}
|
||||
fn ident(&mut self) -> Option<String> {
|
||||
if let Tok::Ident(s) = self.peek() {
|
||||
let s = s.clone();
|
||||
self.advance();
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── top level ──────────────────────────────────────────────────
|
||||
/// Parse every POU in the token stream.
|
||||
pub fn parse_units(&mut self) -> Vec<Pou> {
|
||||
let mut pous = Vec::new();
|
||||
while !self.at_end() {
|
||||
match self.peek() {
|
||||
Tok::Kw(K::Program) => {
|
||||
self.advance();
|
||||
if let Some(p) = self.parse_pou(PouKind::Program, K::EndProgram) {
|
||||
pous.push(p);
|
||||
}
|
||||
}
|
||||
Tok::Kw(K::Function) => {
|
||||
self.advance();
|
||||
if let Some(p) = self.parse_pou(PouKind::Function, K::EndFunction) {
|
||||
pous.push(p);
|
||||
}
|
||||
}
|
||||
Tok::Kw(K::FunctionBlock) => {
|
||||
self.advance();
|
||||
if let Some(p) = self.parse_pou(PouKind::FunctionBlock, K::EndFunctionBlock) {
|
||||
pous.push(p);
|
||||
}
|
||||
}
|
||||
// Skip TYPE...END_TYPE and anything else at top level.
|
||||
_ => {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
pous
|
||||
}
|
||||
|
||||
fn parse_pou(&mut self, kind: PouKind, end: K) -> Option<Pou> {
|
||||
let line = self.line();
|
||||
let name = self.ident().unwrap_or_else(|| "<anonymous>".to_string());
|
||||
// Optional `: return_type` for functions.
|
||||
if self.eat(&Tok::Colon) {
|
||||
let _ = self.advance(); // return type token
|
||||
}
|
||||
|
||||
let mut vars = Vec::new();
|
||||
// Variable sections precede the body.
|
||||
while let Some(section) = self.var_section_kw() {
|
||||
self.advance();
|
||||
let _ = self.eat_kw(K::Constant); // CONSTANT is informational for our rules
|
||||
self.parse_var_decls(section, &mut vars);
|
||||
}
|
||||
|
||||
// Body statements until END_<kind>.
|
||||
let mut body = Vec::new();
|
||||
while !self.at_end() && !self.at_kw(end) {
|
||||
if let Some(s) = self.parse_stmt() {
|
||||
body.push(s);
|
||||
}
|
||||
}
|
||||
self.eat_kw(end);
|
||||
|
||||
Some(Pou {
|
||||
name,
|
||||
kind,
|
||||
vars,
|
||||
body,
|
||||
line,
|
||||
})
|
||||
}
|
||||
|
||||
fn var_section_kw(&self) -> Option<VarSection> {
|
||||
match self.peek() {
|
||||
Tok::Kw(K::Var) => Some(VarSection::Var),
|
||||
Tok::Kw(K::VarInput) => Some(VarSection::Input),
|
||||
Tok::Kw(K::VarOutput) => Some(VarSection::Output),
|
||||
Tok::Kw(K::VarInOut) => Some(VarSection::InOut),
|
||||
Tok::Kw(K::VarGlobal) => Some(VarSection::Global),
|
||||
Tok::Kw(K::VarTemp) => Some(VarSection::Temp),
|
||||
Tok::Kw(K::VarExternal) => Some(VarSection::External),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_var_decls(&mut self, section: VarSection, out: &mut Vec<VarDecl>) {
|
||||
while !self.at_end() && !self.at_kw(K::EndVar) {
|
||||
let line = self.line();
|
||||
// names: a, b, c
|
||||
let mut names = Vec::new();
|
||||
match self.ident() {
|
||||
Some(n) => names.push(n),
|
||||
None => {
|
||||
// Not a declaration we understand — skip to next ; or END_VAR.
|
||||
self.sync_decl();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
while self.eat(&Tok::Comma) {
|
||||
if let Some(n) = self.ident() {
|
||||
names.push(n);
|
||||
}
|
||||
}
|
||||
if !self.eat(&Tok::Colon) {
|
||||
self.sync_decl();
|
||||
continue;
|
||||
}
|
||||
let (type_name, array_bounds) = self.parse_type();
|
||||
let init = if self.eat(&Tok::Assign) {
|
||||
Some(self.parse_expr())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.eat(&Tok::Semi);
|
||||
for n in names {
|
||||
out.push(VarDecl {
|
||||
name: n,
|
||||
section,
|
||||
type_name: type_name.clone(),
|
||||
array_bounds,
|
||||
init: init.clone(),
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.eat_kw(K::EndVar);
|
||||
}
|
||||
|
||||
/// Parse a (possibly ARRAY) type, returning its rendered name and literal
|
||||
/// bounds when present.
|
||||
fn parse_type(&mut self) -> (String, Option<(i64, i64)>) {
|
||||
if self.eat_kw(K::Array) {
|
||||
let mut bounds = None;
|
||||
if self.eat(&Tok::LBrack) {
|
||||
let lo = self.int_lit();
|
||||
self.eat(&Tok::DotDot);
|
||||
let hi = self.int_lit();
|
||||
if let (Some(lo), Some(hi)) = (lo, hi) {
|
||||
bounds = Some((lo, hi));
|
||||
}
|
||||
// Skip any further dimensions / tokens to the closing bracket.
|
||||
while !self.at_end() && !self.eat(&Tok::RBrack) {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
self.eat_kw(K::Of);
|
||||
let elem = self.type_ident();
|
||||
(format!("ARRAY OF {elem}"), bounds)
|
||||
} else {
|
||||
(self.type_ident(), None)
|
||||
}
|
||||
}
|
||||
|
||||
fn type_ident(&mut self) -> String {
|
||||
// Types can be qualified idents; keep it simple: one token, plus any
|
||||
// string-length suffix like STRING[80].
|
||||
let base = match self.advance() {
|
||||
Tok::Ident(s) => s,
|
||||
Tok::Kw(_) => "TYPE".to_string(),
|
||||
other => format!("{other:?}"),
|
||||
};
|
||||
if self.eat(&Tok::LBrack) {
|
||||
while !self.at_end() && !self.eat(&Tok::RBrack) {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn int_lit(&mut self) -> Option<i64> {
|
||||
match self.peek() {
|
||||
Tok::Int(n) => {
|
||||
let n = *n;
|
||||
self.advance();
|
||||
Some(n)
|
||||
}
|
||||
Tok::Minus => {
|
||||
self.advance();
|
||||
if let Tok::Int(n) = self.peek() {
|
||||
let n = -*n;
|
||||
self.advance();
|
||||
Some(n)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_decl(&mut self) {
|
||||
while !self.at_end() && !self.eat(&Tok::Semi) && !self.at_kw(K::EndVar) {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
fn sync_stmt(&mut self) {
|
||||
while !self.at_end() && !self.eat(&Tok::Semi) {
|
||||
// Stop at block terminators so recovery doesn't swallow structure.
|
||||
if matches!(
|
||||
self.peek(),
|
||||
Tok::Kw(
|
||||
K::EndIf
|
||||
| K::EndFor
|
||||
| K::EndWhile
|
||||
| K::EndCase
|
||||
| K::EndRepeat
|
||||
| K::EndProgram
|
||||
| K::EndFunction
|
||||
| K::EndFunctionBlock
|
||||
| K::Else
|
||||
| K::Elsif
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
|
||||
// ── statements ─────────────────────────────────────────────────
|
||||
fn parse_stmt(&mut self) -> Option<Stmt> {
|
||||
let line = self.line();
|
||||
match self.peek().clone() {
|
||||
Tok::Semi => {
|
||||
self.advance();
|
||||
None
|
||||
}
|
||||
Tok::Kw(K::If) => self.parse_if(),
|
||||
Tok::Kw(K::Case) => self.parse_case(),
|
||||
Tok::Kw(K::For) => self.parse_for(),
|
||||
Tok::Kw(K::While) => self.parse_while(),
|
||||
Tok::Kw(K::Repeat) => self.parse_repeat(),
|
||||
Tok::Kw(K::Return) => {
|
||||
self.advance();
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::Return { line })
|
||||
}
|
||||
Tok::Kw(K::Exit) => {
|
||||
self.advance();
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::Exit { line })
|
||||
}
|
||||
Tok::Kw(K::Jmp) => {
|
||||
self.advance();
|
||||
let label = self.ident().unwrap_or_default();
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::Jump { label, line })
|
||||
}
|
||||
Tok::Ident(name) => {
|
||||
// Could be `label:`, `call(...)`, or an assignment.
|
||||
// Lookahead: ident ':' (not ':=') → label.
|
||||
if matches!(
|
||||
self.toks.get(self.pos + 1).map(|t| &t.kind),
|
||||
Some(Tok::Colon)
|
||||
) && !matches!(self.toks.get(self.pos + 2).map(|t| &t.kind), Some(Tok::Eq))
|
||||
{
|
||||
self.advance(); // ident
|
||||
self.advance(); // ':'
|
||||
return Some(Stmt::Label { name, line });
|
||||
}
|
||||
let lhs = self.parse_expr();
|
||||
if self.eat(&Tok::Assign) {
|
||||
let value = self.parse_expr();
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::Assign {
|
||||
target: lhs,
|
||||
value,
|
||||
line,
|
||||
})
|
||||
} else if let Expr::Call { callee, args, .. } = lhs {
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::Call { callee, args, line })
|
||||
} else {
|
||||
// Bare expression / FB invocation without args recognized —
|
||||
// skip to the terminator.
|
||||
self.sync_stmt();
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.sync_stmt();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_block_until(&mut self, terms: &[K]) -> Vec<Stmt> {
|
||||
let mut body = Vec::new();
|
||||
while !self.at_end() && !terms.iter().any(|k| self.at_kw(*k)) {
|
||||
if let Some(s) = self.parse_stmt() {
|
||||
body.push(s);
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn parse_if(&mut self) -> Option<Stmt> {
|
||||
let line = self.line();
|
||||
self.eat_kw(K::If);
|
||||
let mut branches = Vec::new();
|
||||
let cond = self.parse_expr();
|
||||
self.eat_kw(K::Then);
|
||||
let body = self.parse_block_until(&[K::Elsif, K::Else, K::EndIf]);
|
||||
branches.push((cond, body));
|
||||
while self.eat_kw(K::Elsif) {
|
||||
let c = self.parse_expr();
|
||||
self.eat_kw(K::Then);
|
||||
let b = self.parse_block_until(&[K::Elsif, K::Else, K::EndIf]);
|
||||
branches.push((c, b));
|
||||
}
|
||||
let else_body = if self.eat_kw(K::Else) {
|
||||
Some(self.parse_block_until(&[K::EndIf]))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.eat_kw(K::EndIf);
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::If {
|
||||
branches,
|
||||
else_body,
|
||||
line,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_case(&mut self) -> Option<Stmt> {
|
||||
let line = self.line();
|
||||
self.eat_kw(K::Case);
|
||||
let selector = self.parse_expr();
|
||||
self.eat_kw(K::Of);
|
||||
let mut arms = Vec::new();
|
||||
let mut else_body = None;
|
||||
while !self.at_end() && !self.at_kw(K::EndCase) {
|
||||
if self.eat_kw(K::Else) {
|
||||
else_body = Some(self.parse_block_until(&[K::EndCase]));
|
||||
break;
|
||||
}
|
||||
// labels: expr {, expr} :
|
||||
let mut labels = vec![self.parse_expr()];
|
||||
while self.eat(&Tok::Comma) {
|
||||
labels.push(self.parse_expr());
|
||||
}
|
||||
self.eat(&Tok::Colon);
|
||||
let body = self.parse_block_until(&[K::EndCase, K::Else]);
|
||||
arms.push((labels, body));
|
||||
}
|
||||
self.eat_kw(K::EndCase);
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::Case {
|
||||
selector,
|
||||
arms,
|
||||
else_body,
|
||||
line,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_for(&mut self) -> Option<Stmt> {
|
||||
let line = self.line();
|
||||
self.eat_kw(K::For);
|
||||
let var = self.ident().unwrap_or_default();
|
||||
self.eat(&Tok::Assign);
|
||||
let from = self.parse_expr();
|
||||
self.eat_kw(K::To);
|
||||
let to = self.parse_expr();
|
||||
let by = if self.eat_kw(K::By) {
|
||||
Some(self.parse_expr())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.eat_kw(K::Do);
|
||||
let body = self.parse_block_until(&[K::EndFor]);
|
||||
self.eat_kw(K::EndFor);
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::For {
|
||||
var,
|
||||
from,
|
||||
to,
|
||||
by,
|
||||
body,
|
||||
line,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_while(&mut self) -> Option<Stmt> {
|
||||
let line = self.line();
|
||||
self.eat_kw(K::While);
|
||||
let cond = self.parse_expr();
|
||||
self.eat_kw(K::Do);
|
||||
let body = self.parse_block_until(&[K::EndWhile]);
|
||||
self.eat_kw(K::EndWhile);
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::While { cond, body, line })
|
||||
}
|
||||
|
||||
fn parse_repeat(&mut self) -> Option<Stmt> {
|
||||
let line = self.line();
|
||||
self.eat_kw(K::Repeat);
|
||||
let body = self.parse_block_until(&[K::Until, K::EndRepeat]);
|
||||
self.eat_kw(K::Until);
|
||||
let until = self.parse_expr();
|
||||
self.eat_kw(K::EndRepeat);
|
||||
self.eat(&Tok::Semi);
|
||||
Some(Stmt::Repeat { body, until, line })
|
||||
}
|
||||
|
||||
// ── expressions (precedence climbing) ──────────────────────────
|
||||
pub fn parse_expr(&mut self) -> Expr {
|
||||
self.parse_or()
|
||||
}
|
||||
|
||||
fn parse_or(&mut self) -> Expr {
|
||||
let mut lhs = self.parse_and();
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
Tok::Kw(K::Or) => BinOp::Or,
|
||||
Tok::Kw(K::Xor) => BinOp::Xor,
|
||||
_ => break,
|
||||
};
|
||||
let line = self.line();
|
||||
self.advance();
|
||||
let rhs = self.parse_and();
|
||||
lhs = Expr::Binary {
|
||||
op,
|
||||
lhs: Box::new(lhs),
|
||||
rhs: Box::new(rhs),
|
||||
line,
|
||||
};
|
||||
}
|
||||
lhs
|
||||
}
|
||||
|
||||
fn parse_and(&mut self) -> Expr {
|
||||
let mut lhs = self.parse_cmp();
|
||||
while matches!(self.peek(), Tok::Kw(K::And) | Tok::Amp) {
|
||||
let op = BinOp::And;
|
||||
let line = self.line();
|
||||
self.advance();
|
||||
let rhs = self.parse_cmp();
|
||||
lhs = Expr::Binary {
|
||||
op,
|
||||
lhs: Box::new(lhs),
|
||||
rhs: Box::new(rhs),
|
||||
line,
|
||||
};
|
||||
}
|
||||
lhs
|
||||
}
|
||||
|
||||
fn parse_cmp(&mut self) -> Expr {
|
||||
let mut lhs = self.parse_add();
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
Tok::Eq => BinOp::Eq,
|
||||
Tok::Ne => BinOp::Ne,
|
||||
Tok::Lt => BinOp::Lt,
|
||||
Tok::Le => BinOp::Le,
|
||||
Tok::Gt => BinOp::Gt,
|
||||
Tok::Ge => BinOp::Ge,
|
||||
_ => break,
|
||||
};
|
||||
let line = self.line();
|
||||
self.advance();
|
||||
let rhs = self.parse_add();
|
||||
lhs = Expr::Binary {
|
||||
op,
|
||||
lhs: Box::new(lhs),
|
||||
rhs: Box::new(rhs),
|
||||
line,
|
||||
};
|
||||
}
|
||||
lhs
|
||||
}
|
||||
|
||||
fn parse_add(&mut self) -> Expr {
|
||||
let mut lhs = self.parse_mul();
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
Tok::Plus => BinOp::Add,
|
||||
Tok::Minus => BinOp::Sub,
|
||||
_ => break,
|
||||
};
|
||||
let line = self.line();
|
||||
self.advance();
|
||||
let rhs = self.parse_mul();
|
||||
lhs = Expr::Binary {
|
||||
op,
|
||||
lhs: Box::new(lhs),
|
||||
rhs: Box::new(rhs),
|
||||
line,
|
||||
};
|
||||
}
|
||||
lhs
|
||||
}
|
||||
|
||||
fn parse_mul(&mut self) -> Expr {
|
||||
let mut lhs = self.parse_unary();
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
Tok::Star => BinOp::Mul,
|
||||
Tok::Slash => BinOp::Div,
|
||||
Tok::Kw(K::Mod) => BinOp::Mod,
|
||||
Tok::Power => BinOp::Pow,
|
||||
_ => break,
|
||||
};
|
||||
let line = self.line();
|
||||
self.advance();
|
||||
let rhs = self.parse_unary();
|
||||
lhs = Expr::Binary {
|
||||
op,
|
||||
lhs: Box::new(lhs),
|
||||
rhs: Box::new(rhs),
|
||||
line,
|
||||
};
|
||||
}
|
||||
lhs
|
||||
}
|
||||
|
||||
fn parse_unary(&mut self) -> Expr {
|
||||
let line = self.line();
|
||||
match self.peek() {
|
||||
Tok::Kw(K::Not) => {
|
||||
self.advance();
|
||||
Expr::Unary {
|
||||
op: UnOp::Not,
|
||||
expr: Box::new(self.parse_unary()),
|
||||
line,
|
||||
}
|
||||
}
|
||||
Tok::Minus => {
|
||||
self.advance();
|
||||
Expr::Unary {
|
||||
op: UnOp::Neg,
|
||||
expr: Box::new(self.parse_unary()),
|
||||
line,
|
||||
}
|
||||
}
|
||||
_ => self.parse_postfix(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_postfix(&mut self) -> Expr {
|
||||
let mut e = self.parse_primary();
|
||||
loop {
|
||||
let line = self.line();
|
||||
match self.peek() {
|
||||
Tok::LBrack => {
|
||||
self.advance();
|
||||
let index = self.parse_expr();
|
||||
self.eat(&Tok::RBrack);
|
||||
e = Expr::Index {
|
||||
base: Box::new(e),
|
||||
index: Box::new(index),
|
||||
line,
|
||||
};
|
||||
}
|
||||
Tok::Dot => {
|
||||
self.advance();
|
||||
let field = self.ident().unwrap_or_default();
|
||||
e = Expr::Member {
|
||||
base: Box::new(e),
|
||||
field,
|
||||
line,
|
||||
};
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
fn parse_primary(&mut self) -> Expr {
|
||||
let line = self.line();
|
||||
match self.advance() {
|
||||
Tok::Int(n) => Expr::Int(n, line),
|
||||
Tok::Real(r) => Expr::Real(r, line),
|
||||
Tok::Bool(b) => Expr::Bool(b, line),
|
||||
Tok::Str(s) => Expr::Str(s, line),
|
||||
Tok::Time(t) => Expr::Time(t, line),
|
||||
Tok::LParen => {
|
||||
let e = self.parse_expr();
|
||||
self.eat(&Tok::RParen);
|
||||
e
|
||||
}
|
||||
Tok::Ident(name) => {
|
||||
if self.eat(&Tok::LParen) {
|
||||
let args = self.parse_call_args();
|
||||
Expr::Call {
|
||||
callee: name,
|
||||
args,
|
||||
line,
|
||||
}
|
||||
} else {
|
||||
Expr::Ident(name, line)
|
||||
}
|
||||
}
|
||||
// Unrecognized start of expression — yield a placeholder identifier.
|
||||
_ => Expr::Ident(String::new(), line),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_call_args(&mut self) -> Vec<CallArg> {
|
||||
let mut args = Vec::new();
|
||||
if self.eat(&Tok::RParen) {
|
||||
return args;
|
||||
}
|
||||
loop {
|
||||
// Named arg: ident := expr (peek two tokens).
|
||||
if let Tok::Ident(name) = self.peek().clone() {
|
||||
if matches!(
|
||||
self.toks.get(self.pos + 1).map(|t| &t.kind),
|
||||
Some(Tok::Assign)
|
||||
) {
|
||||
self.advance(); // ident
|
||||
self.advance(); // :=
|
||||
let value = self.parse_expr();
|
||||
args.push(CallArg {
|
||||
name: Some(name),
|
||||
value,
|
||||
});
|
||||
if self.eat(&Tok::Comma) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let value = self.parse_expr();
|
||||
args.push(CallArg { name: None, value });
|
||||
if self.eat(&Tok::Comma) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
self.eat(&Tok::RParen);
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse ST source into its POUs.
|
||||
pub fn parse(src: &str) -> Vec<Pou> {
|
||||
let toks = super::lexer::lex(src);
|
||||
Parser::new(toks).parse_units()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
PROGRAM Main
|
||||
VAR
|
||||
idx : INT;
|
||||
pw : STRING := 'admin123';
|
||||
buf : ARRAY[0..9] OF INT;
|
||||
ok : BOOL := FALSE;
|
||||
END_VAR
|
||||
// a comment
|
||||
IF idx > 0 THEN
|
||||
buf[idx] := idx * 2;
|
||||
ELSE
|
||||
JMP done;
|
||||
END_IF;
|
||||
Comm(IP := '10.0.0.1', PORT := 502);
|
||||
done:
|
||||
ok := TRUE;
|
||||
END_PROGRAM
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_program_vars_and_body() {
|
||||
let pous = parse(SAMPLE);
|
||||
assert_eq!(pous.len(), 1, "one POU");
|
||||
let p = &pous[0];
|
||||
assert_eq!(p.name, "Main");
|
||||
assert_eq!(p.kind, PouKind::Program);
|
||||
// vars: idx, pw, buf, ok
|
||||
assert_eq!(p.vars.len(), 4);
|
||||
let pw = p.vars.iter().find(|v| v.name == "pw").expect("pw");
|
||||
assert!(matches!(&pw.init, Some(Expr::Str(s, _)) if s == "admin123"));
|
||||
let buf = p.vars.iter().find(|v| v.name == "buf").expect("buf");
|
||||
assert_eq!(buf.array_bounds, Some((0, 9)));
|
||||
// body has an IF, a Call, a Label, and an Assign
|
||||
assert!(p.body.iter().any(|s| matches!(s, Stmt::If { .. })));
|
||||
assert!(p
|
||||
.body
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stmt::Call { callee, .. } if callee == "Comm")));
|
||||
assert!(p
|
||||
.body
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stmt::Label { name, .. } if name == "done")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jmp_inside_if_is_captured() {
|
||||
let pous = parse(SAMPLE);
|
||||
let p = &pous[0];
|
||||
// find the IF, check its else branch has a JMP
|
||||
let has_jmp = p.body.iter().any(|s| match s {
|
||||
Stmt::If { else_body, .. } => else_body
|
||||
.as_ref()
|
||||
.map(|b| b.iter().any(|s| matches!(s, Stmt::Jump { .. })))
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
});
|
||||
assert!(has_jmp, "JMP should be parsed inside the ELSE branch");
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
//! PLCopen XML → Structured Text POUs.
|
||||
//!
|
||||
//! A PLCopen project stores each POU as `<pou name=".." pouType="..">` with an
|
||||
//! `<interface>` (typed variable sections) and a `<body>` in one of the IEC
|
||||
//! 61131-3 languages. We reconstruct an equivalent Structured-Text source for
|
||||
//! each POU (a `VAR` block from the interface + statements from the body) and run
|
||||
//! it through the ST parser, so raw `.st` files and PLCopen projects — textual or
|
||||
//! graphical — flow through one analysis path.
|
||||
//!
|
||||
//! Body languages:
|
||||
//! - **ST** — taken verbatim.
|
||||
//! - **FBD / LD** — the graphical network is translated to synthetic ST: blocks
|
||||
//! become calls (`TypeName(pin := arg, …)`), out-variables / coils become
|
||||
//! assignments, with input pins resolved by tracing connections. This lets the
|
||||
//! semantic rules see comm calls, hardcoded arguments and safety writes that
|
||||
//! live in graphical logic, not just in text.
|
||||
//! - **SFC** — the step/transition graph itself is skipped; the ST/FBD/LD bodies
|
||||
//! embedded in its actions and transitions are still translated.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use roxmltree::Node;
|
||||
|
||||
use super::ast::Pou;
|
||||
use super::parser;
|
||||
|
||||
/// Parse every POU out of a PLCopen XML document (ST, FBD or LD bodies).
|
||||
pub fn parse_plcopen(xml: &str) -> Vec<Pou> {
|
||||
let doc = match roxmltree::Document::parse(xml) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut pous = Vec::new();
|
||||
for pou in doc.descendants().filter(|n| n.has_tag_name("pou")) {
|
||||
let name = pou.attribute("name").unwrap_or("pou").to_string();
|
||||
let pou_type = pou.attribute("pouType").unwrap_or("program");
|
||||
|
||||
let Some(body) = reconstruct_body(pou) else {
|
||||
continue;
|
||||
};
|
||||
if body.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let var_block = build_var_block(pou);
|
||||
let kw = match pou_type.to_ascii_lowercase().as_str() {
|
||||
"function" => "FUNCTION",
|
||||
"functionblock" | "functionblocktype" => "FUNCTION_BLOCK",
|
||||
_ => "PROGRAM",
|
||||
};
|
||||
let synthetic = format!("{kw} {name}\n{var_block}{body}\nEND_{kw}\n");
|
||||
pous.extend(parser::parse(&synthetic));
|
||||
}
|
||||
pous
|
||||
}
|
||||
|
||||
/// Case-insensitive tag match (PLCopen uses `FBD`/`LD`/`ST`, CODESYS may vary).
|
||||
fn tag_is(n: &Node, name: &str) -> bool {
|
||||
n.tag_name().name().eq_ignore_ascii_case(name)
|
||||
}
|
||||
|
||||
/// Reconstruct a POU's body as Structured Text, whatever language it is written
|
||||
/// in. Concatenates every language body found under `<body>` (SFC actions and
|
||||
/// transitions carry their own ST/FBD/LD sub-bodies).
|
||||
fn reconstruct_body(pou: Node) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
for body in pou.descendants().filter(|n| tag_is(n, "body")) {
|
||||
for lang in body.children().filter(|n| n.is_element()) {
|
||||
let piece = match lang.tag_name().name().to_ascii_uppercase().as_str() {
|
||||
"ST" | "IL" => collect_text(lang),
|
||||
"FBD" | "LD" => translate_network(lang),
|
||||
_ => continue,
|
||||
};
|
||||
if !piece.trim().is_empty() {
|
||||
out.push_str(&piece);
|
||||
if !piece.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── graphical (FBD / LD) → synthetic ST ────────────────────────────────
|
||||
|
||||
/// Translate one FBD/LD network into ST statements: blocks → calls,
|
||||
/// out-variables and coils → assignments.
|
||||
fn translate_network(net: Node) -> String {
|
||||
let by_id = index_local_ids(net);
|
||||
let mut out = String::new();
|
||||
for el in net.children().filter(|n| n.is_element()) {
|
||||
let stmt = match el.tag_name().name().to_ascii_lowercase().as_str() {
|
||||
"block" => block_call(el, &by_id).map(|c| format!("{c};")),
|
||||
"outvariable" => out_assignment(el, &by_id),
|
||||
"coil" => coil_assignment(el, &by_id),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(s) = stmt {
|
||||
out.push_str(&s);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Index every element in a network by its `localId` so connections resolve.
|
||||
fn index_local_ids<'a, 'input>(net: Node<'a, 'input>) -> HashMap<String, Node<'a, 'input>> {
|
||||
net.descendants()
|
||||
.filter(|n| n.is_element())
|
||||
.filter_map(|n| n.attribute("localId").map(|id| (id.to_string(), n)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a call expression for a block: `TypeName(pin := arg, …)`.
|
||||
fn block_call(block: Node, by_id: &HashMap<String, Node>) -> Option<String> {
|
||||
let ty = block.attribute("typeName")?;
|
||||
let mut args = Vec::new();
|
||||
if let Some(inputs) = block.children().find(|n| tag_is(n, "inputVariables")) {
|
||||
for v in inputs.children().filter(|n| tag_is(n, "variable")) {
|
||||
let Some(expr) = input_expr(v, by_id, 0) else {
|
||||
continue;
|
||||
};
|
||||
match v.attribute("formalParameter") {
|
||||
Some(pin) if !pin.is_empty() => args.push(format!("{pin} := {expr}")),
|
||||
_ => args.push(expr),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(format!("{ty}({})", args.join(", ")))
|
||||
}
|
||||
|
||||
/// `target := <traced expression>;` for an FBD out-variable.
|
||||
fn out_assignment(outvar: Node, by_id: &HashMap<String, Node>) -> Option<String> {
|
||||
let target = expression_text(outvar)?;
|
||||
let value = input_expr(outvar, by_id, 0).unwrap_or_else(|| "0".to_string());
|
||||
Some(format!("{target} := {value};"))
|
||||
}
|
||||
|
||||
/// `coil := <traced rung expression>;` for an LD coil (negated → `NOT (…)`).
|
||||
fn coil_assignment(coil: Node, by_id: &HashMap<String, Node>) -> Option<String> {
|
||||
let target = child_text(coil, "variable")?;
|
||||
let rung = input_expr(coil, by_id, 0).unwrap_or_else(|| "TRUE".to_string());
|
||||
let negated = matches!(coil.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true"));
|
||||
let rhs = if negated {
|
||||
format!("NOT ({rung})")
|
||||
} else {
|
||||
rung
|
||||
};
|
||||
Some(format!("{target} := {rhs};"))
|
||||
}
|
||||
|
||||
/// Resolve the expression feeding `node`'s single input connection.
|
||||
fn input_expr(node: Node, by_id: &HashMap<String, Node>, depth: u8) -> Option<String> {
|
||||
let refid = ref_local_id(node)?;
|
||||
Some(expr_for(&refid, by_id, depth))
|
||||
}
|
||||
|
||||
/// Build the ST expression produced by the element with this `localId`.
|
||||
fn expr_for(local_id: &str, by_id: &HashMap<String, Node>, depth: u8) -> String {
|
||||
if depth > 24 {
|
||||
return "0".to_string();
|
||||
}
|
||||
let Some(node) = by_id.get(local_id) else {
|
||||
return format!("__net{local_id}");
|
||||
};
|
||||
match node.tag_name().name().to_ascii_lowercase().as_str() {
|
||||
"invariable" | "inoutvariable" => {
|
||||
expression_text(*node).unwrap_or_else(|| format!("__net{local_id}"))
|
||||
}
|
||||
// A block feeding another element: reference it by a synthetic result
|
||||
// name; the block is emitted as its own call statement, so we neither
|
||||
// duplicate the call nor lose it.
|
||||
"block" => format!("__blk{local_id}"),
|
||||
"contact" => {
|
||||
let var = child_text(*node, "variable").unwrap_or_else(|| "TRUE".to_string());
|
||||
let negated =
|
||||
matches!(node.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true"));
|
||||
let term = if negated { format!("NOT {var}") } else { var };
|
||||
match ref_local_id(*node) {
|
||||
Some(up) => {
|
||||
let upstream = expr_for(&up, by_id, depth + 1);
|
||||
if upstream == "TRUE" {
|
||||
term
|
||||
} else {
|
||||
format!("({upstream} AND {term})")
|
||||
}
|
||||
}
|
||||
None => term,
|
||||
}
|
||||
}
|
||||
"leftpowerrail" => "TRUE".to_string(),
|
||||
_ => format!("__net{local_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `refLocalId` of `node`'s first input connection, if any.
|
||||
fn ref_local_id(node: Node) -> Option<String> {
|
||||
node.descendants()
|
||||
.find(|n| tag_is(n, "connectionPointIn"))
|
||||
.and_then(|cpi| cpi.descendants().find(|n| tag_is(n, "connection")))
|
||||
.and_then(|c| c.attribute("refLocalId"))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Text of a node's `<expression>` child (variable name or literal).
|
||||
fn expression_text(node: Node) -> Option<String> {
|
||||
let e = node.children().find(|n| tag_is(n, "expression"))?;
|
||||
let t = collect_text(e).trim().to_string();
|
||||
if t.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Text of a named child element (e.g. `<variable>` of a contact/coil).
|
||||
fn child_text(node: Node, name: &str) -> Option<String> {
|
||||
let c = node.children().find(|n| tag_is(n, name))?;
|
||||
let t = collect_text(c).trim().to_string();
|
||||
if t.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// Concatenate the text of a node's descendant text nodes (bodies are often
|
||||
/// wrapped in `<xhtml>` and may contain multiple text runs). Only text nodes are
|
||||
/// gathered: an element's `.text()` would re-yield its first child's text, which
|
||||
/// (with the text node itself) would duplicate every value.
|
||||
fn collect_text(node: Node) -> String {
|
||||
node.descendants()
|
||||
.filter(|n| n.is_text())
|
||||
.filter_map(|n| n.text())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
/// Build an ST `VAR … END_VAR` block from a POU's `<interface>` variable
|
||||
/// sections, so declarations (types, initial values) reach the rules.
|
||||
fn build_var_block(pou: Node) -> String {
|
||||
let Some(interface) = pou.children().find(|n| n.has_tag_name("interface")) else {
|
||||
return String::new();
|
||||
};
|
||||
let mut out = String::from("VAR\n");
|
||||
let mut any = false;
|
||||
for container in interface.children().filter(|n| n.is_element()) {
|
||||
// localVars / inputVars / outputVars / inOutVars / tempVars / globalVars / externalVars
|
||||
if !container.tag_name().name().ends_with("Vars") {
|
||||
continue;
|
||||
}
|
||||
for var in container.children().filter(|n| n.has_tag_name("variable")) {
|
||||
let Some(vname) = var.attribute("name") else {
|
||||
continue;
|
||||
};
|
||||
let ty = var
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("type"))
|
||||
.map(type_name)
|
||||
.unwrap_or_else(|| "BOOL".to_string());
|
||||
let init = var
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("initialValue"))
|
||||
.and_then(initial_value);
|
||||
match init {
|
||||
Some(v) => out.push_str(&format!(" {vname} : {ty} := {v};\n")),
|
||||
None => out.push_str(&format!(" {vname} : {ty};\n")),
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
out.push_str("END_VAR\n");
|
||||
if any {
|
||||
out
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a PLCopen `<type>` element as an ST type string.
|
||||
fn type_name(type_node: Node) -> String {
|
||||
let Some(inner) = type_node.children().find(|n| n.is_element()) else {
|
||||
return "BOOL".to_string();
|
||||
};
|
||||
let tag = inner.tag_name().name();
|
||||
match tag {
|
||||
"derived" => inner.attribute("name").unwrap_or("DERIVED").to_string(),
|
||||
"array" => {
|
||||
let dim = inner.children().find(|n| n.has_tag_name("dimension"));
|
||||
let (lo, hi) = dim
|
||||
.map(|d| {
|
||||
(
|
||||
d.attribute("lower").unwrap_or("0").to_string(),
|
||||
d.attribute("upper").unwrap_or("0").to_string(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| ("0".to_string(), "0".to_string()));
|
||||
let base = inner
|
||||
.children()
|
||||
.find(|n| n.has_tag_name("baseType"))
|
||||
.map(type_name)
|
||||
.unwrap_or_else(|| "INT".to_string());
|
||||
format!("ARRAY[{lo}..{hi}] OF {base}")
|
||||
}
|
||||
"string" | "wstring" => "STRING".to_string(),
|
||||
// BOOL, INT, DINT, REAL, TIME, ... — the tag name is the ST type.
|
||||
other => other.to_ascii_uppercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract an initial value as an ST literal (quoting strings).
|
||||
fn initial_value(iv: Node) -> Option<String> {
|
||||
let simple = iv.descendants().find(|n| n.has_tag_name("simpleValue"))?;
|
||||
let raw = simple.attribute("value")?.trim().to_string();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Numbers / booleans / time literals pass through; everything else is a
|
||||
// string literal.
|
||||
let is_scalar = raw.eq_ignore_ascii_case("true")
|
||||
|| raw.eq_ignore_ascii_case("false")
|
||||
|| raw.starts_with(['T', 't', 'D', 'd']) && raw.contains('#')
|
||||
|| raw
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+');
|
||||
if is_scalar || raw.starts_with('\'') || raw.starts_with('"') {
|
||||
Some(raw)
|
||||
} else {
|
||||
Some(format!("'{}'", raw.replace('\'', "''")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_plcopen;
|
||||
use crate::pipeline::plc::rules;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn rule_ids(xml: &str) -> HashSet<&'static str> {
|
||||
parse_plcopen(xml)
|
||||
.iter()
|
||||
.flat_map(rules::analyze)
|
||||
.map(|h| h.rule_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A Ladder Diagram network: a rung (power rail → contact → coil) plus an
|
||||
/// insecure comm block. Coils/contacts translate to assignments; the block
|
||||
/// translates to a call so the port rule fires.
|
||||
#[test]
|
||||
fn ld_coil_and_block_translate_and_are_analysed() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
|
||||
<types><pous>
|
||||
<pou name="Rung" pouType="program">
|
||||
<interface><localVars>
|
||||
<variable name="Motor"><type><BOOL/></type></variable>
|
||||
</localVars></interface>
|
||||
<body><LD>
|
||||
<leftPowerRail localId="0"/>
|
||||
<contact localId="1"><variable>Start</variable>
|
||||
<connectionPointIn><connection refLocalId="0"/></connectionPointIn></contact>
|
||||
<coil localId="2"><variable>Motor</variable>
|
||||
<connectionPointIn><connection refLocalId="1"/></connectionPointIn></coil>
|
||||
<inVariable localId="3"><expression>21</expression></inVariable>
|
||||
<inVariable localId="4"><expression>FALSE</expression></inVariable>
|
||||
<block localId="10" typeName="Ftp_Send">
|
||||
<inputVariables>
|
||||
<variable formalParameter="PORT">
|
||||
<connectionPointIn><connection refLocalId="3"/></connectionPointIn></variable>
|
||||
<variable formalParameter="ENCRYPT">
|
||||
<connectionPointIn><connection refLocalId="4"/></connectionPointIn></variable>
|
||||
</inputVariables>
|
||||
</block>
|
||||
</LD></body>
|
||||
</pou>
|
||||
</pous></types>
|
||||
</project>"#;
|
||||
let ids = rule_ids(xml);
|
||||
// Ftp_Send(PORT := 21, ENCRYPT := FALSE) — port 21 is an insecure protocol.
|
||||
assert!(
|
||||
ids.contains("plc-insecure-protocol-port"),
|
||||
"LD block should flag port 21; got {ids:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Doubled-text regression: a graphical expression must be extracted once,
|
||||
/// so literals like `502` and `FALSE` stay intact (not `502502`/`FALSEFALSE`).
|
||||
#[test]
|
||||
fn graphical_expression_text_is_not_duplicated() {
|
||||
let xml = r#"<?xml version="1.0"?>
|
||||
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
|
||||
<types><pous>
|
||||
<pou name="Comm" pouType="program">
|
||||
<body><FBD>
|
||||
<inVariable localId="1"><expression>502</expression></inVariable>
|
||||
<inVariable localId="2"><expression>FALSE</expression></inVariable>
|
||||
<block localId="10" typeName="Modbus_TCP_Master">
|
||||
<inputVariables>
|
||||
<variable formalParameter="PORT">
|
||||
<connectionPointIn><connection refLocalId="1"/></connectionPointIn></variable>
|
||||
<variable formalParameter="AUTH">
|
||||
<connectionPointIn><connection refLocalId="2"/></connectionPointIn></variable>
|
||||
</inputVariables>
|
||||
</block>
|
||||
</FBD></body>
|
||||
</pou>
|
||||
</pous></types>
|
||||
</project>"#;
|
||||
let ids = rule_ids(xml);
|
||||
assert!(ids.contains("plc-insecure-protocol-port")); // PORT := 502 (not 502502)
|
||||
assert!(ids.contains("plc-insecure-comm")); // AUTH := FALSE (not FALSEFALSE)
|
||||
}
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
//! Semantic control-logic security rules over the Structured Text AST.
|
||||
//!
|
||||
//! Each rule walks the parsed [`Pou`] and yields [`RuleHit`]s the scanner turns
|
||||
//! into findings. Rules reason over structure (declarations, assignments, calls,
|
||||
//! array accesses, division, jumps) rather than raw text, so they see through
|
||||
//! formatting and comments.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use compliance_core::models::Severity;
|
||||
|
||||
use super::ast::*;
|
||||
|
||||
/// One rule match within a POU.
|
||||
pub struct RuleHit {
|
||||
pub line: u32,
|
||||
pub severity: Severity,
|
||||
pub rule_id: &'static str,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub cwe: Option<&'static str>,
|
||||
pub remediation: &'static str,
|
||||
}
|
||||
|
||||
/// Run every rule over a POU.
|
||||
pub fn analyze(pou: &Pou) -> Vec<RuleHit> {
|
||||
let mut hits = Vec::new();
|
||||
let ctx = Ctx::build(pou);
|
||||
|
||||
// Declaration-level rules.
|
||||
for v in &pou.vars {
|
||||
if let Some(init) = &v.init {
|
||||
check_credential_binding(&v.name, init, &pou.name, &mut hits);
|
||||
check_default_password(init, &v.name, &pou.name, &mut hits);
|
||||
}
|
||||
}
|
||||
|
||||
// Body walk.
|
||||
walk(&pou.body, pou, &ctx, &GuardSet::default(), &mut hits);
|
||||
hits
|
||||
}
|
||||
|
||||
/// Per-POU context precomputed once.
|
||||
struct Ctx {
|
||||
/// Names declared in VAR_INPUT (untrusted / externally driven).
|
||||
input_vars: HashSet<String>,
|
||||
/// Array variable name → declared (lo, hi) bounds.
|
||||
arrays: HashMap<String, (i64, i64)>,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn build(pou: &Pou) -> Self {
|
||||
let mut input_vars = HashSet::new();
|
||||
let mut arrays = HashMap::new();
|
||||
for v in &pou.vars {
|
||||
if v.section == VarSection::Input {
|
||||
input_vars.insert(v.name.to_ascii_lowercase());
|
||||
}
|
||||
if let Some(b) = v.array_bounds {
|
||||
arrays.insert(v.name.to_ascii_lowercase(), b);
|
||||
}
|
||||
}
|
||||
Self { input_vars, arrays }
|
||||
}
|
||||
}
|
||||
|
||||
/// Variables proven non-zero on the current control-flow path (from enclosing
|
||||
/// `IF`/`WHILE` conditions), so guarded divisions aren't false-flagged.
|
||||
#[derive(Default, Clone)]
|
||||
struct GuardSet {
|
||||
nonzero: HashSet<String>,
|
||||
}
|
||||
|
||||
impl GuardSet {
|
||||
fn with(&self, names: Vec<String>) -> Self {
|
||||
let mut g = self.clone();
|
||||
g.nonzero.extend(names);
|
||||
g
|
||||
}
|
||||
fn is_nonzero(&self, name: &str) -> bool {
|
||||
self.nonzero.contains(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Variable names a condition proves non-zero (`v <> 0`, `v > 0`, `v >= 1`,
|
||||
/// `v < 0`, and conjunctions thereof).
|
||||
fn guards_from_cond(cond: &Expr) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
collect_nonzero(cond, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_nonzero(e: &Expr, out: &mut Vec<String>) {
|
||||
let Expr::Binary { op, lhs, rhs, .. } = e else {
|
||||
return;
|
||||
};
|
||||
let is_zero = |x: &Expr| {
|
||||
matches!(x, Expr::Int(0, _)) || matches!(x, Expr::Real(r, _) if r.abs() < f64::EPSILON)
|
||||
};
|
||||
let int_of = |x: &Expr| match x {
|
||||
Expr::Int(n, _) => Some(*n),
|
||||
_ => None,
|
||||
};
|
||||
match op {
|
||||
BinOp::And => {
|
||||
collect_nonzero(lhs, out);
|
||||
collect_nonzero(rhs, out);
|
||||
}
|
||||
BinOp::Ne => {
|
||||
if let (Some(v), true) = (lhs.as_ident(), is_zero(rhs)) {
|
||||
out.push(v.to_ascii_lowercase());
|
||||
}
|
||||
if let (true, Some(v)) = (is_zero(lhs), rhs.as_ident()) {
|
||||
out.push(v.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
BinOp::Gt | BinOp::Lt => {
|
||||
// v > 0 or v < 0
|
||||
if let (Some(v), true) = (lhs.as_ident(), is_zero(rhs)) {
|
||||
out.push(v.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
BinOp::Ge => {
|
||||
// v >= n, n >= 1
|
||||
if let (Some(v), Some(n)) = (lhs.as_ident(), int_of(rhs)) {
|
||||
if n >= 1 {
|
||||
out.push(v.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the walker ─────────────────────────────────────────────────────
|
||||
fn walk(stmts: &[Stmt], pou: &Pou, ctx: &Ctx, guards: &GuardSet, hits: &mut Vec<RuleHit>) {
|
||||
for s in stmts {
|
||||
match s {
|
||||
Stmt::Assign {
|
||||
target,
|
||||
value,
|
||||
line,
|
||||
} => {
|
||||
check_safety_bypass(target, value, *line, &pou.name, hits);
|
||||
// A string bound to a secret-looking target is a credential.
|
||||
if let Some(name) = flatten_ident(target) {
|
||||
check_credential_binding(&name, value, &pou.name, hits);
|
||||
check_default_password(value, &name, &pou.name, hits);
|
||||
}
|
||||
walk_expr(target, pou, ctx, guards, hits);
|
||||
walk_expr(value, pou, ctx, guards, hits);
|
||||
}
|
||||
Stmt::Call { callee, args, line } => {
|
||||
check_insecure_comm(callee, args, *line, &pou.name, hits);
|
||||
check_credentials_in_call(callee, args, *line, &pou.name, hits);
|
||||
for a in args {
|
||||
walk_expr(&a.value, pou, ctx, guards, hits);
|
||||
}
|
||||
}
|
||||
Stmt::Jump { label, line } => hits.push(RuleHit {
|
||||
line: *line,
|
||||
severity: Severity::Medium,
|
||||
rule_id: "plc-unstructured-jump",
|
||||
title: "Unstructured jump (JMP) in control logic".to_string(),
|
||||
description: format!(
|
||||
"POU `{}` uses `JMP {label}`. Unstructured jumps make control flow hard to \
|
||||
verify and can bypass safety interlocks or leave outputs in an undefined \
|
||||
state on unexpected paths.",
|
||||
pou.name
|
||||
),
|
||||
cwe: Some("CWE-691"),
|
||||
remediation: "Replace JMP with structured constructs (IF/CASE/loops); reserve \
|
||||
jumps for well-reviewed state machines only.",
|
||||
}),
|
||||
Stmt::If {
|
||||
branches,
|
||||
else_body,
|
||||
..
|
||||
} => {
|
||||
for (cond, body) in branches {
|
||||
walk_expr(cond, pou, ctx, guards, hits);
|
||||
let child = guards.with(guards_from_cond(cond));
|
||||
walk(body, pou, ctx, &child, hits);
|
||||
}
|
||||
if let Some(b) = else_body {
|
||||
walk(b, pou, ctx, guards, hits);
|
||||
}
|
||||
}
|
||||
Stmt::Case {
|
||||
selector,
|
||||
arms,
|
||||
else_body,
|
||||
..
|
||||
} => {
|
||||
walk_expr(selector, pou, ctx, guards, hits);
|
||||
for (labels, body) in arms {
|
||||
for l in labels {
|
||||
walk_expr(l, pou, ctx, guards, hits);
|
||||
}
|
||||
walk(body, pou, ctx, guards, hits);
|
||||
}
|
||||
if let Some(b) = else_body {
|
||||
walk(b, pou, ctx, guards, hits);
|
||||
}
|
||||
}
|
||||
Stmt::For {
|
||||
from, to, by, body, ..
|
||||
} => {
|
||||
walk_expr(from, pou, ctx, guards, hits);
|
||||
walk_expr(to, pou, ctx, guards, hits);
|
||||
if let Some(b) = by {
|
||||
walk_expr(b, pou, ctx, guards, hits);
|
||||
}
|
||||
walk(body, pou, ctx, guards, hits);
|
||||
}
|
||||
Stmt::While { cond, body, .. } => {
|
||||
walk_expr(cond, pou, ctx, guards, hits);
|
||||
let child = guards.with(guards_from_cond(cond));
|
||||
walk(body, pou, ctx, &child, hits);
|
||||
}
|
||||
Stmt::Repeat { body, until, .. } => {
|
||||
walk(body, pou, ctx, guards, hits);
|
||||
walk_expr(until, pou, ctx, guards, hits);
|
||||
}
|
||||
Stmt::Return { .. } | Stmt::Exit { .. } | Stmt::Label { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_expr(e: &Expr, pou: &Pou, ctx: &Ctx, guards: &GuardSet, hits: &mut Vec<RuleHit>) {
|
||||
match e {
|
||||
Expr::Index { base, index, line } => {
|
||||
check_array_bounds(base, index, *line, ctx, &pou.name, hits);
|
||||
walk_expr(base, pou, ctx, guards, hits);
|
||||
walk_expr(index, pou, ctx, guards, hits);
|
||||
}
|
||||
Expr::Binary { op, lhs, rhs, line } => {
|
||||
if matches!(op, BinOp::Div | BinOp::Mod) {
|
||||
check_division(rhs, *line, &pou.name, guards, hits);
|
||||
}
|
||||
walk_expr(lhs, pou, ctx, guards, hits);
|
||||
walk_expr(rhs, pou, ctx, guards, hits);
|
||||
}
|
||||
Expr::Unary { expr, .. } => walk_expr(expr, pou, ctx, guards, hits),
|
||||
Expr::Member { base, .. } => walk_expr(base, pou, ctx, guards, hits),
|
||||
Expr::Call { args, .. } => {
|
||||
for a in args {
|
||||
walk_expr(&a.value, pou, ctx, guards, hits);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── individual rules ───────────────────────────────────────────────
|
||||
const SECRET_HINTS: &[&str] = &[
|
||||
"password",
|
||||
"passwd",
|
||||
"pwd",
|
||||
"secret",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"token",
|
||||
"credential",
|
||||
"privkey",
|
||||
"private_key",
|
||||
"passphrase",
|
||||
];
|
||||
|
||||
const DEFAULT_PASSWORDS: &[&str] = &[
|
||||
"admin",
|
||||
"administrator",
|
||||
"password",
|
||||
"passwd",
|
||||
"1234",
|
||||
"12345",
|
||||
"123456",
|
||||
"0000",
|
||||
"1111",
|
||||
"root",
|
||||
"default",
|
||||
"admin123",
|
||||
"changeme",
|
||||
"letmein",
|
||||
"guest",
|
||||
"user",
|
||||
"system",
|
||||
"plc",
|
||||
"codesys",
|
||||
];
|
||||
|
||||
const COMM_FB_HINTS: &[&str] = &[
|
||||
"modbus", "tcp", "udp", "socket", "mqtt", "opcua", "opc_ua", "ethernet", "ethip", "enip",
|
||||
"dnp3", "ftp", "telnet", "http", "send", "connect", "sock", "comm", "profinet", "s7",
|
||||
];
|
||||
|
||||
/// Insecure cleartext service ports.
|
||||
const INSECURE_PORTS: &[i64] = &[21, 23, 80, 502, 20000, 44818, 102];
|
||||
|
||||
fn check_credential_binding(var_name: &str, value: &Expr, pou: &str, hits: &mut Vec<RuleHit>) {
|
||||
let name = var_name.to_ascii_lowercase();
|
||||
let looks_secret = SECRET_HINTS.iter().any(|h| name.contains(h));
|
||||
if looks_secret {
|
||||
if let Expr::Str(s, line) = value {
|
||||
if !s.is_empty() {
|
||||
hits.push(RuleHit {
|
||||
line: *line,
|
||||
severity: Severity::High,
|
||||
rule_id: "plc-hardcoded-credential",
|
||||
title: "Hardcoded credential in PLC program".to_string(),
|
||||
description: format!(
|
||||
"POU `{pou}` binds a hardcoded secret to `{var_name}`. Credentials \
|
||||
embedded in control logic are extracted trivially from a project export \
|
||||
or a firmware dump and cannot be rotated without a redeploy."
|
||||
),
|
||||
cwe: Some("CWE-798"),
|
||||
remediation: "Store secrets outside the program (secure parameter store / \
|
||||
operator-entered, retained-but-protected memory); never commit \
|
||||
them to the POU.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_default_password(value: &Expr, var_name: &str, pou: &str, hits: &mut Vec<RuleHit>) {
|
||||
if let Expr::Str(s, line) = value {
|
||||
let lower = s.to_ascii_lowercase();
|
||||
if DEFAULT_PASSWORDS.contains(&lower.as_str()) {
|
||||
hits.push(RuleHit {
|
||||
line: *line,
|
||||
severity: Severity::Critical,
|
||||
rule_id: "plc-default-password",
|
||||
title: "Default/weak password in PLC program".to_string(),
|
||||
description: format!(
|
||||
"POU `{pou}` uses the well-known default/weak password `{s}` (bound to \
|
||||
`{var_name}`). Default PLC credentials are the first thing an attacker tries."
|
||||
),
|
||||
cwe: Some("CWE-1393"),
|
||||
remediation:
|
||||
"Require a strong, unique, operator-set password; block commissioning \
|
||||
until the default is changed.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_credentials_in_call(
|
||||
callee: &str,
|
||||
args: &[CallArg],
|
||||
line: u32,
|
||||
pou: &str,
|
||||
hits: &mut Vec<RuleHit>,
|
||||
) {
|
||||
for a in args {
|
||||
if let Some(name) = &a.name {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if SECRET_HINTS.iter().any(|h| n.contains(h)) {
|
||||
if let Expr::Str(s, l) = &a.value {
|
||||
if !s.is_empty() {
|
||||
hits.push(RuleHit {
|
||||
line: *l,
|
||||
severity: Severity::High,
|
||||
rule_id: "plc-hardcoded-credential",
|
||||
title: "Hardcoded credential passed to a function block".to_string(),
|
||||
description: format!(
|
||||
"POU `{pou}` passes a hardcoded secret as `{name}` to `{callee}`."
|
||||
),
|
||||
cwe: Some("CWE-798"),
|
||||
remediation: "Supply credentials from protected configuration at \
|
||||
runtime, not as a literal argument.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = line;
|
||||
}
|
||||
|
||||
fn check_safety_bypass(target: &Expr, value: &Expr, line: u32, pou: &str, hits: &mut Vec<RuleHit>) {
|
||||
let Some(name) = flatten_ident(target) else {
|
||||
return;
|
||||
};
|
||||
let n = name.to_ascii_lowercase();
|
||||
let safety = [
|
||||
"safety",
|
||||
"estop",
|
||||
"e_stop",
|
||||
"emergency",
|
||||
"interlock",
|
||||
"guard",
|
||||
"permit",
|
||||
]
|
||||
.iter()
|
||||
.any(|h| n.contains(h));
|
||||
let watchdog = n.contains("watchdog") || n.contains("wdt");
|
||||
// A safety enable / interlock / watchdog signal driven to FALSE or 0 in
|
||||
// application logic is a bypass (e.g. `Safety_Enable := FALSE`, `Watchdog_Kick := 0`).
|
||||
let disabling = matches!(value, Expr::Bool(false, _)) || matches!(value, Expr::Int(0, _));
|
||||
if (safety || watchdog) && disabling {
|
||||
hits.push(RuleHit {
|
||||
line,
|
||||
severity: Severity::Critical,
|
||||
rule_id: "plc-safety-bypass",
|
||||
title: "Safety interlock / watchdog disabled in logic".to_string(),
|
||||
description: format!(
|
||||
"POU `{pou}` disables a safety-related signal (`{name}`) in program logic. \
|
||||
Bypassing interlocks or watchdogs in code defeats the plant's protective \
|
||||
functions and is a direct hazard."
|
||||
),
|
||||
cwe: Some("CWE-1384"),
|
||||
remediation: "Never disable safety functions from application logic; safety must be \
|
||||
handled by a certified safety controller / hard-wired circuit.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn check_array_bounds(
|
||||
base: &Expr,
|
||||
index: &Expr,
|
||||
line: u32,
|
||||
ctx: &Ctx,
|
||||
pou: &str,
|
||||
hits: &mut Vec<RuleHit>,
|
||||
) {
|
||||
// Only reason about arrays we know the bounds of.
|
||||
let Some(arr_name) = base.as_ident() else {
|
||||
return;
|
||||
};
|
||||
if !ctx.arrays.contains_key(&arr_name.to_ascii_lowercase()) {
|
||||
return;
|
||||
}
|
||||
// Index by an untrusted input variable → potential out-of-bounds access.
|
||||
if let Some(idx_name) = index.as_ident() {
|
||||
if ctx.input_vars.contains(&idx_name.to_ascii_lowercase()) {
|
||||
hits.push(RuleHit {
|
||||
line,
|
||||
severity: Severity::High,
|
||||
rule_id: "plc-array-unchecked-index",
|
||||
title: "Array indexed by unvalidated input".to_string(),
|
||||
description: format!(
|
||||
"POU `{pou}` indexes array `{arr_name}` with the input variable `{idx_name}` \
|
||||
without a validated bounds check. An out-of-range index corrupts adjacent \
|
||||
memory or faults the PLC (loss of control)."
|
||||
),
|
||||
cwe: Some("CWE-129"),
|
||||
remediation: "Clamp or validate the index against the array bounds (e.g. \
|
||||
`LIMIT`/explicit `IF idx >= lo AND idx <= hi`) before the access.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_division(
|
||||
divisor: &Expr,
|
||||
line: u32,
|
||||
pou: &str,
|
||||
guards: &GuardSet,
|
||||
hits: &mut Vec<RuleHit>,
|
||||
) {
|
||||
// A divisor proven non-zero by an enclosing guard is safe.
|
||||
if let Expr::Ident(name, _) = divisor {
|
||||
if guards.is_nonzero(&name.to_ascii_lowercase()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Flag division by a variable (could be zero); nonzero literals are fine.
|
||||
let risky = matches!(
|
||||
divisor,
|
||||
Expr::Ident(_, _) | Expr::Member { .. } | Expr::Index { .. } | Expr::Int(0, _)
|
||||
);
|
||||
if risky {
|
||||
hits.push(RuleHit {
|
||||
line,
|
||||
severity: Severity::Medium,
|
||||
rule_id: "plc-division-by-zero",
|
||||
title: "Division by a variable without a zero-guard".to_string(),
|
||||
description: format!(
|
||||
"POU `{pou}` divides by a variable that is not proven non-zero. A zero divisor \
|
||||
raises a PLC exception and can halt the scan cycle (denial of control)."
|
||||
),
|
||||
cwe: Some("CWE-369"),
|
||||
remediation: "Guard the divisor (`IF d <> 0 THEN …`) or use a safe-divide helper that \
|
||||
returns a defined value for a zero denominator.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn check_insecure_comm(
|
||||
callee: &str,
|
||||
args: &[CallArg],
|
||||
line: u32,
|
||||
pou: &str,
|
||||
hits: &mut Vec<RuleHit>,
|
||||
) {
|
||||
let c = callee.to_ascii_lowercase();
|
||||
let is_comm = COMM_FB_HINTS.iter().any(|h| c.contains(h));
|
||||
if !is_comm {
|
||||
return;
|
||||
}
|
||||
// Auth/encryption explicitly disabled.
|
||||
for a in args {
|
||||
if let Some(name) = &a.name {
|
||||
let n = name.to_ascii_lowercase();
|
||||
let security_flag = ["auth", "secure", "encrypt", "tls", "ssl", "authentication"]
|
||||
.iter()
|
||||
.any(|h| n.contains(h));
|
||||
if security_flag && matches!(a.value, Expr::Bool(false, _)) {
|
||||
hits.push(RuleHit {
|
||||
line,
|
||||
severity: Severity::High,
|
||||
rule_id: "plc-insecure-comm",
|
||||
title: "Network communication with security disabled".to_string(),
|
||||
description: format!(
|
||||
"POU `{pou}` calls `{callee}` with `{name} := FALSE`, disabling \
|
||||
authentication/encryption on an industrial network link."
|
||||
),
|
||||
cwe: Some("CWE-319"),
|
||||
remediation: "Enable authentication + transport encryption; segment OT \
|
||||
networks and restrict the endpoint to trusted peers.",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Well-known cleartext port literal.
|
||||
if let Expr::Int(p, _) = &a.value {
|
||||
if INSECURE_PORTS.contains(p) {
|
||||
hits.push(RuleHit {
|
||||
line,
|
||||
severity: Severity::Medium,
|
||||
rule_id: "plc-insecure-protocol-port",
|
||||
title: "Cleartext industrial protocol port".to_string(),
|
||||
description: format!(
|
||||
"POU `{pou}` opens `{callee}` on port {p}, a well-known cleartext OT \
|
||||
protocol port with no built-in authentication or encryption."
|
||||
),
|
||||
cwe: Some("CWE-319"),
|
||||
remediation: "Front the protocol with a secure gateway/VPN, or use the \
|
||||
authenticated/encrypted variant; never expose it to untrusted \
|
||||
networks.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = line;
|
||||
}
|
||||
|
||||
/// The dotted/base identifier of an lvalue expression (`a`, `a.b` → `a.b`,
|
||||
/// `a[i]` → `a`), for name-based rules.
|
||||
fn flatten_ident(e: &Expr) -> Option<String> {
|
||||
match e {
|
||||
Expr::Ident(n, _) => Some(n.clone()),
|
||||
Expr::Member { base, field, .. } => flatten_ident(base).map(|b| format!("{b}.{field}")),
|
||||
Expr::Index { base, .. } => flatten_ident(base),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pipeline::plc::parser;
|
||||
|
||||
const VULN: &str = r#"
|
||||
FUNCTION_BLOCK CommCtrl
|
||||
VAR_INPUT
|
||||
cmdIndex : INT;
|
||||
END_VAR
|
||||
VAR
|
||||
Password : STRING := 'admin123';
|
||||
buffer : ARRAY[0..15] OF INT;
|
||||
Safety_Enable : BOOL := TRUE;
|
||||
divisor : INT;
|
||||
result : INT;
|
||||
END_VAR
|
||||
Safety_Enable := FALSE;
|
||||
result := 100 / divisor;
|
||||
buffer[cmdIndex] := 1;
|
||||
Modbus_Connect(IP := '192.168.0.10', PORT := 502, AUTH := FALSE);
|
||||
IF cmdIndex > 100 THEN
|
||||
JMP fault;
|
||||
END_IF;
|
||||
fault:
|
||||
result := 0;
|
||||
END_FUNCTION_BLOCK
|
||||
"#;
|
||||
|
||||
fn rule_ids(src: &str) -> Vec<&'static str> {
|
||||
parser::parse(src)
|
||||
.iter()
|
||||
.flat_map(analyze)
|
||||
.map(|h| h.rule_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vulnerable_program_triggers_every_rule() {
|
||||
let ids = rule_ids(VULN);
|
||||
for expected in [
|
||||
"plc-hardcoded-credential",
|
||||
"plc-default-password",
|
||||
"plc-safety-bypass",
|
||||
"plc-division-by-zero",
|
||||
"plc-array-unchecked-index",
|
||||
"plc-insecure-comm",
|
||||
"plc-insecure-protocol-port",
|
||||
"plc-unstructured-jump",
|
||||
] {
|
||||
assert!(
|
||||
ids.contains(&expected),
|
||||
"expected rule {expected}, got {ids:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_program_has_no_findings() {
|
||||
let clean = r#"
|
||||
PROGRAM Clean
|
||||
VAR
|
||||
a : INT := 5;
|
||||
b : INT := 3;
|
||||
total : INT;
|
||||
END_VAR
|
||||
IF b <> 0 THEN
|
||||
total := a / b;
|
||||
END_IF;
|
||||
END_PROGRAM
|
||||
"#;
|
||||
assert!(rule_ids(clean).is_empty(), "clean program should be quiet");
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
//! Control-application dependency SBOM from a CODESYS `.projectarchive`.
|
||||
//!
|
||||
//! A `.projectarchive` is a ZIP that bundles the project plus its referenced
|
||||
//! libraries and the target runtime. Each referenced library is an entry whose
|
||||
//! path segment follows the CODESYS convention
|
||||
//! `Name, Major.Minor.Patch.Build (Company)` (e.g. `Standard, 3.5.18.0 (System)`,
|
||||
//! `CSV Utility SL, 1.9.0.0 (CODESYS)`); the runtime appears as a device-descriptor
|
||||
//! entry `CODESYS Control … <version> …`. We enumerate those entries — no binary
|
||||
//! parsing — and emit SBOM components tagged `pkg:codesys/…`, so the CVE pipeline
|
||||
//! can match them (the runtime `Cmp*` / `3SLicense` components carry real CODESYS
|
||||
//! CVEs).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use compliance_core::models::SbomEntry;
|
||||
|
||||
/// Collect the control-application SBOM from every `.projectarchive` reachable for
|
||||
/// a target: the ingested artifact file itself (an uploaded archive), plus any
|
||||
/// `*.projectarchive` committed inside the working tree — e.g. a git repo or an
|
||||
/// extracted source archive that ships the archive alongside its PLCopen XML / ST
|
||||
/// exports. Deduplicated by (name, version).
|
||||
pub fn collect_sbom(artifact_file: &Path, working_path: &Path, repo_id: &str) -> Vec<SbomEntry> {
|
||||
let mut archives: Vec<PathBuf> = Vec::new();
|
||||
if artifact_file.is_file() {
|
||||
archives.push(artifact_file.to_path_buf());
|
||||
}
|
||||
for entry in walkdir::WalkDir::new(working_path)
|
||||
.max_depth(8)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let p = entry.path();
|
||||
if entry.file_type().is_file()
|
||||
&& p.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.is_some_and(|x| x.eq_ignore_ascii_case("projectarchive"))
|
||||
{
|
||||
archives.push(p.to_path_buf());
|
||||
}
|
||||
}
|
||||
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for a in archives {
|
||||
for e in projectarchive_sbom(&a, repo_id) {
|
||||
if seen.insert((e.name.clone(), e.version.clone())) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract CODESYS library + runtime components from a `.projectarchive` (a zip).
|
||||
/// Best-effort: returns empty if the file is not a readable zip (e.g. a bare
|
||||
/// `.st`/`.xml` project, which carries no library manifest).
|
||||
pub fn projectarchive_sbom(archive: &Path, repo_id: &str) -> Vec<SbomEntry> {
|
||||
let Ok(file) = std::fs::File::open(archive) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(mut zip) = zip::ZipArchive::new(file) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
|
||||
let mut entries = Vec::new();
|
||||
for i in 0..zip.len() {
|
||||
let Ok(entry) = zip.by_index(i) else {
|
||||
continue;
|
||||
};
|
||||
// Entry paths use `\` (Windows-authored) and/or `/` separators; the
|
||||
// component id is one path segment.
|
||||
for seg in entry.name().split(['/', '\\']) {
|
||||
if let Some((name, version)) = parse_library(seg).or_else(|| parse_runtime(seg)) {
|
||||
if seen.insert((name.clone(), version.clone())) {
|
||||
let mut e = SbomEntry::new(
|
||||
repo_id.to_string(),
|
||||
name.clone(),
|
||||
version.clone(),
|
||||
"codesys".to_string(),
|
||||
);
|
||||
e.purl = Some(format!(
|
||||
"pkg:codesys/{}@{version}",
|
||||
name.replace(' ', "%20")
|
||||
));
|
||||
entries.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
/// `Name, X.Y.Z.W (Company)` → (name, version).
|
||||
fn parse_library(seg: &str) -> Option<(String, String)> {
|
||||
let seg = seg.trim();
|
||||
// Company is the trailing "(…)".
|
||||
let open = seg.rfind(" (")?;
|
||||
let rest = &seg[open + 2..];
|
||||
let close = rest.find(')')?;
|
||||
if rest[..close].trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let head = seg[..open].trim(); // "Name, X.Y.Z.W"
|
||||
let comma = head.rfind(", ")?;
|
||||
let name = head[..comma].trim().to_string();
|
||||
let version = head[comma + 2..].trim().to_string();
|
||||
if name.is_empty() || !is_dotted_version(&version) {
|
||||
return None;
|
||||
}
|
||||
Some((name, version))
|
||||
}
|
||||
|
||||
/// Device-descriptor entry `CODESYS Control … X.Y.Z.W …` → (runtime name, version).
|
||||
fn parse_runtime(seg: &str) -> Option<(String, String)> {
|
||||
let seg = seg.trim();
|
||||
if !seg.starts_with("CODESYS Control") {
|
||||
return None;
|
||||
}
|
||||
let version = seg
|
||||
.split_whitespace()
|
||||
.find(|t| is_dotted_version(t))?
|
||||
.to_string();
|
||||
// The runtime name is the first field, before the run of padding spaces that
|
||||
// precede the descriptor's numeric columns.
|
||||
let name = seg.split(" ").next().unwrap_or(seg).trim().to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((name, version))
|
||||
}
|
||||
|
||||
/// A dotted numeric version with at least 3 components (`3.5.18.0`, `4.17.0.0`).
|
||||
fn is_dotted_version(s: &str) -> bool {
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
parts.len() >= 3
|
||||
&& parts
|
||||
.iter()
|
||||
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
/// Build a synthetic `.projectarchive` (zip) mirroring the real CODESYS entry
|
||||
/// naming (verified against Proemion/codesys-examples): a native `.project`,
|
||||
/// referenced libraries as `Name, Version (Company)` segments, and a runtime
|
||||
/// device descriptor.
|
||||
fn synthetic_archive(dir: &Path) -> std::path::PathBuf {
|
||||
let path = dir.join("App.projectarchive");
|
||||
write_synthetic_archive(&path);
|
||||
path
|
||||
}
|
||||
|
||||
fn write_synthetic_archive(path: &Path) {
|
||||
let file = std::fs::File::create(path).expect("create");
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let opts: zip::write::SimpleFileOptions = Default::default();
|
||||
let names = [
|
||||
"App.project",
|
||||
r"{b0b5}\App.Device.Plc.compileinfo",
|
||||
r"{e179}\Standard, 3.5.18.0 (System) standard.compiled-library-v3",
|
||||
r"{e179}\Util, 3.5.21.0 (System) util.compiled-library-v3",
|
||||
r"{e179}\CSV Utility SL, 1.9.0.0 (CODESYS) csv utility sl.compiled-library-v3",
|
||||
r"{e179}\3SLicense, 3.5.20.0 (CODESYS) 3slicense.compiled-library-v3",
|
||||
r"{0c63}\CODESYS Control for Linux ARM SL 0000 0006 4.17.0.0 4096 .zip",
|
||||
];
|
||||
for n in names {
|
||||
zip.start_file(n, opts).expect("start");
|
||||
zip.write_all(b"x").expect("write");
|
||||
}
|
||||
zip.finish().expect("finish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_libraries_and_runtime_from_projectarchive() {
|
||||
let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tmp).expect("mkdir");
|
||||
let archive = synthetic_archive(&tmp);
|
||||
|
||||
let entries = projectarchive_sbom(&archive, "plc-target");
|
||||
let by_name: HashMap<&str, &SbomEntry> =
|
||||
entries.iter().map(|e| (e.name.as_str(), e)).collect();
|
||||
|
||||
// Libraries with their versions.
|
||||
assert_eq!(
|
||||
by_name.get("Standard").map(|e| e.version.as_str()),
|
||||
Some("3.5.18.0")
|
||||
);
|
||||
assert_eq!(
|
||||
by_name.get("Util").map(|e| e.version.as_str()),
|
||||
Some("3.5.21.0")
|
||||
);
|
||||
assert_eq!(
|
||||
by_name.get("CSV Utility SL").map(|e| e.version.as_str()),
|
||||
Some("1.9.0.0"),
|
||||
"multi-word library names must parse"
|
||||
);
|
||||
assert!(by_name.contains_key("3SLicense"));
|
||||
|
||||
// The runtime, from the device descriptor.
|
||||
assert_eq!(
|
||||
by_name
|
||||
.get("CODESYS Control for Linux ARM SL")
|
||||
.map(|e| e.version.as_str()),
|
||||
Some("4.17.0.0")
|
||||
);
|
||||
|
||||
// Every component is CODESYS-tagged with a purl the CVE pipeline can match,
|
||||
// and the native `.project` / compileinfo are not mistaken for components.
|
||||
for e in &entries {
|
||||
assert_eq!(e.package_manager, "codesys");
|
||||
assert!(e.purl.as_deref().unwrap_or("").starts_with("pkg:codesys/"));
|
||||
}
|
||||
assert!(!by_name.contains_key("App"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_sbom_finds_a_projectarchive_committed_in_a_git_tree() {
|
||||
let tmp = std::env::temp_dir().join(format!("cs-plc-collect-{}", uuid::Uuid::new_v4()));
|
||||
let src = tmp.join("clone/src");
|
||||
std::fs::create_dir_all(&src).expect("mkdir");
|
||||
// Simulate a git clone that commits the archive alongside its exports.
|
||||
write_synthetic_archive(&src.join("PumpStation.projectarchive"));
|
||||
|
||||
// The artifact "file" is a git URL (not a real file), so the SBOM must
|
||||
// come from walking the cloned tree.
|
||||
let entries = collect_sbom(Path::new("https://git.example/plc.git"), &tmp, "t");
|
||||
let names: std::collections::HashSet<&str> =
|
||||
entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains("Standard"),
|
||||
"found libs in the committed archive"
|
||||
);
|
||||
assert!(names.contains("CODESYS Control for Linux ARM SL"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_zip_file_yields_no_sbom() {
|
||||
let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-st-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tmp).expect("mkdir");
|
||||
let st = tmp.join("prog.st");
|
||||
std::fs::write(&st, "PROGRAM P\nVAR x : INT; END_VAR\nEND_PROGRAM\n").expect("write");
|
||||
assert!(projectarchive_sbom(&st, "t").is_empty());
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::pipeline::repo_view::RepoView;
|
||||
use compliance_core::models::*;
|
||||
|
||||
use super::dedup::compute_fingerprint;
|
||||
@@ -15,7 +14,7 @@ impl PipelineOrchestrator {
|
||||
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, pr_number))]
|
||||
pub async fn run_pr_review(
|
||||
&self,
|
||||
repo: &RepoView,
|
||||
repo: &TrackedRepository,
|
||||
repo_id: &str,
|
||||
pr_number: u64,
|
||||
base_sha: &str,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
//! `RepoView` — an internal, non-persisted view of a code target for the scan
|
||||
//! pipeline.
|
||||
//!
|
||||
//! It replaces the old persisted `TrackedRepository` model. The pipeline
|
||||
//! (SAST → SBOM → CVE → triage → issues → DAST, and PR review) only ever needs a
|
||||
//! flat bundle of git + issue-tracker + auth fields; those are projected from an
|
||||
//! [`OnboardedTarget`] and its code [`Artifact`] by [`RepoView::from_target`].
|
||||
//! Nothing here is written to Mongo — onboarded targets are the sole persisted
|
||||
//! entity.
|
||||
|
||||
use compliance_core::models::{Artifact, OnboardedTarget, TrackerType};
|
||||
|
||||
/// A flat, pipeline-facing view of a code target. Built from an onboarded
|
||||
/// target; never persisted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RepoView {
|
||||
/// The onboarded target's id (used as `repo_id` across findings/sbom/etc.).
|
||||
pub id: Option<mongodb::bson::oid::ObjectId>,
|
||||
pub name: String,
|
||||
pub git_url: String,
|
||||
pub default_branch: String,
|
||||
pub local_path: Option<String>,
|
||||
pub scan_schedule: Option<String>,
|
||||
pub webhook_enabled: bool,
|
||||
pub webhook_secret: Option<String>,
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
pub tracker_owner: Option<String>,
|
||||
pub tracker_repo: Option<String>,
|
||||
pub tracker_token: Option<String>,
|
||||
pub auth_token: Option<String>,
|
||||
pub auth_username: Option<String>,
|
||||
pub last_scanned_commit: Option<String>,
|
||||
pub findings_count: u32,
|
||||
}
|
||||
|
||||
impl RepoView {
|
||||
/// Project an onboarded target + its code artifact into a pipeline view.
|
||||
pub fn from_target(target: &OnboardedTarget, code: &Artifact) -> Self {
|
||||
let mut view = Self {
|
||||
id: target.id,
|
||||
name: target.name.clone(),
|
||||
git_url: code.source_ref.clone(),
|
||||
default_branch: "main".to_string(),
|
||||
local_path: None,
|
||||
scan_schedule: target.scan_schedule.clone(),
|
||||
webhook_enabled: target.webhook_enabled,
|
||||
webhook_secret: target.webhook_secret.clone(),
|
||||
tracker_type: None,
|
||||
tracker_owner: None,
|
||||
tracker_repo: None,
|
||||
tracker_token: None,
|
||||
auth_token: None,
|
||||
auth_username: None,
|
||||
last_scanned_commit: None,
|
||||
findings_count: target.findings_count,
|
||||
};
|
||||
if let Some(git) = &code.git {
|
||||
view.default_branch = git.default_branch.clone();
|
||||
view.last_scanned_commit = git.last_scanned_commit.clone();
|
||||
view.local_path = git.local_path.clone();
|
||||
}
|
||||
if let Some(auth) = &code.auth {
|
||||
view.auth_token = auth.secret.clone();
|
||||
view.auth_username = auth.username.clone();
|
||||
}
|
||||
if let Some(it) = &target.scan_config.issue_tracker {
|
||||
view.tracker_type = it.tracker_type.clone();
|
||||
view.tracker_owner = it.owner.clone();
|
||||
view.tracker_repo = it.repo.clone();
|
||||
view.tracker_token = it.token.clone();
|
||||
}
|
||||
view
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,8 @@ use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
use compliance_core::models::ScanTrigger;
|
||||
|
||||
use crate::agent::ComplianceAgent;
|
||||
use crate::database::Database;
|
||||
use crate::error::AgentError;
|
||||
|
||||
/// Default tenant the scheduler runs against when neither the tenant
|
||||
/// registry nor `SCHEDULER_TENANT_IDS` are configured. Matches the
|
||||
/// dev-injector default so a bare `cargo run` has the scheduler
|
||||
/// scanning whatever lives in `<prefix>_dev`.
|
||||
const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev";
|
||||
|
||||
/// Request timeout when fetching the live tenant list from the
|
||||
/// registry. Kept short — if the registry is slow we'd rather fall
|
||||
/// back to env-configured ids and finish the tick than block the
|
||||
/// scheduler loop.
|
||||
const REGISTRY_FETCH_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> {
|
||||
let sched = JobScheduler::new()
|
||||
.await
|
||||
@@ -31,14 +18,7 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
||||
let agent = scan_agent.clone();
|
||||
Box::pin(async move {
|
||||
tracing::info!("Scheduled scan triggered");
|
||||
let tenants = scheduler_tenants(&agent).await;
|
||||
tracing::debug!(
|
||||
tenant_count = tenants.len(),
|
||||
"Scheduled scan: tenants resolved"
|
||||
);
|
||||
for tenant_id in tenants {
|
||||
scan_all_repos(&agent, &tenant_id).await;
|
||||
}
|
||||
scan_all_repos(&agent).await;
|
||||
})
|
||||
})
|
||||
.map_err(|e| AgentError::Scheduler(format!("Failed to create scan job: {e}")))?;
|
||||
@@ -54,14 +34,7 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
||||
let agent = cve_agent.clone();
|
||||
Box::pin(async move {
|
||||
tracing::info!("CVE monitor triggered");
|
||||
let tenants = scheduler_tenants(&agent).await;
|
||||
tracing::debug!(
|
||||
tenant_count = tenants.len(),
|
||||
"CVE monitor: tenants resolved"
|
||||
);
|
||||
for tenant_id in tenants {
|
||||
monitor_cves(&agent, &tenant_id).await;
|
||||
}
|
||||
monitor_cves(&agent).await;
|
||||
})
|
||||
})
|
||||
.map_err(|e| AgentError::Scheduler(format!("Failed to create CVE monitor job: {e}")))?;
|
||||
@@ -75,14 +48,8 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
||||
.await
|
||||
.map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?;
|
||||
|
||||
let tenants = scheduler_tenants(agent).await;
|
||||
let source = if agent.config.tenant_registry_url.is_some() {
|
||||
"tenant-registry (env fallback)"
|
||||
} else {
|
||||
"env (SCHEDULER_TENANT_IDS)"
|
||||
};
|
||||
tracing::info!(
|
||||
"Scheduler started: scans='{}', CVE monitor='{}', tenant source={source}, tenants={tenants:?}",
|
||||
"Scheduler started: scans='{}', CVE monitor='{}'",
|
||||
agent.config.scan_schedule,
|
||||
agent.config.cve_monitor_schedule,
|
||||
);
|
||||
@@ -93,251 +60,48 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
||||
}
|
||||
}
|
||||
|
||||
/// Tenants the scheduler iterates each tick.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. **Tenant registry** at `agent.config.tenant_registry_url`
|
||||
/// (`GET /v1/tenants`). Fresh on every tick — picks up newly
|
||||
/// provisioned tenants without an agent restart.
|
||||
/// 2. **`SCHEDULER_TENANT_IDS`** env (comma-separated) — fallback when
|
||||
/// the registry is unreachable, the response is malformed, or no
|
||||
/// registry URL is configured.
|
||||
/// 3. **`DEFAULT_SCHEDULER_TENANT_ID`** (`"dev"`) — last-ditch fallback
|
||||
/// so the scheduler keeps doing something useful in dev.
|
||||
///
|
||||
/// We never panic out of this function — the scheduler must keep
|
||||
/// firing even if the registry is offline.
|
||||
async fn scheduler_tenants(agent: &ComplianceAgent) -> Vec<String> {
|
||||
if let Some(url) = agent.config.tenant_registry_url.as_deref() {
|
||||
match fetch_tenants_from_registry(&agent.http, url).await {
|
||||
Ok(v) if !v.is_empty() => return v,
|
||||
Ok(_) => {
|
||||
tracing::warn!("tenant-registry returned empty list; falling back to env");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
url = %url,
|
||||
error = %e,
|
||||
"tenant-registry fetch failed; falling back to env"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
tenants_from_env()
|
||||
}
|
||||
|
||||
fn tenants_from_env() -> Vec<String> {
|
||||
std::env::var("SCHEDULER_TENANT_IDS")
|
||||
.ok()
|
||||
.map(|s| {
|
||||
s.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()])
|
||||
}
|
||||
|
||||
/// Shape we accept from the registry. Liberal in what we accept:
|
||||
/// the registry can return any field shape as long as either `id` or
|
||||
/// `tenant_id` is present. Other fields are ignored.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RegistryTenant {
|
||||
#[serde(alias = "tenant_id")]
|
||||
id: String,
|
||||
/// Filter out non-running tenants if status is present. Missing
|
||||
/// status defaults to "active" so older registry deployments keep
|
||||
/// working.
|
||||
#[serde(default = "default_status")]
|
||||
status: String,
|
||||
}
|
||||
|
||||
fn default_status() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct RegistryListResponse {
|
||||
data: Vec<RegistryTenant>,
|
||||
}
|
||||
|
||||
async fn fetch_tenants_from_registry(
|
||||
http: &reqwest::Client,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let url = format!("{}/v1/tenants", base_url.trim_end_matches('/'));
|
||||
let resp = http
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(REGISTRY_FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("registry returned {}", resp.status()));
|
||||
}
|
||||
let body: RegistryListResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
Ok(filter_active(body.data))
|
||||
}
|
||||
|
||||
/// Frozen/Archived tenants don't need scheduled scans; the M7.1
|
||||
/// status gate would 402/410 anyway. Skip them so we don't waste
|
||||
/// cycles. Active / trial / demo / anything-else-unknown all run.
|
||||
fn filter_active(rows: Vec<RegistryTenant>) -> Vec<String> {
|
||||
rows.into_iter()
|
||||
.filter(|t| !matches!(t.status.as_str(), "frozen" | "archived"))
|
||||
.map(|t| t.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tenant(id: &str, status: &str) -> RegistryTenant {
|
||||
RegistryTenant {
|
||||
id: id.to_string(),
|
||||
status: status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_active_keeps_running_skips_frozen_archived() {
|
||||
let rows = vec![
|
||||
tenant("a", "active"),
|
||||
tenant("b", "trial"),
|
||||
tenant("c", "demo"),
|
||||
tenant("d", "frozen"),
|
||||
tenant("e", "archived"),
|
||||
tenant("f", "weird-but-not-known-dead"),
|
||||
];
|
||||
let out = filter_active(rows);
|
||||
assert_eq!(out, vec!["a", "b", "c", "f"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_registry_response_accepts_id_or_tenant_id() {
|
||||
let body = r#"{"data":[
|
||||
{"id":"a","status":"active"},
|
||||
{"tenant_id":"b","status":"trial"},
|
||||
{"id":"c"}
|
||||
]}"#;
|
||||
let parsed: RegistryListResponse = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(parsed.data.len(), 3);
|
||||
assert_eq!(parsed.data[0].id, "a");
|
||||
assert_eq!(parsed.data[1].id, "b");
|
||||
assert_eq!(parsed.data[2].id, "c");
|
||||
// Default status for the third entry should be "active"
|
||||
assert_eq!(parsed.data[2].status, "active");
|
||||
}
|
||||
|
||||
/// Combined into a single test: cargo runs tests in parallel and
|
||||
/// env vars are process-global, so two separate tests touching
|
||||
/// `SCHEDULER_TENANT_IDS` race each other. Doing both checks in
|
||||
/// one test keeps them in a deterministic order.
|
||||
#[test]
|
||||
fn tenants_from_env_resolution() {
|
||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
||||
assert_eq!(
|
||||
tenants_from_env(),
|
||||
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
|
||||
"unset → default"
|
||||
);
|
||||
|
||||
std::env::set_var("SCHEDULER_TENANT_IDS", "acme, globex ,,hello");
|
||||
let out = tenants_from_env();
|
||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec!["acme", "globex", "hello"],
|
||||
"splits + trims + drops empty"
|
||||
);
|
||||
|
||||
std::env::set_var("SCHEDULER_TENANT_IDS", "");
|
||||
let out = tenants_from_env();
|
||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
|
||||
"empty → default"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the per-tenant database. Logs and returns `None` on failure
|
||||
/// so the loop in the caller can continue with other tenants.
|
||||
async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option<Database> {
|
||||
match agent.db_pool.for_tenant_id(tenant_id).await {
|
||||
Ok(db) => Some(db),
|
||||
Err(e) => {
|
||||
tracing::error!("Scheduler: cannot open tenant database '{tenant_id}': {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn scan_all_repos(agent: &ComplianceAgent, tenant_id: &str) {
|
||||
async fn scan_all_repos(agent: &ComplianceAgent) {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let db = match tenant_db(agent, tenant_id).await {
|
||||
Some(db) => db,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let cursor = match db.onboarded_targets().find(doc! {}).await {
|
||||
let cursor = match agent.db.repositories().find(doc! {}).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list targets for tenant '{tenant_id}': {e}");
|
||||
tracing::error!("Failed to list repos for scheduled scan: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let targets: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await;
|
||||
let repos: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await;
|
||||
|
||||
for target in targets {
|
||||
let target_id = target.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
if let Err(e) = agent
|
||||
.run_target_scan(tenant_id, &target_id, ScanTrigger::Scheduled)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"Scheduled scan failed for {} (tenant '{tenant_id}'): {e}",
|
||||
target.name
|
||||
);
|
||||
for repo in repos {
|
||||
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
if let Err(e) = agent.run_scan(&repo_id, ScanTrigger::Scheduled).await {
|
||||
tracing::error!("Scheduled scan failed for {}: {e}", repo.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
|
||||
async fn monitor_cves(agent: &ComplianceAgent) {
|
||||
use compliance_core::models::notification::{parse_severity, CveNotification};
|
||||
use compliance_core::models::SbomEntry;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let db = match tenant_db(agent, tenant_id).await {
|
||||
Some(db) => db,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Fetch all SBOM entries grouped by repo
|
||||
let cursor = match db.sbom_entries().find(doc! {}).await {
|
||||
let cursor = match agent.db.sbom_entries().find(doc! {}).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("CVE monitor: failed to list SBOM entries for '{tenant_id}': {e}");
|
||||
tracing::error!("CVE monitor: failed to list SBOM entries: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let entries: Vec<SbomEntry> = cursor.filter_map(|r| async { r.ok() }).collect().await;
|
||||
if entries.is_empty() {
|
||||
tracing::debug!("CVE monitor: no SBOM entries for tenant '{tenant_id}', skipping");
|
||||
tracing::debug!("CVE monitor: no SBOM entries, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"CVE monitor: checking {} dependencies for new CVEs (tenant '{tenant_id}')",
|
||||
"CVE monitor: checking {} dependencies for new CVEs",
|
||||
entries.len()
|
||||
);
|
||||
|
||||
@@ -348,7 +112,7 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
|
||||
std::collections::HashMap::new();
|
||||
for rid in &repo_ids {
|
||||
if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(rid) {
|
||||
if let Ok(Some(repo)) = db.onboarded_targets().find_one(doc! { "_id": oid }).await {
|
||||
if let Ok(Some(repo)) = agent.db.repositories().find_one(doc! { "_id": oid }).await {
|
||||
repo_names.insert(rid.clone(), repo.name.clone());
|
||||
}
|
||||
}
|
||||
@@ -396,7 +160,8 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
|
||||
for alert in &alerts {
|
||||
let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id };
|
||||
let update = doc! { "$setOnInsert": mongodb::bson::to_bson(alert).unwrap_or_default() };
|
||||
let _ = db
|
||||
let _ = agent
|
||||
.db
|
||||
.cve_alerts()
|
||||
.update_one(filter, update)
|
||||
.upsert(true)
|
||||
@@ -409,7 +174,8 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
|
||||
continue;
|
||||
}
|
||||
if let Some(entry_id) = &entry.id {
|
||||
let _ = db
|
||||
let _ = agent
|
||||
.db
|
||||
.sbom_entries()
|
||||
.update_one(
|
||||
doc! { "_id": entry_id },
|
||||
@@ -447,7 +213,8 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
|
||||
let update = doc! {
|
||||
"$setOnInsert": mongodb::bson::to_bson(¬ification).unwrap_or_default()
|
||||
};
|
||||
match db
|
||||
match agent
|
||||
.db
|
||||
.cve_notifications()
|
||||
.update_one(filter, update)
|
||||
.upsert(true)
|
||||
@@ -465,10 +232,8 @@ async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
|
||||
}
|
||||
|
||||
if new_notifications > 0 {
|
||||
tracing::info!(
|
||||
"CVE monitor: created {new_notifications} new notification(s) for tenant '{tenant_id}'"
|
||||
);
|
||||
tracing::info!("CVE monitor: created {new_notifications} new notification(s)");
|
||||
} else {
|
||||
tracing::info!("CVE monitor: no new CVEs found for tenant '{tenant_id}'");
|
||||
tracing::info!("CVE monitor: no new CVEs found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,30 +14,24 @@ type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub async fn handle_gitea_webhook(
|
||||
Extension(agent): Extension<Arc<ComplianceAgent>>,
|
||||
Path((tenant_id, repo_id)): Path<(String, String)>,
|
||||
Path(repo_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
// Look up the repo in the tenant's database to get its webhook secret
|
||||
// Look up the repo to get its webhook secret
|
||||
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
|
||||
Ok(oid) => oid,
|
||||
Err(_) => return StatusCode::NOT_FOUND,
|
||||
};
|
||||
let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!("Gitea webhook: cannot open tenant database '{tenant_id}': {e}");
|
||||
return StatusCode::NOT_FOUND;
|
||||
}
|
||||
};
|
||||
let repo = match db
|
||||
.onboarded_targets()
|
||||
let repo = match agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(mongodb::bson::doc! { "_id": oid })
|
||||
.await
|
||||
{
|
||||
Ok(Some(repo)) => repo,
|
||||
_ => {
|
||||
tracing::warn!("Gitea webhook: repo {repo_id} not found in tenant '{tenant_id}'");
|
||||
tracing::warn!("Gitea webhook: repo {repo_id} not found");
|
||||
return StatusCode::NOT_FOUND;
|
||||
}
|
||||
};
|
||||
@@ -72,21 +66,15 @@ pub async fn handle_gitea_webhook(
|
||||
"push" => {
|
||||
let agent_clone = (*agent).clone();
|
||||
let repo_id = repo_id.clone();
|
||||
let tenant_id = tenant_id.clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!(
|
||||
"Gitea push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
|
||||
);
|
||||
if let Err(e) = agent_clone
|
||||
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
|
||||
.await
|
||||
{
|
||||
tracing::info!("Gitea push webhook: triggering scan for {repo_id}");
|
||||
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
|
||||
tracing::error!("Webhook-triggered scan failed: {e}");
|
||||
}
|
||||
});
|
||||
StatusCode::OK
|
||||
}
|
||||
"pull_request" => handle_pull_request(agent, &tenant_id, &repo_id, &payload).await,
|
||||
"pull_request" => handle_pull_request(agent, &repo_id, &payload).await,
|
||||
_ => {
|
||||
tracing::debug!("Gitea webhook: ignoring event '{event}'");
|
||||
StatusCode::OK
|
||||
@@ -96,7 +84,6 @@ pub async fn handle_gitea_webhook(
|
||||
|
||||
async fn handle_pull_request(
|
||||
agent: Arc<ComplianceAgent>,
|
||||
tenant_id: &str,
|
||||
repo_id: &str,
|
||||
payload: &serde_json::Value,
|
||||
) -> StatusCode {
|
||||
@@ -119,14 +106,13 @@ async fn handle_pull_request(
|
||||
}
|
||||
|
||||
let repo_id = repo_id.to_string();
|
||||
let tenant_id = tenant_id.to_string();
|
||||
let head_sha = head_sha.to_string();
|
||||
let base_sha = base_sha.to_string();
|
||||
let agent_clone = (*agent).clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("Gitea PR webhook: reviewing PR #{pr_number} on {repo_id}");
|
||||
if let Err(e) = agent_clone
|
||||
.run_pr_review(&tenant_id, &repo_id, pr_number, &base_sha, &head_sha)
|
||||
.run_pr_review(&repo_id, pr_number, &base_sha, &head_sha)
|
||||
.await
|
||||
{
|
||||
tracing::error!("PR review failed for #{pr_number}: {e}");
|
||||
|
||||
@@ -14,30 +14,24 @@ type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub async fn handle_github_webhook(
|
||||
Extension(agent): Extension<Arc<ComplianceAgent>>,
|
||||
Path((tenant_id, repo_id)): Path<(String, String)>,
|
||||
Path(repo_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
// Look up the repo in the tenant's database to get its webhook secret
|
||||
// Look up the repo to get its webhook secret
|
||||
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
|
||||
Ok(oid) => oid,
|
||||
Err(_) => return StatusCode::NOT_FOUND,
|
||||
};
|
||||
let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!("GitHub webhook: cannot open tenant database '{tenant_id}': {e}");
|
||||
return StatusCode::NOT_FOUND;
|
||||
}
|
||||
};
|
||||
let repo = match db
|
||||
.onboarded_targets()
|
||||
let repo = match agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(mongodb::bson::doc! { "_id": oid })
|
||||
.await
|
||||
{
|
||||
Ok(Some(repo)) => repo,
|
||||
_ => {
|
||||
tracing::warn!("GitHub webhook: repo {repo_id} not found in tenant '{tenant_id}'");
|
||||
tracing::warn!("GitHub webhook: repo {repo_id} not found");
|
||||
return StatusCode::NOT_FOUND;
|
||||
}
|
||||
};
|
||||
@@ -72,21 +66,15 @@ pub async fn handle_github_webhook(
|
||||
"push" => {
|
||||
let agent_clone = (*agent).clone();
|
||||
let repo_id = repo_id.clone();
|
||||
let tenant_id = tenant_id.clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!(
|
||||
"GitHub push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
|
||||
);
|
||||
if let Err(e) = agent_clone
|
||||
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
|
||||
.await
|
||||
{
|
||||
tracing::info!("GitHub push webhook: triggering scan for {repo_id}");
|
||||
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
|
||||
tracing::error!("Webhook-triggered scan failed: {e}");
|
||||
}
|
||||
});
|
||||
StatusCode::OK
|
||||
}
|
||||
"pull_request" => handle_pull_request(agent, &tenant_id, &repo_id, &payload).await,
|
||||
"pull_request" => handle_pull_request(agent, &repo_id, &payload).await,
|
||||
_ => {
|
||||
tracing::debug!("GitHub webhook: ignoring event '{event}'");
|
||||
StatusCode::OK
|
||||
@@ -96,7 +84,6 @@ pub async fn handle_github_webhook(
|
||||
|
||||
async fn handle_pull_request(
|
||||
agent: Arc<ComplianceAgent>,
|
||||
tenant_id: &str,
|
||||
repo_id: &str,
|
||||
payload: &serde_json::Value,
|
||||
) -> StatusCode {
|
||||
@@ -118,14 +105,13 @@ async fn handle_pull_request(
|
||||
}
|
||||
|
||||
let repo_id = repo_id.to_string();
|
||||
let tenant_id = tenant_id.to_string();
|
||||
let head_sha = head_sha.to_string();
|
||||
let base_sha = base_sha.to_string();
|
||||
let agent_clone = (*agent).clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("GitHub PR webhook: reviewing PR #{pr_number} on {repo_id}");
|
||||
if let Err(e) = agent_clone
|
||||
.run_pr_review(&tenant_id, &repo_id, pr_number, &base_sha, &head_sha)
|
||||
.run_pr_review(&repo_id, pr_number, &base_sha, &head_sha)
|
||||
.await
|
||||
{
|
||||
tracing::error!("PR review failed for #{pr_number}: {e}");
|
||||
|
||||
@@ -10,30 +10,24 @@ use crate::agent::ComplianceAgent;
|
||||
|
||||
pub async fn handle_gitlab_webhook(
|
||||
Extension(agent): Extension<Arc<ComplianceAgent>>,
|
||||
Path((tenant_id, repo_id)): Path<(String, String)>,
|
||||
Path(repo_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
// Look up the repo in the tenant's database to get its webhook secret
|
||||
// Look up the repo to get its webhook secret
|
||||
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
|
||||
Ok(oid) => oid,
|
||||
Err(_) => return StatusCode::NOT_FOUND,
|
||||
};
|
||||
let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!("GitLab webhook: cannot open tenant database '{tenant_id}': {e}");
|
||||
return StatusCode::NOT_FOUND;
|
||||
}
|
||||
};
|
||||
let repo = match db
|
||||
.onboarded_targets()
|
||||
let repo = match agent
|
||||
.db
|
||||
.repositories()
|
||||
.find_one(mongodb::bson::doc! { "_id": oid })
|
||||
.await
|
||||
{
|
||||
Ok(Some(repo)) => repo,
|
||||
_ => {
|
||||
tracing::warn!("GitLab webhook: repo {repo_id} not found in tenant '{tenant_id}'");
|
||||
tracing::warn!("GitLab webhook: repo {repo_id} not found");
|
||||
return StatusCode::NOT_FOUND;
|
||||
}
|
||||
};
|
||||
@@ -65,21 +59,15 @@ pub async fn handle_gitlab_webhook(
|
||||
"push" => {
|
||||
let agent_clone = (*agent).clone();
|
||||
let repo_id = repo_id.clone();
|
||||
let tenant_id = tenant_id.clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!(
|
||||
"GitLab push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
|
||||
);
|
||||
if let Err(e) = agent_clone
|
||||
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
|
||||
.await
|
||||
{
|
||||
tracing::info!("GitLab push webhook: triggering scan for {repo_id}");
|
||||
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
|
||||
tracing::error!("Webhook-triggered scan failed: {e}");
|
||||
}
|
||||
});
|
||||
StatusCode::OK
|
||||
}
|
||||
"merge_request" => handle_merge_request(agent, &tenant_id, &repo_id, &payload).await,
|
||||
"merge_request" => handle_merge_request(agent, &repo_id, &payload).await,
|
||||
_ => {
|
||||
tracing::debug!("GitLab webhook: ignoring event '{event_type}'");
|
||||
StatusCode::OK
|
||||
@@ -89,7 +77,6 @@ pub async fn handle_gitlab_webhook(
|
||||
|
||||
async fn handle_merge_request(
|
||||
agent: Arc<ComplianceAgent>,
|
||||
tenant_id: &str,
|
||||
repo_id: &str,
|
||||
payload: &serde_json::Value,
|
||||
) -> StatusCode {
|
||||
@@ -114,14 +101,13 @@ async fn handle_merge_request(
|
||||
}
|
||||
|
||||
let repo_id = repo_id.to_string();
|
||||
let tenant_id = tenant_id.to_string();
|
||||
let head_sha = head_sha.to_string();
|
||||
let base_sha = base_sha.to_string();
|
||||
let agent_clone = (*agent).clone();
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("GitLab MR webhook: reviewing MR !{mr_iid} on {repo_id}");
|
||||
if let Err(e) = agent_clone
|
||||
.run_pr_review(&tenant_id, &repo_id, mr_iid, &base_sha, &head_sha)
|
||||
.run_pr_review(&repo_id, mr_iid, &base_sha, &head_sha)
|
||||
.await
|
||||
{
|
||||
tracing::error!("MR review failed for !{mr_iid}: {e}");
|
||||
|
||||
@@ -9,21 +9,17 @@ use crate::webhooks::{gitea, github, gitlab};
|
||||
|
||||
pub async fn start_webhook_server(agent: &ComplianceAgent) -> Result<(), AgentError> {
|
||||
let app = Router::new()
|
||||
// Per-tenant per-repo webhook URLs: /webhook/{tenant_id}/{platform}/{repo_id}
|
||||
// The tenant_id is resolved from the URL path because webhooks
|
||||
// arrive without a JWT — they're authenticated via per-repo HMAC,
|
||||
// not via the tenant gate. The dashboard surfaces the full URL
|
||||
// including the tenant_id when the repo is registered.
|
||||
// Per-repo webhook URLs: /webhook/{platform}/{repo_id}
|
||||
.route(
|
||||
"/webhook/{tenant_id}/github/{repo_id}",
|
||||
"/webhook/github/{repo_id}",
|
||||
post(github::handle_github_webhook),
|
||||
)
|
||||
.route(
|
||||
"/webhook/{tenant_id}/gitlab/{repo_id}",
|
||||
"/webhook/gitlab/{repo_id}",
|
||||
post(gitlab::handle_gitlab_webhook),
|
||||
)
|
||||
.route(
|
||||
"/webhook/{tenant_id}/gitea/{repo_id}",
|
||||
"/webhook/gitea/{repo_id}",
|
||||
post(gitea::handle_gitea_webhook),
|
||||
)
|
||||
.layer(Extension(Arc::new(agent.clone())));
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
//! Werkbank control-plane: the dynamic-execution job queue.
|
||||
//!
|
||||
//! The control plane enqueues declarative [`Job`](compliance_core::models::werkbank::Job)s
|
||||
//! and Werkbank runners lease, run, and complete them. [`queue::JobQueue`] is the
|
||||
//! Mongo-backed queue behind that flow (WB-02); the runner-facing HTTP transport
|
||||
//! and the runner itself land in later stories.
|
||||
|
||||
pub mod queue;
|
||||
|
||||
pub use queue::{JobQueue, SweepOutcome};
|
||||
@@ -1,309 +0,0 @@
|
||||
//! The Mongo-backed Werkbank job queue (WB-02).
|
||||
//!
|
||||
//! A pull queue: the control plane [`enqueue`](JobQueue::enqueue)s jobs; a runner
|
||||
//! [`lease`](JobQueue::lease)s the oldest queued job it can run (matched by
|
||||
//! executor + labels), [`heartbeat`](JobQueue::heartbeat)s while it works, and
|
||||
//! [`complete`](JobQueue::complete)s it. Leases carry a visibility timeout: if a
|
||||
//! runner dies mid-job its heartbeats stop, the lease expires, and
|
||||
//! [`sweep_expired`](JobQueue::sweep_expired) returns the job to `queued` (or
|
||||
//! `expired` once it has been retried too many times).
|
||||
//!
|
||||
//! All state transitions are single atomic Mongo updates guarded by the lease
|
||||
//! token, so two runners can never both own a job. Every operation takes an
|
||||
//! explicit `now` so the queue's time-dependent behaviour is deterministically
|
||||
//! testable.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use mongodb::bson::{doc, Bson, DateTime as BsonDateTime};
|
||||
use mongodb::error::{ErrorKind, WriteFailure};
|
||||
use mongodb::options::ReturnDocument;
|
||||
use mongodb::Collection;
|
||||
|
||||
use compliance_core::models::werkbank::{
|
||||
Executor, HeartbeatAck, Job, JobRecord, JobResult, JobStatus, LeasedJob,
|
||||
};
|
||||
|
||||
use crate::database::Database;
|
||||
use crate::error::AgentError;
|
||||
|
||||
/// The non-terminal states a job can be swept or cancelled from.
|
||||
const ACTIVE_STATES: [&str; 2] = ["leased", "running"];
|
||||
/// Every terminal state (no further transitions).
|
||||
const TERMINAL_STATES: [&str; 4] = ["succeeded", "failed", "expired", "cancelled"];
|
||||
|
||||
/// What a visibility-timeout sweep did.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct SweepOutcome {
|
||||
/// Expired-lease jobs returned to `queued` for another runner.
|
||||
pub requeued: u64,
|
||||
/// Jobs that had exhausted their attempts and were marked `expired`.
|
||||
pub expired: u64,
|
||||
}
|
||||
|
||||
/// The Mongo-backed job queue.
|
||||
pub struct JobQueue {
|
||||
coll: Collection<JobRecord>,
|
||||
}
|
||||
|
||||
impl JobQueue {
|
||||
/// Build a queue over a tenant database's `werkbank_jobs` collection.
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self {
|
||||
coll: db.werkbank_jobs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueue a job. Idempotent by job id: a job that is already present is a
|
||||
/// no-op. Returns `true` if this call inserted it, `false` if it existed.
|
||||
pub async fn enqueue(&self, job: Job, now: DateTime<Utc>) -> Result<bool, AgentError> {
|
||||
let record = JobRecord::queued(job, now);
|
||||
match self.coll.insert_one(&record).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) if is_duplicate_key(&e) => Ok(false),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically lease the oldest `queued` job this runner can run — matched by
|
||||
/// executor and by labels (every label the job requires must be one the
|
||||
/// runner advertises). Returns the job plus a lease token, or `None` if
|
||||
/// nothing is runnable.
|
||||
pub async fn lease(
|
||||
&self,
|
||||
runner_id: &str,
|
||||
executor: Executor,
|
||||
runner_labels: &[String],
|
||||
lease_ttl: Duration,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Option<LeasedJob>, AgentError> {
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
let expires = bson_dt(now + ttl(lease_ttl));
|
||||
let executor_bson = mongodb::bson::to_bson(&executor).unwrap_or(Bson::Null);
|
||||
|
||||
let filter = doc! {
|
||||
"status": "queued",
|
||||
"cancel_requested": { "$ne": true },
|
||||
"job.executor": executor_bson,
|
||||
// Every label the job requires must be in the runner's set — i.e. the
|
||||
// job has no label that is not offered by the runner. Absent/empty
|
||||
// job labels match any runner.
|
||||
"job.labels": { "$not": { "$elemMatch": { "$nin": runner_labels.to_vec() } } },
|
||||
};
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"status": "leased",
|
||||
"lease_token": &token,
|
||||
"leased_by": runner_id,
|
||||
"lease_expires_at": expires,
|
||||
"heartbeat_at": bson_dt(now),
|
||||
"updated_at": bson_dt(now),
|
||||
},
|
||||
"$inc": { "attempts": 1 },
|
||||
};
|
||||
|
||||
let record = self
|
||||
.coll
|
||||
.find_one_and_update(filter, update)
|
||||
.sort(doc! { "created_at": 1 }) // FIFO
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?;
|
||||
Ok(record.map(|r| LeasedJob {
|
||||
job: r.job,
|
||||
lease_token: token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Extend a lease and report whether the job has been asked to cancel.
|
||||
/// Transitions the job to `running` on the first heartbeat. Returns `None`
|
||||
/// when the lease is no longer valid (token mismatch, or the job is already
|
||||
/// terminal) — the runner should then abandon the work.
|
||||
pub async fn heartbeat(
|
||||
&self,
|
||||
job_id: &str,
|
||||
lease_token: &str,
|
||||
lease_ttl: Duration,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<Option<HeartbeatAck>, AgentError> {
|
||||
let filter = doc! {
|
||||
"job.id": job_id,
|
||||
"lease_token": lease_token,
|
||||
"status": { "$in": ACTIVE_STATES.to_vec() },
|
||||
};
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"status": "running",
|
||||
"lease_expires_at": bson_dt(now + ttl(lease_ttl)),
|
||||
"heartbeat_at": bson_dt(now),
|
||||
"updated_at": bson_dt(now),
|
||||
},
|
||||
};
|
||||
let record = self
|
||||
.coll
|
||||
.find_one_and_update(filter, update)
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?;
|
||||
Ok(record.map(|r| HeartbeatAck {
|
||||
cancelled: r.cancel_requested,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Record a job's terminal result. Guarded by the lease token and only from
|
||||
/// an active (`leased`/`running`) state, so it is idempotent — a duplicate or
|
||||
/// late submission after the job already finished matches nothing. Returns
|
||||
/// `true` if this call recorded the result.
|
||||
pub async fn complete(
|
||||
&self,
|
||||
job_id: &str,
|
||||
lease_token: &str,
|
||||
result: &JobResult,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<bool, AgentError> {
|
||||
let status = result.status.unwrap_or(JobStatus::Failed);
|
||||
let status_bson = mongodb::bson::to_bson(&status).unwrap_or(Bson::String("failed".into()));
|
||||
let result_bson =
|
||||
mongodb::bson::to_bson(result).map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
|
||||
let filter = doc! {
|
||||
"job.id": job_id,
|
||||
"lease_token": lease_token,
|
||||
"status": { "$in": ACTIVE_STATES.to_vec() },
|
||||
};
|
||||
let update = doc! {
|
||||
"$set": {
|
||||
"status": status_bson,
|
||||
"result": result_bson,
|
||||
"lease_token": Bson::Null,
|
||||
"lease_expires_at": Bson::Null,
|
||||
"updated_at": bson_dt(now),
|
||||
},
|
||||
};
|
||||
let res = self.coll.update_one(filter, update).await?;
|
||||
Ok(res.modified_count == 1)
|
||||
}
|
||||
|
||||
/// Request cancellation of a job. A still-`queued` job is cancelled outright;
|
||||
/// an in-flight one is flagged so the runner sees it on its next heartbeat and
|
||||
/// tears down. Returns `true` if a non-terminal job matched.
|
||||
pub async fn cancel(&self, job_id: &str, now: DateTime<Utc>) -> Result<bool, AgentError> {
|
||||
let filter = doc! {
|
||||
"job.id": job_id,
|
||||
"status": { "$nin": TERMINAL_STATES.to_vec() },
|
||||
};
|
||||
// Pipeline update: flag cancellation, and if still queued flip straight to
|
||||
// cancelled (nothing is running it).
|
||||
let pipeline = vec![doc! {
|
||||
"$set": {
|
||||
"cancel_requested": true,
|
||||
"status": {
|
||||
"$cond": [ { "$eq": ["$status", "queued"] }, "cancelled", "$status" ]
|
||||
},
|
||||
"updated_at": bson_dt(now),
|
||||
}
|
||||
}];
|
||||
let res = self.coll.update_one(filter, pipeline).await?;
|
||||
Ok(res.matched_count == 1)
|
||||
}
|
||||
|
||||
/// Sweep leases whose visibility timeout has elapsed: return them to `queued`
|
||||
/// for another runner, or mark them `expired` once they have been leased
|
||||
/// `max_attempts` times. This is what makes a crashed runner's job recover.
|
||||
pub async fn sweep_expired(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
max_attempts: u32,
|
||||
// (kept explicit rather than a const so callers can tune retry policy)
|
||||
) -> Result<SweepOutcome, AgentError> {
|
||||
let now_bson = bson_dt(now);
|
||||
let max = i64::from(max_attempts);
|
||||
|
||||
let requeue = self
|
||||
.coll
|
||||
.update_many(
|
||||
doc! {
|
||||
"status": { "$in": ACTIVE_STATES.to_vec() },
|
||||
"lease_expires_at": { "$lt": &now_bson },
|
||||
"attempts": { "$lt": max },
|
||||
},
|
||||
doc! { "$set": {
|
||||
"status": "queued",
|
||||
"lease_token": Bson::Null,
|
||||
"leased_by": Bson::Null,
|
||||
"lease_expires_at": Bson::Null,
|
||||
"updated_at": &now_bson,
|
||||
} },
|
||||
)
|
||||
.await?;
|
||||
|
||||
let expire = self
|
||||
.coll
|
||||
.update_many(
|
||||
doc! {
|
||||
"status": { "$in": ACTIVE_STATES.to_vec() },
|
||||
"lease_expires_at": { "$lt": &now_bson },
|
||||
"attempts": { "$gte": max },
|
||||
},
|
||||
doc! { "$set": {
|
||||
"status": "expired",
|
||||
"lease_token": Bson::Null,
|
||||
"lease_expires_at": Bson::Null,
|
||||
"updated_at": &now_bson,
|
||||
} },
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(SweepOutcome {
|
||||
requeued: requeue.modified_count,
|
||||
expired: expire.modified_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch a job record by job id (inspection / control-plane reads).
|
||||
pub async fn get(&self, job_id: &str) -> Result<Option<JobRecord>, AgentError> {
|
||||
Ok(self.coll.find_one(doc! { "job.id": job_id }).await?)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `chrono::Duration` for a lease TTL, saturating rather than panicking on an
|
||||
/// absurd input (`chrono::Duration::seconds` panics past its internal bound).
|
||||
fn ttl(d: Duration) -> chrono::Duration {
|
||||
let secs = i64::try_from(d.as_secs()).unwrap_or(i64::MAX);
|
||||
chrono::Duration::try_seconds(secs).unwrap_or(chrono::Duration::MAX)
|
||||
}
|
||||
|
||||
/// A chrono instant as a BSON date (so Mongo stores/compares it as a real date).
|
||||
fn bson_dt(dt: DateTime<Utc>) -> BsonDateTime {
|
||||
BsonDateTime::from_chrono(dt)
|
||||
}
|
||||
|
||||
/// Whether a Mongo error is a duplicate-key (E11000) violation — a job with this
|
||||
/// id is already enqueued.
|
||||
fn is_duplicate_key(e: &mongodb::error::Error) -> bool {
|
||||
match &*e.kind {
|
||||
ErrorKind::Write(WriteFailure::WriteError(we)) => we.code == 11000,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ttl_saturates_and_converts() {
|
||||
assert_eq!(ttl(Duration::from_secs(30)), chrono::Duration::seconds(30));
|
||||
// An absurd TTL saturates instead of panicking.
|
||||
assert_eq!(ttl(Duration::from_secs(u64::MAX)), chrono::Duration::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_constants_are_disjoint() {
|
||||
for s in ACTIVE_STATES {
|
||||
assert!(
|
||||
!TERMINAL_STATES.contains(&s),
|
||||
"{s} cannot be both active and terminal"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,68 +2,15 @@
|
||||
//
|
||||
// Spins up the agent API server on a random port with an isolated test
|
||||
// database. Each test gets a fresh database that is dropped on cleanup.
|
||||
//
|
||||
// Included via `mod common;` in several test binaries; not every binary uses
|
||||
// every helper, so allow dead code here.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use compliance_agent::agent::ComplianceAgent;
|
||||
use compliance_agent::api;
|
||||
use compliance_agent::database::DatabasePool;
|
||||
use compliance_agent::database::Database;
|
||||
use compliance_core::AgentConfig;
|
||||
use secrecy::SecretString;
|
||||
|
||||
/// The runner bearer token wired into the test config.
|
||||
pub const TEST_RUNNER_TOKEN: &str = "test-runner-token";
|
||||
|
||||
/// A minimal dev [`AgentConfig`] for tests: unauthenticated (no Keycloak), the
|
||||
/// Werkbank runner API enabled with [`TEST_RUNNER_TOKEN`].
|
||||
pub fn dev_config(mongodb_uri: String, db_name: String) -> AgentConfig {
|
||||
AgentConfig {
|
||||
mongodb_uri,
|
||||
mongodb_database: db_name,
|
||||
litellm_url: std::env::var("TEST_LITELLM_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:4000".into()),
|
||||
litellm_api_key: SecretString::from(String::new()),
|
||||
litellm_model: "gpt-4o".into(),
|
||||
litellm_embed_model: "text-embedding-3-small".into(),
|
||||
agent_port: 0, // not used — we bind ourselves
|
||||
scan_schedule: String::new(),
|
||||
cve_monitor_schedule: String::new(),
|
||||
git_clone_base_path: "/tmp/compliance-scanner-tests/repos".into(),
|
||||
artifact_store_base_path: "/tmp/compliance-scanner-tests/artifacts".into(),
|
||||
ssh_key_path: "/tmp/compliance-scanner-tests/ssh/id_ed25519".into(),
|
||||
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,
|
||||
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: false,
|
||||
pentest_imap_username: None,
|
||||
pentest_imap_password: None,
|
||||
admin_api_token: None,
|
||||
tenant_registry_url: None,
|
||||
plc_runtime: compliance_core::PlcRuntimeConfig::default(),
|
||||
werkbank_runner_token: Some(SecretString::from(TEST_RUNNER_TOKEN.to_string())),
|
||||
breakpilot: compliance_core::config::BreakpilotConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A running test server with a unique database.
|
||||
pub struct TestServer {
|
||||
pub base_url: String,
|
||||
@@ -78,25 +25,55 @@ impl TestServer {
|
||||
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
|
||||
// Unique db-name prefix per run. Must fit the pool's 30-char cap
|
||||
// (`<prefix>_<32 hex>` <= 63), so use a 16-hex-char suffix.
|
||||
let db_name = format!("t_{}", &uuid::Uuid::new_v4().simple().to_string()[..16]);
|
||||
// Unique database name per test run to avoid collisions
|
||||
let db_name = format!("test_{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
let db_pool = DatabasePool::connect(&mongodb_uri, &db_name)
|
||||
let db = Database::connect(&mongodb_uri, &db_name)
|
||||
.await
|
||||
.expect("Failed to build DatabasePool");
|
||||
.expect("Failed to connect to MongoDB — is it running?");
|
||||
db.ensure_indexes().await.expect("Failed to create indexes");
|
||||
|
||||
let config = dev_config(mongodb_uri.clone(), db_name.clone());
|
||||
let config = AgentConfig {
|
||||
mongodb_uri: mongodb_uri.clone(),
|
||||
mongodb_database: db_name.clone(),
|
||||
litellm_url: std::env::var("TEST_LITELLM_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:4000".into()),
|
||||
litellm_api_key: SecretString::from(String::new()),
|
||||
litellm_model: "gpt-4o".into(),
|
||||
litellm_embed_model: "text-embedding-3-small".into(),
|
||||
agent_port: 0, // not used — we bind ourselves
|
||||
scan_schedule: String::new(),
|
||||
cve_monitor_schedule: String::new(),
|
||||
git_clone_base_path: "/tmp/compliance-scanner-tests/repos".into(),
|
||||
ssh_key_path: "/tmp/compliance-scanner-tests/ssh/id_ed25519".into(),
|
||||
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,
|
||||
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: false,
|
||||
pentest_imap_username: None,
|
||||
pentest_imap_password: None,
|
||||
};
|
||||
|
||||
let agent = ComplianceAgent::new(config, db_pool);
|
||||
let agent = ComplianceAgent::new(config, db);
|
||||
|
||||
// Build the router with the agent extension. After M7.2-B every
|
||||
// handler takes a TenantCtx extractor; without KC in the test
|
||||
// harness, the dev-tenant injector mounts a synthetic context so
|
||||
// tests run end-to-end against `<db_name>_dev`.
|
||||
// Build the router with the agent extension
|
||||
let app = api::routes::build_router()
|
||||
.layer(axum::extract::Extension(Arc::new(agent)))
|
||||
.layer(axum::middleware::from_fn(api::server::inject_dev_tenant))
|
||||
.layer(tower_http::cors::CorsLayer::permissive());
|
||||
|
||||
// Bind to port 0 to get a random available port
|
||||
@@ -179,19 +156,10 @@ impl TestServer {
|
||||
&self.db_name
|
||||
}
|
||||
|
||||
/// Drop every per-tenant database belonging to this test run.
|
||||
/// Post-M7.2-D the agent never opens a `db_name` directly —
|
||||
/// data lives only in `<db_name>_<tenant>` per-tenant databases.
|
||||
/// Drop the test database on cleanup
|
||||
pub async fn cleanup(&self) {
|
||||
if let Ok(client) = mongodb::Client::with_uri_str(&self.mongodb_uri).await {
|
||||
if let Ok(names) = client.list_database_names().await {
|
||||
let prefix = format!("{}_", self.db_name);
|
||||
for name in names {
|
||||
if name.starts_with(&prefix) {
|
||||
client.database(&name).drop().await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
client.database(&self.db_name).drop().await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,16 +113,15 @@ async fn delete_repo_cascades_to_dast_and_pentest_data() {
|
||||
// Create a repo
|
||||
let resp = server
|
||||
.post(
|
||||
"/api/v1/targets",
|
||||
"/api/v1/repositories",
|
||||
&json!({
|
||||
"name": "cascade-test",
|
||||
"target_type": "web_app",
|
||||
"artifacts": [{ "kind": "git_repo", "source_ref": "https://github.com/example/cascade-test.git", "branch": "main" }],
|
||||
"git_url": "https://github.com/example/cascade-test.git",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let repo_id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
|
||||
let repo_id = body["data"]["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Insert DAST target linked to repo
|
||||
let target_id = insert_dast_target(&server, &repo_id, "cascade-target").await;
|
||||
@@ -141,7 +140,9 @@ async fn delete_repo_cascades_to_dast_and_pentest_data() {
|
||||
assert_eq!(count_docs(&server, "dast_findings").await, 1);
|
||||
|
||||
// Delete the repo
|
||||
let resp = server.delete(&format!("/api/v1/targets/{repo_id}")).await;
|
||||
let resp = server
|
||||
.delete(&format!("/api/v1/repositories/{repo_id}"))
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// All downstream data should be gone
|
||||
@@ -160,16 +161,15 @@ async fn delete_repo_cascades_sast_findings_and_sbom() {
|
||||
// Create a repo
|
||||
let resp = server
|
||||
.post(
|
||||
"/api/v1/targets",
|
||||
"/api/v1/repositories",
|
||||
&json!({
|
||||
"name": "sast-cascade",
|
||||
"target_type": "web_app",
|
||||
"artifacts": [{ "kind": "git_repo", "source_ref": "https://github.com/example/sast-cascade.git", "branch": "main" }],
|
||||
"git_url": "https://github.com/example/sast-cascade.git",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let repo_id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
|
||||
let repo_id = body["data"]["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Insert SAST finding and SBOM entry
|
||||
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
|
||||
@@ -209,7 +209,9 @@ async fn delete_repo_cascades_sast_findings_and_sbom() {
|
||||
assert_eq!(count_docs(&server, "sbom_entries").await, 1);
|
||||
|
||||
// Delete repo
|
||||
server.delete(&format!("/api/v1/targets/{repo_id}")).await;
|
||||
server
|
||||
.delete(&format!("/api/v1/repositories/{repo_id}"))
|
||||
.await;
|
||||
|
||||
// Both should be gone
|
||||
assert_eq!(count_docs(&server, "findings").await, 0);
|
||||
|
||||
@@ -2,5 +2,5 @@ mod cascade_delete;
|
||||
mod dast;
|
||||
mod findings;
|
||||
mod health;
|
||||
mod onboarding;
|
||||
mod repositories;
|
||||
mod stats;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
use crate::common::TestServer;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_list_and_applicable_scans() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// Initially empty.
|
||||
let resp = server.get("/api/v1/targets").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["data"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Create a web-app target with a git repo + a live URL.
|
||||
let resp = server
|
||||
.post(
|
||||
"/api/v1/targets",
|
||||
&json!({
|
||||
"name": "acme-web",
|
||||
"target_type": "web_app",
|
||||
"artifacts": [
|
||||
{ "kind": "git_repo", "source_ref": "https://git/acme.git", "branch": "main" },
|
||||
{ "kind": "live_url", "source_ref": "https://acme.example.com" }
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
|
||||
assert!(!id.is_empty());
|
||||
assert_eq!(body["data"]["artifacts"].as_array().unwrap().len(), 2);
|
||||
|
||||
// List returns it.
|
||||
let resp = server.get("/api/v1/targets").await;
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["data"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Applicable scans: SAST present + DAST offered (live URL present), pentest supported.
|
||||
let resp = server
|
||||
.get(&format!("/api/v1/targets/{id}/applicable-scans"))
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let scans = body["data"]["scans"].as_array().unwrap();
|
||||
let names: Vec<&str> = scans.iter().filter_map(|s| s["scan"].as_str()).collect();
|
||||
assert!(names.contains(&"sast"));
|
||||
assert!(names.contains(&"dast"));
|
||||
assert_eq!(body["data"]["pentest_supported"], true);
|
||||
|
||||
server.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_classifies_a_plc_target() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// A PLC project artifact is a strong kind-based signal.
|
||||
let resp = server
|
||||
.post(
|
||||
"/api/v1/targets",
|
||||
&json!({
|
||||
"name": "line-controller",
|
||||
"target_type": "backend_service", // deliberately wrong; detect should suggest PLC
|
||||
"artifacts": [
|
||||
{ "kind": "plc_project", "source_ref": "line.xml", "plc_format": "plcopen_xml" }
|
||||
]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = server
|
||||
.post(&format!("/api/v1/targets/{id}/detect"), &json!({}))
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["data"]["classification"]["suggested"], "plc_sps");
|
||||
|
||||
server.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_artifact_and_delete_target() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
let resp = server
|
||||
.post(
|
||||
"/api/v1/targets",
|
||||
&json!({ "name": "svc", "target_type": "backend_service" }),
|
||||
)
|
||||
.await;
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
|
||||
|
||||
// Attach a git repo.
|
||||
let resp = server
|
||||
.post(
|
||||
&format!("/api/v1/targets/{id}/artifacts"),
|
||||
&json!({ "kind": "git_repo", "source_ref": "https://git/svc.git" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["data"]["artifacts"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Delete it.
|
||||
let resp = server.delete(&format!("/api/v1/targets/{id}")).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let resp = server.get(&format!("/api/v1/targets/{id}")).await;
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
server.cleanup().await;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use crate::common::TestServer;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_and_list_repository() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// Initially empty
|
||||
let resp = server.get("/api/v1/repositories").await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["data"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Add a repository
|
||||
let resp = server
|
||||
.post(
|
||||
"/api/v1/repositories",
|
||||
&json!({
|
||||
"name": "test-repo",
|
||||
"git_url": "https://github.com/example/test-repo.git",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let repo_id = body["data"]["id"].as_str().unwrap().to_string();
|
||||
assert!(!repo_id.is_empty());
|
||||
|
||||
// List should now return 1
|
||||
let resp = server.get("/api/v1/repositories").await;
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let repos = body["data"].as_array().unwrap();
|
||||
assert_eq!(repos.len(), 1);
|
||||
assert_eq!(repos[0]["name"], "test-repo");
|
||||
|
||||
server.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_duplicate_repository_fails() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
let payload = json!({
|
||||
"name": "dup-repo",
|
||||
"git_url": "https://github.com/example/dup-repo.git",
|
||||
});
|
||||
|
||||
// First add succeeds
|
||||
let resp = server.post("/api/v1/repositories", &payload).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Second add with same git_url should fail (unique index)
|
||||
let resp = server.post("/api/v1/repositories", &payload).await;
|
||||
assert_ne!(resp.status(), 200);
|
||||
|
||||
server.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_repository() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// Add a repo
|
||||
let resp = server
|
||||
.post(
|
||||
"/api/v1/repositories",
|
||||
&json!({
|
||||
"name": "to-delete",
|
||||
"git_url": "https://github.com/example/to-delete.git",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let repo_id = body["data"]["id"].as_str().unwrap();
|
||||
|
||||
// Delete it
|
||||
let resp = server
|
||||
.delete(&format!("/api/v1/repositories/{repo_id}"))
|
||||
.await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// List should be empty again
|
||||
let resp = server.get("/api/v1/repositories").await;
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["data"].as_array().unwrap().len(), 0);
|
||||
|
||||
server.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_nonexistent_repository_returns_404() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
let resp = server
|
||||
.delete("/api/v1/repositories/000000000000000000000000")
|
||||
.await;
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
server.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_invalid_id_returns_400() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
let resp = server.delete("/api/v1/repositories/not-a-valid-id").await;
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
server.cleanup().await;
|
||||
}
|
||||
@@ -5,14 +5,13 @@ use serde_json::json;
|
||||
async fn stats_overview_reflects_inserted_data() {
|
||||
let server = TestServer::start().await;
|
||||
|
||||
// Add a target
|
||||
// Add a repo
|
||||
server
|
||||
.post(
|
||||
"/api/v1/targets",
|
||||
"/api/v1/repositories",
|
||||
&json!({
|
||||
"name": "stats-repo",
|
||||
"target_type": "web_app",
|
||||
"artifacts": [{ "kind": "git_repo", "source_ref": "https://github.com/example/stats-repo.git", "branch": "main" }],
|
||||
"git_url": "https://github.com/example/stats-repo.git",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
//! M7.2-A — `DatabasePool` isolation proof.
|
||||
//!
|
||||
//! Two `TenantContext`s, two databases, one client. Insert on A, query
|
||||
//! on B → empty. Insert on B, query on A → only A's docs. Proves that
|
||||
//! the per-tenant database split actually isolates at the driver level
|
||||
//! and not at "we hope we filter."
|
||||
//!
|
||||
//! Requires MongoDB. Set `TEST_MONGODB_URI` to override the default
|
||||
//! `mongodb://root:example@localhost:27017/?authSource=admin`.
|
||||
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use compliance_agent::database::DatabasePool;
|
||||
use compliance_core::models::{Artifact, OnboardedTarget, TargetType};
|
||||
use compliance_core::{OrgRole, TenantContext, TenantStatus};
|
||||
use mongodb::bson::doc;
|
||||
|
||||
fn ctx(tenant_id: &str, slug: &str) -> TenantContext {
|
||||
TenantContext {
|
||||
tenant_id: tenant_id.to_string(),
|
||||
tenant_slug: slug.to_string(),
|
||||
org_roles: vec![OrgRole::ItAdmin],
|
||||
products: vec!["compliance-scanner".to_string()],
|
||||
plan: "starter".to_string(),
|
||||
status: TenantStatus::Active,
|
||||
user_id: "u-1".to_string(),
|
||||
user_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_repo(name: &str, git_url: &str) -> OnboardedTarget {
|
||||
let mut target = OnboardedTarget::new(name.to_string(), TargetType::WebApp);
|
||||
target
|
||||
.artifacts
|
||||
.push(Artifact::git_repo(git_url.to_string(), "main".to_string()));
|
||||
target
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pool_isolates_tenants_at_driver_level() {
|
||||
let uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
// Unique per run so parallel test invocations don't collide. Kept
|
||||
// short because Mongo caps db names at 63 bytes (prefix + tenant_id).
|
||||
let prefix = format!("m72a_{}", short_id());
|
||||
|
||||
let pool = DatabasePool::connect(&uri, &prefix)
|
||||
.await
|
||||
.expect("Failed to connect to MongoDB — is it running?");
|
||||
|
||||
let acme = ctx("00000000-0000-0000-0000-00000000acme", "acme");
|
||||
let globex = ctx("00000000-0000-0000-0000-0000globex000", "globex");
|
||||
|
||||
let acme_db = pool.for_tenant(&acme).await.expect("acme db");
|
||||
let globex_db = pool.for_tenant(&globex).await.expect("globex db");
|
||||
|
||||
// Write distinct repos into each tenant's database.
|
||||
acme_db
|
||||
.onboarded_targets()
|
||||
.insert_one(fixture_repo("acme-app", "git@example.com:acme/app.git"))
|
||||
.await
|
||||
.expect("insert acme");
|
||||
globex_db
|
||||
.onboarded_targets()
|
||||
.insert_one(fixture_repo(
|
||||
"globex-platform",
|
||||
"git@example.com:globex/platform.git",
|
||||
))
|
||||
.await
|
||||
.expect("insert globex");
|
||||
|
||||
// The point of the whole exercise: acme can ONLY see acme's repo
|
||||
// and globex can ONLY see globex's, with no filter doc anywhere
|
||||
// because the isolation is at the database handle, not in the query.
|
||||
let acme_seen = collect(&acme_db).await;
|
||||
let globex_seen = collect(&globex_db).await;
|
||||
|
||||
assert_eq!(acme_seen.len(), 1, "acme should see exactly its own repo");
|
||||
assert_eq!(acme_seen[0].name, "acme-app");
|
||||
assert_eq!(
|
||||
globex_seen.len(),
|
||||
1,
|
||||
"globex should see exactly its own repo"
|
||||
);
|
||||
assert_eq!(globex_seen[0].name, "globex-platform");
|
||||
|
||||
// Sanity: the two databases really are different by name.
|
||||
let acme_db_name = pool.tenant_db_name(&acme.tenant_id);
|
||||
let globex_db_name = pool.tenant_db_name(&globex.tenant_id);
|
||||
assert_ne!(acme_db_name, globex_db_name);
|
||||
assert!(acme_db_name.starts_with(&prefix));
|
||||
|
||||
// Cleanup — drop both per-tenant databases.
|
||||
pool.client()
|
||||
.database(&acme_db_name)
|
||||
.drop()
|
||||
.await
|
||||
.expect("drop acme");
|
||||
pool.client()
|
||||
.database(&globex_db_name)
|
||||
.drop()
|
||||
.await
|
||||
.expect("drop globex");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn for_tenant_is_idempotent_index_creation() {
|
||||
let uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
let prefix = format!("m72a_{}", short_id());
|
||||
let pool = DatabasePool::connect(&uri, &prefix).await.expect("connect");
|
||||
|
||||
let acme = ctx("00000000-0000-0000-0000-00000000acme", "acme");
|
||||
|
||||
// Second call must not fail (ensure_indexes already ran, in-memory
|
||||
// marker is set, Mongo's createIndex is idempotent by name anyway).
|
||||
let _ = pool.for_tenant(&acme).await.expect("first call");
|
||||
let _ = pool.for_tenant(&acme).await.expect("second call");
|
||||
let _ = pool.for_tenant(&acme).await.expect("third call");
|
||||
|
||||
// Cleanup
|
||||
let db_name = pool.tenant_db_name(&acme.tenant_id);
|
||||
pool.client().database(&db_name).drop().await.expect("drop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tenant_db_name_sanitizes_unsafe_characters() {
|
||||
let uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
let pool = DatabasePool::connect(&uri, "m72a_sanitize")
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
// Mongo db names cannot contain `/ \ . " $ <space> NUL`. The pool
|
||||
// must rewrite these without exploding on connect.
|
||||
let funky = "te/n.a\\nt$id\" with spaces";
|
||||
let name = pool.tenant_db_name(funky);
|
||||
for c in ['/', '\\', '.', '"', '$', ' '] {
|
||||
assert!(
|
||||
!name.contains(c),
|
||||
"sanitized db name still contains {c:?}: {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_helpers_list_and_drop_tenant_dbs() {
|
||||
let uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
let prefix = format!("m72d_{}", short_id());
|
||||
let pool = DatabasePool::connect(&uri, &prefix).await.expect("connect");
|
||||
|
||||
let acme = ctx("00000000-0000-0000-0000-00000000acme", "acme");
|
||||
let globex = ctx("00000000-0000-0000-0000-0000globex000", "globex");
|
||||
|
||||
// Provision two tenants and write a doc into each so the databases
|
||||
// actually materialize on the cluster (Mongo lazily creates DBs).
|
||||
let acme_db = pool.for_tenant(&acme).await.expect("acme db");
|
||||
let globex_db = pool.for_tenant(&globex).await.expect("globex db");
|
||||
acme_db
|
||||
.onboarded_targets()
|
||||
.insert_one(fixture_repo("acme-app", "git@example.com:acme/app.git"))
|
||||
.await
|
||||
.expect("insert acme");
|
||||
globex_db
|
||||
.onboarded_targets()
|
||||
.insert_one(fixture_repo("globex-app", "git@example.com:globex/app.git"))
|
||||
.await
|
||||
.expect("insert globex");
|
||||
|
||||
// list_tenant_db_names sees both, filtered by prefix
|
||||
let names = pool.list_tenant_db_names().await.expect("list tenants");
|
||||
let acme_name = pool.tenant_db_name(&acme.tenant_id);
|
||||
let globex_name = pool.tenant_db_name(&globex.tenant_id);
|
||||
assert!(
|
||||
names.contains(&acme_name),
|
||||
"expected {acme_name} in {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&globex_name),
|
||||
"expected {globex_name} in {names:?}"
|
||||
);
|
||||
for name in &names {
|
||||
assert!(name.starts_with(&format!("{prefix}_")));
|
||||
}
|
||||
|
||||
// drop_tenant removes acme's DB
|
||||
pool.drop_tenant(&acme.tenant_id)
|
||||
.await
|
||||
.expect("drop acme tenant");
|
||||
let after = pool
|
||||
.list_tenant_db_names()
|
||||
.await
|
||||
.expect("list tenants after drop");
|
||||
assert!(
|
||||
!after.contains(&acme_name),
|
||||
"acme should be gone after drop, got {after:?}"
|
||||
);
|
||||
assert!(
|
||||
after.contains(&globex_name),
|
||||
"globex should still be present, got {after:?}"
|
||||
);
|
||||
|
||||
// Cleanup remaining
|
||||
pool.drop_tenant(&globex.tenant_id)
|
||||
.await
|
||||
.expect("drop globex tenant");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tenant_db_name_falls_back_to_hash_when_too_long() {
|
||||
let uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
let pool = DatabasePool::connect(&uri, "m72a_long")
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
// 100-byte tenant_id would overflow the 63-byte db-name cap with
|
||||
// any reasonable prefix. The pool must hash it down.
|
||||
let huge = "x".repeat(100);
|
||||
let name = pool.tenant_db_name(&huge);
|
||||
assert!(name.len() <= 63, "hashed name should fit: {name}");
|
||||
assert!(name.starts_with("m72a_long_"));
|
||||
// The hash suffix is 32 hex chars (16-byte SHA-256 truncation).
|
||||
let suffix = name.trim_start_matches("m72a_long_");
|
||||
assert_eq!(
|
||||
suffix.len(),
|
||||
32,
|
||||
"expected 32-hex suffix (16-byte hash), got {suffix:?}"
|
||||
);
|
||||
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
|
||||
// Stable: same input → same output.
|
||||
assert_eq!(name, pool.tenant_db_name(&huge));
|
||||
|
||||
// Different inputs → different outputs (collision check on a tiny
|
||||
// sample — full birthday-resistance is a proof not a test).
|
||||
let huge2 = "y".repeat(100);
|
||||
assert_ne!(pool.tenant_db_name(&huge), pool.tenant_db_name(&huge2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_rejects_overlong_db_prefix() {
|
||||
let uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
|
||||
// MAX_PREFIX_LEN is 30 (= 63 - 1 - 32). A 31-char prefix MUST be
|
||||
// rejected at construction so the hash-fallback path can never
|
||||
// produce an over-long db name at runtime.
|
||||
let too_long = "a".repeat(31);
|
||||
let err = DatabasePool::connect(&uri, &too_long).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("max is 30") || msg.contains(&too_long),
|
||||
"error should explain the cap: {msg}"
|
||||
);
|
||||
|
||||
// Exactly 30 chars is the inclusive bound — must succeed.
|
||||
let just_right = "a".repeat(30);
|
||||
let _ = DatabasePool::connect(&uri, &just_right)
|
||||
.await
|
||||
.expect("30-char prefix should be accepted");
|
||||
}
|
||||
|
||||
/// Short UUID slug for keeping test prefixes well under Mongo's 63-byte
|
||||
/// db-name cap.
|
||||
fn short_id() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string()[..8].to_string()
|
||||
}
|
||||
|
||||
/// Drain a `repositories` find cursor on the given tenant database.
|
||||
async fn collect(db: &compliance_agent::database::Database) -> Vec<OnboardedTarget> {
|
||||
let mut cursor = db
|
||||
.onboarded_targets()
|
||||
.find(doc! {})
|
||||
.await
|
||||
.expect("find repositories");
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await.expect("advance") {
|
||||
out.push(cursor.deserialize_current().expect("deserialize"));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//! M7.1 — integration tests for `compliance_core::auth::require_tenant_status`.
|
||||
//!
|
||||
//! Exercises the middleware end-to-end through an Axum router so we
|
||||
//! catch wiring bugs (extension propagation, method matching) that pure
|
||||
//! unit tests would miss.
|
||||
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Request,
|
||||
http::{Method, StatusCode},
|
||||
middleware::{from_fn, Next},
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use compliance_core::{auth::require_tenant_status, TenantContext, TenantStatus};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn ctx_with(status: TenantStatus) -> TenantContext {
|
||||
TenantContext {
|
||||
tenant_id: "t-1".to_string(),
|
||||
tenant_slug: "acme".to_string(),
|
||||
org_roles: vec![],
|
||||
products: vec![],
|
||||
plan: "starter".to_string(),
|
||||
status,
|
||||
user_id: "u-1".to_string(),
|
||||
user_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn router_with_ctx(ctx: Option<TenantContext>) -> Router {
|
||||
let injector = move |mut req: Request, next: Next| {
|
||||
let ctx = ctx.clone();
|
||||
async move {
|
||||
if let Some(c) = ctx {
|
||||
req.extensions_mut().insert(c);
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
};
|
||||
|
||||
Router::new()
|
||||
.route("/r", get(|| async { "read" }))
|
||||
.route("/w", post(|| async { "write" }))
|
||||
.layer(from_fn(require_tenant_status))
|
||||
.layer(from_fn(injector))
|
||||
}
|
||||
|
||||
async fn call(router: Router, method: Method, path: &str) -> Response {
|
||||
let req = Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.expect("request build");
|
||||
router.oneshot(req).await.expect("oneshot")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_tenant_can_read_and_write() {
|
||||
let r = router_with_ctx(Some(ctx_with(TenantStatus::Active)));
|
||||
assert_eq!(
|
||||
call(r.clone(), Method::GET, "/r").await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trial_tenant_can_read_and_write() {
|
||||
let r = router_with_ctx(Some(ctx_with(TenantStatus::Trial)));
|
||||
assert_eq!(
|
||||
call(r.clone(), Method::GET, "/r").await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn demo_tenant_can_read_and_write() {
|
||||
let r = router_with_ctx(Some(ctx_with(TenantStatus::Demo)));
|
||||
assert_eq!(
|
||||
call(r.clone(), Method::GET, "/r").await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frozen_tenant_can_read_but_not_write() {
|
||||
let r = router_with_ctx(Some(ctx_with(TenantStatus::Frozen)));
|
||||
assert_eq!(
|
||||
call(r.clone(), Method::GET, "/r").await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(r, Method::POST, "/w").await.status(),
|
||||
StatusCode::PAYMENT_REQUIRED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archived_tenant_is_gone_on_every_method() {
|
||||
let r = router_with_ctx(Some(ctx_with(TenantStatus::Archived)));
|
||||
assert_eq!(
|
||||
call(r.clone(), Method::GET, "/r").await.status(),
|
||||
StatusCode::GONE
|
||||
);
|
||||
assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::GONE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_context_passes_through() {
|
||||
let r = router_with_ctx(None);
|
||||
assert_eq!(
|
||||
call(r.clone(), Method::GET, "/r").await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::OK);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
//! Integration tests for the Werkbank runner endpoints (WB-05).
|
||||
//!
|
||||
//! Drives the real HTTP handlers (lease/heartbeat/complete) against a live Mongo:
|
||||
//! a runner leases a seeded job, completes it, and the result's findings are
|
||||
//! persisted against the job's target. Also checks the bearer-token gate. Skips
|
||||
//! cleanly when no Mongo is reachable.
|
||||
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use axum::{middleware, Extension, Router};
|
||||
|
||||
use compliance_agent::agent::ComplianceAgent;
|
||||
use compliance_agent::api::handlers::werkbank_jobs;
|
||||
use compliance_agent::database::DatabasePool;
|
||||
use compliance_agent::werkbank::JobQueue;
|
||||
use compliance_core::models::werkbank::{InputRef, Job, JobResult, JobStatus, LeasedJob};
|
||||
use compliance_core::models::{
|
||||
Artifact, Finding, OnboardedTarget, PlcFormat, ScanType, Severity, TargetType,
|
||||
};
|
||||
|
||||
use common::{dev_config, TEST_RUNNER_TOKEN};
|
||||
|
||||
const TENANT: &str = "dev";
|
||||
|
||||
/// A running werkbank API on a random port, or `None` if no Mongo.
|
||||
struct Harness {
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
pool: DatabasePool,
|
||||
db_name: String,
|
||||
}
|
||||
|
||||
async fn start() -> Option<Harness> {
|
||||
let uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
let db_name = format!("wba_{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
|
||||
let pool = match DatabasePool::connect(&uri, &db_name).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
eprintln!("SKIP werkbank_api: no MongoDB reachable at {uri}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Touch the tenant DB so indexes are ensured before the queue is used.
|
||||
pool.for_tenant_id(TENANT).await.expect("tenant db");
|
||||
|
||||
let agent = ComplianceAgent::new(dev_config(uri, db_name.clone()), pool.clone());
|
||||
let app = Router::new()
|
||||
.route("/api/v1/werkbank/jobs/lease", post(werkbank_jobs::lease))
|
||||
.route(
|
||||
"/api/v1/werkbank/jobs/heartbeat",
|
||||
post(werkbank_jobs::heartbeat),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/werkbank/jobs/complete",
|
||||
post(werkbank_jobs::complete),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/werkbank/jobs/enqueue",
|
||||
post(werkbank_jobs::enqueue),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/werkbank/artifacts/{hash}",
|
||||
get(werkbank_jobs::serve_artifact),
|
||||
)
|
||||
.layer(middleware::from_fn(werkbank_jobs::require_runner_token))
|
||||
.layer(Extension(Arc::new(agent)));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.ok();
|
||||
});
|
||||
|
||||
Some(Harness {
|
||||
base_url: format!("http://127.0.0.1:{port}"),
|
||||
client: reqwest::Client::new(),
|
||||
pool,
|
||||
db_name,
|
||||
})
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn post(
|
||||
&self,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
body: serde_json::Value,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let mut r = self
|
||||
.client
|
||||
.post(format!("{}{path}", self.base_url))
|
||||
.json(&body);
|
||||
if let Some(t) = token {
|
||||
r = r.bearer_auth(t);
|
||||
}
|
||||
r
|
||||
}
|
||||
async fn cleanup(&self) {
|
||||
let _ = self
|
||||
.pool
|
||||
.client()
|
||||
.database(&format!("{}_{TENANT}", self.db_name))
|
||||
.drop()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn finding_for(target: &str, fp: &str) -> Finding {
|
||||
let mut f = Finding::new(
|
||||
target.to_string(),
|
||||
fp.to_string(),
|
||||
"ics-probe".to_string(),
|
||||
ScanType::IcsProbe,
|
||||
"Modbus exposed".to_string(),
|
||||
"unauthenticated".to_string(),
|
||||
Severity::Critical,
|
||||
);
|
||||
f.rule_id = Some("ics-modbus-exposed".to_string());
|
||||
f
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lease_complete_persists_findings_against_the_target() {
|
||||
let Some(h) = start().await else { return };
|
||||
let db = h.pool.for_tenant_id(TENANT).await.unwrap();
|
||||
let queue = JobQueue::new(&db);
|
||||
|
||||
// Seed a queued job.
|
||||
let job = Job::plc_provision("job-1", TENANT, "target-1", InputRef::blob("sha256:x"), 180);
|
||||
assert!(queue.enqueue(job, chrono::Utc::now()).await.unwrap());
|
||||
|
||||
// Lease it over HTTP.
|
||||
let resp = h
|
||||
.post(
|
||||
"/api/v1/werkbank/jobs/lease",
|
||||
Some(TEST_RUNNER_TOKEN),
|
||||
serde_json::json!({
|
||||
"tenant": TENANT, "runner_id": "r1", "executor": "docker",
|
||||
"labels": [], "lease_ttl_secs": 60
|
||||
}),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "lease should return a job");
|
||||
let leased: LeasedJob = resp.json().await.unwrap();
|
||||
assert_eq!(leased.job.id, "job-1");
|
||||
|
||||
// Complete it with a finding.
|
||||
let mut result = JobResult::succeeded("job-1");
|
||||
result.findings = vec![finding_for("target-1", "fp-abc")];
|
||||
let resp = h
|
||||
.post(
|
||||
"/api/v1/werkbank/jobs/complete",
|
||||
Some(TEST_RUNNER_TOKEN),
|
||||
serde_json::json!({
|
||||
"tenant": TENANT, "job_id": "job-1",
|
||||
"lease_token": leased.lease_token, "result": result
|
||||
}),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
assert!(resp.json::<serde_json::Value>().await.unwrap()["recorded"]
|
||||
.as_bool()
|
||||
.unwrap());
|
||||
|
||||
// The job is now succeeded, and the finding was persisted to the target.
|
||||
assert_eq!(
|
||||
queue.get("job-1").await.unwrap().unwrap().status,
|
||||
JobStatus::Succeeded
|
||||
);
|
||||
let stored = db
|
||||
.findings()
|
||||
.find_one(mongodb::bson::doc! { "fingerprint": "fp-abc" })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stored.is_some(), "finding should be persisted");
|
||||
|
||||
h.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_extracts_program_stores_a_blob_and_serves_it() {
|
||||
let Some(h) = start().await else { return };
|
||||
let db = h.pool.for_tenant_id(TENANT).await.unwrap();
|
||||
|
||||
// A PlcSps target with a single complete ST program uploaded.
|
||||
let dir = std::env::temp_dir().join(format!("wbq-prog-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let st = dir.join("main.st");
|
||||
std::fs::write(
|
||||
&st,
|
||||
"PROGRAM Main\nEND_PROGRAM\nCONFIGURATION C\n RESOURCE R\nEND_CONFIGURATION\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut target = OnboardedTarget::new("plc".into(), TargetType::PlcSps);
|
||||
let mut art = Artifact::plc_project("main.st", PlcFormat::StructuredText);
|
||||
art.stored_path = Some(st.to_string_lossy().to_string());
|
||||
target.artifacts.push(art);
|
||||
let ins = db.onboarded_targets().insert_one(&target).await.unwrap();
|
||||
let target_id = ins.inserted_id.as_object_id().unwrap().to_hex();
|
||||
|
||||
// Enqueue → a plc-provision job whose program is a content-addressed blob.
|
||||
let resp = h
|
||||
.post(
|
||||
"/api/v1/werkbank/jobs/enqueue",
|
||||
Some(TEST_RUNNER_TOKEN),
|
||||
serde_json::json!({ "tenant": TENANT, "target_id": target_id }),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "enqueue should succeed");
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let job_id = body["job_id"].as_str().unwrap().to_string();
|
||||
|
||||
let rec = JobQueue::new(&db).get(&job_id).await.unwrap().unwrap();
|
||||
let hash = rec
|
||||
.job
|
||||
.inputs
|
||||
.get("program")
|
||||
.and_then(|i| i.blob.clone())
|
||||
.expect("program blob");
|
||||
|
||||
// Serve the blob back and confirm it's the program source (what the runner
|
||||
// would fetch).
|
||||
let served = h
|
||||
.client
|
||||
.get(format!("{}/api/v1/werkbank/artifacts/{hash}", h.base_url))
|
||||
.bearer_auth(TEST_RUNNER_TOKEN)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(served.status(), 200);
|
||||
assert!(served.text().await.unwrap().contains("CONFIGURATION"));
|
||||
|
||||
h.cleanup().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_queue_leases_nothing() {
|
||||
let Some(h) = start().await else { return };
|
||||
let resp = h
|
||||
.post(
|
||||
"/api/v1/werkbank/jobs/lease",
|
||||
Some(TEST_RUNNER_TOKEN),
|
||||
serde_json::json!({
|
||||
"tenant": TENANT, "runner_id": "r1", "executor": "docker",
|
||||
"labels": [], "lease_ttl_secs": 60
|
||||
}),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 204, "no job → 204");
|
||||
h.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runner_endpoints_require_the_bearer_token() {
|
||||
let Some(h) = start().await else { return };
|
||||
let body = serde_json::json!({
|
||||
"tenant": TENANT, "runner_id": "r1", "executor": "docker",
|
||||
"labels": [], "lease_ttl_secs": 60
|
||||
});
|
||||
|
||||
let no_token = h
|
||||
.post("/api/v1/werkbank/jobs/lease", None, body.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(no_token.status(), 401, "missing token → 401");
|
||||
|
||||
let bad_token = h
|
||||
.post("/api/v1/werkbank/jobs/lease", Some("wrong"), body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bad_token.status(), 401, "wrong token → 401");
|
||||
|
||||
h.cleanup().await;
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Integration tests for the Werkbank job queue (WB-02).
|
||||
//!
|
||||
//! Exercises the atomic lease/heartbeat/complete/sweep flow against a real
|
||||
//! MongoDB — the guarantees (idempotent enqueue, single-owner lease, visibility
|
||||
//! timeout) are Mongo-semantics-dependent and can't be unit-tested in isolation.
|
||||
//! Skips cleanly when no Mongo is reachable (set `TEST_MONGODB_URI` to point at
|
||||
//! one; defaults to the local dev cluster).
|
||||
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
use compliance_agent::database::Database;
|
||||
use compliance_agent::werkbank::JobQueue;
|
||||
use compliance_core::models::werkbank::{Executor, InputRef, Job, JobResult};
|
||||
|
||||
/// Connect + ensure indexes on a throwaway database, or `None` if no Mongo.
|
||||
async fn setup() -> Option<(JobQueue, mongodb::Database)> {
|
||||
let uri = std::env::var("TEST_MONGODB_URI")
|
||||
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
|
||||
let db_name = format!("wbq_{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
|
||||
let db = match Database::connect(&uri, &db_name).await {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
eprintln!("SKIP werkbank_queue: no MongoDB reachable at {uri}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
db.ensure_indexes().await.expect("ensure indexes");
|
||||
let queue = JobQueue::new(&db);
|
||||
Some((queue, db.inner().clone()))
|
||||
}
|
||||
|
||||
fn base_time() -> DateTime<Utc> {
|
||||
Utc.timestamp_opt(1_700_000_000, 0).unwrap()
|
||||
}
|
||||
|
||||
fn job(id: &str) -> Job {
|
||||
Job::plc_provision(id, "acme", "target-1", InputRef::blob("sha256:abc"), 180)
|
||||
}
|
||||
|
||||
fn job_with_labels(id: &str, labels: &[&str]) -> Job {
|
||||
let mut j = job(id);
|
||||
j.labels = labels.iter().map(|s| s.to_string()).collect();
|
||||
j
|
||||
}
|
||||
|
||||
macro_rules! skip_if_no_mongo {
|
||||
() => {
|
||||
match setup().await {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_is_idempotent() {
|
||||
let (q, db) = skip_if_no_mongo!();
|
||||
let now = base_time();
|
||||
|
||||
assert!(q.enqueue(job("j1"), now).await.expect("enqueue"));
|
||||
// Same id again — no duplicate row, reports "already present".
|
||||
assert!(!q.enqueue(job("j1"), now).await.expect("enqueue2"));
|
||||
|
||||
let rec = q.get("j1").await.expect("get").expect("exists");
|
||||
assert_eq!(
|
||||
rec.status,
|
||||
compliance_core::models::werkbank::JobStatus::Queued
|
||||
);
|
||||
assert_eq!(rec.attempts, 0);
|
||||
|
||||
db.drop().await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lease_matches_executor_and_labels_and_is_fifo() {
|
||||
let (q, db) = skip_if_no_mongo!();
|
||||
let t0 = base_time();
|
||||
|
||||
// Two docker jobs (j_old older than j_new) + one requiring a kvm label.
|
||||
q.enqueue(job("j_old"), t0).await.unwrap();
|
||||
q.enqueue(job("j_new"), t0 + chrono::Duration::seconds(5))
|
||||
.await
|
||||
.unwrap();
|
||||
q.enqueue(job_with_labels("j_kvm", &["kvm=true"]), t0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Wrong executor: a shell runner leases nothing.
|
||||
assert!(q
|
||||
.lease("r-shell", Executor::Shell, &[], Duration::from_secs(30), t0)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// A docker runner without the kvm label gets the oldest label-free job (FIFO).
|
||||
let leased = q
|
||||
.lease("r1", Executor::Docker, &[], Duration::from_secs(30), t0)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("leased");
|
||||
assert_eq!(leased.job.id, "j_old", "oldest matching job first");
|
||||
assert!(!leased.lease_token.is_empty());
|
||||
|
||||
// The kvm job stays unleased for that runner (missing label)...
|
||||
let none = q
|
||||
.lease("r1", Executor::Docker, &[], Duration::from_secs(30), t0)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("next");
|
||||
assert_eq!(none.job.id, "j_new", "label-free job, not the kvm one");
|
||||
|
||||
// ...but a runner advertising kvm can take it.
|
||||
let kvm = q
|
||||
.lease(
|
||||
"r2",
|
||||
Executor::Docker,
|
||||
&["kvm=true".to_string(), "arch=amd64".to_string()],
|
||||
Duration::from_secs(30),
|
||||
t0,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("kvm leased");
|
||||
assert_eq!(kvm.job.id, "j_kvm");
|
||||
|
||||
// A leased job increments attempts and is no longer queued.
|
||||
let rec = q.get("j_old").await.unwrap().unwrap();
|
||||
assert_eq!(rec.attempts, 1);
|
||||
assert_eq!(rec.leased_by.as_deref(), Some("r1"));
|
||||
|
||||
db.drop().await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn heartbeat_extends_lease_and_surfaces_cancel() {
|
||||
let (q, db) = skip_if_no_mongo!();
|
||||
let now = base_time();
|
||||
|
||||
q.enqueue(job("j1"), now).await.unwrap();
|
||||
let leased = q
|
||||
.lease("r1", Executor::Docker, &[], Duration::from_secs(30), now)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// A valid heartbeat moves it to running and reports not-cancelled.
|
||||
let ack = q
|
||||
.heartbeat("j1", &leased.lease_token, Duration::from_secs(30), now)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("valid lease");
|
||||
assert!(!ack.cancelled);
|
||||
assert_eq!(
|
||||
q.get("j1").await.unwrap().unwrap().status,
|
||||
compliance_core::models::werkbank::JobStatus::Running
|
||||
);
|
||||
|
||||
// A wrong token is a lost lease.
|
||||
assert!(q
|
||||
.heartbeat("j1", "wrong-token", Duration::from_secs(30), now)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// Cancelling an in-flight job flags it; the next heartbeat reports cancelled.
|
||||
assert!(q.cancel("j1", now).await.unwrap());
|
||||
let ack = q
|
||||
.heartbeat("j1", &leased.lease_token, Duration::from_secs(30), now)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("still leased");
|
||||
assert!(ack.cancelled);
|
||||
|
||||
db.drop().await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_is_idempotent_and_token_guarded() {
|
||||
let (q, db) = skip_if_no_mongo!();
|
||||
let now = base_time();
|
||||
|
||||
q.enqueue(job("j1"), now).await.unwrap();
|
||||
let leased = q
|
||||
.lease("r1", Executor::Docker, &[], Duration::from_secs(30), now)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Wrong token cannot complete.
|
||||
let mut result = JobResult::succeeded("j1");
|
||||
result.findings = Vec::new();
|
||||
assert!(!q.complete("j1", "nope", &result, now).await.unwrap());
|
||||
|
||||
// The lease holder completes it once...
|
||||
assert!(q
|
||||
.complete("j1", &leased.lease_token, &result, now)
|
||||
.await
|
||||
.unwrap());
|
||||
let rec = q.get("j1").await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
rec.status,
|
||||
compliance_core::models::werkbank::JobStatus::Succeeded
|
||||
);
|
||||
assert!(rec.result.is_some());
|
||||
assert!(rec.lease_token.is_none(), "lease cleared on completion");
|
||||
|
||||
// ...and a second (duplicate) completion is a no-op.
|
||||
assert!(!q
|
||||
.complete("j1", &leased.lease_token, &result, now)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
db.drop().await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sweep_requeues_expired_then_expires_after_max_attempts() {
|
||||
let (q, db) = skip_if_no_mongo!();
|
||||
let t0 = base_time();
|
||||
|
||||
q.enqueue(job("j1"), t0).await.unwrap();
|
||||
|
||||
// Lease #1 with a 10s TTL; then time jumps past expiry.
|
||||
q.lease("r1", Executor::Docker, &[], Duration::from_secs(10), t0)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let past = t0 + chrono::Duration::seconds(60);
|
||||
|
||||
// attempts=1 < max=2 → requeued.
|
||||
let swept = q.sweep_expired(past, 2).await.unwrap();
|
||||
assert_eq!(swept.requeued, 1);
|
||||
assert_eq!(swept.expired, 0);
|
||||
assert_eq!(
|
||||
q.get("j1").await.unwrap().unwrap().status,
|
||||
compliance_core::models::werkbank::JobStatus::Queued
|
||||
);
|
||||
|
||||
// Lease #2 (attempts=2), let it expire again → now expired (>= max).
|
||||
q.lease("r2", Executor::Docker, &[], Duration::from_secs(10), past)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let later = past + chrono::Duration::seconds(60);
|
||||
let swept = q.sweep_expired(later, 2).await.unwrap();
|
||||
assert_eq!(swept.requeued, 0);
|
||||
assert_eq!(swept.expired, 1);
|
||||
assert_eq!(
|
||||
q.get("j1").await.unwrap().unwrap().status,
|
||||
compliance_core::models::werkbank::JobStatus::Expired
|
||||
);
|
||||
|
||||
db.drop().await.ok();
|
||||
}
|
||||
@@ -50,7 +50,3 @@ axum = { version = "0.8", optional = true }
|
||||
jsonwebtoken = { version = "9", optional = true }
|
||||
reqwest = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Parse the declarative TOML job specs in the Werkbank contract tests.
|
||||
toml = "0.8"
|
||||
|
||||
@@ -63,24 +63,16 @@ struct Claims {
|
||||
|
||||
const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"];
|
||||
|
||||
/// Path prefixes that bypass JWT validation. The admin sub-router
|
||||
/// (`/api/v1/admin/*`) and the Werkbank runner API (`/api/v1/werkbank/*`)
|
||||
/// have their own static-bearer middleware and must not be routed through the
|
||||
/// customer-JWT path — a Keycloak token always carries a single tenant_id and
|
||||
/// would semantically conflict with these cross-tenant / machine operations.
|
||||
const PUBLIC_PREFIXES: &[&str] = &["/api/v1/admin/", "/api/v1/werkbank/"];
|
||||
|
||||
/// Middleware that validates Bearer JWT tokens against Keycloak's JWKS
|
||||
/// and attaches a `TenantContext` extension on success.
|
||||
///
|
||||
/// Skips validation for the health endpoint and any path under one of
|
||||
/// the [`PUBLIC_PREFIXES`]. If `JwksState` is not present (Keycloak
|
||||
/// not configured), requests pass through and downstream code must
|
||||
/// handle the missing context.
|
||||
/// Skips validation for the health endpoint.
|
||||
/// If `JwksState` is not present (Keycloak not configured), requests
|
||||
/// pass through and downstream code must handle the missing context.
|
||||
pub async fn require_jwt_auth(mut request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path();
|
||||
|
||||
if PUBLIC_ENDPOINTS.contains(&path) || PUBLIC_PREFIXES.iter().any(|p| path.starts_with(p)) {
|
||||
if PUBLIC_ENDPOINTS.contains(&path) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,6 @@ pub struct AgentConfig {
|
||||
pub scan_schedule: String,
|
||||
pub cve_monitor_schedule: String,
|
||||
pub git_clone_base_path: String,
|
||||
/// Base directory for content-addressed artifact blobs and per-run working
|
||||
/// dirs (`<base>/blobs/<sha[0:2]>/<sha>`, `<base>/work/<target>/<artifact>/`).
|
||||
pub artifact_store_base_path: String,
|
||||
pub ssh_key_path: String,
|
||||
pub keycloak_url: Option<String>,
|
||||
pub keycloak_realm: Option<String>,
|
||||
@@ -40,98 +37,6 @@ pub struct AgentConfig {
|
||||
pub pentest_imap_tls: bool,
|
||||
pub pentest_imap_username: Option<String>,
|
||||
pub pentest_imap_password: Option<SecretString>,
|
||||
/// Static bearer for the cross-tenant admin endpoints under
|
||||
/// `/api/v1/admin/*`. When `None`, those endpoints are not
|
||||
/// mounted at all (defense-in-depth: ops endpoints never reach
|
||||
/// any auth path if no operator has explicitly opted in).
|
||||
pub admin_api_token: Option<SecretString>,
|
||||
/// Live tenant-registry URL the scheduler consults for the list
|
||||
/// of tenants to iterate. When `None` or unreachable, scheduler
|
||||
/// falls back to `SCHEDULER_TENANT_IDS` env (M7.2-C).
|
||||
pub tenant_registry_url: Option<String>,
|
||||
/// Ephemeral soft-PLC provisioning for dynamic PLC testing (#183). Off by
|
||||
/// default: it needs Docker access in the agent's runtime, which is a
|
||||
/// deployment opt-in.
|
||||
pub plc_runtime: PlcRuntimeConfig,
|
||||
/// Static bearer for the Werkbank runner endpoints
|
||||
/// (`/api/v1/werkbank/jobs/*`). Machine auth for runners leasing/completing
|
||||
/// jobs — NOT a Keycloak JWT, since a runner acts across tenants. When
|
||||
/// `None`, those endpoints are not mounted at all.
|
||||
pub werkbank_runner_token: Option<SecretString>,
|
||||
/// Source for the OSCAL control catalog pulled from breakpilot-compliance
|
||||
/// (drives the [`crate::traits::ControlsProvider`]). Disabled when
|
||||
/// `base_url` is `None`.
|
||||
pub breakpilot: BreakpilotConfig,
|
||||
}
|
||||
|
||||
/// Where to pull the OSCAL control catalog from breakpilot-compliance, and where
|
||||
/// to snapshot it for deterministic / offline reuse.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BreakpilotConfig {
|
||||
/// Backend base URL (e.g. `http://backend-compliance:8002`). `None` disables
|
||||
/// the OSCAL controls provider.
|
||||
pub base_url: Option<String>,
|
||||
/// Optional bearer token for the catalog endpoint.
|
||||
pub token: Option<SecretString>,
|
||||
/// Directory for catalog snapshots.
|
||||
pub snapshot_dir: String,
|
||||
}
|
||||
|
||||
impl Default for BreakpilotConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: None,
|
||||
token: None,
|
||||
snapshot_dir: "/data/compliance-scanner/oscal".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the ephemeral soft-PLC "provision-and-test" path (#183).
|
||||
///
|
||||
/// When a PLC/SPS target ships control logic but no reachable live device, the
|
||||
/// agent can instantiate that logic itself: spin up a throwaway soft-PLC
|
||||
/// (OpenPLC) container in-cluster, load the program, start the runtime, probe it
|
||||
/// over industrial protocols, then tear it down. This struct carries the knobs
|
||||
/// for that container's lifecycle and the OpenPLC web-UI credentials used to
|
||||
/// upload the program.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlcRuntimeConfig {
|
||||
/// Master switch. Provision-and-test does nothing unless this is set — it
|
||||
/// shells out to `docker`, which requires the agent container to have Docker
|
||||
/// access (socket mount), an explicit deployment decision.
|
||||
pub enabled: bool,
|
||||
/// Container image for the ephemeral soft-PLC (OpenPLC).
|
||||
pub image: String,
|
||||
/// Docker network the instance joins. Must be the agent's own network so it
|
||||
/// is reachable in-cluster by container name and never published to the host.
|
||||
pub network: String,
|
||||
/// Memory cap passed to `docker run --memory` (e.g. `512m`).
|
||||
pub memory: String,
|
||||
/// CPU cap passed to `docker run --cpus` (e.g. `0.5`).
|
||||
pub cpus: String,
|
||||
/// Hard ceiling on a provisioned instance's lifetime. Teardown is guaranteed
|
||||
/// no later than this even if a load/probe step hangs.
|
||||
pub max_lifetime_secs: u64,
|
||||
/// OpenPLC web-UI username for the program upload (image default `openplc`).
|
||||
pub openplc_user: String,
|
||||
/// OpenPLC web-UI password (image default `openplc`).
|
||||
pub openplc_password: SecretString,
|
||||
}
|
||||
|
||||
impl Default for PlcRuntimeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
image: "registry.meghsakha.com/openplc:latest".to_string(),
|
||||
network: "certifai".to_string(),
|
||||
memory: "512m".to_string(),
|
||||
cpus: "0.5".to_string(),
|
||||
max_lifetime_secs: 180,
|
||||
openplc_user: "openplc".to_string(),
|
||||
openplc_password: SecretString::from("openplc".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod scan_matrix;
|
||||
#[cfg(feature = "telemetry")]
|
||||
pub mod telemetry;
|
||||
pub mod tenant;
|
||||
@@ -13,6 +12,6 @@ pub mod auth;
|
||||
#[cfg(feature = "axum")]
|
||||
pub mod tenant_ctx;
|
||||
|
||||
pub use config::{AgentConfig, DashboardConfig, PlcRuntimeConfig};
|
||||
pub use config::{AgentConfig, DashboardConfig};
|
||||
pub use error::CoreError;
|
||||
pub use tenant::{OrgRole, TenantContext, TenantStatus};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
//! Per-tenant API tokens used by `compliance-mcp` to authenticate MCP
|
||||
//! HTTP requests on behalf of LLM clients (Claude Desktop, Cursor,
|
||||
//! ChatGPT, etc.) that can't run a Keycloak OIDC flow.
|
||||
//!
|
||||
//! Tokens are opaque strings of the form `mcpt_<44 url-safe random
|
||||
//! chars>`. The raw value is shown to the user exactly once at
|
||||
//! creation; the database only ever sees the SHA-256 hash. Lookups go
|
||||
//! through the cross-tenant `<prefix>__admin.mcp_tokens` collection
|
||||
//! and return the `tenant_id` the MCP server should route to.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Persisted token metadata. `token_hash` is the SHA-256 hex of the
|
||||
/// raw token; the raw token itself is never stored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToken {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<bson::oid::ObjectId>,
|
||||
/// SHA-256 hex of the raw token. Unique index in the collection.
|
||||
pub token_hash: String,
|
||||
/// First 8 chars of the raw token — purely for UI display so users
|
||||
/// can identify which token is which without re-issuing.
|
||||
pub token_prefix: String,
|
||||
/// Routes to `<db_prefix>_<tenant_id>` on MCP requests.
|
||||
pub tenant_id: String,
|
||||
/// User-given label, e.g. "Claude Desktop" or "Sharang's laptop".
|
||||
pub name: String,
|
||||
/// Keycloak `sub` of the user who created this token, for audit.
|
||||
pub created_by: String,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
/// Soft-delete flag. A revoked token doc stays around for audit
|
||||
/// but never authenticates.
|
||||
#[serde(default)]
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
/// Public projection of a token — never includes the hash.
|
||||
/// Returned by `GET /api/v1/mcp-tokens`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpTokenView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `mcpt_xxxx…` so the user can identify which row is which.
|
||||
pub token_prefix: String,
|
||||
pub created_by: String,
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
impl From<&McpToken> for McpTokenView {
|
||||
fn from(t: &McpToken) -> Self {
|
||||
Self {
|
||||
id: t.id.map(|o| o.to_hex()).unwrap_or_default(),
|
||||
name: t.name.clone(),
|
||||
token_prefix: t.token_prefix.clone(),
|
||||
created_by: t.created_by.clone(),
|
||||
created_at: t.created_at,
|
||||
last_used_at: t.last_used_at,
|
||||
revoked: t.revoked,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,12 @@ pub mod finding;
|
||||
pub mod graph;
|
||||
pub mod issue;
|
||||
pub mod mcp;
|
||||
pub mod mcp_token;
|
||||
pub mod notification;
|
||||
pub mod onboarding;
|
||||
pub mod oscal;
|
||||
pub mod oscal_assessment;
|
||||
pub mod pentest;
|
||||
pub mod repository;
|
||||
pub mod sbom;
|
||||
pub mod scan;
|
||||
pub(crate) mod serde_helpers;
|
||||
pub mod werkbank;
|
||||
|
||||
pub use auth::AuthInfo;
|
||||
pub use chat::{ChatMessage, ChatRequest, ChatResponse, SourceReference};
|
||||
@@ -33,27 +28,13 @@ pub use graph::{
|
||||
};
|
||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
||||
pub use mcp_token::{McpToken, McpTokenView};
|
||||
pub use notification::{CveNotification, NotificationSeverity, NotificationStatus};
|
||||
pub use onboarding::{
|
||||
default_compliance_profile, Artifact, ArtifactAuth, ArtifactKind, Classification,
|
||||
ComplianceFramework, ComplianceProfile, DetectedFact, ExternalRef, ExternalSystem,
|
||||
GitArtifactConfig, IssueTrackerConfig, OnboardedTarget, PlcArtifactConfig, PlcFormat,
|
||||
TargetScanConfig, TargetType, TargetTypeCandidate, WebArtifactConfig,
|
||||
};
|
||||
pub use oscal::OscalDocument;
|
||||
pub use oscal_assessment::{assess, AssessmentResultsDoc, ControlLinker};
|
||||
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;
|
||||
pub use repository::{ScanTrigger, TrackedRepository};
|
||||
pub use sbom::{SbomEntry, VulnRef};
|
||||
pub use scan::{ScanPhase, ScanRun, ScanRunStatus, ScanType};
|
||||
pub use werkbank::{
|
||||
CompleteRequest, CompleteResponse, DastCollect, Executor, HeartbeatAck, HeartbeatRequest,
|
||||
InputRef, Job, JobCollect, JobRecord, JobResult, JobRuntime, JobStatus, JobType, LeaseRequest,
|
||||
LeasedJob,
|
||||
};
|
||||
|
||||
@@ -1,756 +0,0 @@
|
||||
//! The unified onboarding model.
|
||||
//!
|
||||
//! An [`OnboardedTarget`] is the single source of truth for anything the scanner
|
||||
//! can analyze. It records *what kind of software* the target is ([`TargetType`]),
|
||||
//! the concrete [`Artifact`]s that were provided for it (a git repo, a firmware
|
||||
//! image, a live URL, a PLC project, ...), the classifier's verdict, and the scan
|
||||
//! configuration. It replaces the older git-only `TrackedRepository` and the
|
||||
//! standalone `DastTarget`, both of which fold into this type as artifacts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::dast::{DastAuthConfig, DastTargetType};
|
||||
use super::issue::TrackerType;
|
||||
use super::pentest::{Environment, PentestConfig, PentestStrategy};
|
||||
use super::scan::ScanType;
|
||||
|
||||
/// The family of software a target belongs to.
|
||||
///
|
||||
/// Targets look endlessly varied but fall into a small enumerable set classified
|
||||
/// by where the analyzable signal lives. This drives the scan-applicability
|
||||
/// matrix and the onboarding wizard's type selection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TargetType {
|
||||
/// Browser-facing web application (front end + server).
|
||||
WebApp,
|
||||
/// Headless backend service / API (REST, GraphQL, gRPC).
|
||||
BackendService,
|
||||
/// Desktop application (Windows/macOS/Linux GUI or CLI binary).
|
||||
DesktopApp,
|
||||
/// Android application (APK / AAB).
|
||||
AndroidApp,
|
||||
/// iOS application (IPA).
|
||||
IosApp,
|
||||
/// Bare-metal embedded firmware (no operating system).
|
||||
FirmwareBareMetal,
|
||||
/// Embedded firmware running on an RTOS (Zephyr, FreeRTOS, ...).
|
||||
FirmwareRtos,
|
||||
/// Embedded Linux built with Yocto / OpenEmbedded (BSP + image).
|
||||
EmbeddedLinuxYocto,
|
||||
/// Programmable logic controller software (IEC 61131-3, PLCopen / SPS).
|
||||
PlcSps,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TargetType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::WebApp => write!(f, "web_app"),
|
||||
Self::BackendService => write!(f, "backend_service"),
|
||||
Self::DesktopApp => write!(f, "desktop_app"),
|
||||
Self::AndroidApp => write!(f, "android_app"),
|
||||
Self::IosApp => write!(f, "ios_app"),
|
||||
Self::FirmwareBareMetal => write!(f, "firmware_bare_metal"),
|
||||
Self::FirmwareRtos => write!(f, "firmware_rtos"),
|
||||
Self::EmbeddedLinuxYocto => write!(f, "embedded_linux_yocto"),
|
||||
Self::PlcSps => write!(f, "plc_sps"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of artifact provided for a target.
|
||||
///
|
||||
/// Which scans are possible is a function of the target type *and* which of
|
||||
/// these are present (SAST needs code, DAST needs a running URL, and so on).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ArtifactKind {
|
||||
/// A git repository (cloned for static analysis).
|
||||
GitRepo,
|
||||
/// A source archive (zip / tarball) with no live git remote.
|
||||
SourceArchive,
|
||||
/// A firmware image or binary blob.
|
||||
FirmwareImage,
|
||||
/// A mobile package: Android APK/AAB or iOS IPA.
|
||||
MobilePackage,
|
||||
/// An OCI/Docker container image reference.
|
||||
ContainerImage,
|
||||
/// A reachable running instance (base URL / endpoint) for dynamic testing.
|
||||
LiveUrl,
|
||||
/// A PLC project: PLCopen XML or Structured Text source.
|
||||
PlcProject,
|
||||
/// Free-form plaintext describing the target (feeds classification only).
|
||||
PlaintextDescription,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ArtifactKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::GitRepo => write!(f, "git_repo"),
|
||||
Self::SourceArchive => write!(f, "source_archive"),
|
||||
Self::FirmwareImage => write!(f, "firmware_image"),
|
||||
Self::MobilePackage => write!(f, "mobile_package"),
|
||||
Self::ContainerImage => write!(f, "container_image"),
|
||||
Self::LiveUrl => write!(f, "live_url"),
|
||||
Self::PlcProject => write!(f, "plc_project"),
|
||||
Self::PlaintextDescription => write!(f, "plaintext_description"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials attached to an artifact.
|
||||
///
|
||||
/// This folds both `TrackedRepository`'s git auth (`auth_token` / `auth_username`
|
||||
/// / SSH key) and `DastAuthConfig`'s HTTP auth (form / bearer / cookie) into one
|
||||
/// shape so a single artifact carries whatever it needs to be fetched or probed.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ArtifactAuth {
|
||||
/// Auth method: `none` | `token` | `basic` | `bearer` | `cookie` | `form` | `ssh`.
|
||||
#[serde(default)]
|
||||
pub method: String,
|
||||
/// Username (git user, basic-auth user, or `x-access-token` for PATs).
|
||||
pub username: Option<String>,
|
||||
/// The secret credential: PAT, password, or bearer token. Encrypted at rest.
|
||||
pub secret: Option<String>,
|
||||
/// Path to an SSH private key for git-over-SSH.
|
||||
pub ssh_key_path: Option<String>,
|
||||
/// Login URL for form-based authentication.
|
||||
pub login_url: Option<String>,
|
||||
/// Extra headers to send when authenticating / probing.
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl From<DastAuthConfig> for ArtifactAuth {
|
||||
fn from(c: DastAuthConfig) -> Self {
|
||||
Self {
|
||||
method: c.method,
|
||||
username: c.username,
|
||||
// Prefer a bearer token; otherwise fall back to the password.
|
||||
secret: c.token.or(c.password),
|
||||
ssh_key_path: None,
|
||||
login_url: c.login_url,
|
||||
headers: c.headers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Git-specific configuration for a [`ArtifactKind::GitRepo`] artifact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitArtifactConfig {
|
||||
/// Branch to scan.
|
||||
pub default_branch: String,
|
||||
/// Commit SHA of the last completed scan (change-detection watermark).
|
||||
pub last_scanned_commit: Option<String>,
|
||||
/// Local clone path once the repo has been fetched.
|
||||
pub local_path: Option<String>,
|
||||
}
|
||||
|
||||
impl GitArtifactConfig {
|
||||
/// Config for a fresh git artifact on the given branch.
|
||||
pub fn on_branch(branch: impl Into<String>) -> Self {
|
||||
Self {
|
||||
default_branch: branch.into(),
|
||||
last_scanned_commit: None,
|
||||
local_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GitArtifactConfig {
|
||||
fn default() -> Self {
|
||||
Self::on_branch("main")
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic-analysis configuration for a [`ArtifactKind::LiveUrl`] artifact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebArtifactConfig {
|
||||
/// Whether the endpoint is a web app, REST API, or GraphQL API.
|
||||
pub target_kind: DastTargetType,
|
||||
/// URL paths to exclude from crawling / scanning.
|
||||
#[serde(default)]
|
||||
pub excluded_paths: Vec<String>,
|
||||
/// Maximum crawl depth.
|
||||
pub max_crawl_depth: u32,
|
||||
/// Rate limit in requests per second.
|
||||
pub rate_limit: u32,
|
||||
/// Whether destructive methods (DELETE / PUT) are permitted.
|
||||
#[serde(default)]
|
||||
pub allow_destructive: bool,
|
||||
}
|
||||
|
||||
impl Default for WebArtifactConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target_kind: DastTargetType::WebApp,
|
||||
excluded_paths: Vec::new(),
|
||||
max_crawl_depth: 3,
|
||||
rate_limit: 10,
|
||||
allow_destructive: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The source format of a PLC project artifact.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlcFormat {
|
||||
/// PLCopen XML project export.
|
||||
PlcopenXml,
|
||||
/// IEC 61131-3 Structured Text source.
|
||||
StructuredText,
|
||||
/// A CODESYS project archive (`.projectarchive` — a zip bundling the project
|
||||
/// plus its referenced libraries and runtime; the source of the control-app SBOM).
|
||||
ProjectArchive,
|
||||
}
|
||||
|
||||
/// PLC-specific configuration for a [`ArtifactKind::PlcProject`] artifact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlcArtifactConfig {
|
||||
/// The project source format.
|
||||
pub format: PlcFormat,
|
||||
}
|
||||
|
||||
/// A single fact discovered about a target by ingest or classification
|
||||
/// (e.g. `language=rust`, `build_system=cmake`, `mcu=stm32f429`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectedFact {
|
||||
/// The fact name.
|
||||
pub key: String,
|
||||
/// The fact value.
|
||||
pub value: String,
|
||||
/// What produced the fact (e.g. `tramiton`, `language-fingerprint`).
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
impl DetectedFact {
|
||||
/// Build a fact from its parts.
|
||||
pub fn new(
|
||||
key: impl Into<String>,
|
||||
value: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
value: value.into(),
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One concrete thing provided for a target: code, a binary, a URL, etc.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
/// Stable per-artifact id (UUID v4) — scan steps reference this.
|
||||
pub id: String,
|
||||
/// What kind of artifact this is.
|
||||
pub kind: ArtifactKind,
|
||||
/// The source reference: git URL, blob id, live URL, or image ref.
|
||||
pub source_ref: String,
|
||||
/// Optional human-friendly label.
|
||||
pub display_name: Option<String>,
|
||||
/// Content-addressed storage path once ingested (blobs only).
|
||||
pub stored_path: Option<String>,
|
||||
/// SHA-256 of the ingested content (git artifacts store the head SHA).
|
||||
pub content_hash: Option<String>,
|
||||
/// Size of the stored blob in bytes.
|
||||
pub size_bytes: Option<u64>,
|
||||
/// Credentials for fetching or probing this artifact.
|
||||
pub auth: Option<ArtifactAuth>,
|
||||
/// Git configuration (present for [`ArtifactKind::GitRepo`]).
|
||||
pub git: Option<GitArtifactConfig>,
|
||||
/// Dynamic-analysis configuration (present for [`ArtifactKind::LiveUrl`]).
|
||||
pub web: Option<WebArtifactConfig>,
|
||||
/// PLC configuration (present for [`ArtifactKind::PlcProject`]).
|
||||
pub plc: Option<PlcArtifactConfig>,
|
||||
/// Facts discovered about this artifact by ingest / classification.
|
||||
#[serde(default)]
|
||||
pub detected: Vec<DetectedFact>,
|
||||
/// When this artifact was last ingested.
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub ingested_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Artifact {
|
||||
/// A bare artifact of the given kind and source reference.
|
||||
fn bare(kind: ArtifactKind, source_ref: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
kind,
|
||||
source_ref: source_ref.into(),
|
||||
display_name: None,
|
||||
stored_path: None,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
auth: None,
|
||||
git: None,
|
||||
web: None,
|
||||
plc: None,
|
||||
detected: Vec::new(),
|
||||
ingested_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A git-repository artifact tracking the given branch.
|
||||
pub fn git_repo(url: impl Into<String>, branch: impl Into<String>) -> Self {
|
||||
let mut a = Self::bare(ArtifactKind::GitRepo, url);
|
||||
a.git = Some(GitArtifactConfig::on_branch(branch));
|
||||
a
|
||||
}
|
||||
|
||||
/// A live-URL artifact with default crawl settings.
|
||||
pub fn live_url(url: impl Into<String>) -> Self {
|
||||
let mut a = Self::bare(ArtifactKind::LiveUrl, url);
|
||||
a.web = Some(WebArtifactConfig::default());
|
||||
a
|
||||
}
|
||||
|
||||
/// A firmware-image artifact referenced by name (blob ingested later).
|
||||
pub fn firmware_image(source_ref: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::FirmwareImage, source_ref)
|
||||
}
|
||||
|
||||
/// A source-archive artifact referenced by name (blob ingested later).
|
||||
pub fn source_archive(source_ref: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::SourceArchive, source_ref)
|
||||
}
|
||||
|
||||
/// A mobile-package artifact (APK/AAB/IPA) referenced by name.
|
||||
pub fn mobile_package(source_ref: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::MobilePackage, source_ref)
|
||||
}
|
||||
|
||||
/// A container-image artifact referenced by OCI ref.
|
||||
pub fn container_image(source_ref: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::ContainerImage, source_ref)
|
||||
}
|
||||
|
||||
/// A PLC-project artifact in the given format.
|
||||
pub fn plc_project(source_ref: impl Into<String>, format: PlcFormat) -> Self {
|
||||
let mut a = Self::bare(ArtifactKind::PlcProject, source_ref);
|
||||
a.plc = Some(PlcArtifactConfig { format });
|
||||
a
|
||||
}
|
||||
|
||||
/// A plaintext-description artifact (classification input only).
|
||||
pub fn plaintext(text: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::PlaintextDescription, text)
|
||||
}
|
||||
}
|
||||
|
||||
/// One ranked candidate produced by the classifier.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TargetTypeCandidate {
|
||||
/// The candidate target type.
|
||||
pub target_type: TargetType,
|
||||
/// Confidence in `[0.0, 1.0]`.
|
||||
pub confidence: f32,
|
||||
/// Why this candidate was proposed.
|
||||
pub rationale: String,
|
||||
}
|
||||
|
||||
/// The classifier's verdict for a target: a suggested type plus ranked
|
||||
/// alternatives and the facts the decision rested on.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Classification {
|
||||
/// The top-ranked target type.
|
||||
pub suggested: TargetType,
|
||||
/// All candidates, sorted by descending confidence.
|
||||
#[serde(default)]
|
||||
pub candidates: Vec<TargetTypeCandidate>,
|
||||
/// Facts gathered during classification.
|
||||
#[serde(default)]
|
||||
pub facts: Vec<DetectedFact>,
|
||||
/// Which classifiers contributed (e.g. `["tramiton", "language-fingerprint"]`).
|
||||
#[serde(default)]
|
||||
pub detected_by: Vec<String>,
|
||||
/// When classification ran.
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub detected_at: DateTime<Utc>,
|
||||
/// Whether a human confirmed the suggestion.
|
||||
#[serde(default)]
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
/// Issue-tracker linkage, migrated from `TrackedRepository`'s `tracker_*` fields.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct IssueTrackerConfig {
|
||||
/// The tracker platform.
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
/// Tracker owner / organization.
|
||||
pub owner: Option<String>,
|
||||
/// Tracker repository / project.
|
||||
pub repo: Option<String>,
|
||||
/// Per-target tracker access token.
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// How a target should be scanned.
|
||||
///
|
||||
/// `enabled_scans` / `disabled_scans` override the scan-applicability matrix
|
||||
/// defaults; the pentest and tracker blocks reuse the existing wizard config.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TargetScanConfig {
|
||||
/// Scans explicitly turned on (empty means "use matrix defaults").
|
||||
#[serde(default)]
|
||||
pub enabled_scans: Vec<ScanType>,
|
||||
/// Scans explicitly turned off.
|
||||
#[serde(default)]
|
||||
pub disabled_scans: Vec<ScanType>,
|
||||
/// Target environment (gates destructive / active testing).
|
||||
#[serde(default)]
|
||||
pub environment: Environment,
|
||||
/// Whether destructive tests are permitted for this target.
|
||||
#[serde(default)]
|
||||
pub allow_destructive: bool,
|
||||
/// Pentest strategy selector.
|
||||
pub strategy: Option<PentestStrategy>,
|
||||
/// Full pentest wizard configuration.
|
||||
pub pentest: Option<PentestConfig>,
|
||||
/// Issue-tracker linkage.
|
||||
pub issue_tracker: Option<IssueTrackerConfig>,
|
||||
}
|
||||
|
||||
/// A sibling product in the company suite that may already hold authoritative
|
||||
/// data for a target. compliance-scanner reconciles with these rather than
|
||||
/// recomputing what they already know.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExternalSystem {
|
||||
/// Reproducible-build & firmware compliance engine (build plan, SBOM, VEX,
|
||||
/// attestation).
|
||||
Tramiton,
|
||||
/// Code assistant (downstream remediation consumer).
|
||||
Werkpilot,
|
||||
/// Compliance-controls RAG (atomic controls derived from laws).
|
||||
BreakpilotCompliance,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExternalSystem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Tramiton => write!(f, "tramiton"),
|
||||
Self::Werkpilot => write!(f, "werkpilot"),
|
||||
Self::BreakpilotCompliance => write!(f, "breakpilot_compliance"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A link from this target to a record in a sibling product, used to reconcile
|
||||
/// existing evidence instead of recomputing it.
|
||||
///
|
||||
/// For tramiton, `project_id` is the shared cross-product key and
|
||||
/// `subject_sha256` matches a firmware artifact's [`Artifact::content_hash`]
|
||||
/// (which equals tramiton's `Artifact.sha256`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExternalRef {
|
||||
/// Which sibling product this reference points at.
|
||||
pub system: ExternalSystem,
|
||||
/// The sibling product's project identifier, if known.
|
||||
pub project_id: Option<String>,
|
||||
/// Content digest of the subject artifact (firmware sha256), if known.
|
||||
pub subject_sha256: Option<String>,
|
||||
/// Reconciliation status: `linked` | `reconciled` | `unavailable`.
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
/// Opaque, offline-verifiable entitlement grant (e.g. tramiton's signed
|
||||
/// `LicenseGrant`), if the tenant provided one.
|
||||
pub license_grant: Option<String>,
|
||||
/// When evidence was last reconciled from this system.
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub last_reconciled_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl ExternalRef {
|
||||
/// A freshly linked (not yet reconciled) reference to a sibling system.
|
||||
pub fn linked(system: ExternalSystem) -> Self {
|
||||
Self {
|
||||
system,
|
||||
project_id: None,
|
||||
subject_sha256: None,
|
||||
status: "linked".to_string(),
|
||||
license_grant: None,
|
||||
last_reconciled_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A regulatory / standards framework a target must comply with. Drives which
|
||||
/// controls the mapping engine pulls from the [`crate::traits::ControlsProvider`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComplianceFramework {
|
||||
/// EU Cyber Resilience Act.
|
||||
Cra,
|
||||
/// IEC 62443 (industrial automation & control systems security).
|
||||
Iec62443,
|
||||
/// EU General Data Protection Regulation.
|
||||
Gdpr,
|
||||
/// SOC 2.
|
||||
Soc2,
|
||||
/// ISO/IEC 27001.
|
||||
Iso27001,
|
||||
/// EU Radio Equipment Directive (RED) cybersecurity articles.
|
||||
RedDirective,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ComplianceFramework {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Cra => write!(f, "cra"),
|
||||
Self::Iec62443 => write!(f, "iec_62443"),
|
||||
Self::Gdpr => write!(f, "gdpr"),
|
||||
Self::Soc2 => write!(f, "soc2"),
|
||||
Self::Iso27001 => write!(f, "iso_27001"),
|
||||
Self::RedDirective => write!(f, "red_directive"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The compliance scope of a target: which frameworks apply and, optionally, the
|
||||
/// jurisdiction. Captured at onboarding (with per-target-type defaults from
|
||||
/// [`default_compliance_profile`]).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ComplianceProfile {
|
||||
/// Applicable frameworks.
|
||||
#[serde(default)]
|
||||
pub frameworks: Vec<ComplianceFramework>,
|
||||
/// Free-form jurisdiction (e.g. `eu`, `us`, `de`).
|
||||
pub jurisdiction: Option<String>,
|
||||
}
|
||||
|
||||
/// The sensible default compliance scope for a target type. Firmware / PLC /
|
||||
/// embedded default to CRA + IEC 62443; software defaults to GDPR + SOC 2.
|
||||
pub fn default_compliance_profile(target_type: TargetType) -> ComplianceProfile {
|
||||
use ComplianceFramework::{Cra, Gdpr, Iec62443, Soc2};
|
||||
let frameworks = match target_type {
|
||||
TargetType::PlcSps
|
||||
| TargetType::FirmwareBareMetal
|
||||
| TargetType::FirmwareRtos
|
||||
| TargetType::EmbeddedLinuxYocto => vec![Cra, Iec62443],
|
||||
TargetType::WebApp | TargetType::BackendService => vec![Gdpr, Soc2],
|
||||
TargetType::DesktopApp | TargetType::AndroidApp | TargetType::IosApp => {
|
||||
vec![Gdpr, Cra]
|
||||
}
|
||||
};
|
||||
ComplianceProfile {
|
||||
frameworks,
|
||||
jurisdiction: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A target onboarded for scanning: the unified replacement for the legacy
|
||||
/// `TrackedRepository` (SAST) and `DastTarget` (DAST) records.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OnboardedTarget {
|
||||
/// Mongo id. Preserved from the legacy record during migration so every
|
||||
/// downstream collection keyed by `repo_id` / `target_id` keeps resolving.
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<bson::oid::ObjectId>,
|
||||
/// Human-friendly name.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// The software family this target belongs to.
|
||||
pub target_type: TargetType,
|
||||
/// Optional free-form description (also a classification input).
|
||||
pub description: Option<String>,
|
||||
/// The artifacts provided for this target.
|
||||
#[serde(default)]
|
||||
pub artifacts: Vec<Artifact>,
|
||||
/// The classifier's verdict, once run.
|
||||
pub classification: Option<Classification>,
|
||||
/// How this target should be scanned.
|
||||
#[serde(default)]
|
||||
pub scan_config: TargetScanConfig,
|
||||
/// The compliance scope (applicable frameworks / jurisdiction).
|
||||
#[serde(default)]
|
||||
pub compliance_profile: ComplianceProfile,
|
||||
/// Links to sibling products (tramiton, ...) holding reconcilable evidence.
|
||||
#[serde(default)]
|
||||
pub external_refs: Vec<ExternalRef>,
|
||||
/// Cron schedule for recurring scans, if any.
|
||||
pub scan_schedule: Option<String>,
|
||||
/// Whether inbound webhooks are enabled for this target.
|
||||
#[serde(default)]
|
||||
pub webhook_enabled: bool,
|
||||
/// HMAC secret for verifying inbound webhooks.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webhook_secret: Option<String>,
|
||||
/// Cached count of findings across this target's scans.
|
||||
#[serde(default)]
|
||||
pub findings_count: u32,
|
||||
/// Creation timestamp.
|
||||
#[serde(
|
||||
default = "chrono::Utc::now",
|
||||
with = "super::serde_helpers::bson_datetime"
|
||||
)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Last-update timestamp.
|
||||
#[serde(
|
||||
default = "chrono::Utc::now",
|
||||
with = "super::serde_helpers::bson_datetime"
|
||||
)]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl OnboardedTarget {
|
||||
/// A new target of the given type with a freshly generated webhook secret.
|
||||
pub fn new(name: String, target_type: TargetType) -> Self {
|
||||
let now = Utc::now();
|
||||
let webhook_secret = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||
Self {
|
||||
id: None,
|
||||
name,
|
||||
target_type,
|
||||
description: None,
|
||||
artifacts: Vec::new(),
|
||||
classification: None,
|
||||
scan_config: TargetScanConfig::default(),
|
||||
compliance_profile: default_compliance_profile(target_type),
|
||||
external_refs: Vec::new(),
|
||||
scan_schedule: None,
|
||||
webhook_enabled: false,
|
||||
webhook_secret: Some(webhook_secret),
|
||||
findings_count: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first artifact of the given kind, if present.
|
||||
pub fn first_of(&self, kind: ArtifactKind) -> Option<&Artifact> {
|
||||
self.artifacts.iter().find(|a| a.kind == kind)
|
||||
}
|
||||
|
||||
/// Whether the target has at least one artifact of the given kind.
|
||||
pub fn has(&self, kind: ArtifactKind) -> bool {
|
||||
self.artifacts.iter().any(|a| a.kind == kind)
|
||||
}
|
||||
|
||||
/// The primary code artifact (git repo or source archive), if any.
|
||||
pub fn code_artifact(&self) -> Option<&Artifact> {
|
||||
self.artifacts
|
||||
.iter()
|
||||
.find(|a| matches!(a.kind, ArtifactKind::GitRepo | ArtifactKind::SourceArchive))
|
||||
}
|
||||
|
||||
/// The live-URL artifact, if any.
|
||||
pub fn live_url(&self) -> Option<&Artifact> {
|
||||
self.first_of(ArtifactKind::LiveUrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_target() -> OnboardedTarget {
|
||||
let mut t = OnboardedTarget::new("acme-web".to_string(), TargetType::WebApp);
|
||||
t.artifacts.push(Artifact::git_repo(
|
||||
"https://git.example.com/acme.git",
|
||||
"main",
|
||||
));
|
||||
t.artifacts
|
||||
.push(Artifact::live_url("https://acme.example.com"));
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarded_target_bson_round_trip() {
|
||||
let t = sample_target();
|
||||
let b = bson::to_bson(&t).expect("serialize");
|
||||
let back: OnboardedTarget = bson::from_bson(b.clone()).expect("deserialize");
|
||||
let b2 = bson::to_bson(&back).expect("re-serialize");
|
||||
assert_eq!(b, b2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_display_is_snake_case() {
|
||||
assert_eq!(
|
||||
TargetType::FirmwareBareMetal.to_string(),
|
||||
"firmware_bare_metal"
|
||||
);
|
||||
assert_eq!(TargetType::PlcSps.to_string(), "plc_sps");
|
||||
assert_eq!(ArtifactKind::PlcProject.to_string(), "plc_project");
|
||||
assert_eq!(ArtifactKind::MobilePackage.to_string(), "mobile_package");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_locate_artifacts() {
|
||||
let t = sample_target();
|
||||
assert!(t.has(ArtifactKind::GitRepo));
|
||||
assert!(t.live_url().is_some());
|
||||
assert!(t.code_artifact().is_some());
|
||||
assert!(!t.has(ArtifactKind::FirmwareImage));
|
||||
assert_eq!(
|
||||
t.first_of(ArtifactKind::GitRepo).map(|a| a.kind),
|
||||
Some(ArtifactKind::GitRepo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_target_generates_webhook_secret() {
|
||||
let t = OnboardedTarget::new("t".to_string(), TargetType::BackendService);
|
||||
let secret = t.webhook_secret.expect("secret present");
|
||||
assert_eq!(secret.len(), 32);
|
||||
assert!(!secret.contains('-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dast_auth_folds_into_artifact_auth() {
|
||||
let dast = DastAuthConfig {
|
||||
method: "bearer".to_string(),
|
||||
login_url: Some("https://x/login".to_string()),
|
||||
username: Some("user".to_string()),
|
||||
password: Some("pw".to_string()),
|
||||
token: Some("tok".to_string()),
|
||||
headers: None,
|
||||
};
|
||||
let auth = ArtifactAuth::from(dast);
|
||||
assert_eq!(auth.method, "bearer");
|
||||
// Bearer token wins over password.
|
||||
assert_eq!(auth.secret.as_deref(), Some("tok"));
|
||||
assert_eq!(auth.login_url.as_deref(), Some("https://x/login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_artifact_gets_a_unique_id() {
|
||||
let a = Artifact::firmware_image("fw.bin");
|
||||
let b = Artifact::firmware_image("fw.bin");
|
||||
assert_ne!(a.id, b.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_default_profile_is_cra_and_62443() {
|
||||
let p = default_compliance_profile(TargetType::FirmwareBareMetal);
|
||||
assert!(p.frameworks.contains(&ComplianceFramework::Cra));
|
||||
assert!(p.frameworks.contains(&ComplianceFramework::Iec62443));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webapp_default_profile_is_gdpr_and_soc2() {
|
||||
let p = default_compliance_profile(TargetType::WebApp);
|
||||
assert!(p.frameworks.contains(&ComplianceFramework::Gdpr));
|
||||
assert!(p.frameworks.contains(&ComplianceFramework::Soc2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_target_gets_default_profile_and_no_external_refs() {
|
||||
let t = OnboardedTarget::new("fw".to_string(), TargetType::FirmwareRtos);
|
||||
assert!(!t.compliance_profile.frameworks.is_empty());
|
||||
assert!(t.external_refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_ref_linked_defaults() {
|
||||
let r = ExternalRef::linked(ExternalSystem::Tramiton);
|
||||
assert_eq!(r.system, ExternalSystem::Tramiton);
|
||||
assert_eq!(r.status, "linked");
|
||||
assert!(r.project_id.is_none());
|
||||
assert!(r.last_reconciled_at.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
//! OSCAL 1.1 catalog types + mapping into the controls corpus.
|
||||
//!
|
||||
//! Deserialises the OSCAL catalog served by breakpilot-compliance
|
||||
//! (`GET /api/compliance/v1/oscal/catalog`) and maps its controls into the
|
||||
//! framework-agnostic [`crate::traits::Control`] that the mapping engine consumes.
|
||||
//! Only the fields we use are modelled; unknown OSCAL fields are ignored so the
|
||||
//! producer can add detail without breaking us.
|
||||
//!
|
||||
//! Scope boundary: this is the *catalog* (domain content). Assessment objectives
|
||||
//! and scanner routing live in our assessment layer, not here — see
|
||||
//! [`crate::traits::ControlsProvider`].
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::onboarding::ComplianceFramework;
|
||||
use crate::traits::Control as CorpusControl;
|
||||
|
||||
/// A parsed OSCAL catalog document (`{"catalog": {...}}`).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct OscalDocument {
|
||||
pub catalog: Catalog,
|
||||
}
|
||||
|
||||
/// An OSCAL catalog: metadata + a tree of control groups.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Catalog {
|
||||
pub uuid: String,
|
||||
pub metadata: Metadata,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<Group>,
|
||||
#[serde(rename = "back-matter", default)]
|
||||
pub back_matter: Option<BackMatter>,
|
||||
}
|
||||
|
||||
/// Catalog metadata (title/version + provenance props).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Metadata {
|
||||
pub title: String,
|
||||
pub version: String,
|
||||
#[serde(rename = "oscal-version")]
|
||||
pub oscal_version: String,
|
||||
#[serde(default)]
|
||||
pub props: Vec<Prop>,
|
||||
}
|
||||
|
||||
/// A name/value property, optionally namespaced.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Prop {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub ns: Option<String>,
|
||||
}
|
||||
|
||||
/// A control group (may nest sub-groups and controls).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Group {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub controls: Vec<Control>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<Group>,
|
||||
}
|
||||
|
||||
/// An OSCAL control (may nest enhancement controls).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Control {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub props: Vec<Prop>,
|
||||
#[serde(default)]
|
||||
pub parts: Vec<Part>,
|
||||
#[serde(default)]
|
||||
pub links: Vec<Link>,
|
||||
#[serde(default)]
|
||||
pub controls: Vec<Control>,
|
||||
}
|
||||
|
||||
/// A control part (e.g. the `statement`), may nest sub-parts.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Part {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub prose: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parts: Vec<Part>,
|
||||
}
|
||||
|
||||
/// A link, e.g. a `reference` to a back-matter resource.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Link {
|
||||
pub href: String,
|
||||
#[serde(default)]
|
||||
pub rel: Option<String>,
|
||||
}
|
||||
|
||||
/// Back-matter holding referenced resources (e.g. the CRA measures).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BackMatter {
|
||||
#[serde(default)]
|
||||
pub resources: Vec<Resource>,
|
||||
}
|
||||
|
||||
/// A back-matter resource referenced by control links.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Resource {
|
||||
pub uuid: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
/// First prop value with the given name.
|
||||
pub fn prop(&self, name: &str) -> Option<&str> {
|
||||
self.props
|
||||
.iter()
|
||||
.find(|p| p.name == name)
|
||||
.map(|p| p.value.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl Control {
|
||||
/// First prop value with the given name.
|
||||
pub fn prop(&self, name: &str) -> Option<&str> {
|
||||
self.props
|
||||
.iter()
|
||||
.find(|p| p.name == name)
|
||||
.map(|p| p.value.as_str())
|
||||
}
|
||||
|
||||
/// The control's `statement` prose, if present.
|
||||
pub fn statement(&self) -> Option<&str> {
|
||||
self.parts
|
||||
.iter()
|
||||
.find(|p| p.name == "statement")
|
||||
.and_then(|p| p.prose.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
impl OscalDocument {
|
||||
/// The framework this catalog declares (`metadata.props[name="framework"]`).
|
||||
pub fn framework(&self) -> Option<ComplianceFramework> {
|
||||
framework_from_str(self.catalog.metadata.prop("framework")?)
|
||||
}
|
||||
|
||||
/// The catalog `content-hash` prop — consumers pin this to snapshot/detect drift.
|
||||
pub fn content_hash(&self) -> Option<&str> {
|
||||
self.catalog.metadata.prop("content-hash")
|
||||
}
|
||||
|
||||
/// Flatten the catalog into the corpus controls the mapping engine consumes.
|
||||
pub fn to_controls(&self) -> Vec<CorpusControl> {
|
||||
let framework = self.framework().unwrap_or(ComplianceFramework::Cra);
|
||||
let source_label = self.catalog.metadata.title.as_str();
|
||||
let mut out = Vec::new();
|
||||
for group in &self.catalog.groups {
|
||||
collect_group(group, framework, source_label, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an OSCAL framework token (e.g. `"cra"`) to [`ComplianceFramework`] via its
|
||||
/// serde snake_case representation.
|
||||
fn framework_from_str(raw: &str) -> Option<ComplianceFramework> {
|
||||
serde_json::from_value(serde_json::Value::String(raw.to_string())).ok()
|
||||
}
|
||||
|
||||
fn collect_group(
|
||||
group: &Group,
|
||||
framework: ComplianceFramework,
|
||||
source_label: &str,
|
||||
out: &mut Vec<CorpusControl>,
|
||||
) {
|
||||
for control in &group.controls {
|
||||
collect_control(control, framework, source_label, out);
|
||||
}
|
||||
for sub in &group.groups {
|
||||
collect_group(sub, framework, source_label, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_control(
|
||||
control: &Control,
|
||||
framework: ComplianceFramework,
|
||||
source_label: &str,
|
||||
out: &mut Vec<CorpusControl>,
|
||||
) {
|
||||
let source = match control.prop("annex-anchor") {
|
||||
Some(anchor) => Some(format!("{source_label} · {anchor}")),
|
||||
None => Some(source_label.to_string()),
|
||||
};
|
||||
out.push(CorpusControl {
|
||||
id: control.id.clone(),
|
||||
framework,
|
||||
title: control.title.clone(),
|
||||
text: control.statement().unwrap_or_default().to_string(),
|
||||
source,
|
||||
});
|
||||
for enhancement in &control.controls {
|
||||
collect_control(enhancement, framework, source_label, out);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CATALOG: &str = include_str!("../../tests/data/cra_catalog.json");
|
||||
|
||||
fn parse() -> OscalDocument {
|
||||
serde_json::from_str(CATALOG).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_full_catalog() {
|
||||
let doc = parse();
|
||||
assert_eq!(doc.catalog.metadata.oscal_version, "1.1.2");
|
||||
assert!(!doc.catalog.groups.is_empty());
|
||||
assert!(doc.catalog.back_matter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_all_controls_to_corpus() {
|
||||
let doc = parse();
|
||||
let controls = doc.to_controls();
|
||||
assert_eq!(controls.len(), 40);
|
||||
assert_eq!(doc.framework(), Some(ComplianceFramework::Cra));
|
||||
|
||||
let c8 = controls.iter().find(|c| c.id == "cra-ai-8").unwrap();
|
||||
assert_eq!(c8.framework, ComplianceFramework::Cra);
|
||||
assert!(!c8.title.is_empty());
|
||||
assert!(!c8.text.is_empty(), "statement prose should map into text");
|
||||
assert!(c8.source.as_deref().unwrap_or_default().contains("Annex I"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposes_content_hash_for_snapshotting() {
|
||||
assert_eq!(parse().content_hash().map(str::len), Some(64));
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
//! OSCAL 1.1 assessment-results — assess our findings against catalog controls.
|
||||
//!
|
||||
//! The catalog (domain content) comes from the producer; the **assessment** is
|
||||
//! ours. This links compliance [`Finding`]s to catalog control-ids and emits a
|
||||
//! standard OSCAL assessment-results document: an observation per linked finding,
|
||||
//! and a per-control finding with a `not-satisfied` status. `reviewed-controls`
|
||||
//! records the full catalog set we considered.
|
||||
//!
|
||||
//! Deterministic: stable `uuid5` ids; the caller supplies the assessment
|
||||
//! timestamp. Pure — no DB, no network.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::finding::Finding;
|
||||
use crate::traits::Control;
|
||||
|
||||
const OSCAL_VERSION: &str = "1.1.2";
|
||||
/// Same namespace as the catalog exporter, so ids are stable and correlatable.
|
||||
const NAMESPACE: Uuid = Uuid::from_bytes([
|
||||
0x6f, 0x1e, 0x7c, 0x2a, 0x3b, 0x4d, 0x5e, 0x6f, 0x8a, 0x9b, 0x0c, 0x1d, 0x2e, 0x3f, 0x4a, 0x5b,
|
||||
]);
|
||||
|
||||
fn det_uuid(name: &str) -> String {
|
||||
Uuid::new_v5(&NAMESPACE, name.as_bytes()).to_string()
|
||||
}
|
||||
|
||||
/// Links findings to the catalog control-ids they provide evidence for.
|
||||
pub struct ControlLinker {
|
||||
cwe_to_controls: HashMap<u32, Vec<String>>,
|
||||
}
|
||||
|
||||
impl ControlLinker {
|
||||
/// Build a linker from an explicit CWE → control-id map.
|
||||
pub fn new(cwe_to_controls: HashMap<u32, Vec<String>>) -> Self {
|
||||
Self { cwe_to_controls }
|
||||
}
|
||||
|
||||
/// Seed of CWE → CRA Annex I control mappings (mirrors breakpilot's
|
||||
/// `_CWE_TO_REQ`; extend as scanner coverage grows).
|
||||
pub fn cra_seed() -> Self {
|
||||
let pairs: &[(u32, &str)] = &[
|
||||
(798, "cra-ai-8"),
|
||||
(259, "cra-ai-8"),
|
||||
(1392, "cra-ai-8"),
|
||||
(327, "cra-ai-13"),
|
||||
(326, "cra-ai-13"),
|
||||
(319, "cra-ai-15"),
|
||||
(311, "cra-ai-15"),
|
||||
(89, "cra-ai-20"),
|
||||
(79, "cra-ai-20"),
|
||||
(78, "cra-ai-20"),
|
||||
(22, "cra-ai-20"),
|
||||
];
|
||||
let mut map: HashMap<u32, Vec<String>> = HashMap::new();
|
||||
for (cwe, id) in pairs {
|
||||
map.entry(*cwe).or_default().push((*id).to_string());
|
||||
}
|
||||
Self::new(map)
|
||||
}
|
||||
|
||||
/// Parse a CWE token such as `"CWE-798"` or `"798"` into its number.
|
||||
fn parse_cwe(raw: &str) -> Option<u32> {
|
||||
raw.trim_start_matches(|c: char| !c.is_ascii_digit())
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
/// The control-ids a finding provides evidence for (via its CWE).
|
||||
pub fn controls_for(&self, finding: &Finding) -> Vec<String> {
|
||||
finding
|
||||
.cwe
|
||||
.as_deref()
|
||||
.and_then(Self::parse_cwe)
|
||||
.and_then(|cwe| self.cwe_to_controls.get(&cwe))
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Assess `findings` against `controls`: link findings to control-ids and build a
|
||||
/// standard OSCAL assessment-results document. `at` is the assessment timestamp.
|
||||
pub fn assess(
|
||||
controls: &[Control],
|
||||
findings: &[Finding],
|
||||
linker: &ControlLinker,
|
||||
at: DateTime<Utc>,
|
||||
) -> AssessmentResultsDoc {
|
||||
let ts = at.to_rfc3339();
|
||||
|
||||
let mut observations = Vec::new();
|
||||
let mut obs_by_control: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for finding in findings {
|
||||
let targets = linker.controls_for(finding);
|
||||
if targets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let obs_uuid = det_uuid(&format!("obs:{}", finding.fingerprint));
|
||||
let location = match (&finding.file_path, finding.line_number) {
|
||||
(Some(f), Some(l)) => Some(format!("{f}:{l}")),
|
||||
(Some(f), None) => Some(f.clone()),
|
||||
_ => None,
|
||||
};
|
||||
observations.push(Observation {
|
||||
uuid: obs_uuid.clone(),
|
||||
description: finding.title.clone(),
|
||||
methods: vec!["TEST".to_string()],
|
||||
collected: ts.clone(),
|
||||
relevant_evidence: vec![RelevantEvidence {
|
||||
href: location.map(|l| format!("file://{l}")),
|
||||
description: format!("[{}] {}", finding.scanner, finding.title),
|
||||
}],
|
||||
});
|
||||
for control_id in targets {
|
||||
obs_by_control
|
||||
.entry(control_id)
|
||||
.or_default()
|
||||
.push(obs_uuid.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let titles: HashMap<&str, &str> = controls
|
||||
.iter()
|
||||
.map(|c| (c.id.as_str(), c.title.as_str()))
|
||||
.collect();
|
||||
let mut hit_controls: Vec<&String> = obs_by_control.keys().collect();
|
||||
hit_controls.sort();
|
||||
let ar_findings: Vec<ArFinding> = hit_controls
|
||||
.into_iter()
|
||||
.map(|control_id| {
|
||||
let title = titles.get(control_id.as_str()).copied().unwrap_or("");
|
||||
ArFinding {
|
||||
uuid: det_uuid(&format!("finding:{control_id}")),
|
||||
title: format!("Findings affect {control_id}: {title}"),
|
||||
target: FindingTarget {
|
||||
target_type: "statement-id".to_string(),
|
||||
target_id: format!("{control_id}_smt"),
|
||||
status: TargetStatus {
|
||||
state: "not-satisfied".to_string(),
|
||||
},
|
||||
},
|
||||
related_observations: obs_by_control[control_id]
|
||||
.iter()
|
||||
.map(|u| RelatedObservation {
|
||||
observation_uuid: u.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let include_controls = controls
|
||||
.iter()
|
||||
.map(|c| SelectControlById {
|
||||
control_id: c.id.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let result = ArResult {
|
||||
uuid: det_uuid("result:cra"),
|
||||
title: "Automated code-compliance assessment".to_string(),
|
||||
description: format!(
|
||||
"{} finding-linked observation(s) across {} reviewed control(s)",
|
||||
observations.len(),
|
||||
controls.len()
|
||||
),
|
||||
start: ts.clone(),
|
||||
reviewed_controls: ReviewedControls {
|
||||
control_selections: vec![ControlSelection { include_controls }],
|
||||
},
|
||||
observations,
|
||||
findings: ar_findings,
|
||||
};
|
||||
|
||||
AssessmentResultsDoc {
|
||||
assessment_results: AssessmentResults {
|
||||
uuid: det_uuid("assessment-results:cra"),
|
||||
metadata: ArMetadata {
|
||||
title: "Compliance scanner — OSCAL assessment results".to_string(),
|
||||
last_modified: ts,
|
||||
version: "1.0.0".to_string(),
|
||||
oscal_version: OSCAL_VERSION.to_string(),
|
||||
},
|
||||
import_ap: ImportAp {
|
||||
href: "#cra-annex-i".to_string(),
|
||||
},
|
||||
results: vec![result],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── OSCAL assessment-results document (serialise) ────────────────────────────
|
||||
|
||||
/// The root OSCAL assessment-results document.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AssessmentResultsDoc {
|
||||
#[serde(rename = "assessment-results")]
|
||||
pub assessment_results: AssessmentResults,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AssessmentResults {
|
||||
pub uuid: String,
|
||||
pub metadata: ArMetadata,
|
||||
#[serde(rename = "import-ap")]
|
||||
pub import_ap: ImportAp,
|
||||
pub results: Vec<ArResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ArMetadata {
|
||||
pub title: String,
|
||||
#[serde(rename = "last-modified")]
|
||||
pub last_modified: String,
|
||||
pub version: String,
|
||||
#[serde(rename = "oscal-version")]
|
||||
pub oscal_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ImportAp {
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ArResult {
|
||||
pub uuid: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start: String,
|
||||
#[serde(rename = "reviewed-controls")]
|
||||
pub reviewed_controls: ReviewedControls,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub observations: Vec<Observation>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub findings: Vec<ArFinding>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ReviewedControls {
|
||||
#[serde(rename = "control-selections")]
|
||||
pub control_selections: Vec<ControlSelection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ControlSelection {
|
||||
#[serde(rename = "include-controls", skip_serializing_if = "Vec::is_empty")]
|
||||
pub include_controls: Vec<SelectControlById>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SelectControlById {
|
||||
#[serde(rename = "control-id")]
|
||||
pub control_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Observation {
|
||||
pub uuid: String,
|
||||
pub description: String,
|
||||
pub methods: Vec<String>,
|
||||
pub collected: String,
|
||||
#[serde(rename = "relevant-evidence", skip_serializing_if = "Vec::is_empty")]
|
||||
pub relevant_evidence: Vec<RelevantEvidence>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RelevantEvidence {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub href: Option<String>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ArFinding {
|
||||
pub uuid: String,
|
||||
pub title: String,
|
||||
pub target: FindingTarget,
|
||||
#[serde(rename = "related-observations", skip_serializing_if = "Vec::is_empty")]
|
||||
pub related_observations: Vec<RelatedObservation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FindingTarget {
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
#[serde(rename = "target-id")]
|
||||
pub target_id: String,
|
||||
pub status: TargetStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TargetStatus {
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RelatedObservation {
|
||||
#[serde(rename = "observation-uuid")]
|
||||
pub observation_uuid: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::finding::Severity;
|
||||
use crate::models::onboarding::ComplianceFramework;
|
||||
use crate::models::scan::ScanType;
|
||||
|
||||
fn control(id: &str, title: &str) -> Control {
|
||||
Control {
|
||||
id: id.into(),
|
||||
framework: ComplianceFramework::Cra,
|
||||
title: title.into(),
|
||||
text: String::new(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn finding(fp: &str, cwe: Option<&str>) -> Finding {
|
||||
let mut f = Finding::new(
|
||||
"repo".into(),
|
||||
fp.into(),
|
||||
"semgrep".into(),
|
||||
ScanType::Sast,
|
||||
"hardcoded credential".into(),
|
||||
"desc".into(),
|
||||
Severity::High,
|
||||
);
|
||||
f.cwe = cwe.map(Into::into);
|
||||
f.file_path = Some("src/auth.rs".into());
|
||||
f.line_number = Some(42);
|
||||
f
|
||||
}
|
||||
|
||||
fn at() -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339("2026-07-20T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_cwe_finding_to_control_not_satisfied() {
|
||||
let controls = vec![
|
||||
control("cra-ai-8", "No default passwords"),
|
||||
control("cra-ai-13", "Crypto"),
|
||||
];
|
||||
let findings = vec![finding("f1", Some("CWE-798"))];
|
||||
let doc = assess(&controls, &findings, &ControlLinker::cra_seed(), at());
|
||||
let r = &doc.assessment_results.results[0];
|
||||
assert_eq!(r.observations.len(), 1);
|
||||
assert_eq!(r.findings.len(), 1);
|
||||
assert_eq!(r.findings[0].target.target_id, "cra-ai-8_smt");
|
||||
assert_eq!(r.findings[0].target.status.state, "not-satisfied");
|
||||
assert_eq!(r.findings[0].related_observations.len(), 1);
|
||||
assert_eq!(
|
||||
r.reviewed_controls.control_selections[0]
|
||||
.include_controls
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlinked_finding_yields_no_control_finding() {
|
||||
let controls = vec![control("cra-ai-8", "x")];
|
||||
let findings = vec![finding("f1", Some("CWE-99999"))];
|
||||
let doc = assess(&controls, &findings, &ControlLinker::cra_seed(), at());
|
||||
let r = &doc.assessment_results.results[0];
|
||||
assert!(r.observations.is_empty());
|
||||
assert!(r.findings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_deterministic_and_valid_oscal() {
|
||||
let controls = vec![control("cra-ai-8", "x")];
|
||||
let findings = vec![finding("f1", Some("798"))];
|
||||
let a = serde_json::to_string(&assess(
|
||||
&controls,
|
||||
&findings,
|
||||
&ControlLinker::cra_seed(),
|
||||
at(),
|
||||
))
|
||||
.unwrap();
|
||||
let b = serde_json::to_string(&assess(
|
||||
&controls,
|
||||
&findings,
|
||||
&ControlLinker::cra_seed(),
|
||||
at(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert!(a.contains("\"oscal-version\":\"1.1.2\""));
|
||||
assert!(a.contains("\"not-satisfied\""));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use super::issue::TrackerType;
|
||||
|
||||
/// What initiated a scan.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScanTrigger {
|
||||
@@ -8,3 +10,92 @@ pub enum ScanTrigger {
|
||||
Webhook,
|
||||
Manual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrackedRepository {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<bson::oid::ObjectId>,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub git_url: String,
|
||||
#[serde(default = "default_branch")]
|
||||
pub default_branch: String,
|
||||
pub local_path: Option<String>,
|
||||
pub scan_schedule: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webhook_enabled: bool,
|
||||
/// Auto-generated HMAC secret for verifying incoming webhooks
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webhook_secret: Option<String>,
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
pub tracker_owner: Option<String>,
|
||||
pub tracker_repo: Option<String>,
|
||||
/// Optional per-repo PAT for the issue tracker (GitHub/GitLab/Jira)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tracker_token: Option<String>,
|
||||
/// Optional auth token for HTTPS private repos (PAT or password)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_token: Option<String>,
|
||||
/// Optional username for HTTPS auth (defaults to "x-access-token" for PATs)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auth_username: Option<String>,
|
||||
pub last_scanned_commit: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_findings_count")]
|
||||
pub findings_count: u32,
|
||||
#[serde(
|
||||
default = "chrono::Utc::now",
|
||||
with = "super::serde_helpers::bson_datetime"
|
||||
)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(
|
||||
default = "chrono::Utc::now",
|
||||
with = "super::serde_helpers::bson_datetime"
|
||||
)]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn default_branch() -> String {
|
||||
"main".to_string()
|
||||
}
|
||||
|
||||
fn deserialize_findings_count<'de, D>(deserializer: D) -> Result<u32, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let bson = bson::Bson::deserialize(deserializer)?;
|
||||
match &bson {
|
||||
bson::Bson::Int32(n) => Ok(*n as u32),
|
||||
bson::Bson::Int64(n) => Ok(*n as u32),
|
||||
bson::Bson::Double(n) => Ok(*n as u32),
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
impl TrackedRepository {
|
||||
pub fn new(name: String, git_url: String) -> Self {
|
||||
let now = Utc::now();
|
||||
// Generate a random webhook secret (hex-encoded UUID v4, no dashes)
|
||||
let webhook_secret = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||
Self {
|
||||
id: None,
|
||||
name,
|
||||
git_url,
|
||||
default_branch: "main".to_string(),
|
||||
local_path: None,
|
||||
scan_schedule: None,
|
||||
auth_token: None,
|
||||
auth_username: None,
|
||||
webhook_enabled: false,
|
||||
webhook_secret: Some(webhook_secret),
|
||||
tracker_type: None,
|
||||
tracker_owner: None,
|
||||
tracker_repo: None,
|
||||
tracker_token: None,
|
||||
last_scanned_commit: None,
|
||||
findings_count: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::repository::ScanTrigger;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ScanType {
|
||||
Sast,
|
||||
@@ -16,17 +16,6 @@ pub enum ScanType {
|
||||
SecretDetection,
|
||||
Lint,
|
||||
CodeReview,
|
||||
/// Static analysis of a firmware image (unpack + component CVE).
|
||||
FirmwareStatic,
|
||||
/// Control-logic security analysis of PLC / SPS programs.
|
||||
PlcControlLogic,
|
||||
/// Static analysis of a mobile package (APK / AAB / IPA).
|
||||
MobileStatic,
|
||||
/// Static analysis of a container image.
|
||||
ContainerScan,
|
||||
/// Dynamic probing of a running PLC/SPS device over industrial protocols
|
||||
/// (Modbus/TCP, OPC UA, …) for exposed/unauthenticated control access.
|
||||
IcsProbe,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ScanType {
|
||||
@@ -42,11 +31,6 @@ impl std::fmt::Display for ScanType {
|
||||
Self::SecretDetection => write!(f, "secret_detection"),
|
||||
Self::Lint => write!(f, "lint"),
|
||||
Self::CodeReview => write!(f, "code_review"),
|
||||
Self::FirmwareStatic => write!(f, "firmware_static"),
|
||||
Self::PlcControlLogic => write!(f, "plc_control_logic"),
|
||||
Self::MobileStatic => write!(f, "mobile_static"),
|
||||
Self::ContainerScan => write!(f, "container_scan"),
|
||||
Self::IcsProbe => write!(f, "ics_probe"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,8 +47,6 @@ pub enum ScanRunStatus {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScanPhase {
|
||||
ChangeDetection,
|
||||
ArtifactIngest,
|
||||
Classification,
|
||||
Sast,
|
||||
SbomGeneration,
|
||||
CveScanning,
|
||||
@@ -73,14 +55,9 @@ pub enum ScanPhase {
|
||||
LintScanning,
|
||||
CodeReview,
|
||||
GraphBuilding,
|
||||
FirmwareStatic,
|
||||
PlcAnalysis,
|
||||
MobileStatic,
|
||||
ContainerScan,
|
||||
LlmTriage,
|
||||
IssueCreation,
|
||||
DastScanning,
|
||||
IcsProbe,
|
||||
Completed,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
//! The Werkbank job/result contract (WB-01).
|
||||
//!
|
||||
//! The shared, dependency-free vocabulary the control plane and the Werkbank
|
||||
//! execution runner agree on: what a [`Job`] is, which [`Executor`] runs it, how
|
||||
//! it moves through the queue ([`JobStatus`]), and what a [`JobResult`] carries
|
||||
//! back. Jobs are declarative — TOML on disk, JSON on the wire — and results
|
||||
//! reuse the existing scanner result types ([`Finding`], [`DastFinding`],
|
||||
//! [`SbomEntry`]) so the runner produces exactly what the control plane persists.
|
||||
//!
|
||||
//! This module is intentionally free of the `mongodb`/`axum` features so the
|
||||
//! runner can depend on `compliance-core` without pulling the server stack.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::dast::DastFinding;
|
||||
use super::finding::Finding;
|
||||
use super::sbom::SbomEntry;
|
||||
|
||||
/// The kind of dynamic-execution job.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum JobType {
|
||||
/// Instantiate control logic on an ephemeral soft-PLC and probe it.
|
||||
PlcProvision,
|
||||
/// Boot a firmware image under QEMU and run dynamic checks.
|
||||
QemuBoot,
|
||||
/// Crawl and dynamically test a running web endpoint.
|
||||
Dast,
|
||||
/// Run an active penetration test against a running target.
|
||||
Pentest,
|
||||
}
|
||||
|
||||
/// How a runner executes a job — the CI-runner-style classification. A runner
|
||||
/// advertises exactly one; a job requires one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Executor {
|
||||
/// A subprocess on the runner host (dev / trusted single-node).
|
||||
Shell,
|
||||
/// One or more containers on the runner's Docker (default; QEMU runs here).
|
||||
Docker,
|
||||
/// A Pod/Job in a Kubernetes cluster (scale-out / multi-tenant).
|
||||
K8s,
|
||||
}
|
||||
|
||||
/// Lifecycle state of a job in the queue.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JobStatus {
|
||||
/// Waiting to be leased.
|
||||
Queued,
|
||||
/// Leased by a runner but not yet started.
|
||||
Leased,
|
||||
/// Executing on a runner.
|
||||
Running,
|
||||
/// Completed successfully.
|
||||
Succeeded,
|
||||
/// Completed with an error.
|
||||
Failed,
|
||||
/// The lease/lifetime deadline elapsed before completion.
|
||||
Expired,
|
||||
/// Cancelled by the control plane.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl JobStatus {
|
||||
/// Whether the job has reached a terminal state (no further transitions).
|
||||
pub fn is_terminal(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
JobStatus::Succeeded | JobStatus::Failed | JobStatus::Expired | JobStatus::Cancelled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to an input artifact. Resolved by the runner from a source it can
|
||||
/// reach; the blob itself never flows through the control plane (so an on-prem
|
||||
/// runner keeps customer data local). Exactly one of `blob`/`url` should be set.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InputRef {
|
||||
/// Content-addressed blob (e.g. `sha256:…`) the runner fetches from its store.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub blob: Option<String>,
|
||||
/// A URL the runner can reach (git repo, internal artifact store, …).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
impl InputRef {
|
||||
/// A content-addressed blob reference.
|
||||
pub fn blob(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
blob: Some(id.into()),
|
||||
url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sandbox runtime knobs. Fields are executor/job-type specific and all optional;
|
||||
/// `extra` carries anything not modelled explicitly.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JobRuntime {
|
||||
/// Container image (Docker executor).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
/// Memory cap (e.g. `512m`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory: Option<String>,
|
||||
/// CPU cap (e.g. `0.5`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cpus: Option<String>,
|
||||
/// Network to join (e.g. `isolated`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub network: Option<String>,
|
||||
/// QEMU machine type (qemu-boot).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub machine: Option<String>,
|
||||
/// QEMU target architecture (qemu-boot).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub arch: Option<String>,
|
||||
/// Executor-specific extras not modelled above.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub extra: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// DAST collection settings for jobs that scan a web endpoint.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DastCollect {
|
||||
/// Maximum crawl depth (kept shallow for ephemeral instances).
|
||||
pub max_crawl_depth: u32,
|
||||
}
|
||||
|
||||
/// What to collect from a run.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct JobCollect {
|
||||
/// Run the industrial-protocol probe (Modbus/OPC-UA/EtherNet-IP).
|
||||
#[serde(default)]
|
||||
pub ics_probe: bool,
|
||||
/// Run DAST against the provisioned/booted web endpoint.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dast: Option<DastCollect>,
|
||||
/// Run an active pentest.
|
||||
#[serde(default)]
|
||||
pub pentest: bool,
|
||||
/// Collect an SBOM.
|
||||
#[serde(default)]
|
||||
pub sbom: bool,
|
||||
}
|
||||
|
||||
/// A declarative dynamic-execution job the control plane enqueues and a Werkbank
|
||||
/// runner leases and executes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Job {
|
||||
/// Unique job id (assigned by the control plane on enqueue).
|
||||
pub id: String,
|
||||
/// What kind of job this is.
|
||||
#[serde(rename = "type")]
|
||||
pub job_type: JobType,
|
||||
/// Owning tenant.
|
||||
pub tenant: String,
|
||||
/// The onboarded target this job tests.
|
||||
pub target_id: String,
|
||||
/// The executor a runner must provide to run this job.
|
||||
pub executor: Executor,
|
||||
/// Runner capabilities this job requires (e.g. `arch=amd64`, `kvm=true`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub labels: Vec<String>,
|
||||
/// Hard lifetime deadline for the whole job.
|
||||
pub timeout_secs: u64,
|
||||
/// Named input artifacts (e.g. `program`, `firmware`), by reference.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub inputs: BTreeMap<String, InputRef>,
|
||||
/// Sandbox runtime knobs.
|
||||
#[serde(default)]
|
||||
pub runtime: JobRuntime,
|
||||
/// What to collect from the run.
|
||||
#[serde(default)]
|
||||
pub collect: JobCollect,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
/// A `plc-provision` job: instantiate the control logic named `program` on an
|
||||
/// ephemeral soft-PLC (Docker executor) and collect the ICS probe + DAST.
|
||||
pub fn plc_provision(
|
||||
id: impl Into<String>,
|
||||
tenant: impl Into<String>,
|
||||
target_id: impl Into<String>,
|
||||
program: InputRef,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
let mut inputs = BTreeMap::new();
|
||||
inputs.insert("program".to_string(), program);
|
||||
Self {
|
||||
id: id.into(),
|
||||
job_type: JobType::PlcProvision,
|
||||
tenant: tenant.into(),
|
||||
target_id: target_id.into(),
|
||||
executor: Executor::Docker,
|
||||
labels: Vec::new(),
|
||||
timeout_secs,
|
||||
inputs,
|
||||
runtime: JobRuntime::default(),
|
||||
collect: JobCollect {
|
||||
ics_probe: true,
|
||||
dast: Some(DastCollect { max_crawl_depth: 2 }),
|
||||
pentest: false,
|
||||
sbom: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of running a job, posted back to the control plane. Findings and
|
||||
/// SBOM reuse the shared scanner types, so the control plane persists them
|
||||
/// unchanged. Submission is idempotent — keyed by [`JobResult::job_id`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct JobResult {
|
||||
/// The job this result is for.
|
||||
pub job_id: String,
|
||||
/// Terminal status of the job.
|
||||
pub status: Option<JobStatus>,
|
||||
/// General scanner findings (e.g. ICS-probe findings).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub findings: Vec<Finding>,
|
||||
/// DAST findings from a web-endpoint scan.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dast_findings: Vec<DastFinding>,
|
||||
/// SBOM components collected from the run.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sbom: Vec<SbomEntry>,
|
||||
/// Error message when the job failed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
/// Captured execution log (truncated by the runner).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub logs: Option<String>,
|
||||
/// When execution started on the runner.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
/// When execution finished.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl JobResult {
|
||||
/// A successful result for a job.
|
||||
pub fn succeeded(job_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
job_id: job_id.into(),
|
||||
status: Some(JobStatus::Succeeded),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed result carrying an error message.
|
||||
pub fn failed(job_id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
job_id: job_id.into(),
|
||||
status: Some(JobStatus::Failed),
|
||||
error: Some(error.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A queued job as persisted by the control plane (WB-02): the [`Job`] contract
|
||||
/// plus the queue bookkeeping — status, lease ownership, attempt count, and the
|
||||
/// eventual result. The runner never sees this record; on lease it receives a
|
||||
/// [`LeasedJob`] (the job plus a token it presents to heartbeat/complete).
|
||||
///
|
||||
/// Timestamps persist as native BSON dates so the queue's range queries (lease
|
||||
/// FIFO by `created_at`, visibility-timeout sweep by `lease_expires_at`) compare
|
||||
/// correctly.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JobRecord {
|
||||
/// The job to run.
|
||||
pub job: Job,
|
||||
/// Current queue state.
|
||||
pub status: JobStatus,
|
||||
/// The lease token held by the current runner (proves lease ownership).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lease_token: Option<String>,
|
||||
/// Id of the runner holding the lease.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub leased_by: Option<String>,
|
||||
/// When the current lease expires — the visibility timeout after which a
|
||||
/// crashed runner's job is swept back to `queued`.
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub lease_expires_at: Option<DateTime<Utc>>,
|
||||
/// Last heartbeat from the runner.
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub heartbeat_at: Option<DateTime<Utc>>,
|
||||
/// How many times the job has been leased (incremented on each lease).
|
||||
#[serde(default)]
|
||||
pub attempts: u32,
|
||||
/// Set when the control plane requests cancellation; the runner sees it on
|
||||
/// its next heartbeat and aborts.
|
||||
#[serde(default)]
|
||||
pub cancel_requested: bool,
|
||||
/// The result, once the job reaches a terminal state.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<JobResult>,
|
||||
/// When the job was enqueued.
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Last modification.
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl JobRecord {
|
||||
/// A freshly-enqueued (`queued`) record for a job.
|
||||
pub fn queued(job: Job, now: DateTime<Utc>) -> Self {
|
||||
Self {
|
||||
job,
|
||||
status: JobStatus::Queued,
|
||||
lease_token: None,
|
||||
leased_by: None,
|
||||
lease_expires_at: None,
|
||||
heartbeat_at: None,
|
||||
attempts: 0,
|
||||
cancel_requested: false,
|
||||
result: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A job handed to a runner on lease: what to run plus the token the runner must
|
||||
/// present to heartbeat and complete it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LeasedJob {
|
||||
/// The job to execute.
|
||||
pub job: Job,
|
||||
/// The lease token proving ownership (opaque to the runner).
|
||||
pub lease_token: String,
|
||||
}
|
||||
|
||||
/// The runner's view of a heartbeat: whether the control plane has asked the job
|
||||
/// to stop. `None` from the queue means the lease was lost (token mismatch or the
|
||||
/// job already terminal) and the runner should abandon the work.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HeartbeatAck {
|
||||
/// The control plane requested cancellation — the runner should tear down.
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
// --- Runner ↔ control-plane transport (the pull API wire types) ---------------
|
||||
// Shared so the runner (client) and the control plane (server) agree on shapes.
|
||||
|
||||
/// Runner → control plane: lease the oldest runnable job for this runner.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LeaseRequest {
|
||||
/// The tenant queue to lease from.
|
||||
pub tenant: String,
|
||||
/// The runner id (advertised for attribution).
|
||||
pub runner_id: String,
|
||||
/// The executor this runner provides.
|
||||
pub executor: Executor,
|
||||
/// The capability labels this runner advertises.
|
||||
#[serde(default)]
|
||||
pub labels: Vec<String>,
|
||||
/// Requested lease lifetime (the visibility timeout), in seconds.
|
||||
pub lease_ttl_secs: u64,
|
||||
}
|
||||
|
||||
/// Runner → control plane: prove lease ownership and extend it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeartbeatRequest {
|
||||
/// The tenant queue.
|
||||
pub tenant: String,
|
||||
/// The job being worked.
|
||||
pub job_id: String,
|
||||
/// The lease token from the [`LeasedJob`].
|
||||
pub lease_token: String,
|
||||
/// Lease lifetime to extend to, in seconds.
|
||||
pub lease_ttl_secs: u64,
|
||||
}
|
||||
|
||||
/// Runner → control plane: record a job's terminal result.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompleteRequest {
|
||||
/// The tenant queue.
|
||||
pub tenant: String,
|
||||
/// The job being completed.
|
||||
pub job_id: String,
|
||||
/// The lease token proving ownership.
|
||||
pub lease_token: String,
|
||||
/// The result to record.
|
||||
pub result: JobResult,
|
||||
}
|
||||
|
||||
/// Control plane → runner: whether the completion was recorded (false if the
|
||||
/// lease was already lost — token mismatch or the job had become terminal).
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub struct CompleteResponse {
|
||||
/// Whether the result was recorded.
|
||||
pub recorded: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn job_round_trips_through_json() {
|
||||
let job = Job::plc_provision("job_1", "acme", "64f0aa", InputRef::blob("sha256:abc"), 180);
|
||||
let json = serde_json::to_string(&job).expect("serialize");
|
||||
let back: Job = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(job, back);
|
||||
// Enum wire forms are the kebab/lowercase the contract documents.
|
||||
assert!(json.contains("\"type\":\"plc-provision\""));
|
||||
assert!(json.contains("\"executor\":\"docker\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_design_doc_plc_provision_toml() {
|
||||
// The exact shape from docs/DESIGN.md §5 (wrapped in a [job] table).
|
||||
#[derive(Deserialize)]
|
||||
struct JobFile {
|
||||
job: Job,
|
||||
}
|
||||
let src = r#"
|
||||
[job]
|
||||
id = "job_01H"
|
||||
type = "plc-provision"
|
||||
tenant = "acme"
|
||||
target_id = "64f0"
|
||||
executor = "docker"
|
||||
labels = ["arch=amd64"]
|
||||
timeout_secs = 180
|
||||
|
||||
[job.inputs]
|
||||
program = { blob = "sha256:deadbeef" }
|
||||
|
||||
[job.runtime]
|
||||
image = "openplc:latest"
|
||||
memory = "512m"
|
||||
cpus = "0.5"
|
||||
network = "isolated"
|
||||
|
||||
[job.collect]
|
||||
ics_probe = true
|
||||
dast = { max_crawl_depth = 2 }
|
||||
"#;
|
||||
let file: JobFile = toml::from_str(src).expect("parse job toml");
|
||||
let job = file.job;
|
||||
assert_eq!(job.job_type, JobType::PlcProvision);
|
||||
assert_eq!(job.executor, Executor::Docker);
|
||||
assert_eq!(job.labels, vec!["arch=amd64".to_string()]);
|
||||
assert_eq!(
|
||||
job.inputs.get("program").and_then(|i| i.blob.as_deref()),
|
||||
Some("sha256:deadbeef")
|
||||
);
|
||||
assert_eq!(job.runtime.image.as_deref(), Some("openplc:latest"));
|
||||
assert!(job.collect.ics_probe);
|
||||
assert_eq!(job.collect.dast.map(|d| d.max_crawl_depth), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qemu_boot_runtime_fields_parse() {
|
||||
#[derive(Deserialize)]
|
||||
struct JobFile {
|
||||
job: Job,
|
||||
}
|
||||
let src = r#"
|
||||
[job]
|
||||
id = "j2"
|
||||
type = "qemu-boot"
|
||||
tenant = "acme"
|
||||
target_id = "t"
|
||||
executor = "docker"
|
||||
labels = ["kvm=true"]
|
||||
timeout_secs = 600
|
||||
[job.inputs]
|
||||
firmware = { blob = "sha256:cafe" }
|
||||
[job.runtime]
|
||||
machine = "virt"
|
||||
arch = "arm"
|
||||
memory = "1g"
|
||||
"#;
|
||||
let file: JobFile = toml::from_str(src).expect("parse");
|
||||
assert_eq!(file.job.job_type, JobType::QemuBoot);
|
||||
assert_eq!(file.job.runtime.arch.as_deref(), Some("arm"));
|
||||
assert_eq!(
|
||||
file.job
|
||||
.inputs
|
||||
.get("firmware")
|
||||
.and_then(|i| i.blob.as_deref()),
|
||||
Some("sha256:cafe")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_terminality() {
|
||||
assert!(JobStatus::Succeeded.is_terminal());
|
||||
assert!(JobStatus::Expired.is_terminal());
|
||||
assert!(!JobStatus::Queued.is_terminal());
|
||||
assert!(!JobStatus::Running.is_terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_constructors() {
|
||||
assert_eq!(JobResult::succeeded("j").status, Some(JobStatus::Succeeded));
|
||||
let f = JobResult::failed("j", "boom");
|
||||
assert_eq!(f.status, Some(JobStatus::Failed));
|
||||
assert_eq!(f.error.as_deref(), Some("boom"));
|
||||
}
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
//! The scan-applicability matrix.
|
||||
//!
|
||||
//! Which scans are possible for a target is a function of its [`TargetType`] and
|
||||
//! which [`ArtifactKind`]s are actually present: SAST needs code, DAST needs a
|
||||
//! running URL, firmware-static analysis needs a firmware image, and so on. This
|
||||
//! module encodes that as a table — one rule set per target type — and resolves
|
||||
//! it against a concrete [`OnboardedTarget`] into a list of [`ScanOption`]s the
|
||||
//! onboarding wizard and the scan pipeline both consume.
|
||||
|
||||
use crate::models::{ArtifactKind, OnboardedTarget, ScanType, TargetType};
|
||||
|
||||
/// What an artifact a scan needs in order to run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ArtifactRequirement {
|
||||
/// Source code — a git repo or a source archive.
|
||||
Code,
|
||||
/// A reachable running instance (any live URL / endpoint, scheme-agnostic —
|
||||
/// e.g. the ICS probe works off the host:port of a modbus:// or http:// ref).
|
||||
RunningUrl,
|
||||
/// A reachable **web** endpoint — a live URL with an http(s) scheme. DAST is
|
||||
/// an HTTP crawler, so a modbus:// / opc.tcp:// endpoint does not satisfy it.
|
||||
HttpUrl,
|
||||
/// A firmware image / binary blob.
|
||||
Firmware,
|
||||
/// A PLC project (PLCopen XML or Structured Text).
|
||||
Plc,
|
||||
/// A mobile package (APK / AAB / IPA).
|
||||
Mobile,
|
||||
/// A container image.
|
||||
Container,
|
||||
/// No specific artifact required.
|
||||
Any,
|
||||
}
|
||||
|
||||
/// A static rule: this scan applies to a target type, needs this artifact, and
|
||||
/// defaults on/off. The rationale explains the entry to the user.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ScanRule {
|
||||
/// The scan this rule governs.
|
||||
pub scan: ScanType,
|
||||
/// Whether the scan is on by default (only when its artifact is present).
|
||||
pub default_on: bool,
|
||||
/// Human-readable explanation of what the scan does here.
|
||||
pub rationale: &'static str,
|
||||
/// The artifact the scan consumes.
|
||||
pub requires: ArtifactRequirement,
|
||||
}
|
||||
|
||||
impl ScanRule {
|
||||
const fn new(
|
||||
scan: ScanType,
|
||||
default_on: bool,
|
||||
rationale: &'static str,
|
||||
requires: ArtifactRequirement,
|
||||
) -> Self {
|
||||
Self {
|
||||
scan,
|
||||
default_on,
|
||||
rationale,
|
||||
requires,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved scan choice for a specific target: a rule intersected with the
|
||||
/// artifacts actually present. `blocked_reason` is `Some` when the required
|
||||
/// artifact is missing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScanOption {
|
||||
/// The scan.
|
||||
pub scan: ScanType,
|
||||
/// Whether to pre-select the scan (false when blocked).
|
||||
pub default_on: bool,
|
||||
/// Why the scan is offered.
|
||||
pub rationale: String,
|
||||
/// The artifact kind the scan needs, if any specific one.
|
||||
pub required_artifact: Option<ArtifactKind>,
|
||||
/// Set when the required artifact is absent, explaining the block.
|
||||
pub blocked_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// The SAST umbrella: every static-analysis sub-scan that runs over source code.
|
||||
fn sast_umbrella() -> Vec<ScanRule> {
|
||||
use ArtifactRequirement::Code;
|
||||
vec![
|
||||
ScanRule::new(
|
||||
ScanType::Sast,
|
||||
true,
|
||||
"Static analysis (Semgrep) over source",
|
||||
Code,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::Sbom,
|
||||
true,
|
||||
"Software bill of materials from source",
|
||||
Code,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::Cve,
|
||||
true,
|
||||
"Match dependencies against known CVEs",
|
||||
Code,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::SecretDetection,
|
||||
true,
|
||||
"Scan source for committed secrets",
|
||||
Code,
|
||||
),
|
||||
ScanRule::new(ScanType::Lint, true, "Language linters over source", Code),
|
||||
ScanRule::new(
|
||||
ScanType::Gdpr,
|
||||
true,
|
||||
"GDPR data-handling pattern checks",
|
||||
Code,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::OAuth,
|
||||
true,
|
||||
"OAuth misconfiguration patterns",
|
||||
Code,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::Graph,
|
||||
true,
|
||||
"Build the code graph for impact analysis",
|
||||
Code,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::CodeReview,
|
||||
false,
|
||||
"LLM code review over changed source",
|
||||
Code,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The rule set for a target type. Scans that are never applicable to a type are
|
||||
/// simply absent (e.g. DAST is not listed for a PLC target).
|
||||
pub fn rules_for(target_type: TargetType) -> Vec<ScanRule> {
|
||||
use ArtifactRequirement::{Firmware, HttpUrl, Mobile, Plc, RunningUrl};
|
||||
match target_type {
|
||||
TargetType::WebApp | TargetType::BackendService => {
|
||||
let mut r = sast_umbrella();
|
||||
r.push(ScanRule::new(
|
||||
ScanType::Dast,
|
||||
true,
|
||||
"Dynamic scan of the running endpoint",
|
||||
HttpUrl,
|
||||
));
|
||||
r
|
||||
}
|
||||
TargetType::DesktopApp => sast_umbrella(),
|
||||
TargetType::AndroidApp | TargetType::IosApp => {
|
||||
let mut r = sast_umbrella();
|
||||
r.push(ScanRule::new(
|
||||
ScanType::MobileStatic,
|
||||
true,
|
||||
"Static analysis of the mobile package (manifest, permissions, libs)",
|
||||
Mobile,
|
||||
));
|
||||
r
|
||||
}
|
||||
TargetType::FirmwareBareMetal | TargetType::FirmwareRtos => {
|
||||
let mut r = sast_umbrella();
|
||||
r.push(ScanRule::new(
|
||||
ScanType::FirmwareStatic,
|
||||
true,
|
||||
"Unpack and statically analyze the firmware image",
|
||||
Firmware,
|
||||
));
|
||||
r.push(ScanRule::new(
|
||||
ScanType::Sbom,
|
||||
true,
|
||||
"SBOM from the firmware image (binwalk / tramiton)",
|
||||
Firmware,
|
||||
));
|
||||
r.push(ScanRule::new(
|
||||
ScanType::Cve,
|
||||
true,
|
||||
"Match firmware components against known CVEs",
|
||||
Firmware,
|
||||
));
|
||||
r
|
||||
}
|
||||
TargetType::EmbeddedLinuxYocto => {
|
||||
let mut r = sast_umbrella();
|
||||
r.push(ScanRule::new(
|
||||
ScanType::FirmwareStatic,
|
||||
true,
|
||||
"EMBA / binwalk static analysis of the image",
|
||||
Firmware,
|
||||
));
|
||||
r.push(ScanRule::new(
|
||||
ScanType::Sbom,
|
||||
true,
|
||||
"SBOM from image layers / recipes",
|
||||
Firmware,
|
||||
));
|
||||
r.push(ScanRule::new(
|
||||
ScanType::Cve,
|
||||
true,
|
||||
"Match image components against known CVEs",
|
||||
Firmware,
|
||||
));
|
||||
r.push(ScanRule::new(
|
||||
ScanType::Dast,
|
||||
false,
|
||||
"Dynamic scan of exposed network services (if any)",
|
||||
HttpUrl,
|
||||
));
|
||||
r
|
||||
}
|
||||
TargetType::PlcSps => {
|
||||
// A PLC/SPS device is a composite: the control application *and* the
|
||||
// device it runs on (firmware/OS + reachable runtime services). The
|
||||
// control-logic scan runs on the PLC project; the firmware and DAST
|
||||
// scans light up only when a firmware image / running endpoint is
|
||||
// attached (e.g. a CODESYS runtime on a Yocto image with WebVisu).
|
||||
// Firmware-image SBOM/CVE *execution* is shared with the firmware
|
||||
// families and tracked in #151/#128; DAST over a WebVisu/OPC-UA
|
||||
// endpoint uses the existing DAST path.
|
||||
vec![
|
||||
ScanRule::new(
|
||||
ScanType::PlcControlLogic,
|
||||
true,
|
||||
"Control-logic security rules over the PLC program",
|
||||
Plc,
|
||||
),
|
||||
// Device-level scans are offered but opt-in (default-off): they
|
||||
// apply only when a firmware image is attached, and firmware-image
|
||||
// SBOM/CVE *execution* is shared with the firmware families and
|
||||
// still landing (#151/#128), so they must not silently auto-run.
|
||||
ScanRule::new(
|
||||
ScanType::FirmwareStatic,
|
||||
false,
|
||||
"Static analysis of the device firmware image (OS + runtime)",
|
||||
Firmware,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::Sbom,
|
||||
false,
|
||||
"SBOM from the device firmware image (OS packages + CODESYS runtime)",
|
||||
Firmware,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::Cve,
|
||||
false,
|
||||
"Match device firmware components against known CVEs",
|
||||
Firmware,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::Dast,
|
||||
false,
|
||||
"Dynamic scan of the running device (WebVisu / exposed services)",
|
||||
HttpUrl,
|
||||
),
|
||||
ScanRule::new(
|
||||
ScanType::IcsProbe,
|
||||
false,
|
||||
"Probe the running device over industrial protocols (Modbus/TCP, …)",
|
||||
RunningUrl,
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an active penetration test is applicable to this target type.
|
||||
///
|
||||
/// Pentest runs as its own session (not a [`ScanType`] scan) and needs a
|
||||
/// reachable running target, so it is offered only for the network-reachable
|
||||
/// families.
|
||||
pub fn supports_pentest(target_type: TargetType) -> bool {
|
||||
matches!(
|
||||
target_type,
|
||||
TargetType::WebApp
|
||||
| TargetType::BackendService
|
||||
| TargetType::AndroidApp
|
||||
| TargetType::IosApp
|
||||
| TargetType::EmbeddedLinuxYocto
|
||||
// A PLC/SPS device exposes reachable runtime services (WebVisu, OPC UA,
|
||||
// the CODESYS programming protocol), so an active pentest applies.
|
||||
| TargetType::PlcSps
|
||||
)
|
||||
}
|
||||
|
||||
/// The representative artifact kind a requirement is satisfied by.
|
||||
fn representative_kind(req: ArtifactRequirement) -> Option<ArtifactKind> {
|
||||
match req {
|
||||
ArtifactRequirement::Code => Some(ArtifactKind::GitRepo),
|
||||
ArtifactRequirement::RunningUrl | ArtifactRequirement::HttpUrl => {
|
||||
Some(ArtifactKind::LiveUrl)
|
||||
}
|
||||
ArtifactRequirement::Firmware => Some(ArtifactKind::FirmwareImage),
|
||||
ArtifactRequirement::Plc => Some(ArtifactKind::PlcProject),
|
||||
ArtifactRequirement::Mobile => Some(ArtifactKind::MobilePackage),
|
||||
ArtifactRequirement::Container => Some(ArtifactKind::ContainerImage),
|
||||
ArtifactRequirement::Any => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a live-URL reference is an http(s) web endpoint (vs. an industrial
|
||||
/// endpoint like `modbus://` / `opc.tcp://`, which DAST cannot crawl).
|
||||
fn is_http_url(source_ref: &str) -> bool {
|
||||
let s = source_ref.trim();
|
||||
s.starts_with("http://") || s.starts_with("https://")
|
||||
}
|
||||
|
||||
/// Whether the target carries an artifact that satisfies the requirement.
|
||||
fn requirement_satisfied(req: ArtifactRequirement, target: &OnboardedTarget) -> bool {
|
||||
match req {
|
||||
ArtifactRequirement::Code => target.code_artifact().is_some(),
|
||||
ArtifactRequirement::RunningUrl => target.has(ArtifactKind::LiveUrl),
|
||||
ArtifactRequirement::HttpUrl => target
|
||||
.artifacts
|
||||
.iter()
|
||||
.any(|a| a.kind == ArtifactKind::LiveUrl && is_http_url(&a.source_ref)),
|
||||
ArtifactRequirement::Firmware => target.has(ArtifactKind::FirmwareImage),
|
||||
// A PLC project artifact, or a code artifact (git repo / source archive)
|
||||
// holding the control logic as PLCopen XML / ST exports — the common way
|
||||
// CODESYS projects are version-controlled.
|
||||
ArtifactRequirement::Plc => {
|
||||
target.has(ArtifactKind::PlcProject) || target.code_artifact().is_some()
|
||||
}
|
||||
ArtifactRequirement::Mobile => target.has(ArtifactKind::MobilePackage),
|
||||
ArtifactRequirement::Container => target.has(ArtifactKind::ContainerImage),
|
||||
ArtifactRequirement::Any => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the matrix for a concrete target into the scans it can run, marking
|
||||
/// any whose required artifact is missing as blocked.
|
||||
pub fn applicable_scans(target: &OnboardedTarget) -> Vec<ScanOption> {
|
||||
rules_for(target.target_type)
|
||||
.into_iter()
|
||||
.map(|rule| {
|
||||
let satisfied = requirement_satisfied(rule.requires, target);
|
||||
let required_artifact = representative_kind(rule.requires);
|
||||
let blocked_reason = if satisfied {
|
||||
None
|
||||
} else if rule.requires == ArtifactRequirement::HttpUrl {
|
||||
// A live URL may be present but non-HTTP (e.g. modbus://): be
|
||||
// specific so the user knows DAST needs a web endpoint.
|
||||
Some("no http(s) live URL — DAST needs a web endpoint".to_string())
|
||||
} else {
|
||||
Some(match required_artifact {
|
||||
Some(kind) => format!("no {kind} artifact provided"),
|
||||
None => "required artifact missing".to_string(),
|
||||
})
|
||||
};
|
||||
ScanOption {
|
||||
scan: rule.scan,
|
||||
default_on: rule.default_on && satisfied,
|
||||
rationale: rule.rationale.to_string(),
|
||||
required_artifact,
|
||||
blocked_reason,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{Artifact, PlcFormat};
|
||||
|
||||
fn target_with(target_type: TargetType, artifacts: Vec<Artifact>) -> OnboardedTarget {
|
||||
let mut t = OnboardedTarget::new("t".to_string(), target_type);
|
||||
t.artifacts = artifacts;
|
||||
t
|
||||
}
|
||||
|
||||
fn option<'a>(opts: &'a [ScanOption], scan: ScanType) -> Option<&'a ScanOption> {
|
||||
opts.iter().find(|o| o.scan == scan)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webapp_with_code_and_url_offers_sast_and_dast() {
|
||||
let t = target_with(
|
||||
TargetType::WebApp,
|
||||
vec![
|
||||
Artifact::git_repo("u", "main"),
|
||||
Artifact::live_url("http://x"),
|
||||
],
|
||||
);
|
||||
let opts = applicable_scans(&t);
|
||||
let sast = option(&opts, ScanType::Sast).expect("sast offered");
|
||||
assert!(sast.default_on && sast.blocked_reason.is_none());
|
||||
let dast = option(&opts, ScanType::Dast).expect("dast offered");
|
||||
assert!(dast.default_on && dast.blocked_reason.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webapp_without_url_blocks_dast() {
|
||||
let t = target_with(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
|
||||
let opts = applicable_scans(&t);
|
||||
let dast = option(&opts, ScanType::Dast).expect("dast listed");
|
||||
assert!(!dast.default_on);
|
||||
assert!(dast.blocked_reason.is_some());
|
||||
assert_eq!(dast.required_artifact, Some(ArtifactKind::LiveUrl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_offers_firmware_static_and_not_dast() {
|
||||
let t = target_with(
|
||||
TargetType::FirmwareBareMetal,
|
||||
vec![Artifact::firmware_image("fw.bin")],
|
||||
);
|
||||
let opts = applicable_scans(&t);
|
||||
let fw = option(&opts, ScanType::FirmwareStatic).expect("firmware static offered");
|
||||
assert!(fw.default_on && fw.blocked_reason.is_none());
|
||||
assert!(option(&opts, ScanType::Dast).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_control_logic_is_default_on_and_device_scans_block_without_artifacts() {
|
||||
// A PLC project alone: control-logic runs; the device-level scans are
|
||||
// offered but blocked until a firmware image / running endpoint is added.
|
||||
let t = target_with(
|
||||
TargetType::PlcSps,
|
||||
vec![Artifact::plc_project("p.xml", PlcFormat::PlcopenXml)],
|
||||
);
|
||||
let opts = applicable_scans(&t);
|
||||
let plc = option(&opts, ScanType::PlcControlLogic).expect("control-logic offered");
|
||||
assert!(plc.default_on && plc.blocked_reason.is_none());
|
||||
for scan in [ScanType::FirmwareStatic, ScanType::Sbom, ScanType::Cve] {
|
||||
let o = option(&opts, scan).expect("device scan offered");
|
||||
assert!(
|
||||
!o.default_on,
|
||||
"{scan} must not pre-select without a firmware image"
|
||||
);
|
||||
assert!(o.blocked_reason.is_some());
|
||||
}
|
||||
let dast = option(&opts, ScanType::Dast).expect("dast offered");
|
||||
assert!(!dast.default_on);
|
||||
assert!(dast.blocked_reason.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_control_logic_is_satisfied_by_a_git_repo() {
|
||||
// A CODESYS project version-controlled in git (PLCopen XML / ST exports),
|
||||
// no uploaded PlcProject artifact.
|
||||
let t = target_with(TargetType::PlcSps, vec![Artifact::git_repo("u", "main")]);
|
||||
let opts = applicable_scans(&t);
|
||||
let plc = option(&opts, ScanType::PlcControlLogic).expect("control-logic offered");
|
||||
assert!(
|
||||
plc.default_on && plc.blocked_reason.is_none(),
|
||||
"a git repo should satisfy PLC control-logic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_composite_lights_up_device_scans_with_firmware_and_url() {
|
||||
// A CODESYS-on-Yocto device: PLC project + firmware image + WebVisu URL.
|
||||
let t = target_with(
|
||||
TargetType::PlcSps,
|
||||
vec![
|
||||
Artifact::plc_project("p.xml", PlcFormat::PlcopenXml),
|
||||
Artifact::firmware_image("device.img"),
|
||||
Artifact::live_url("http://plc.local/webvisu"),
|
||||
],
|
||||
);
|
||||
let opts = applicable_scans(&t);
|
||||
for scan in [
|
||||
ScanType::PlcControlLogic,
|
||||
ScanType::FirmwareStatic,
|
||||
ScanType::Sbom,
|
||||
ScanType::Cve,
|
||||
] {
|
||||
let o = option(&opts, scan).expect("scan offered");
|
||||
assert!(o.blocked_reason.is_none(), "{scan} should be unblocked");
|
||||
}
|
||||
// Control-logic auto-runs; the device-level scans are unblocked but opt-in
|
||||
// (default-off) until firmware-image execution lands (#151/#128).
|
||||
assert!(option(&opts, ScanType::PlcControlLogic).unwrap().default_on);
|
||||
assert!(!option(&opts, ScanType::Sbom).unwrap().default_on);
|
||||
assert!(!option(&opts, ScanType::Dast).unwrap().default_on);
|
||||
assert!(option(&opts, ScanType::Dast)
|
||||
.unwrap()
|
||||
.blocked_reason
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_with_modbus_url_offers_ics_probe_but_blocks_dast() {
|
||||
// A soft-PLC reachable only over Modbus/TCP (no WebVisu). The ICS probe
|
||||
// is applicable (it works off host:port), but DAST — an HTTP crawler —
|
||||
// must be blocked so it isn't offered/run against a non-web endpoint.
|
||||
let t = target_with(
|
||||
TargetType::PlcSps,
|
||||
vec![Artifact::live_url("modbus://plc-sim:502")],
|
||||
);
|
||||
let opts = applicable_scans(&t);
|
||||
let ics = option(&opts, ScanType::IcsProbe).expect("ics probe offered");
|
||||
assert!(
|
||||
ics.blocked_reason.is_none(),
|
||||
"ICS probe should be unblocked for a modbus:// endpoint"
|
||||
);
|
||||
assert!(!ics.default_on, "ICS probe stays opt-in (default-off)");
|
||||
let dast = option(&opts, ScanType::Dast).expect("dast listed");
|
||||
assert!(
|
||||
dast.blocked_reason.is_some(),
|
||||
"DAST must be blocked without an http(s) endpoint"
|
||||
);
|
||||
assert!(!dast.default_on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plc_with_http_webvisu_offers_both_dast_and_ics_probe() {
|
||||
// A PLC exposing a WebVisu over HTTP: both DAST (web) and the ICS probe
|
||||
// (OT ports on the same host) are applicable.
|
||||
let t = target_with(
|
||||
TargetType::PlcSps,
|
||||
vec![Artifact::live_url("http://plc.local/webvisu")],
|
||||
);
|
||||
let opts = applicable_scans(&t);
|
||||
assert!(option(&opts, ScanType::Dast)
|
||||
.expect("dast offered")
|
||||
.blocked_reason
|
||||
.is_none());
|
||||
assert!(option(&opts, ScanType::IcsProbe)
|
||||
.expect("ics probe offered")
|
||||
.blocked_reason
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pentest_support_matches_reachable_families() {
|
||||
assert!(supports_pentest(TargetType::WebApp));
|
||||
assert!(supports_pentest(TargetType::BackendService));
|
||||
assert!(supports_pentest(TargetType::EmbeddedLinuxYocto));
|
||||
// A PLC/SPS device is network-reachable (WebVisu / OPC UA / 11740).
|
||||
assert!(supports_pentest(TargetType::PlcSps));
|
||||
assert!(!supports_pentest(TargetType::FirmwareBareMetal));
|
||||
assert!(!supports_pentest(TargetType::DesktopApp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_target_type_has_at_least_one_rule() {
|
||||
for tt in [
|
||||
TargetType::WebApp,
|
||||
TargetType::BackendService,
|
||||
TargetType::DesktopApp,
|
||||
TargetType::AndroidApp,
|
||||
TargetType::IosApp,
|
||||
TargetType::FirmwareBareMetal,
|
||||
TargetType::FirmwareRtos,
|
||||
TargetType::EmbeddedLinuxYocto,
|
||||
TargetType::PlcSps,
|
||||
] {
|
||||
assert!(!rules_for(tt).is_empty(), "{tt} has no rules");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
//! The target-classification port.
|
||||
//!
|
||||
//! A [`TargetClassifier`] inspects a target's artifacts (and optionally their
|
||||
//! ingested working directories) and proposes one or more [`ClassifierVerdict`]s
|
||||
//! — a target type, a confidence, and the facts the decision rested on. Concrete
|
||||
//! classifiers live in the agent (language/build-system fingerprinting, a
|
||||
//! firmware detector backed by tramiton, etc.); a registry merges and ranks
|
||||
//! their verdicts. This mirrors the [`crate::traits::Scanner`] port so the two
|
||||
//! read the same way.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::CoreError;
|
||||
use crate::models::{Artifact, DetectedFact, TargetType};
|
||||
|
||||
/// Everything a classifier needs to reason about a target.
|
||||
pub struct ClassificationInput<'a> {
|
||||
/// The artifacts declared for the target.
|
||||
pub artifacts: &'a [Artifact],
|
||||
/// Ingested working paths, keyed by [`Artifact::id`]. Absent for artifacts
|
||||
/// with no on-disk form (e.g. a live URL).
|
||||
pub working_paths: &'a HashMap<String, PathBuf>,
|
||||
/// Free-form description of the target, if provided.
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// A single classifier's proposal for a target.
|
||||
pub struct ClassifierVerdict {
|
||||
/// The proposed target type.
|
||||
pub target_type: TargetType,
|
||||
/// Confidence in `[0.0, 1.0]`.
|
||||
pub confidence: f32,
|
||||
/// Facts that informed the proposal.
|
||||
pub facts: Vec<DetectedFact>,
|
||||
/// Human-readable explanation.
|
||||
pub rationale: String,
|
||||
}
|
||||
|
||||
/// A source of target-type classification.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait TargetClassifier: Send + Sync {
|
||||
/// Stable identifier for this classifier (recorded in `detected_by`).
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Propose zero or more ranked verdicts for the given input.
|
||||
async fn classify(
|
||||
&self,
|
||||
input: &ClassificationInput<'_>,
|
||||
) -> Result<Vec<ClassifierVerdict>, CoreError>;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! The compliance-controls provider port.
|
||||
//!
|
||||
//! The mapping engine turns findings into compliance status against a corpus of
|
||||
//! controls. That corpus is pluggable: the built-in OSCAL catalog by default, or
|
||||
//! a tenant-owned RAG of atomic controls derived from laws
|
||||
//! (`breakpilot-compliance`) when available. A [`ControlsProvider`] abstracts the
|
||||
//! source so the mapping engine does not hardcode a catalog.
|
||||
|
||||
use crate::error::CoreError;
|
||||
use crate::models::ComplianceFramework;
|
||||
|
||||
/// A control retrieved from a controls corpus.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Control {
|
||||
/// Stable control identifier (e.g. an OSCAL control id or a RAG chunk id).
|
||||
pub id: String,
|
||||
/// The framework this control belongs to.
|
||||
pub framework: ComplianceFramework,
|
||||
/// Short human-readable title.
|
||||
pub title: String,
|
||||
/// The control text / requirement.
|
||||
pub text: String,
|
||||
/// Free-form source reference (catalog name, law citation, ...).
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
/// A query for relevant controls.
|
||||
pub struct ControlQuery<'a> {
|
||||
/// Frameworks in scope for the target.
|
||||
pub frameworks: &'a [ComplianceFramework],
|
||||
/// Free-text describing what to map (a finding summary, a component, ...).
|
||||
pub context: &'a str,
|
||||
/// Maximum number of controls to return.
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
/// A source of compliance controls (built-in OSCAL catalog, breakpilot RAG, ...).
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait ControlsProvider: Send + Sync {
|
||||
/// Stable identifier for this provider.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Retrieve the controls most relevant to the query.
|
||||
async fn controls(&self, query: &ControlQuery<'_>) -> Result<Vec<Control>, CoreError>;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
//! The external-evidence provider port.
|
||||
//!
|
||||
//! A sibling product (tramiton, for firmware) may already hold authoritative
|
||||
//! analysis for an artifact. An [`EvidenceProvider`] lets compliance-scanner
|
||||
//! *reconcile* that evidence — a build plan, an SBOM, a VEX document, a
|
||||
//! reproducible-build lock, an attestation — instead of recomputing it. The key
|
||||
//! used to match is the artifact content digest (a firmware sha256, which equals
|
||||
//! [`crate::models::Artifact::content_hash`]).
|
||||
//!
|
||||
//! Concrete providers live in the agent (a tramiton CLI shell-out today, a cloud
|
||||
//! client later) plus a deterministic mock for tests, so nothing here depends on
|
||||
//! an external binary.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::error::CoreError;
|
||||
use crate::models::{Artifact, ExternalSystem};
|
||||
|
||||
/// A single reconcilable evidence document fetched from a sibling product.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvidenceDocument {
|
||||
/// What the document is: `build_plan` | `sbom` | `vex` | `lock` | `attestation`.
|
||||
pub kind: String,
|
||||
/// The document's format (e.g. `cyclonedx-1.5`, `openvex-0.2.0`, `toml`, `json`).
|
||||
pub format: String,
|
||||
/// The raw document payload.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// The evidence a provider could return for a target's artifact.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ReconciledEvidence {
|
||||
/// The sibling's project identifier, if resolved.
|
||||
pub project_id: Option<String>,
|
||||
/// The subject content digest the evidence pertains to.
|
||||
pub subject_sha256: Option<String>,
|
||||
/// The documents fetched (any of build plan / SBOM / VEX / lock / attestation).
|
||||
pub documents: Vec<EvidenceDocument>,
|
||||
}
|
||||
|
||||
/// A source of externally-held, reconcilable evidence for an artifact.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait EvidenceProvider: Send + Sync {
|
||||
/// Which sibling product this provider integrates.
|
||||
fn system(&self) -> ExternalSystem;
|
||||
|
||||
/// Whether this provider can handle the given artifact + working path
|
||||
/// (e.g. tramiton handles firmware images / embedded source trees).
|
||||
fn handles(&self, artifact: &Artifact, working_path: Option<&Path>) -> bool;
|
||||
|
||||
/// Reconcile existing evidence for the artifact, keyed by its content digest.
|
||||
/// Returns `Ok(None)` when the provider has nothing for this artifact.
|
||||
async fn reconcile(
|
||||
&self,
|
||||
artifact: &Artifact,
|
||||
working_path: Option<&Path>,
|
||||
) -> Result<Option<ReconciledEvidence>, CoreError>;
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
pub mod classifier;
|
||||
pub mod controls;
|
||||
pub mod dast_agent;
|
||||
pub mod evidence;
|
||||
pub mod graph_builder;
|
||||
pub mod issue_tracker;
|
||||
pub mod pentest_tool;
|
||||
pub mod scanner;
|
||||
|
||||
pub use classifier::{ClassificationInput, ClassifierVerdict, TargetClassifier};
|
||||
pub use controls::{Control, ControlQuery, ControlsProvider};
|
||||
pub use dast_agent::{DastAgent, DastContext, DiscoveredEndpoint, EndpointParameter};
|
||||
pub use evidence::{EvidenceDocument, EvidenceProvider, ReconciledEvidence};
|
||||
pub use graph_builder::{LanguageParser, ParseOutput};
|
||||
pub use issue_tracker::IssueTracker;
|
||||
pub use pentest_tool::{PentestTool, PentestToolContext, PentestToolResult};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user