Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420af3af9e |
@@ -47,11 +47,6 @@ PLC_RUNTIME_MAX_LIFETIME_SECS=180
|
|||||||
PLC_RUNTIME_OPENPLC_USER=openplc
|
PLC_RUNTIME_OPENPLC_USER=openplc
|
||||||
PLC_RUNTIME_OPENPLC_PASSWORD=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
|
||||||
DASHBOARD_PORT=8080
|
DASHBOARD_PORT=8080
|
||||||
AGENT_API_URL=http://localhost:3001
|
AGENT_API_URL=http://localhost:3001
|
||||||
|
|||||||
+13
-36
@@ -7,13 +7,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# registry + cosign creds via env, NOT inline ${{ }}: the Harbor robot
|
|
||||||
# username contains '$', which sh expands when interpolated into the
|
|
||||||
# script (robot$ci-push -> robot-push) => docker login unauthorized.
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
COSIGN_KEY: ${{ secrets.COSIGN_KEY }}
|
|
||||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUSTFLAGS: "-D warnings"
|
RUSTFLAGS: "-D warnings"
|
||||||
# Compile cache: sccache -> Hetzner S3 (breakpilot-sccache), runner-independent
|
# Compile cache: sccache -> Hetzner S3 (breakpilot-sccache), runner-independent
|
||||||
@@ -72,7 +65,7 @@ jobs:
|
|||||||
echo '[source.crates-io]'
|
echo '[source.crates-io]'
|
||||||
echo 'replace-with = "kellnr"'
|
echo 'replace-with = "kellnr"'
|
||||||
echo '[registries.kellnr]'
|
echo '[registries.kellnr]'
|
||||||
echo 'index = "sparse+https://crates.breakpilot.com/api/v1/cratesio/"'
|
echo 'index = "sparse+https://crates.meghsakha.com/api/v1/cratesio/"'
|
||||||
} >> "$CARGO_HOME/config.toml"
|
} >> "$CARGO_HOME/config.toml"
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: ""
|
RUSTC_WRAPPER: ""
|
||||||
@@ -94,8 +87,8 @@ jobs:
|
|||||||
- name: Configure git auth for private tramiton dependency
|
- name: Configure git auth for private tramiton dependency
|
||||||
run: |
|
run: |
|
||||||
git config --global \
|
git config --global \
|
||||||
url."https://sharang:${{ secrets.TRAMITON_FETCH_TOKEN }}@git.breakpilot.com/".insteadOf \
|
url."https://sharang:${{ secrets.TRAMITON_FETCH_TOKEN }}@gitea.meghsakha.com/".insteadOf \
|
||||||
"ssh://git@git.breakpilot.com:22222/"
|
"ssh://git@gitea.meghsakha.com:22222/"
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: ""
|
RUSTC_WRAPPER: ""
|
||||||
|
|
||||||
@@ -114,10 +107,6 @@ jobs:
|
|||||||
run: cargo clippy -p compliance-dashboard --features web --no-default-features -- -D warnings
|
run: cargo clippy -p compliance-dashboard --features web --no-default-features -- -D warnings
|
||||||
- name: Clippy (mcp)
|
- name: Clippy (mcp)
|
||||||
run: cargo clippy -p compliance-mcp -- -D warnings
|
run: cargo clippy -p compliance-mcp -- -D warnings
|
||||||
- name: Clippy (werkbank-exec)
|
|
||||||
run: cargo clippy -p werkbank-exec -- -D warnings
|
|
||||||
- name: Clippy (control-map)
|
|
||||||
run: cargo clippy -p control-map -- -D warnings
|
|
||||||
|
|
||||||
# Security audit
|
# Security audit
|
||||||
- name: Security Audit
|
- name: Security Audit
|
||||||
@@ -126,8 +115,8 @@ jobs:
|
|||||||
RUSTC_WRAPPER: ""
|
RUSTC_WRAPPER: ""
|
||||||
|
|
||||||
# Tests (reuses compilation artifacts from clippy)
|
# Tests (reuses compilation artifacts from clippy)
|
||||||
- name: Tests (core + agent + werkbank-exec + control-map)
|
- name: Tests (core + agent)
|
||||||
run: cargo test -p compliance-core -p compliance-agent -p werkbank-exec -p control-map --lib
|
run: cargo test -p compliance-core -p compliance-agent --lib
|
||||||
- name: Tests (dashboard server)
|
- name: Tests (dashboard server)
|
||||||
run: cargo test -p compliance-dashboard --features server --no-default-features
|
run: cargo test -p compliance-dashboard --features server --no-default-features
|
||||||
- name: Tests (dashboard web)
|
- name: Tests (dashboard web)
|
||||||
@@ -213,14 +202,11 @@ jobs:
|
|||||||
apk add --no-cache git curl openssl
|
apk add --no-cache git curl openssl
|
||||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||||
IMAGE=repo.breakpilot.com/certifai/compliance-agent
|
IMAGE=registry.meghsakha.com/compliance-agent
|
||||||
echo "$REGISTRY_PASSWORD" | docker login repo.breakpilot.com -u "$REGISTRY_USERNAME" --password-stdin
|
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 \
|
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||||
-f Dockerfile.agent -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
-f Dockerfile.agent -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||||
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
||||||
{ command -v cosign >/dev/null 2>&1 || curl -sSfLo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64 || wget -qO /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64; } || echo "::warning::cosign fetch failed"
|
|
||||||
chmod +x /usr/local/bin/cosign 2>/dev/null || true
|
|
||||||
cosign sign --yes --key env://COSIGN_KEY "$IMAGE:latest" || echo "::warning::cosign failed"
|
|
||||||
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy agent"}}' "${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}')
|
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||||
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$SIG" -d "$PAYLOAD"); echo "$RESP"
|
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$SIG" -d "$PAYLOAD"); echo "$RESP"
|
||||||
@@ -240,14 +226,11 @@ jobs:
|
|||||||
apk add --no-cache git curl openssl
|
apk add --no-cache git curl openssl
|
||||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||||
IMAGE=repo.breakpilot.com/certifai/compliance-dashboard
|
IMAGE=registry.meghsakha.com/compliance-dashboard
|
||||||
echo "$REGISTRY_PASSWORD" | docker login repo.breakpilot.com -u "$REGISTRY_USERNAME" --password-stdin
|
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 \
|
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||||
-f Dockerfile.dashboard -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
-f Dockerfile.dashboard -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||||
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
||||||
{ command -v cosign >/dev/null 2>&1 || curl -sSfLo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64 || wget -qO /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64; } || echo "::warning::cosign fetch failed"
|
|
||||||
chmod +x /usr/local/bin/cosign 2>/dev/null || true
|
|
||||||
cosign sign --yes --key env://COSIGN_KEY "$IMAGE:latest" || echo "::warning::cosign failed"
|
|
||||||
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy dashboard"}}' "${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}')
|
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||||
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$SIG" -d "$PAYLOAD"); echo "$RESP"
|
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$SIG" -d "$PAYLOAD"); echo "$RESP"
|
||||||
@@ -265,13 +248,10 @@ jobs:
|
|||||||
apk add --no-cache git curl openssl
|
apk add --no-cache git curl openssl
|
||||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||||
IMAGE=repo.breakpilot.com/certifai/compliance-docs
|
IMAGE=registry.meghsakha.com/compliance-docs
|
||||||
echo "$REGISTRY_PASSWORD" | docker login repo.breakpilot.com -u "$REGISTRY_USERNAME" --password-stdin
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
docker build -f Dockerfile.docs -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
docker build -f Dockerfile.docs -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||||
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
||||||
{ command -v cosign >/dev/null 2>&1 || curl -sSfLo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64 || wget -qO /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64; } || echo "::warning::cosign fetch failed"
|
|
||||||
chmod +x /usr/local/bin/cosign 2>/dev/null || true
|
|
||||||
cosign sign --yes --key env://COSIGN_KEY "$IMAGE:latest" || echo "::warning::cosign failed"
|
|
||||||
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy docs"}}' "${GITHUB_SHA}")
|
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy docs"}}' "${GITHUB_SHA}")
|
||||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||||
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$SIG" -d "$PAYLOAD"); echo "$RESP"
|
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$SIG" -d "$PAYLOAD"); echo "$RESP"
|
||||||
@@ -291,14 +271,11 @@ jobs:
|
|||||||
apk add --no-cache git curl openssl
|
apk add --no-cache git curl openssl
|
||||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||||
IMAGE=repo.breakpilot.com/certifai/compliance-mcp
|
IMAGE=registry.meghsakha.com/compliance-mcp
|
||||||
echo "$REGISTRY_PASSWORD" | docker login repo.breakpilot.com -u "$REGISTRY_USERNAME" --password-stdin
|
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 \
|
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||||
-f Dockerfile.mcp -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
-f Dockerfile.mcp -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||||
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
docker push "$IMAGE:latest" && docker push "$IMAGE:${GITHUB_SHA}"
|
||||||
{ command -v cosign >/dev/null 2>&1 || curl -sSfLo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64 || wget -qO /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64; } || echo "::warning::cosign fetch failed"
|
|
||||||
chmod +x /usr/local/bin/cosign 2>/dev/null || true
|
|
||||||
cosign sign --yes --key env://COSIGN_KEY "$IMAGE:latest" || echo "::warning::cosign failed"
|
|
||||||
PAYLOAD=$(printf '{"ref":"refs/heads/main","repository":{"full_name":"sharang/compliance-scanner-agent"},"head_commit":{"id":"%s","message":"deploy mcp"}}' "${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}')
|
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||||
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$SIG" -d "$PAYLOAD"); echo "$RESP"
|
RESP=$(curl -fsS -w "\nHTTP %{http_code}" -X POST "http://46.225.100.82:6880/api/v1/webhooks/github" -H "Content-Type: application/json" -H "X-Hub-Signature-256: sha256=$SIG" -d "$PAYLOAD"); echo "$RESP"
|
||||||
|
|||||||
Generated
+14
-51
@@ -666,7 +666,6 @@ dependencies = [
|
|||||||
"compliance-core",
|
"compliance-core",
|
||||||
"compliance-dast",
|
"compliance-dast",
|
||||||
"compliance-graph",
|
"compliance-graph",
|
||||||
"control-map",
|
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -700,7 +699,6 @@ dependencies = [
|
|||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"werkbank-exec",
|
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -969,15 +967,6 @@ dependencies = [
|
|||||||
"charset",
|
"charset",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "control-map"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -2116,7 +2105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2474,9 +2463,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.19"
|
version = "0.4.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2796,7 +2785,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.2",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
@@ -3711,7 +3700,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4298,7 +4287,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash 2.1.1",
|
"rustc-hash 2.1.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -4335,9 +4324,9 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4711,7 +4700,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.12.1",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5099,12 +5088,6 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sha1_smol"
|
|
||||||
version = "1.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -5598,7 +5581,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.1",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6176,7 +6159,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "tramiton-core"
|
name = "tramiton-core"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
source = "git+ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -6188,7 +6171,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "tramiton-repro"
|
name = "tramiton-repro"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
source = "git+ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6203,7 +6186,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "tramiton-sbom"
|
name = "tramiton-sbom"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
source = "git+ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"object",
|
"object",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6488,7 +6471,6 @@ dependencies = [
|
|||||||
"getrandom 0.4.1",
|
"getrandom 0.4.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"sha1_smol",
|
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6732,25 +6714,6 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"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]]
|
[[package]]
|
||||||
name = "which"
|
name = "which"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
@@ -6791,7 +6754,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+1
-4
@@ -7,8 +7,6 @@ members = [
|
|||||||
"compliance-dast",
|
"compliance-dast",
|
||||||
"compliance-mcp",
|
"compliance-mcp",
|
||||||
"compliance-smoke",
|
"compliance-smoke",
|
||||||
"werkbank-exec",
|
|
||||||
"control-map",
|
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -18,7 +16,6 @@ expect_used = "deny"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
compliance-core = { path = "compliance-core", default-features = false }
|
compliance-core = { path = "compliance-core", default-features = false }
|
||||||
control-map = { path = "control-map" }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
@@ -30,7 +27,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart", "co
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
secrecy = { version = "0.10", features = ["serde"] }
|
secrecy = { version = "0.10", features = ["serde"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
zip = { version = "2", features = ["aes-crypto", "deflate"] }
|
zip = { version = "2", features = ["aes-crypto", "deflate"] }
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@ COPY . .
|
|||||||
RUN --mount=type=secret,id=tramiton_token \
|
RUN --mount=type=secret,id=tramiton_token \
|
||||||
if [ -s /run/secrets/tramiton_token ]; then \
|
if [ -s /run/secrets/tramiton_token ]; then \
|
||||||
git config --global \
|
git config --global \
|
||||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@git.breakpilot.com/".insteadOf \
|
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||||
"ssh://git@git.breakpilot.com:22222/"; \
|
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||||
fi && \
|
fi && \
|
||||||
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-agent
|
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-agent
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ ENV DOCS_URL=${DOCS_URL}
|
|||||||
RUN --mount=type=secret,id=tramiton_token \
|
RUN --mount=type=secret,id=tramiton_token \
|
||||||
if [ -s /run/secrets/tramiton_token ]; then \
|
if [ -s /run/secrets/tramiton_token ]; then \
|
||||||
git config --global \
|
git config --global \
|
||||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@git.breakpilot.com/".insteadOf \
|
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||||
"ssh://git@git.breakpilot.com:22222/"; \
|
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||||
fi && \
|
fi && \
|
||||||
CARGO_NET_GIT_FETCH_WITH_CLI=true dx build --release --package compliance-dashboard
|
CARGO_NET_GIT_FETCH_WITH_CLI=true dx build --release --package compliance-dashboard
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@ COPY . .
|
|||||||
RUN --mount=type=secret,id=tramiton_token \
|
RUN --mount=type=secret,id=tramiton_token \
|
||||||
if [ -s /run/secrets/tramiton_token ]; then \
|
if [ -s /run/secrets/tramiton_token ]; then \
|
||||||
git config --global \
|
git config --global \
|
||||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@git.breakpilot.com/".insteadOf \
|
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||||
"ssh://git@git.breakpilot.com:22222/"; \
|
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||||
fi && \
|
fi && \
|
||||||
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-mcp
|
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-mcp
|
||||||
|
|
||||||
|
|||||||
@@ -8,24 +8,18 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] }
|
compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] }
|
||||||
control-map = { workspace = true }
|
|
||||||
compliance-graph = { path = "../compliance-graph" }
|
compliance-graph = { path = "../compliance-graph" }
|
||||||
compliance-dast = { path = "../compliance-dast" }
|
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.
|
# Native firmware build/target detection for bare-metal & RTOS artifacts.
|
||||||
# Same-company IP, used directly (not via CLI) so the whole tramiton suite is
|
# Same-company IP, used directly (not via CLI) so the whole tramiton suite is
|
||||||
# available to the onboarding classifier. Repo renamed tramiton -> firmwerk
|
# available to the onboarding classifier. NOTE: CI must be able to fetch this
|
||||||
# (git.breakpilot.com/sharang/firmwerk); crates stay tramiton-* at tag v0.4.1.
|
|
||||||
# NOTE: CI must be able to fetch this
|
|
||||||
# private repo (see the git-auth step in .gitea/workflows/ci.yml).
|
# private repo (see the git-auth step in .gitea/workflows/ci.yml).
|
||||||
tramiton-core = { git = "ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git", tag = "v0.4.1" }
|
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
|
# tramiton-repro drives the reproducible build (NixBackend seal_and_build) that
|
||||||
# yields a sealed lock; `libraries_from_inputs` is the analysis-only fallback.
|
# yields a sealed lock; `libraries_from_inputs` is the analysis-only fallback.
|
||||||
tramiton-repro = { git = "ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git", tag = "v0.4.1" }
|
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 renders the bill of materials from a sealed lock (+ binary SCA).
|
||||||
tramiton-sbom = { git = "ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git", tag = "v0.4.1" }
|
tramiton-sbom = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
# Custom semgrep rules for CRA controls that no off-the-shelf ruleset digs out.
|
|
||||||
# Each rule id is `cra-ai-<n>-<slug>` and is keyed back to its control via the
|
|
||||||
# `control-map` LUT (by rule-id suffix, so semgrep's path prefix on check_id does
|
|
||||||
# not matter). Detection here is deterministic; the grounded LLM judge downstream
|
|
||||||
# only confirms/refutes — it never detects. Keep patterns tight: a false positive
|
|
||||||
# that the judge refutes marks the whole finding a false positive.
|
|
||||||
rules:
|
|
||||||
# --- cra-ai-1: Secure-by-Default-Konfiguration -------------------------------
|
|
||||||
- id: cra-ai-1-flask-debug-enabled
|
|
||||||
languages: [python]
|
|
||||||
severity: WARNING
|
|
||||||
message: Flask app started with debug=True — ships an interactive debugger / code execution in production (secure-by-default violation).
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-489: Active Debug Code"]
|
|
||||||
control: cra-ai-1
|
|
||||||
patterns:
|
|
||||||
- pattern: '$APP.run(..., debug=True, ...)'
|
|
||||||
|
|
||||||
- id: cra-ai-1-django-debug-true
|
|
||||||
languages: [python]
|
|
||||||
severity: WARNING
|
|
||||||
message: Django DEBUG = True — leaks stack traces / settings in production (secure-by-default violation).
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-489: Active Debug Code"]
|
|
||||||
control: cra-ai-1
|
|
||||||
patterns:
|
|
||||||
- pattern: 'DEBUG = True'
|
|
||||||
|
|
||||||
- id: cra-ai-1-tls-verify-disabled
|
|
||||||
languages: [python]
|
|
||||||
severity: ERROR
|
|
||||||
message: TLS certificate verification disabled (verify=False) — defeats transport security by default.
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-295: Improper Certificate Validation"]
|
|
||||||
control: cra-ai-1
|
|
||||||
patterns:
|
|
||||||
- pattern: 'requests.$M(..., verify=False, ...)'
|
|
||||||
|
|
||||||
- id: cra-ai-1-cors-wildcard
|
|
||||||
languages: [javascript, typescript]
|
|
||||||
severity: WARNING
|
|
||||||
message: CORS Access-Control-Allow-Origin set to "*" — opens the API to any origin by default.
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-942: Permissive Cross-domain Policy with Untrusted Domains"]
|
|
||||||
control: cra-ai-1
|
|
||||||
patterns:
|
|
||||||
- pattern-either:
|
|
||||||
- pattern: '$RES.header("Access-Control-Allow-Origin", "*")'
|
|
||||||
- pattern: '$RES.setHeader("Access-Control-Allow-Origin", "*")'
|
|
||||||
|
|
||||||
# --- cra-ai-7: Starke Authentifizierung (weak password hashing) --------------
|
|
||||||
- id: cra-ai-7-weak-password-hash
|
|
||||||
languages: [python]
|
|
||||||
severity: ERROR
|
|
||||||
message: Password/secret hashed with a fast, broken digest (md5/sha1) — use a password KDF (bcrypt/scrypt/argon2).
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-916: Use of Password Hash With Insufficient Computational Effort"]
|
|
||||||
control: cra-ai-7
|
|
||||||
patterns:
|
|
||||||
- pattern-either:
|
|
||||||
- pattern: 'hashlib.md5($PW)'
|
|
||||||
- pattern: 'hashlib.sha1($PW)'
|
|
||||||
- metavariable-regex:
|
|
||||||
metavariable: $PW
|
|
||||||
regex: '(?i).*(pass|pwd|secret|cred|token).*'
|
|
||||||
|
|
||||||
# --- cra-ai-10: Sitzungsmanagement (insecure session cookies) ----------------
|
|
||||||
- id: cra-ai-10-session-cookie-insecure
|
|
||||||
languages: [python]
|
|
||||||
severity: ERROR
|
|
||||||
message: Session cookie hardened flag explicitly disabled (Secure/HttpOnly = False) — session token exposed to theft.
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute"]
|
|
||||||
control: cra-ai-10
|
|
||||||
patterns:
|
|
||||||
- pattern-either:
|
|
||||||
- pattern: 'SESSION_COOKIE_SECURE = False'
|
|
||||||
- pattern: 'SESSION_COOKIE_HTTPONLY = False'
|
|
||||||
|
|
||||||
- id: cra-ai-10-express-cookie-insecure
|
|
||||||
languages: [javascript, typescript]
|
|
||||||
severity: ERROR
|
|
||||||
message: Express cookie set with secure/httpOnly = false — session token exposed to interception / XSS theft.
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute"]
|
|
||||||
control: cra-ai-10
|
|
||||||
patterns:
|
|
||||||
- pattern-either:
|
|
||||||
- pattern: '$RES.cookie($NAME, $VAL, {..., secure: false, ...})'
|
|
||||||
- pattern: '$RES.cookie($NAME, $VAL, {..., httpOnly: false, ...})'
|
|
||||||
|
|
||||||
# --- cra-ai-14: Speicher-Schutz / Data at Rest (weak cipher) -----------------
|
|
||||||
- id: cra-ai-14-python-weak-cipher
|
|
||||||
languages: [python]
|
|
||||||
severity: ERROR
|
|
||||||
message: Data-at-rest encrypted with a broken cipher/mode (ECB, DES, 3DES) — provides no real confidentiality.
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-327: Use of a Broken or Risky Cryptographic Algorithm"]
|
|
||||||
control: cra-ai-14
|
|
||||||
patterns:
|
|
||||||
- pattern-either:
|
|
||||||
- pattern: 'AES.new($K, AES.MODE_ECB, ...)'
|
|
||||||
- pattern: 'DES.new(...)'
|
|
||||||
- pattern: 'DES3.new(...)'
|
|
||||||
|
|
||||||
- id: cra-ai-14-node-weak-cipher
|
|
||||||
languages: [javascript, typescript]
|
|
||||||
severity: ERROR
|
|
||||||
message: Data-at-rest encrypted with a broken cipher (DES / deprecated createCipher) — provides no real confidentiality.
|
|
||||||
metadata:
|
|
||||||
cwe: ["CWE-327: Use of a Broken or Risky Cryptographic Algorithm"]
|
|
||||||
control: cra-ai-14
|
|
||||||
patterns:
|
|
||||||
- pattern-either:
|
|
||||||
- pattern: 'crypto.createCipheriv("des-ecb", ...)'
|
|
||||||
- pattern: 'crypto.createCipheriv("des", ...)'
|
|
||||||
- pattern: 'crypto.createCipher(...)'
|
|
||||||
@@ -10,7 +10,6 @@ pub mod issues;
|
|||||||
pub mod mcp_tokens;
|
pub mod mcp_tokens;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
pub mod oscal;
|
|
||||||
pub mod pentest_handlers;
|
pub mod pentest_handlers;
|
||||||
pub use pentest_handlers as pentest;
|
pub use pentest_handlers as pentest;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
//! OSCAL assessment endpoint.
|
|
||||||
//!
|
|
||||||
//! Returns a standard OSCAL assessment-results document for a target's findings,
|
|
||||||
//! driven by each finding's stamped `control_refs` (from the scan's control-triage
|
|
||||||
//! stage): mapped findings target their controls, unmapped findings are reported
|
|
||||||
//! as-is. See `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::oscal_assessment::assess;
|
|
||||||
use compliance_core::models::Finding;
|
|
||||||
use compliance_core::tenant_ctx::TenantCtx;
|
|
||||||
|
|
||||||
use super::dto::{collect_cursor_async, tenant_db, AgentExt};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct AssessRequest {
|
|
||||||
/// The target / repo id whose findings are assessed.
|
|
||||||
pub target_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/v1/oscal/assess` — OSCAL assessment-results for a target's findings.
|
|
||||||
pub async fn assess_target(
|
|
||||||
Extension(agent): AgentExt,
|
|
||||||
tenant: TenantCtx,
|
|
||||||
Json(req): Json<AssessRequest>,
|
|
||||||
) -> Response {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Json(assess(&findings, chrono::Utc::now())).into_response()
|
|
||||||
}
|
|
||||||
@@ -10,20 +10,18 @@
|
|||||||
//! so a job run by a remote runner lands the same findings an in-process run
|
//! so a job run by a remote runner lands the same findings an in-process run
|
||||||
//! would (WB-05, the control-plane cut-over).
|
//! would (WB-05, the control-plane cut-over).
|
||||||
|
|
||||||
use axum::extract::{Extension, Path, Request};
|
use axum::extract::{Extension, Request};
|
||||||
use axum::http::{header, StatusCode};
|
use axum::http::{header, StatusCode};
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use mongodb::bson::{doc, oid::ObjectId};
|
use mongodb::bson::doc;
|
||||||
use secrecy::ExposeSecret;
|
use secrecy::ExposeSecret;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use compliance_core::models::werkbank::{
|
use compliance_core::models::werkbank::{
|
||||||
CompleteRequest, CompleteResponse, HeartbeatRequest, InputRef, Job, JobResult, LeaseRequest,
|
CompleteRequest, CompleteResponse, HeartbeatRequest, JobResult, LeaseRequest,
|
||||||
};
|
};
|
||||||
use compliance_core::models::ArtifactKind;
|
|
||||||
|
|
||||||
use super::dto::AgentExt;
|
use super::dto::AgentExt;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
@@ -124,100 +122,6 @@ pub async fn complete(
|
|||||||
Ok(Json(CompleteResponse { recorded }))
|
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
|
/// Persist a job result's findings against its target: general findings
|
||||||
/// (dedup'd by fingerprint) and DAST findings. Best-effort — a persistence hiccup
|
/// (dedup'd by fingerprint) and DAST findings. Best-effort — a persistence hiccup
|
||||||
/// is logged, not surfaced to the runner (its result is already recorded).
|
/// is logged, not surfaced to the runner (its result is already recorded).
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use crate::api::handlers;
|
|||||||
pub fn build_router() -> Router {
|
pub fn build_router() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/v1/health", get(handlers::health))
|
.route("/api/v1/health", get(handlers::health))
|
||||||
.route("/api/v1/oscal/assess", post(handlers::oscal::assess_target))
|
|
||||||
.route("/api/v1/stats/overview", get(handlers::stats_overview))
|
.route("/api/v1/stats/overview", get(handlers::stats_overview))
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/settings/ssh-public-key",
|
"/api/v1/settings/ssh-public-key",
|
||||||
|
|||||||
@@ -91,14 +91,6 @@ pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), A
|
|||||||
"/api/v1/werkbank/jobs/complete",
|
"/api/v1/werkbank/jobs/complete",
|
||||||
post(handlers::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(
|
.layer(middleware::from_fn(
|
||||||
handlers::werkbank_jobs::require_runner_token,
|
handlers::werkbank_jobs::require_runner_token,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use compliance_core::config::{BreakpilotConfig, PlcRuntimeConfig};
|
use compliance_core::config::PlcRuntimeConfig;
|
||||||
use compliance_core::AgentConfig;
|
use compliance_core::AgentConfig;
|
||||||
use secrecy::SecretString;
|
use secrecy::SecretString;
|
||||||
|
|
||||||
@@ -66,7 +66,6 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
|||||||
tenant_registry_url: env_var_opt("TENANT_REGISTRY_URL"),
|
tenant_registry_url: env_var_opt("TENANT_REGISTRY_URL"),
|
||||||
plc_runtime: load_plc_runtime_config(),
|
plc_runtime: load_plc_runtime_config(),
|
||||||
werkbank_runner_token: env_secret_opt("WERKBANK_RUNNER_TOKEN"),
|
werkbank_runner_token: env_secret_opt("WERKBANK_RUNNER_TOKEN"),
|
||||||
breakpilot: load_breakpilot_config(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,20 +90,3 @@ fn load_plc_runtime_config() -> PlcRuntimeConfig {
|
|||||||
.unwrap_or(d.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),
|
|
||||||
semantic_mapping: env_var_opt("BREAKPILOT_SEMANTIC_MAPPING")
|
|
||||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
|
||||||
.unwrap_or(d.semantic_mapping),
|
|
||||||
grounded_control_checks: env_var_opt("BREAKPILOT_GROUNDED_CHECKS")
|
|
||||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
|
||||||
.unwrap_or(d.grounded_control_checks),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
//! The grounded control checker: judge each candidate region for a control, then
|
|
||||||
//! keep only the verdicts that survive the grounding gate.
|
|
||||||
//!
|
|
||||||
//! Generic over [`ControlJudge`] so tests drive it with a deterministic stub —
|
|
||||||
//! the whole recognize → ground path is then exercised without an LLM. With the
|
|
||||||
//! real judge, determinism comes from temperature 0 plus the gate.
|
|
||||||
|
|
||||||
use compliance_core::control_check::{ground, CandidateRegion, ControlCheckSpec};
|
|
||||||
use compliance_core::models::Finding;
|
|
||||||
|
|
||||||
use super::judge::ControlJudge;
|
|
||||||
|
|
||||||
/// Runs a [`ControlJudge`] over candidate regions and grounds the results.
|
|
||||||
pub struct GroundedControlChecker<J> {
|
|
||||||
judge: J,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<J: ControlJudge> GroundedControlChecker<J> {
|
|
||||||
pub fn new(judge: J) -> Self {
|
|
||||||
Self { judge }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Judge every candidate region for `spec` and return the grounded findings.
|
|
||||||
/// A verdict that doesn't quote real code in its region is dropped by
|
|
||||||
/// [`ground`], so nothing fabricated reaches the caller.
|
|
||||||
pub async fn check(
|
|
||||||
&self,
|
|
||||||
spec: &ControlCheckSpec,
|
|
||||||
regions: &[CandidateRegion],
|
|
||||||
repo_id: &str,
|
|
||||||
) -> Vec<Finding> {
|
|
||||||
let mut findings = Vec::new();
|
|
||||||
for region in regions {
|
|
||||||
let verdict = self.judge.judge(spec, region).await;
|
|
||||||
if let Some(finding) = ground(spec, region, &verdict, repo_id) {
|
|
||||||
findings.push(finding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
findings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use compliance_core::control_check::LlmVerdict;
|
|
||||||
use compliance_core::models::finding::Severity;
|
|
||||||
|
|
||||||
/// Deterministic stub: returns a fixed verdict for every region, so the
|
|
||||||
/// recognize → ground composition is tested without an LLM.
|
|
||||||
struct StubJudge {
|
|
||||||
verdict: LlmVerdict,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ControlJudge for StubJudge {
|
|
||||||
async fn judge(&self, _spec: &ControlCheckSpec, _region: &CandidateRegion) -> LlmVerdict {
|
|
||||||
self.verdict.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spec() -> ControlCheckSpec {
|
|
||||||
ControlCheckSpec {
|
|
||||||
control_id: "cra-ai-8".into(),
|
|
||||||
title: "No default passwords".into(),
|
|
||||||
requirement: "No default credentials".into(),
|
|
||||||
default_cwe: Some("CWE-798".into()),
|
|
||||||
severity: Severity::High,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn region(content: &str) -> CandidateRegion {
|
|
||||||
CandidateRegion {
|
|
||||||
file: "src/auth.py".into(),
|
|
||||||
start_line: 1,
|
|
||||||
content: content.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn keeps_grounded_and_drops_ungrounded() {
|
|
||||||
let checker = GroundedControlChecker::new(StubJudge {
|
|
||||||
verdict: LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: "PASSWORD = \"admin\"".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let regions = vec![
|
|
||||||
region("x = 1\nPASSWORD = \"admin\"\n"), // quotes real code → grounded
|
|
||||||
region("totally unrelated code\n"), // snippet absent → dropped
|
|
||||||
];
|
|
||||||
let findings = checker.check(&spec(), ®ions, "repo").await;
|
|
||||||
assert_eq!(findings.len(), 1);
|
|
||||||
assert_eq!(findings[0].control_refs, vec!["cra-ai-8".to_string()]);
|
|
||||||
assert_eq!(findings[0].line_number, Some(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn non_violation_yields_nothing() {
|
|
||||||
let checker = GroundedControlChecker::new(StubJudge {
|
|
||||||
verdict: LlmVerdict {
|
|
||||||
violates: false,
|
|
||||||
snippet: String::new(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let findings = checker
|
|
||||||
.check(&spec(), &[region("PASSWORD = \"admin\"\n")], "repo")
|
|
||||||
.await;
|
|
||||||
assert!(findings.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
//! In-memory embedding index over the control corpus, for region → control
|
|
||||||
//! retrieval.
|
|
||||||
//!
|
|
||||||
//! At master-control scale (~13.6k) findings can't be mapped by CWE (the master
|
|
||||||
//! controls carry none), so we map by *similarity*: embed each control's
|
|
||||||
//! requirement text once, then for a code region pull the top-K nearest controls
|
|
||||||
//! to hand to the grounded judge. This is the retrieval half of the semantic path.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use compliance_core::control_check::ControlCheckSpec;
|
|
||||||
use compliance_core::error::CoreError;
|
|
||||||
|
|
||||||
use crate::llm::LlmClient;
|
|
||||||
|
|
||||||
/// A control spec paired with its requirement-text embedding.
|
|
||||||
pub struct ControlIndex {
|
|
||||||
entries: Vec<(ControlCheckSpec, Vec<f64>)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// On-disk form of the index: the corpus identity hash plus every spec+embedding.
|
|
||||||
/// The hash lets a later scan reuse the embeddings only if the corpus is unchanged.
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct PersistedIndex {
|
|
||||||
corpus_hash: String,
|
|
||||||
entries: Vec<PersistedEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct PersistedEntry {
|
|
||||||
spec: ControlCheckSpec,
|
|
||||||
embedding: Vec<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stable hash of the corpus identity (each control's id + requirement text, in
|
|
||||||
/// order). Same catalog → same hash → the cached embeddings are reused instead of
|
|
||||||
/// re-embedding the whole corpus.
|
|
||||||
fn corpus_hash(specs: &[ControlCheckSpec]) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
for s in specs {
|
|
||||||
hasher.update(s.control_id.as_bytes());
|
|
||||||
hasher.update([0u8]);
|
|
||||||
hasher.update(s.requirement.as_bytes());
|
|
||||||
hasher.update([0u8]);
|
|
||||||
}
|
|
||||||
format!("{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ControlIndex {
|
|
||||||
/// Build directly from precomputed embeddings (used by tests + callers that
|
|
||||||
/// already embedded the corpus).
|
|
||||||
pub fn from_embeddings(entries: Vec<(ControlCheckSpec, Vec<f64>)>) -> Self {
|
|
||||||
Self { entries }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the index from `cache_path` if it still matches the current corpus,
|
|
||||||
/// otherwise embed the corpus and persist it there. This turns the per-scan
|
|
||||||
/// re-embed of the whole (~13.6k) master-control corpus into a one-time cost
|
|
||||||
/// that survives across scans; the cache self-invalidates when the catalog
|
|
||||||
/// changes (its [`corpus_hash`] no longer matches).
|
|
||||||
pub async fn load_or_build(
|
|
||||||
llm: &LlmClient,
|
|
||||||
specs: Vec<ControlCheckSpec>,
|
|
||||||
cache_path: &Path,
|
|
||||||
) -> Result<Self, CoreError> {
|
|
||||||
let hash = corpus_hash(&specs);
|
|
||||||
if let Some(index) = Self::load_cache(cache_path, &hash).await {
|
|
||||||
tracing::debug!(
|
|
||||||
controls = index.len(),
|
|
||||||
"reusing cached control embedding index"
|
|
||||||
);
|
|
||||||
return Ok(index);
|
|
||||||
}
|
|
||||||
let index = Self::build(llm, specs).await?;
|
|
||||||
if let Err(e) = index.write_cache(cache_path, &hash).await {
|
|
||||||
tracing::warn!(error = %e, "failed to persist control embedding index");
|
|
||||||
}
|
|
||||||
Ok(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a persisted index, returning it only if its corpus hash matches.
|
|
||||||
async fn load_cache(path: &Path, hash: &str) -> Option<Self> {
|
|
||||||
let raw = tokio::fs::read(path).await.ok()?;
|
|
||||||
let persisted: PersistedIndex = serde_json::from_slice(&raw).ok()?;
|
|
||||||
if persisted.corpus_hash != hash {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Self {
|
|
||||||
entries: persisted
|
|
||||||
.entries
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| (e.spec, e.embedding))
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the index atomically (temp file + rename) keyed by corpus hash.
|
|
||||||
async fn write_cache(&self, path: &Path, hash: &str) -> Result<(), CoreError> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
|
||||||
}
|
|
||||||
let persisted = PersistedIndex {
|
|
||||||
corpus_hash: hash.to_string(),
|
|
||||||
entries: self
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.map(|(spec, emb)| PersistedEntry {
|
|
||||||
spec: spec.clone(),
|
|
||||||
embedding: emb.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
};
|
|
||||||
let raw = serde_json::to_vec(&persisted)?;
|
|
||||||
let tmp = path.with_extension("json.tmp");
|
|
||||||
tokio::fs::write(&tmp, &raw).await?;
|
|
||||||
tokio::fs::rename(&tmp, path).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build by embedding each control's requirement text.
|
|
||||||
pub async fn build(llm: &LlmClient, specs: Vec<ControlCheckSpec>) -> Result<Self, CoreError> {
|
|
||||||
if specs.is_empty() {
|
|
||||||
return Ok(Self {
|
|
||||||
entries: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let texts: Vec<String> = specs.iter().map(|s| s.requirement.clone()).collect();
|
|
||||||
let embeddings = llm
|
|
||||||
.embed(texts)
|
|
||||||
.await
|
|
||||||
.map_err(|e| CoreError::Llm(e.to_string()))?;
|
|
||||||
Ok(Self {
|
|
||||||
entries: specs.into_iter().zip(embeddings).collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.entries.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.entries.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The top-`k` control specs whose embedding is nearest (cosine) to `query`.
|
|
||||||
pub fn nearest(&self, query: &[f64], k: usize) -> Vec<ControlCheckSpec> {
|
|
||||||
let mut scored: Vec<(f64, &ControlCheckSpec)> = self
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.map(|(spec, emb)| (cosine(query, emb), spec))
|
|
||||||
.collect();
|
|
||||||
scored.sort_by(|a, b| b.0.total_cmp(&a.0));
|
|
||||||
scored.into_iter().take(k).map(|(_, s)| s.clone()).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cosine similarity; 0.0 for length-mismatched, empty, or zero vectors.
|
|
||||||
fn cosine(a: &[f64], b: &[f64]) -> f64 {
|
|
||||||
if a.len() != b.len() || a.is_empty() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
|
||||||
let na: f64 = a.iter().map(|x| x * x).sum();
|
|
||||||
let nb: f64 = b.iter().map(|x| x * x).sum();
|
|
||||||
if na == 0.0 || nb == 0.0 {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
dot / (na.sqrt() * nb.sqrt())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use compliance_core::models::finding::Severity;
|
|
||||||
|
|
||||||
fn spec(id: &str) -> ControlCheckSpec {
|
|
||||||
ControlCheckSpec {
|
|
||||||
control_id: id.into(),
|
|
||||||
title: id.into(),
|
|
||||||
requirement: id.into(),
|
|
||||||
default_cwe: None,
|
|
||||||
severity: Severity::Medium,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nearest_ranks_by_cosine() {
|
|
||||||
let index = ControlIndex::from_embeddings(vec![
|
|
||||||
(spec("a"), vec![1.0, 0.0]),
|
|
||||||
(spec("b"), vec![0.0, 1.0]),
|
|
||||||
(spec("c"), vec![0.7, 0.7]),
|
|
||||||
]);
|
|
||||||
let hits = index.nearest(&[0.9, 0.1], 2);
|
|
||||||
assert_eq!(hits.len(), 2);
|
|
||||||
assert_eq!(hits[0].control_id, "a"); // closest to [0.9,0.1]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cosine_edges_are_zero() {
|
|
||||||
assert_eq!(cosine(&[1.0], &[1.0, 2.0]), 0.0); // length mismatch
|
|
||||||
assert_eq!(cosine(&[0.0, 0.0], &[1.0, 1.0]), 0.0); // zero vector
|
|
||||||
assert!((cosine(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-9); // identical
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn corpus_hash_is_stable_and_identity_sensitive() {
|
|
||||||
let a = corpus_hash(&[spec("x"), spec("y")]);
|
|
||||||
assert_eq!(a, corpus_hash(&[spec("x"), spec("y")])); // same corpus → same hash
|
|
||||||
assert_ne!(a, corpus_hash(&[spec("y"), spec("x")])); // reorder → different
|
|
||||||
assert_ne!(a, corpus_hash(&[spec("x")])); // fewer controls → different
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
async fn cache_round_trips_and_misses_on_corpus_change() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("cidx-{}", uuid::Uuid::new_v4()));
|
|
||||||
let path = dir.join("control-index.json");
|
|
||||||
let specs = [spec("a"), spec("b")];
|
|
||||||
let hash = corpus_hash(&specs);
|
|
||||||
let index = ControlIndex::from_embeddings(vec![
|
|
||||||
(spec("a"), vec![1.0, 0.0]),
|
|
||||||
(spec("b"), vec![0.0, 1.0]),
|
|
||||||
]);
|
|
||||||
index.write_cache(&path, &hash).await.unwrap();
|
|
||||||
|
|
||||||
// matching corpus hash → hit
|
|
||||||
let loaded = ControlIndex::load_cache(&path, &hash).await.unwrap();
|
|
||||||
assert_eq!(loaded.len(), 2);
|
|
||||||
assert_eq!(loaded.nearest(&[0.9, 0.1], 1)[0].control_id, "a");
|
|
||||||
// corpus changed → miss (forces a rebuild)
|
|
||||||
assert!(ControlIndex::load_cache(&path, "differenthash")
|
|
||||||
.await
|
|
||||||
.is_none());
|
|
||||||
// absent file → miss, not an error
|
|
||||||
assert!(
|
|
||||||
ControlIndex::load_cache(dir.join("nope.json").as_path(), &hash)
|
|
||||||
.await
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
//! The "recognize" stage: judge whether a code region violates a control.
|
|
||||||
//!
|
|
||||||
//! Behind the [`ControlJudge`] trait so the grounded checker can be driven by a
|
|
||||||
//! deterministic stub in tests. The real [`LlmControlJudge`] runs the model at
|
|
||||||
//! temperature 0 with a closed prompt — it must quote the offending code VERBATIM,
|
|
||||||
//! and everything it returns is then re-checked by the grounding gate
|
|
||||||
//! ([`compliance_core::control_check::ground`]). The judge is allowed to be
|
|
||||||
//! smart; it is never trusted.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use compliance_core::control_check::{CandidateRegion, ControlCheckSpec, LlmVerdict};
|
|
||||||
|
|
||||||
use crate::llm::LlmClient;
|
|
||||||
|
|
||||||
/// Prompt/logic version — part of the verdict cache key, bump on any change here.
|
|
||||||
pub const PROMPT_VERSION: &str = "control-judge-v1";
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT: &str = "You are a precise security & compliance code auditor. \
|
|
||||||
You are given ONE compliance control (a requirement) and ONE code region. Decide \
|
|
||||||
ONLY whether the code region VIOLATES the control. Rules: (1) Judge only the code \
|
|
||||||
shown — never assume code that is not present. (2) If and only if it violates, copy \
|
|
||||||
the EXACT offending code VERBATIM into `snippet`, character-for-character from the \
|
|
||||||
region — do not paraphrase, reformat, or reconstruct it. (3) If it does not clearly \
|
|
||||||
violate, set violates=false and leave snippet empty. (4) Prefer false over guessing. \
|
|
||||||
Respond with STRICT JSON only, no prose: \
|
|
||||||
{\"violates\": bool, \"snippet\": \"<verbatim code or empty>\", \"cwe\": \"CWE-NNN or null\", \"confidence\": 0.0-1.0}";
|
|
||||||
|
|
||||||
/// Judges one (control, region). Async-in-trait so a stub can drive tests.
|
|
||||||
#[allow(async_fn_in_trait)]
|
|
||||||
pub trait ControlJudge: Send + Sync {
|
|
||||||
async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The real judge: the LLM at temperature 0 with the closed, verbatim-snippet prompt.
|
|
||||||
pub struct LlmControlJudge {
|
|
||||||
llm: Arc<LlmClient>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LlmControlJudge {
|
|
||||||
pub fn new(llm: Arc<LlmClient>) -> Self {
|
|
||||||
Self { llm }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ControlJudge for LlmControlJudge {
|
|
||||||
async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict {
|
|
||||||
let user = build_user_prompt(spec, region);
|
|
||||||
match self.llm.chat(SYSTEM_PROMPT, &user, Some(0.0)).await {
|
|
||||||
Ok(response) => parse_verdict(&response),
|
|
||||||
Err(e) => {
|
|
||||||
// Fail closed: a transient model error yields no finding, never a
|
|
||||||
// fabricated one.
|
|
||||||
tracing::warn!(control = %spec.control_id, error = %e, "control judge call failed");
|
|
||||||
no_violation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_user_prompt(spec: &ControlCheckSpec, region: &CandidateRegion) -> String {
|
|
||||||
format!(
|
|
||||||
"CONTROL {id} — {title}\nRequirement: {req}\n\nCODE ({file}, first line = {line}):\n```\n{code}\n```\n\nReturn the JSON verdict.",
|
|
||||||
id = spec.control_id,
|
|
||||||
title = spec.title,
|
|
||||||
req = spec.requirement,
|
|
||||||
file = region.file,
|
|
||||||
line = region.start_line,
|
|
||||||
code = region.content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
struct RawVerdict {
|
|
||||||
#[serde(default)]
|
|
||||||
violates: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
snippet: String,
|
|
||||||
#[serde(default)]
|
|
||||||
cwe: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
confidence: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the model's JSON verdict, tolerant of ```json fencing. Any parse failure
|
|
||||||
/// degrades to a non-violation (never a fabricated finding).
|
|
||||||
fn parse_verdict(response: &str) -> LlmVerdict {
|
|
||||||
let cleaned = response
|
|
||||||
.trim()
|
|
||||||
.trim_start_matches("```json")
|
|
||||||
.trim_start_matches("```")
|
|
||||||
.trim_end_matches("```")
|
|
||||||
.trim();
|
|
||||||
match serde_json::from_str::<RawVerdict>(cleaned) {
|
|
||||||
Ok(raw) => LlmVerdict {
|
|
||||||
violates: raw.violates,
|
|
||||||
snippet: raw.snippet,
|
|
||||||
cwe: raw.cwe.filter(|c| !c.trim().is_empty()),
|
|
||||||
confidence: raw.confidence,
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, "failed to parse control verdict; treating as non-violation");
|
|
||||||
no_violation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn no_violation() -> LlmVerdict {
|
|
||||||
LlmVerdict {
|
|
||||||
violates: false,
|
|
||||||
snippet: String::new(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use compliance_core::models::finding::Severity;
|
|
||||||
|
|
||||||
fn spec() -> ControlCheckSpec {
|
|
||||||
ControlCheckSpec {
|
|
||||||
control_id: "cra-ai-8".into(),
|
|
||||||
title: "No default passwords".into(),
|
|
||||||
requirement: "Products must not ship default credentials".into(),
|
|
||||||
default_cwe: Some("CWE-798".into()),
|
|
||||||
severity: Severity::High,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_plain_and_fenced_json() {
|
|
||||||
let plain = r#"{"violates": true, "snippet": "PASSWORD = \"x\"", "cwe": "CWE-798", "confidence": 0.9}"#;
|
|
||||||
let v = parse_verdict(plain);
|
|
||||||
assert!(v.violates);
|
|
||||||
assert_eq!(v.snippet, "PASSWORD = \"x\"");
|
|
||||||
assert_eq!(v.cwe.as_deref(), Some("CWE-798"));
|
|
||||||
|
|
||||||
let fenced = "```json\n{\"violates\": false, \"snippet\": \"\", \"cwe\": null, \"confidence\": 0.1}\n```";
|
|
||||||
assert!(!parse_verdict(fenced).violates);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn garbage_and_empty_cwe_are_safe() {
|
|
||||||
assert!(!parse_verdict("not json at all").violates); // fail closed
|
|
||||||
let no_cwe =
|
|
||||||
parse_verdict(r#"{"violates": true, "snippet": "x", "cwe": " ", "confidence": 0.5}"#);
|
|
||||||
assert!(no_cwe.cwe.is_none()); // blank CWE normalised away
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_prompt_carries_control_and_code() {
|
|
||||||
let region = CandidateRegion {
|
|
||||||
file: "src/auth.py".into(),
|
|
||||||
start_line: 10,
|
|
||||||
content: "PASSWORD = \"admin\"".into(),
|
|
||||||
};
|
|
||||||
let p = build_user_prompt(&spec(), ®ion);
|
|
||||||
assert!(p.contains("cra-ai-8"));
|
|
||||||
assert!(p.contains("Products must not ship default credentials"));
|
|
||||||
assert!(p.contains("PASSWORD = \"admin\""));
|
|
||||||
assert!(p.contains("src/auth.py"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +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 checker;
|
|
||||||
mod index;
|
|
||||||
mod judge;
|
|
||||||
mod oscal_provider;
|
|
||||||
mod scan_triage;
|
|
||||||
mod semantic;
|
|
||||||
mod surface;
|
|
||||||
mod triage;
|
|
||||||
|
|
||||||
pub use checker::GroundedControlChecker;
|
|
||||||
pub use index::ControlIndex;
|
|
||||||
pub use judge::{ControlJudge, LlmControlJudge, PROMPT_VERSION};
|
|
||||||
pub use oscal_provider::OscalControlsProvider;
|
|
||||||
pub use scan_triage::{grounded_surface_findings, semantic_stamp_findings, triage_repo_findings};
|
|
||||||
pub use semantic::SemanticControlChecker;
|
|
||||||
pub use triage::{ControlTriage, TriageOutcome};
|
|
||||||
@@ -1,229 +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: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/api/compliance/v1/oscal/catalog?framework={framework}",
|
|
||||||
self.base_url.trim_end_matches('/')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_path(&self, framework: &str) -> PathBuf {
|
|
||||||
self.snapshot_dir
|
|
||||||
.join(format!("oscal-catalog-{framework}.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch the raw catalog bytes for a framework token over HTTP.
|
|
||||||
async fn fetch_raw(&self, framework: &str) -> 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: &str, 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: &str) -> 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 token: fetch fresh + snapshot the exact
|
|
||||||
/// bytes; on network failure, fall back to the last snapshot so scans run.
|
|
||||||
async fn load_token(&self, framework: &str) -> 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),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the OSCAL catalog for a compliance framework.
|
|
||||||
pub async fn load(&self, framework: ComplianceFramework) -> Result<OscalDocument, CoreError> {
|
|
||||||
self.load_token(&framework.to_string()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the code-checkable master-controls catalog
|
|
||||||
/// (`?framework=master-controls`).
|
|
||||||
pub async fn load_master_controls(&self) -> Result<OscalDocument, CoreError> {
|
|
||||||
self.load_token("master-controls").await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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("cra"),
|
|
||||||
"http://unused/api/compliance/v1/oscal/catalog?framework=cra"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
p.snapshot_path("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("cra").await.unwrap().is_none());
|
|
||||||
p.write_snapshot("cra", MINI_CATALOG.as_bytes())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let doc = p.read_snapshot("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,299 +0,0 @@
|
|||||||
//! Scan-pipeline integration for control triage.
|
|
||||||
//!
|
|
||||||
//! After the deterministic tools have produced findings, this stamps each finding
|
|
||||||
//! with the compliance control(s) it's evidence for and marks control-level false
|
|
||||||
//! positives — using the ingested OSCAL catalog for control text, the
|
|
||||||
//! `control-map` LUT for the finding→control link, and the grounded LLM judge to
|
|
||||||
//! confirm. Skipped entirely unless breakpilot is configured.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use compliance_core::control_check::{CandidateRegion, ControlCheckSpec};
|
|
||||||
use compliance_core::models::finding::{Finding, FindingStatus, Severity};
|
|
||||||
use compliance_core::models::onboarding::ComplianceFramework;
|
|
||||||
use compliance_core::AgentConfig;
|
|
||||||
use control_map::ControlMap;
|
|
||||||
|
|
||||||
use super::surface;
|
|
||||||
use super::{
|
|
||||||
ControlIndex, ControlTriage, GroundedControlChecker, LlmControlJudge, OscalControlsProvider,
|
|
||||||
SemanticControlChecker, TriageOutcome,
|
|
||||||
};
|
|
||||||
use crate::llm::LlmClient;
|
|
||||||
|
|
||||||
/// Nearest master controls judged per code region in the semantic pass.
|
|
||||||
const SEMANTIC_TOP_K: usize = 5;
|
|
||||||
|
|
||||||
/// Lines of context to read on each side of a finding's line.
|
|
||||||
const REGION_WINDOW: usize = 6;
|
|
||||||
|
|
||||||
/// Triage every finding in `findings` against the CRA control map: stamp
|
|
||||||
/// `control_refs` on confirmed findings and flag control false positives. Returns
|
|
||||||
/// the number of findings tagged with at least one control.
|
|
||||||
pub async fn triage_repo_findings(
|
|
||||||
config: &AgentConfig,
|
|
||||||
llm: Arc<LlmClient>,
|
|
||||||
repo_path: &Path,
|
|
||||||
findings: &mut [Finding],
|
|
||||||
) -> usize {
|
|
||||||
let Some(base_url) = config.breakpilot.base_url.clone() else {
|
|
||||||
return 0; // control triage is opt-in via BREAKPILOT_BASE_URL
|
|
||||||
};
|
|
||||||
let provider = OscalControlsProvider::new(
|
|
||||||
reqwest::Client::new(),
|
|
||||||
base_url,
|
|
||||||
config.breakpilot.token.clone(),
|
|
||||||
&config.breakpilot.snapshot_dir,
|
|
||||||
);
|
|
||||||
let specs = build_specs(&provider).await;
|
|
||||||
if specs.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
let map = match ControlMap::cra() {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "control map failed to load; skipping control triage");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let triage = ControlTriage::new(LlmControlJudge::new(llm), map, specs);
|
|
||||||
|
|
||||||
let mut tagged = 0;
|
|
||||||
for finding in findings.iter_mut() {
|
|
||||||
let (Some(file), Some(line)) = (finding.file_path.clone(), finding.line_number) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(region) = fetch_region(repo_path, &file, line) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match triage.triage(finding, ®ion).await {
|
|
||||||
TriageOutcome::Confirmed(controls) => {
|
|
||||||
finding.control_refs = controls;
|
|
||||||
tagged += 1;
|
|
||||||
}
|
|
||||||
TriageOutcome::FalsePositive => {
|
|
||||||
finding.status = FindingStatus::FalsePositive;
|
|
||||||
finding.triage_action = Some("control_false_positive".to_string());
|
|
||||||
}
|
|
||||||
TriageOutcome::Unmapped => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tagged
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the control requirement specs (by id) from the ingested OSCAL catalog.
|
|
||||||
async fn build_specs(provider: &OscalControlsProvider) -> HashMap<String, ControlCheckSpec> {
|
|
||||||
let mut specs = HashMap::new();
|
|
||||||
match provider.load(ComplianceFramework::Cra).await {
|
|
||||||
Ok(doc) => {
|
|
||||||
for control in doc.to_controls() {
|
|
||||||
specs.insert(
|
|
||||||
control.id.clone(),
|
|
||||||
ControlCheckSpec {
|
|
||||||
control_id: control.id,
|
|
||||||
title: control.title,
|
|
||||||
requirement: control.text,
|
|
||||||
default_cwe: None,
|
|
||||||
severity: Severity::Medium,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(error = %e, "could not load control catalog for triage"),
|
|
||||||
}
|
|
||||||
specs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Absence-based control pass (the grounded half of the hybrid coverage): for each
|
|
||||||
/// control with a [`surface`] definition, deterministically retrieve the code
|
|
||||||
/// surfaces it governs (login routes, logging setup, update/download code) and have
|
|
||||||
/// the grounded judge decide whether the control holds there. Returns net-new
|
|
||||||
/// findings, each already tagged with its control and grounded to a real snippet.
|
|
||||||
///
|
|
||||||
/// The orchestrator runs this when `breakpilot.grounded_control_checks` is set
|
|
||||||
/// (on by default). Validated live; it covers the 8 absence-based CRA controls
|
|
||||||
/// (the judge decides presence/absence, grounded to a real snippet).
|
|
||||||
pub async fn grounded_surface_findings(
|
|
||||||
config: &AgentConfig,
|
|
||||||
llm: Arc<LlmClient>,
|
|
||||||
repo_path: &Path,
|
|
||||||
repo_id: &str,
|
|
||||||
) -> Vec<Finding> {
|
|
||||||
let Some(base_url) = config.breakpilot.base_url.clone() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let provider = OscalControlsProvider::new(
|
|
||||||
reqwest::Client::new(),
|
|
||||||
base_url,
|
|
||||||
config.breakpilot.token.clone(),
|
|
||||||
&config.breakpilot.snapshot_dir,
|
|
||||||
);
|
|
||||||
let specs = build_specs(&provider).await;
|
|
||||||
if specs.is_empty() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let checker = GroundedControlChecker::new(LlmControlJudge::new(llm));
|
|
||||||
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for surf in surface::SURFACES {
|
|
||||||
let Some(spec) = specs.get(surf.control_id) else {
|
|
||||||
continue; // catalog doesn't carry this control
|
|
||||||
};
|
|
||||||
let regions = surface::retrieve(repo_path, surf.terms);
|
|
||||||
if regions.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.extend(checker.check(spec, ®ions, repo_id).await);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a window of lines around `line` (1-based) from `repo_path/file`.
|
|
||||||
fn fetch_region(repo_path: &Path, file: &str, line: u32) -> Option<CandidateRegion> {
|
|
||||||
let content = std::fs::read_to_string(repo_path.join(file)).ok()?;
|
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
|
||||||
if lines.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let center = (line.saturating_sub(1) as usize).min(lines.len() - 1);
|
|
||||||
let start = center.saturating_sub(REGION_WINDOW);
|
|
||||||
let end = (center + REGION_WINDOW + 1).min(lines.len());
|
|
||||||
Some(CandidateRegion {
|
|
||||||
file: file.to_string(),
|
|
||||||
start_line: (start as u32) + 1,
|
|
||||||
content: lines[start..end].join("\n"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Master-controls **semantic** pass: for each finding's code region, retrieve the
|
|
||||||
/// top-K nearest master controls by embedding, have the grounded judge confirm,
|
|
||||||
/// and stamp the confirmed control ids onto the finding — the scale path for the
|
|
||||||
/// ~13.6k master-control corpus (which has no CWE to LUT on). Returns the number
|
|
||||||
/// of findings that gained a master-control ref.
|
|
||||||
///
|
|
||||||
/// The orchestrator runs this when `breakpilot.semantic_mapping` is set (on by
|
|
||||||
/// default). The control embedding index is built once and cached to
|
|
||||||
/// `snapshot_dir` keyed by corpus hash ([`ControlIndex::load_or_build`]), so only
|
|
||||||
/// the first scan after a catalog change pays the embedding cost.
|
|
||||||
pub async fn semantic_stamp_findings(
|
|
||||||
config: &AgentConfig,
|
|
||||||
llm: Arc<LlmClient>,
|
|
||||||
repo_path: &Path,
|
|
||||||
findings: &mut [Finding],
|
|
||||||
) -> usize {
|
|
||||||
let Some(base_url) = config.breakpilot.base_url.clone() else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
let provider = OscalControlsProvider::new(
|
|
||||||
reqwest::Client::new(),
|
|
||||||
base_url,
|
|
||||||
config.breakpilot.token.clone(),
|
|
||||||
&config.breakpilot.snapshot_dir,
|
|
||||||
);
|
|
||||||
let doc = match provider.load_master_controls().await {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "master-controls catalog unavailable; skipping semantic pass");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let specs: Vec<ControlCheckSpec> = doc
|
|
||||||
.to_controls()
|
|
||||||
.into_iter()
|
|
||||||
.map(|c| ControlCheckSpec {
|
|
||||||
control_id: c.id,
|
|
||||||
title: c.title,
|
|
||||||
requirement: c.text,
|
|
||||||
default_cwe: None,
|
|
||||||
severity: Severity::Medium,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let cache_path =
|
|
||||||
Path::new(&config.breakpilot.snapshot_dir).join("control-index-master-controls.json");
|
|
||||||
let index = match ControlIndex::load_or_build(&llm, specs, &cache_path).await {
|
|
||||||
Ok(i) if !i.is_empty() => i,
|
|
||||||
Ok(_) => return 0,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "failed to embed master-controls corpus");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let checker = SemanticControlChecker::new(LlmControlJudge::new(llm.clone()));
|
|
||||||
|
|
||||||
let mut tagged = 0;
|
|
||||||
for finding in findings.iter_mut() {
|
|
||||||
if finding.status == FindingStatus::FalsePositive {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (Some(file), Some(line)) = (finding.file_path.clone(), finding.line_number) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(region) = fetch_region(repo_path, &file, line) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Retrieve on the finding's intent + the code, not the region alone: two
|
|
||||||
// findings in one file share overlapping windows and otherwise embed alike,
|
|
||||||
// collapsing onto the same controls. The finding's title/description carry
|
|
||||||
// the discriminating signal (e.g. "brute-force protection" vs "weak hash").
|
|
||||||
// The raw `region` still goes to the judge for snippet grounding.
|
|
||||||
let query = format!(
|
|
||||||
"{}\n{}\n\n{}",
|
|
||||||
finding.title, finding.description, region.content
|
|
||||||
);
|
|
||||||
let query_emb = match llm.embed(vec![query]).await {
|
|
||||||
Ok(mut embs) => match embs.pop() {
|
|
||||||
Some(v) => v,
|
|
||||||
None => continue,
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "query embed failed; skipping finding");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let confirmed = checker
|
|
||||||
.check(
|
|
||||||
&index,
|
|
||||||
®ion,
|
|
||||||
&query_emb,
|
|
||||||
SEMANTIC_TOP_K,
|
|
||||||
&finding.repo_id,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let before = finding.control_refs.len();
|
|
||||||
for f in confirmed {
|
|
||||||
for cref in f.control_refs {
|
|
||||||
if !finding.control_refs.contains(&cref) {
|
|
||||||
finding.control_refs.push(cref);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if finding.control_refs.len() > before {
|
|
||||||
tagged += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tagged
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch_region_windows_around_the_line() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("triage-region-{}", uuid::Uuid::new_v4()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let file = "a.py";
|
|
||||||
std::fs::write(dir.join(file), "l1\nl2\nl3\nSECRET=1\nl5\nl6\n").unwrap();
|
|
||||||
let r = fetch_region(&dir, file, 4).unwrap();
|
|
||||||
assert!(r.content.contains("SECRET=1"));
|
|
||||||
assert_eq!(r.start_line, 1); // window clamps to file start
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fetch_region_missing_file_is_none() {
|
|
||||||
assert!(fetch_region(Path::new("/nonexistent"), "nope.py", 1).is_none());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
//! Semantic control mapping: retrieve the top-K controls nearest a code region,
|
|
||||||
//! then confirm each with the grounded judge.
|
|
||||||
//!
|
|
||||||
//! The `region → controls` direction (vs. the CWE-LUT's `finding → control`) is
|
|
||||||
//! what scales to the full master-control corpus: the LLM only ever judges a
|
|
||||||
//! handful of retrieved candidates, and every surviving verdict is still anchored
|
|
||||||
//! to real code by the grounding gate.
|
|
||||||
|
|
||||||
use compliance_core::control_check::{ground, CandidateRegion};
|
|
||||||
use compliance_core::models::Finding;
|
|
||||||
|
|
||||||
use super::index::ControlIndex;
|
|
||||||
use super::judge::ControlJudge;
|
|
||||||
|
|
||||||
/// Retrieve → judge → ground, generic over the judge so tests use a stub.
|
|
||||||
pub struct SemanticControlChecker<J> {
|
|
||||||
judge: J,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<J: ControlJudge> SemanticControlChecker<J> {
|
|
||||||
pub fn new(judge: J) -> Self {
|
|
||||||
Self { judge }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a code region to the controls it violates. `query_embedding` is the
|
|
||||||
/// caller-supplied retrieval embedding — typically the finding's intent
|
|
||||||
/// (title/description) plus the region, so retrieval keys on what the finding
|
|
||||||
/// is *about*, not just the ambient code. The top-`k` nearest controls in
|
|
||||||
/// `index` are then judged against the raw `region` and grounded.
|
|
||||||
pub async fn check(
|
|
||||||
&self,
|
|
||||||
index: &ControlIndex,
|
|
||||||
region: &CandidateRegion,
|
|
||||||
query_embedding: &[f64],
|
|
||||||
k: usize,
|
|
||||||
repo_id: &str,
|
|
||||||
) -> Vec<Finding> {
|
|
||||||
let candidates = index.nearest(query_embedding, k);
|
|
||||||
let mut findings = Vec::new();
|
|
||||||
for spec in &candidates {
|
|
||||||
let verdict = self.judge.judge(spec, region).await;
|
|
||||||
if let Some(finding) = ground(spec, region, &verdict, repo_id) {
|
|
||||||
findings.push(finding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
findings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use compliance_core::control_check::{ControlCheckSpec, LlmVerdict};
|
|
||||||
use compliance_core::models::finding::Severity;
|
|
||||||
|
|
||||||
struct StubJudge {
|
|
||||||
verdict: LlmVerdict,
|
|
||||||
}
|
|
||||||
impl ControlJudge for StubJudge {
|
|
||||||
async fn judge(&self, _s: &ControlCheckSpec, _r: &CandidateRegion) -> LlmVerdict {
|
|
||||||
self.verdict.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spec(id: &str) -> ControlCheckSpec {
|
|
||||||
ControlCheckSpec {
|
|
||||||
control_id: id.into(),
|
|
||||||
title: id.into(),
|
|
||||||
requirement: id.into(),
|
|
||||||
default_cwe: None,
|
|
||||||
severity: Severity::Medium,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn retrieves_then_grounds_the_nearest_control() {
|
|
||||||
let index = ControlIndex::from_embeddings(vec![
|
|
||||||
(spec("mc-near"), vec![1.0, 0.0]),
|
|
||||||
(spec("mc-far"), vec![0.0, 1.0]),
|
|
||||||
]);
|
|
||||||
let checker = SemanticControlChecker::new(StubJudge {
|
|
||||||
verdict: LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: "PASSWORD = \"admin\"".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let region = CandidateRegion {
|
|
||||||
file: "src/auth.py".into(),
|
|
||||||
start_line: 1,
|
|
||||||
content: "PASSWORD = \"admin\"\n".into(),
|
|
||||||
};
|
|
||||||
// Query embedding nearest to mc-near; k=1 → only mc-near is judged.
|
|
||||||
let findings = checker
|
|
||||||
.check(&index, ®ion, &[0.95, 0.05], 1, "repo")
|
|
||||||
.await;
|
|
||||||
assert_eq!(findings.len(), 1);
|
|
||||||
assert_eq!(findings[0].control_refs, vec!["mc-near".to_string()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn ungrounded_verdict_is_dropped() {
|
|
||||||
let index = ControlIndex::from_embeddings(vec![(spec("mc-near"), vec![1.0, 0.0])]);
|
|
||||||
let checker = SemanticControlChecker::new(StubJudge {
|
|
||||||
verdict: LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: "not in the region".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let region = CandidateRegion {
|
|
||||||
file: "f".into(),
|
|
||||||
start_line: 1,
|
|
||||||
content: "real code\n".into(),
|
|
||||||
};
|
|
||||||
let findings = checker.check(&index, ®ion, &[1.0, 0.0], 1, "repo").await;
|
|
||||||
assert!(findings.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
//! Surface retrieval for absence-based controls.
|
|
||||||
//!
|
|
||||||
//! Some CRA controls are violated by an *absence* — no rate limiting on login, no
|
|
||||||
//! security logging, no signature check on an update — so there's no offending
|
|
||||||
//! pattern for semgrep to match. Instead we deterministically locate the code
|
|
||||||
//! *surface* the control governs (a login route, a logging setup, update/download
|
|
||||||
//! code) by identifier/route terms, then hand each surface region to the grounded
|
|
||||||
//! judge, which decides whether the control is satisfied there. The resulting
|
|
||||||
//! finding grounds to the surface snippet, so nothing fabricated survives.
|
|
||||||
//!
|
|
||||||
//! Retrieval is intentionally cheap and bounded: keyword match + a fixed window,
|
|
||||||
//! capped per control to keep the downstream LLM cost predictable.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use compliance_core::control_check::CandidateRegion;
|
|
||||||
|
|
||||||
/// An absence-based control and the case-insensitive terms that mark the code
|
|
||||||
/// surface it governs.
|
|
||||||
pub struct Surface {
|
|
||||||
pub control_id: &'static str,
|
|
||||||
pub terms: &'static [&'static str],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The absence-based CRA controls we retrieve surfaces for — the grounded half of
|
|
||||||
/// the hybrid coverage (the pattern-expressible half is custom semgrep rules).
|
|
||||||
pub const SURFACES: &[Surface] = &[
|
|
||||||
Surface {
|
|
||||||
control_id: "cra-ai-6", // Integritaetspruefung
|
|
||||||
terms: &[
|
|
||||||
"checksum",
|
|
||||||
"sha256",
|
|
||||||
"signature",
|
|
||||||
"hmac",
|
|
||||||
"integrity",
|
|
||||||
"verify",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Surface {
|
|
||||||
control_id: "cra-ai-11", // Brute-Force-Schutz
|
|
||||||
terms: &[
|
|
||||||
"login",
|
|
||||||
"signin",
|
|
||||||
"authenticate",
|
|
||||||
"/auth",
|
|
||||||
"password",
|
|
||||||
"ratelimit",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Surface {
|
|
||||||
control_id: "cra-ai-12", // Rollenbasierte Autorisierung (RBAC)
|
|
||||||
terms: &[
|
|
||||||
"authorize",
|
|
||||||
"permission",
|
|
||||||
"role",
|
|
||||||
"rbac",
|
|
||||||
"require_role",
|
|
||||||
"has_role",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Surface {
|
|
||||||
control_id: "cra-ai-24", // Security-Logging
|
|
||||||
terms: &["login", "authorize", "permission", "role", "admin", "audit"],
|
|
||||||
},
|
|
||||||
Surface {
|
|
||||||
control_id: "cra-ai-27", // Log-Integritaet und -Aufbewahrung
|
|
||||||
terms: &["logging", "logger", "getlogger", "audit_log"],
|
|
||||||
},
|
|
||||||
Surface {
|
|
||||||
control_id: "cra-ai-28", // Sichere Update-Mechanismen
|
|
||||||
terms: &["update", "upgrade", "download", "firmware"],
|
|
||||||
},
|
|
||||||
Surface {
|
|
||||||
control_id: "cra-ai-29", // Update-Authentizitaet
|
|
||||||
terms: &["update", "signature", "verify", "pubkey", "certificate"],
|
|
||||||
},
|
|
||||||
Surface {
|
|
||||||
control_id: "cra-ai-30", // Update-Integritaet
|
|
||||||
terms: &["update", "checksum", "digest", "integrity", "verify"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Source file extensions worth reading (skip binaries/assets/lockfiles).
|
|
||||||
const CODE_EXTS: &[&str] = &[
|
|
||||||
"py", "js", "ts", "tsx", "jsx", "go", "java", "rb", "php", "rs", "cs", "kt",
|
|
||||||
];
|
|
||||||
/// Directories never worth walking.
|
|
||||||
const SKIP_DIRS: &[&str] = &[
|
|
||||||
".git",
|
|
||||||
"node_modules",
|
|
||||||
"target",
|
|
||||||
"vendor",
|
|
||||||
".venv",
|
|
||||||
"__pycache__",
|
|
||||||
"dist",
|
|
||||||
"build",
|
|
||||||
];
|
|
||||||
/// Lines of context on each side of a hit.
|
|
||||||
const WINDOW: usize = 6;
|
|
||||||
/// Cap on regions per control, to bound downstream LLM calls.
|
|
||||||
const MAX_REGIONS_PER_CONTROL: usize = 8;
|
|
||||||
/// Skip files larger than this (generated/minified).
|
|
||||||
const MAX_FILE_BYTES: u64 = 512 * 1024;
|
|
||||||
|
|
||||||
/// Deterministically retrieve up to [`MAX_REGIONS_PER_CONTROL`] code regions in
|
|
||||||
/// `repo_path` whose lines mention any of `terms`. Hits close together within a
|
|
||||||
/// file are merged into one region; results are capped to bound LLM cost.
|
|
||||||
pub fn retrieve(repo_path: &Path, terms: &[&str]) -> Vec<CandidateRegion> {
|
|
||||||
let lowered: Vec<String> = terms.iter().map(|t| t.to_lowercase()).collect();
|
|
||||||
let mut regions = Vec::new();
|
|
||||||
for entry in walk(repo_path) {
|
|
||||||
if regions.len() >= MAX_REGIONS_PER_CONTROL {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let path = entry.path();
|
|
||||||
if !has_code_ext(path) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(meta) = entry.metadata() else { continue };
|
|
||||||
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(content) = std::fs::read_to_string(path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let rel = path
|
|
||||||
.strip_prefix(repo_path)
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
|
||||||
let hits: Vec<usize> = lines
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, line)| {
|
|
||||||
let ll = line.to_lowercase();
|
|
||||||
lowered.iter().any(|t| ll.contains(t.as_str()))
|
|
||||||
})
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.collect();
|
|
||||||
for center in merge_centers(&hits) {
|
|
||||||
if regions.len() >= MAX_REGIONS_PER_CONTROL {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let start = center.saturating_sub(WINDOW);
|
|
||||||
let end = (center + WINDOW + 1).min(lines.len());
|
|
||||||
regions.push(CandidateRegion {
|
|
||||||
file: rel.clone(),
|
|
||||||
start_line: (start as u32) + 1,
|
|
||||||
content: lines[start..end].join("\n"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
regions
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collapse ascending hit indices that fall within one window into a single
|
|
||||||
/// representative center, so overlapping regions aren't judged repeatedly.
|
|
||||||
fn merge_centers(hits: &[usize]) -> Vec<usize> {
|
|
||||||
let mut out: Vec<usize> = Vec::new();
|
|
||||||
for &h in hits {
|
|
||||||
match out.last() {
|
|
||||||
Some(&last) if h.saturating_sub(last) <= WINDOW => {}
|
|
||||||
_ => out.push(h),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn has_code_ext(path: &Path) -> bool {
|
|
||||||
path.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.is_some_and(|e| CODE_EXTS.contains(&e))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk(root: &Path) -> Vec<walkdir::DirEntry> {
|
|
||||||
walkdir::WalkDir::new(root)
|
|
||||||
.into_iter()
|
|
||||||
.filter_entry(|e| {
|
|
||||||
let name = e.file_name().to_string_lossy();
|
|
||||||
!SKIP_DIRS.contains(&name.as_ref())
|
|
||||||
})
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[allow(clippy::unwrap_used)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn write(dir: &Path, rel: &str, body: &str) {
|
|
||||||
let p = dir.join(rel);
|
|
||||||
if let Some(parent) = p.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap();
|
|
||||||
}
|
|
||||||
std::fs::write(p, body).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn terms_for(control_id: &str) -> &'static [&'static str] {
|
|
||||||
SURFACES
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.control_id == control_id)
|
|
||||||
.unwrap()
|
|
||||||
.terms
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn surfaces_cover_the_absence_based_controls() {
|
|
||||||
assert_eq!(SURFACES.len(), 8);
|
|
||||||
for id in [
|
|
||||||
"cra-ai-6",
|
|
||||||
"cra-ai-11",
|
|
||||||
"cra-ai-12",
|
|
||||||
"cra-ai-24",
|
|
||||||
"cra-ai-27",
|
|
||||||
"cra-ai-28",
|
|
||||||
"cra-ai-29",
|
|
||||||
"cra-ai-30",
|
|
||||||
] {
|
|
||||||
assert!(SURFACES.iter().any(|s| s.control_id == id), "{id} missing");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn retrieves_matching_region_with_context() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("surface-{}", uuid::Uuid::new_v4()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
write(
|
|
||||||
&dir,
|
|
||||||
"app/auth.py",
|
|
||||||
"import x\n\n\n\n\n\n\ndef login(u, p):\n return check(u, p)\n",
|
|
||||||
);
|
|
||||||
let regions = retrieve(&dir, terms_for("cra-ai-11"));
|
|
||||||
assert_eq!(regions.len(), 1);
|
|
||||||
assert!(regions[0].content.contains("def login"));
|
|
||||||
assert_eq!(regions[0].file, "app/auth.py");
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skips_non_code_and_vendored() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("surface-{}", uuid::Uuid::new_v4()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
write(&dir, "README.md", "login and password and audit\n"); // not code ext
|
|
||||||
write(&dir, "node_modules/pkg/index.js", "function login() {}\n"); // vendored
|
|
||||||
assert!(retrieve(&dir, terms_for("cra-ai-11")).is_empty());
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merges_adjacent_hits_into_one_region() {
|
|
||||||
// Two hits one line apart collapse to a single center/region.
|
|
||||||
assert_eq!(merge_centers(&[10, 11, 30]), vec![10, 30]);
|
|
||||||
assert_eq!(merge_centers(&[]), Vec::<usize>::new());
|
|
||||||
assert_eq!(merge_centers(&[5]), vec![5]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
//! Triage step: confirm/refute a deterministic tool finding against the controls
|
|
||||||
//! it maps to (via the `control-map` LUT), grounding the judgment.
|
|
||||||
//!
|
|
||||||
//! This is where the LLM finally enters — as a **false-positive filter over tool
|
|
||||||
//! output**, never as the detector (the ZeroFalse / IRIS pattern). A tool
|
|
||||||
//! (semgrep, gitleaks, syft/osv) detects deterministically; `controls_for(tool,
|
|
||||||
//! cwe)` attaches the finding to the control(s) it's evidence for; the grounded
|
|
||||||
//! judge then confirms or refutes each, and only judgments anchored to real code
|
|
||||||
//! survive.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use compliance_core::control_check::{ground, CandidateRegion, ControlCheckSpec};
|
|
||||||
use compliance_core::models::Finding;
|
|
||||||
use control_map::ControlMap;
|
|
||||||
|
|
||||||
use super::judge::ControlJudge;
|
|
||||||
|
|
||||||
/// What triage decided for one tool finding.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum TriageOutcome {
|
|
||||||
/// The finding maps to no control in the LUT — keep it, untagged.
|
|
||||||
Unmapped,
|
|
||||||
/// Maps to controls and the grounded judge confirmed at least one — keep the
|
|
||||||
/// finding and tag it with these control ids.
|
|
||||||
Confirmed(Vec<String>),
|
|
||||||
/// Maps to controls but the judge grounded none — treat as a false positive.
|
|
||||||
FalsePositive,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Triages tool findings against the control map, confirming with a grounded judge.
|
|
||||||
pub struct ControlTriage<J> {
|
|
||||||
judge: J,
|
|
||||||
map: ControlMap,
|
|
||||||
/// Control requirement specs (by control id), built from the ingested catalog.
|
|
||||||
specs: HashMap<String, ControlCheckSpec>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<J: ControlJudge> ControlTriage<J> {
|
|
||||||
pub fn new(judge: J, map: ControlMap, specs: HashMap<String, ControlCheckSpec>) -> Self {
|
|
||||||
Self { judge, map, specs }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Triage one tool finding. `region` is the code around the finding, used as
|
|
||||||
/// the grounding evidence for the judge.
|
|
||||||
pub async fn triage(&self, finding: &Finding, region: &CandidateRegion) -> TriageOutcome {
|
|
||||||
// Match by CWE (off-the-shelf findings) and/or rule id (our custom
|
|
||||||
// detectors, which carry no LUT-bound CWE). A finding with neither is
|
|
||||||
// simply unmapped.
|
|
||||||
let mapped = self.map.controls_for_finding(
|
|
||||||
&finding.scanner,
|
|
||||||
finding.cwe.as_deref(),
|
|
||||||
finding.rule_id.as_deref(),
|
|
||||||
);
|
|
||||||
if mapped.is_empty() {
|
|
||||||
return TriageOutcome::Unmapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut confirmed = Vec::new();
|
|
||||||
for entry in mapped {
|
|
||||||
let Some(spec) = self.specs.get(&entry.control) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let verdict = self.judge.judge(spec, region).await;
|
|
||||||
// The verdict only counts if it grounds to real code in the region.
|
|
||||||
if ground(spec, region, &verdict, &finding.repo_id).is_some() {
|
|
||||||
confirmed.push(entry.control.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if confirmed.is_empty() {
|
|
||||||
TriageOutcome::FalsePositive
|
|
||||||
} else {
|
|
||||||
TriageOutcome::Confirmed(confirmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use compliance_core::control_check::LlmVerdict;
|
|
||||||
use compliance_core::models::finding::Severity;
|
|
||||||
use compliance_core::models::scan::ScanType;
|
|
||||||
|
|
||||||
struct StubJudge {
|
|
||||||
verdict: LlmVerdict,
|
|
||||||
}
|
|
||||||
impl ControlJudge for StubJudge {
|
|
||||||
async fn judge(&self, _s: &ControlCheckSpec, _r: &CandidateRegion) -> LlmVerdict {
|
|
||||||
self.verdict.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn specs() -> HashMap<String, ControlCheckSpec> {
|
|
||||||
let mut m = HashMap::new();
|
|
||||||
m.insert(
|
|
||||||
"cra-ai-8".to_string(),
|
|
||||||
ControlCheckSpec {
|
|
||||||
control_id: "cra-ai-8".into(),
|
|
||||||
title: "No default passwords".into(),
|
|
||||||
requirement: "No default credentials".into(),
|
|
||||||
default_cwe: Some("CWE-798".into()),
|
|
||||||
severity: Severity::High,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
m
|
|
||||||
}
|
|
||||||
|
|
||||||
fn semgrep_finding(cwe: &str) -> Finding {
|
|
||||||
let mut f = Finding::new(
|
|
||||||
"repo".into(),
|
|
||||||
"fp1".into(),
|
|
||||||
"semgrep".into(),
|
|
||||||
ScanType::Sast,
|
|
||||||
"hardcoded credential".into(),
|
|
||||||
"desc".into(),
|
|
||||||
Severity::High,
|
|
||||||
);
|
|
||||||
f.cwe = Some(cwe.into());
|
|
||||||
f
|
|
||||||
}
|
|
||||||
|
|
||||||
fn region() -> CandidateRegion {
|
|
||||||
CandidateRegion {
|
|
||||||
file: "src/auth.py".into(),
|
|
||||||
start_line: 1,
|
|
||||||
content: "PASSWORD = \"admin123\"\n".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn confirmed_finding_is_tagged_with_control() {
|
|
||||||
let triage = ControlTriage::new(
|
|
||||||
StubJudge {
|
|
||||||
verdict: LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: "PASSWORD = \"admin123\"".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ControlMap::cra().unwrap(),
|
|
||||||
specs(),
|
|
||||||
);
|
|
||||||
let out = triage.triage(&semgrep_finding("CWE-798"), ®ion()).await;
|
|
||||||
assert_eq!(out, TriageOutcome::Confirmed(vec!["cra-ai-8".to_string()]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn refuted_mapped_finding_is_false_positive() {
|
|
||||||
// Maps to cra-ai-8, but the judge doesn't confirm (no violation) → FP.
|
|
||||||
let triage = ControlTriage::new(
|
|
||||||
StubJudge {
|
|
||||||
verdict: LlmVerdict {
|
|
||||||
violates: false,
|
|
||||||
snippet: String::new(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ControlMap::cra().unwrap(),
|
|
||||||
specs(),
|
|
||||||
);
|
|
||||||
let out = triage.triage(&semgrep_finding("CWE-798"), ®ion()).await;
|
|
||||||
assert_eq!(out, TriageOutcome::FalsePositive);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn custom_rule_finding_without_cwe_is_confirmed() {
|
|
||||||
// A custom detector finding carries a rule id but no LUT-bound CWE; it must
|
|
||||||
// still map (by rule id) and confirm.
|
|
||||||
let mut specs = specs();
|
|
||||||
specs.insert(
|
|
||||||
"cra-ai-1".to_string(),
|
|
||||||
ControlCheckSpec {
|
|
||||||
control_id: "cra-ai-1".into(),
|
|
||||||
title: "Secure-by-Default".into(),
|
|
||||||
requirement: "Ship secure defaults".into(),
|
|
||||||
default_cwe: None,
|
|
||||||
severity: Severity::Medium,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let triage = ControlTriage::new(
|
|
||||||
StubJudge {
|
|
||||||
verdict: LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: "app.run(debug=True)".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ControlMap::cra().unwrap(),
|
|
||||||
specs,
|
|
||||||
);
|
|
||||||
let mut f = Finding::new(
|
|
||||||
"repo".into(),
|
|
||||||
"fp".into(),
|
|
||||||
"semgrep".into(),
|
|
||||||
ScanType::Sast,
|
|
||||||
"flask debug".into(),
|
|
||||||
"desc".into(),
|
|
||||||
Severity::Medium,
|
|
||||||
);
|
|
||||||
f.rule_id = Some("tmp.x.cra-ai-1-flask-debug-enabled".into()); // no cwe
|
|
||||||
let region = CandidateRegion {
|
|
||||||
file: "app.py".into(),
|
|
||||||
start_line: 1,
|
|
||||||
content: "app.run(debug=True)\n".into(),
|
|
||||||
};
|
|
||||||
let out = triage.triage(&f, ®ion).await;
|
|
||||||
assert_eq!(out, TriageOutcome::Confirmed(vec!["cra-ai-1".to_string()]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn unmapped_cwe_is_left_untagged() {
|
|
||||||
let triage = ControlTriage::new(
|
|
||||||
StubJudge {
|
|
||||||
verdict: LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: "PASSWORD = \"admin123\"".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ControlMap::cra().unwrap(),
|
|
||||||
specs(),
|
|
||||||
);
|
|
||||||
let out = triage
|
|
||||||
.triage(&semgrep_finding("CWE-99999"), ®ion())
|
|
||||||
.await;
|
|
||||||
assert_eq!(out, TriageOutcome::Unmapped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,9 +27,6 @@ pub enum AgentError {
|
|||||||
#[error("Configuration error: {0}")]
|
#[error("Configuration error: {0}")]
|
||||||
Config(String),
|
Config(String),
|
||||||
|
|
||||||
#[error("Dynamic-execution error: {0}")]
|
|
||||||
Exec(#[from] werkbank_exec::ExecError),
|
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
//! Curated demo targets (#187).
|
|
||||||
//!
|
|
||||||
//! `fixtures/demo-targets/targets.json` is the versioned, reproducible set of
|
|
||||||
//! representative targets every scan path can be exercised against: PlcSps
|
|
||||||
//! (composite), a plain git SAST repo, a WebApp (git + live URL) and an RTOS
|
|
||||||
//! firmware repo. The manifest is consumed by:
|
|
||||||
//!
|
|
||||||
//! * `scripts/seed-demo-targets.sh` — onboards the targets through the
|
|
||||||
//! public API (optionally triggering a first scan),
|
|
||||||
//! * the nightly regression (#188) — the `expect` block is the golden
|
|
||||||
//! baseline per target,
|
|
||||||
//! * the lib tests below — which keep the manifest well-formed and assert the
|
|
||||||
//! PLC baselines offline (no Mongo, no network) on every CI run.
|
|
||||||
//!
|
|
||||||
//! Artifacts come in two flavours: `source_ref` (git URL / live URL / image
|
|
||||||
//! ref; may be overridden by the env var named in `source_ref_env`) and
|
|
||||||
//! `upload` (a file path relative to the workspace root, pushed through
|
|
||||||
//! `POST /targets/{id}/artifacts/upload`; may instead come from the env var
|
|
||||||
//! named in `upload_env`). Artifacts flagged `optional` are skipped when their
|
|
||||||
//! env var is unset, so the set seeds cleanly on a laptop without OT infra.
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use compliance_core::models::onboarding::{ArtifactKind, PlcFormat, TargetType};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Manifest path, relative to the workspace root.
|
|
||||||
pub const MANIFEST_PATH: &str = "fixtures/demo-targets/targets.json";
|
|
||||||
|
|
||||||
/// Why a manifest could not be loaded.
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum FixtureError {
|
|
||||||
#[error("read {path}: {source}")]
|
|
||||||
Io {
|
|
||||||
path: PathBuf,
|
|
||||||
#[source]
|
|
||||||
source: std::io::Error,
|
|
||||||
},
|
|
||||||
#[error("parse {path}: {source}")]
|
|
||||||
Parse {
|
|
||||||
path: PathBuf,
|
|
||||||
#[source]
|
|
||||||
source: serde_json::Error,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The whole demo-target set.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DemoTargets {
|
|
||||||
/// Bumped on incompatible manifest changes.
|
|
||||||
pub schema_version: u32,
|
|
||||||
/// Prepended to every target name on seed; the seed script's `--reset`
|
|
||||||
/// deletes exactly the targets carrying this prefix.
|
|
||||||
pub name_prefix: String,
|
|
||||||
pub targets: Vec<DemoTarget>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One curated target.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DemoTarget {
|
|
||||||
/// Stable machine key (used in baseline reports and as the default
|
|
||||||
/// `repo_id`-ish handle in nightly output).
|
|
||||||
pub key: String,
|
|
||||||
/// Human name (without the prefix).
|
|
||||||
pub name: String,
|
|
||||||
pub target_type: TargetType,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub artifacts: Vec<DemoArtifact>,
|
|
||||||
/// Golden baseline for the nightly regression.
|
|
||||||
#[serde(default)]
|
|
||||||
pub expect: Expect,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One artifact of a curated target.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct DemoArtifact {
|
|
||||||
pub kind: ArtifactKind,
|
|
||||||
/// Literal reference (git URL, live URL, image ref) — the default when
|
|
||||||
/// `source_ref_env` is unset in the environment.
|
|
||||||
#[serde(default)]
|
|
||||||
pub source_ref: Option<String>,
|
|
||||||
/// Env var that overrides `source_ref` at seed time.
|
|
||||||
#[serde(default)]
|
|
||||||
pub source_ref_env: Option<String>,
|
|
||||||
/// Workspace-relative file to upload as this artifact's content.
|
|
||||||
#[serde(default)]
|
|
||||||
pub upload: Option<String>,
|
|
||||||
/// Env var holding an absolute path to upload instead of `upload`.
|
|
||||||
#[serde(default)]
|
|
||||||
pub upload_env: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub branch: Option<String>,
|
|
||||||
/// Commit the baseline was recorded against (informational: the agent
|
|
||||||
/// clones `branch`; re-pin when the baseline moves).
|
|
||||||
#[serde(default)]
|
|
||||||
pub pin: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub pin_tag: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub plc_format: Option<PlcFormat>,
|
|
||||||
/// Skip silently when the env var is unset (needs infra not every
|
|
||||||
/// environment has).
|
|
||||||
#[serde(default)]
|
|
||||||
pub optional: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DemoArtifact {
|
|
||||||
/// The upload path, resolved against the workspace root. `None` for
|
|
||||||
/// reference-style artifacts or env-only uploads whose var is unset.
|
|
||||||
pub fn upload_path(&self, root: &Path) -> Option<PathBuf> {
|
|
||||||
if let Some(var) = &self.upload_env {
|
|
||||||
if let Ok(p) = std::env::var(var) {
|
|
||||||
if !p.is_empty() {
|
|
||||||
return Some(PathBuf::from(p));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.upload.as_ref().map(|p| root.join(p))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when this artifact only exists if its env var is provided.
|
|
||||||
pub fn env_only(&self) -> bool {
|
|
||||||
self.upload.is_none() && self.source_ref.is_none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Golden baseline; every field is optional so a target can assert only what
|
|
||||||
/// is deterministic for it.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Expect {
|
|
||||||
/// Lower bound on total findings after a full scan.
|
|
||||||
#[serde(default)]
|
|
||||||
pub min_findings: Option<usize>,
|
|
||||||
/// Rule ids that must be present among SAST findings.
|
|
||||||
#[serde(default)]
|
|
||||||
pub sast_rule_ids: Vec<String>,
|
|
||||||
/// CWE ids (`CWE-NNN`) that must be present.
|
|
||||||
#[serde(default)]
|
|
||||||
pub cwes: Vec<String>,
|
|
||||||
/// Control refs (e.g. `cra-ai-8`) that must be stamped on some finding.
|
|
||||||
#[serde(default)]
|
|
||||||
pub control_refs: Vec<String>,
|
|
||||||
/// Lower bound on SBOM components.
|
|
||||||
#[serde(default)]
|
|
||||||
pub min_sbom_components: Option<usize>,
|
|
||||||
/// Scans `applicable-scans` must offer for this target.
|
|
||||||
#[serde(default)]
|
|
||||||
pub scans_offered: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub pentest_supported: Option<bool>,
|
|
||||||
/// `key -> value` facts `detect` must surface.
|
|
||||||
#[serde(default)]
|
|
||||||
pub detected_facts: BTreeMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DemoTargets {
|
|
||||||
/// Workspace root, derived from this crate's manifest dir.
|
|
||||||
pub fn workspace_root() -> PathBuf {
|
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.parent()
|
|
||||||
.map(Path::to_path_buf)
|
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the checked-in manifest.
|
|
||||||
pub fn load() -> Result<Self, FixtureError> {
|
|
||||||
Self::load_from(&Self::workspace_root().join(MANIFEST_PATH))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a manifest from an explicit path.
|
|
||||||
pub fn load_from(path: &Path) -> Result<Self, FixtureError> {
|
|
||||||
let raw = std::fs::read_to_string(path).map_err(|source| FixtureError::Io {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
serde_json::from_str(&raw).map_err(|source| FixtureError::Parse {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Display name as the seed script creates it.
|
|
||||||
pub fn full_name(&self, t: &DemoTarget) -> String {
|
|
||||||
format!("{}{}", self.name_prefix, t.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::pipeline::plc::analyze_tree;
|
|
||||||
use std::collections::{BTreeSet, HashSet};
|
|
||||||
|
|
||||||
fn manifest() -> DemoTargets {
|
|
||||||
DemoTargets::load().expect("demo manifest loads")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn manifest_is_well_formed() {
|
|
||||||
let m = manifest();
|
|
||||||
let root = DemoTargets::workspace_root();
|
|
||||||
assert_eq!(m.schema_version, 1);
|
|
||||||
assert!(!m.targets.is_empty());
|
|
||||||
|
|
||||||
let mut keys = HashSet::new();
|
|
||||||
for t in &m.targets {
|
|
||||||
assert!(keys.insert(t.key.as_str()), "duplicate key {}", t.key);
|
|
||||||
assert!(!t.artifacts.is_empty(), "{}: needs artifacts", t.key);
|
|
||||||
for a in &t.artifacts {
|
|
||||||
let refs = usize::from(a.source_ref.is_some()) + usize::from(a.upload.is_some());
|
|
||||||
let env_only = a.env_only();
|
|
||||||
assert!(
|
|
||||||
refs == 1 || (env_only && a.optional),
|
|
||||||
"{}: artifact {:?} must have exactly one of source_ref/upload, \
|
|
||||||
or be optional + env-only",
|
|
||||||
t.key,
|
|
||||||
a.kind
|
|
||||||
);
|
|
||||||
if let Some(p) = &a.upload {
|
|
||||||
assert!(root.join(p).is_file(), "{}: upload {p} missing", t.key);
|
|
||||||
}
|
|
||||||
match a.kind {
|
|
||||||
ArtifactKind::PlcProject => {
|
|
||||||
assert!(
|
|
||||||
a.plc_format.is_some(),
|
|
||||||
"{}: PLC upload needs plc_format",
|
|
||||||
t.key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ArtifactKind::GitRepo => {
|
|
||||||
assert!(a.branch.is_some(), "{}: git artifact needs branch", t.key);
|
|
||||||
assert!(a.pin.is_some(), "{}: git artifact needs a pin", t.key);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coverage the story asks for: PlcSps, firmware, web app, plain git.
|
|
||||||
let types: HashSet<TargetType> = m.targets.iter().map(|t| t.target_type).collect();
|
|
||||||
for want in [
|
|
||||||
TargetType::PlcSps,
|
|
||||||
TargetType::FirmwareRtos,
|
|
||||||
TargetType::WebApp,
|
|
||||||
TargetType::BackendService,
|
|
||||||
] {
|
|
||||||
assert!(types.contains(&want), "manifest lacks a {want:?} target");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Offline golden baseline: the checked-in PLC fixtures must keep producing
|
|
||||||
/// the rule ids the manifest promises. Runs the real control-logic
|
|
||||||
/// analyzer over the fixture files.
|
|
||||||
#[test]
|
|
||||||
fn plc_fixtures_meet_golden_baseline() {
|
|
||||||
let m = manifest();
|
|
||||||
let root = DemoTargets::workspace_root();
|
|
||||||
let all = analyze_tree(&root.join("examples/plc-demo"), "demo");
|
|
||||||
|
|
||||||
for t in m
|
|
||||||
.targets
|
|
||||||
.iter()
|
|
||||||
.filter(|t| t.target_type == TargetType::PlcSps)
|
|
||||||
{
|
|
||||||
let files: Vec<String> = t
|
|
||||||
.artifacts
|
|
||||||
.iter()
|
|
||||||
.filter(|a| a.kind == ArtifactKind::PlcProject)
|
|
||||||
.filter_map(|a| a.upload.as_deref())
|
|
||||||
.filter_map(|p| Path::new(p).file_name())
|
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
|
||||||
.collect();
|
|
||||||
assert!(!files.is_empty(), "{}: no PLC uploads", t.key);
|
|
||||||
|
|
||||||
let mine: Vec<_> = all
|
|
||||||
.iter()
|
|
||||||
.filter(|f| {
|
|
||||||
f.file_path
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|p| files.iter().any(|n| p.ends_with(n.as_str())))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let rules: BTreeSet<&str> = mine.iter().filter_map(|f| f.rule_id.as_deref()).collect();
|
|
||||||
eprintln!("{} -> {} findings, rules {:?}", t.key, mine.len(), rules);
|
|
||||||
|
|
||||||
if let Some(min) = t.expect.min_findings {
|
|
||||||
assert!(
|
|
||||||
mine.len() >= min,
|
|
||||||
"{}: {} findings < min {min}",
|
|
||||||
t.key,
|
|
||||||
mine.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for r in &t.expect.sast_rule_ids {
|
|
||||||
assert!(
|
|
||||||
rules.contains(r.as_str()),
|
|
||||||
"{}: missing rule {r}; got {rules:?}",
|
|
||||||
t.key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,30 +32,6 @@ pub fn hash_file(path: &Path) -> Result<(String, u64), AgentError> {
|
|||||||
Ok((hex::encode(hasher.finalize()), total))
|
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
|
/// Copy `src` into the content-addressed blob store under `base`, returning the
|
||||||
/// stored path. Idempotent: an already-present blob is not rewritten.
|
/// stored path. Idempotent: an already-present blob is not rewritten.
|
||||||
pub fn store_file(base: &Path, src: &Path, sha: &str) -> Result<PathBuf, AgentError> {
|
pub fn store_file(base: &Path, src: &Path, sha: &str) -> Result<PathBuf, AgentError> {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! is also the reconciliation key against sibling products (a firmware sha256
|
//! is also the reconciliation key against sibling products (a firmware sha256
|
||||||
//! matches tramiton's `Artifact.sha256`).
|
//! matches tramiton's `Artifact.sha256`).
|
||||||
|
|
||||||
pub(crate) mod blob;
|
mod blob;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ pub mod agent;
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod classify;
|
pub mod classify;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod controls;
|
|
||||||
pub mod database;
|
pub mod database;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod fixtures;
|
|
||||||
pub mod ingest;
|
pub mod ingest;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod pentest;
|
pub mod pentest;
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ struct EmbeddingData {
|
|||||||
index: usize,
|
index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Max inputs per embedding request. The bge/OpenAI-like backends cap the input
|
|
||||||
/// array (bge-multilingual-gemma2 rejects >25 with "batch size overflow"), so we
|
|
||||||
/// chunk larger corpora — a whole control catalog (~1.8k) would otherwise 500.
|
|
||||||
const EMBED_BATCH_SIZE: usize = 16;
|
|
||||||
|
|
||||||
// ── Embedding implementation ───────────────────────────────────
|
// ── Embedding implementation ───────────────────────────────────
|
||||||
|
|
||||||
impl LlmClient {
|
impl LlmClient {
|
||||||
@@ -34,21 +29,8 @@ impl LlmClient {
|
|||||||
&self.embed_model
|
&self.embed_model
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate embeddings for a batch of texts, chunking into backend-sized
|
/// Generate embeddings for a batch of texts
|
||||||
/// requests and preserving input order across chunks.
|
|
||||||
pub async fn embed(&self, texts: Vec<String>) -> Result<Vec<Vec<f64>>, AgentError> {
|
pub async fn embed(&self, texts: Vec<String>) -> Result<Vec<Vec<f64>>, AgentError> {
|
||||||
if texts.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut out = Vec::with_capacity(texts.len());
|
|
||||||
for chunk in texts.chunks(EMBED_BATCH_SIZE) {
|
|
||||||
out.extend(self.embed_batch(chunk.to_vec()).await?);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Embed one backend-sized batch (≤ [`EMBED_BATCH_SIZE`]) in a single request.
|
|
||||||
async fn embed_batch(&self, texts: Vec<String>) -> Result<Vec<Vec<f64>>, AgentError> {
|
|
||||||
let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/'));
|
let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
let request_body = EmbeddingRequest {
|
let request_body = EmbeddingRequest {
|
||||||
@@ -90,33 +72,3 @@ impl LlmClient {
|
|||||||
Ok(data.into_iter().map(|d| d.embedding).collect())
|
Ok(data.into_iter().map(|d| d.embedding).collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
fn client() -> LlmClient {
|
|
||||||
LlmClient::new(
|
|
||||||
"http://unused".into(),
|
|
||||||
SecretString::from(String::new()),
|
|
||||||
"m".into(),
|
|
||||||
"e".into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_input_makes_no_request() {
|
|
||||||
// Must short-circuit before any HTTP call (base_url is unroutable).
|
|
||||||
let out = client().embed(Vec::new()).await.unwrap();
|
|
||||||
assert!(out.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn batch_size_is_within_backend_cap() {
|
|
||||||
assert!(
|
|
||||||
EMBED_BATCH_SIZE <= 25,
|
|
||||||
"must stay under the bge 25-input cap"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -344,7 +344,6 @@ mod tests {
|
|||||||
tenant_registry_url: None,
|
tenant_registry_url: None,
|
||||||
plc_runtime: compliance_core::PlcRuntimeConfig::default(),
|
plc_runtime: compliance_core::PlcRuntimeConfig::default(),
|
||||||
werkbank_runner_token: None,
|
werkbank_runner_token: None,
|
||||||
breakpilot: compliance_core::config::BreakpilotConfig::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use compliance_core::models::{Finding, ScanType, Severity};
|
use compliance_core::models::{Finding, ScanType, Severity};
|
||||||
|
|
||||||
use crate::fingerprint as dedup;
|
use crate::pipeline::dedup;
|
||||||
|
|
||||||
/// Well-known deep-probe ports (each independent of any WebVisu HTTP port).
|
/// Well-known deep-probe ports (each independent of any WebVisu HTTP port).
|
||||||
const MODBUS_PORT: u16 = 502;
|
const MODBUS_PORT: u16 = 502;
|
||||||
@@ -5,6 +5,7 @@ pub mod firmware_sbom;
|
|||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod gitleaks;
|
pub mod gitleaks;
|
||||||
mod graph_build;
|
mod graph_build;
|
||||||
|
pub mod ics;
|
||||||
mod issue_creation;
|
mod issue_creation;
|
||||||
pub mod lint;
|
pub mod lint;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
|
|||||||
@@ -215,72 +215,8 @@ impl PipelineOrchestrator {
|
|||||||
.await;
|
.await;
|
||||||
tracing::info!("[{repo_id}] Triaged: {triaged} findings passed confidence threshold");
|
tracing::info!("[{repo_id}] Triaged: {triaged} findings passed confidence threshold");
|
||||||
|
|
||||||
// Stage 5b: control triage — stamp findings with the compliance control(s)
|
// Dedup against existing findings and insert new ones
|
||||||
// they're evidence for and flag control false positives (grounded LLM over
|
|
||||||
// deterministic tool output). No-op unless breakpilot is configured.
|
|
||||||
self.update_phase(scan_run_id, "control_triage").await;
|
|
||||||
let tagged = crate::controls::triage_repo_findings(
|
|
||||||
&self.config,
|
|
||||||
self.llm.clone(),
|
|
||||||
&repo_path,
|
|
||||||
&mut all_findings,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if tagged > 0 {
|
|
||||||
tracing::info!("[{repo_id}] Control triage tagged {tagged} findings with control refs");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 5c: semantic control mapping — scale path for the master-controls
|
|
||||||
// corpus (no CWE to LUT on): embed each finding's region, retrieve the
|
|
||||||
// nearest master controls, grounded-judge, and stamp confirmed refs. On by
|
|
||||||
// default (validated live); the corpus embedding is cached so only the
|
|
||||||
// first scan after a catalog change pays it.
|
|
||||||
if self.config.breakpilot.semantic_mapping {
|
|
||||||
self.update_phase(scan_run_id, "semantic_control_mapping")
|
|
||||||
.await;
|
|
||||||
let sem = crate::controls::semantic_stamp_findings(
|
|
||||||
&self.config,
|
|
||||||
self.llm.clone(),
|
|
||||||
&repo_path,
|
|
||||||
&mut all_findings,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if sem > 0 {
|
|
||||||
tracing::info!(
|
|
||||||
"[{repo_id}] Semantic mapping tagged {sem} findings with master-control refs"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage 5d: grounded surface checks — the absence-based controls (no
|
|
||||||
// rate limiting, no security logging, no update-signature check) have no
|
|
||||||
// syntactic pattern to match, so we retrieve the code surface each governs
|
|
||||||
// and let the grounded judge decide whether it holds, producing net-new
|
|
||||||
// findings already tagged + grounded. On by default (validated live); it
|
|
||||||
// covers the 8 absence-based CRA controls.
|
|
||||||
if self.config.breakpilot.grounded_control_checks {
|
|
||||||
self.update_phase(scan_run_id, "grounded_control_checks")
|
|
||||||
.await;
|
|
||||||
let grounded = crate::controls::grounded_surface_findings(
|
|
||||||
&self.config,
|
|
||||||
self.llm.clone(),
|
|
||||||
&repo_path,
|
|
||||||
&repo_id,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
if !grounded.is_empty() {
|
|
||||||
tracing::info!(
|
|
||||||
"[{repo_id}] Grounded surface checks raised {} control findings",
|
|
||||||
grounded.len()
|
|
||||||
);
|
|
||||||
all_findings.extend(grounded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dedup against existing findings: insert first-seen ones, and refresh the
|
|
||||||
// control mappings on ones we've seen before.
|
|
||||||
let mut new_count = 0u32;
|
let mut new_count = 0u32;
|
||||||
let mut refreshed_count = 0u32;
|
|
||||||
let mut new_findings: Vec<Finding> = Vec::new();
|
let mut new_findings: Vec<Finding> = Vec::new();
|
||||||
for mut finding in all_findings {
|
for mut finding in all_findings {
|
||||||
finding.scan_run_id = Some(scan_run_id.to_string());
|
finding.scan_run_id = Some(scan_run_id.to_string());
|
||||||
@@ -295,25 +231,8 @@ impl PipelineOrchestrator {
|
|||||||
finding.id = result.inserted_id.as_object_id();
|
finding.id = result.inserted_id.as_object_id();
|
||||||
new_findings.push(finding);
|
new_findings.push(finding);
|
||||||
new_count += 1;
|
new_count += 1;
|
||||||
} else if !finding.control_refs.is_empty() {
|
|
||||||
// Re-scan refresh: a mapping pass (newly enabled or tuned) computed
|
|
||||||
// control_refs for a finding first seen before mapping ran. Persist
|
|
||||||
// them onto the existing row — the insert path alone never would.
|
|
||||||
self.db
|
|
||||||
.findings()
|
|
||||||
.update_one(
|
|
||||||
doc! { "fingerprint": &finding.fingerprint },
|
|
||||||
doc! { "$set": { "control_refs": finding.control_refs.clone() } },
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
refreshed_count += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if refreshed_count > 0 {
|
|
||||||
tracing::info!(
|
|
||||||
"[{repo_id}] Refreshed control_refs on {refreshed_count} existing findings"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stale SBOM entries for this repo before reinserting
|
// Remove stale SBOM entries for this repo before reinserting
|
||||||
if !sbom_entries.is_empty() {
|
if !sbom_entries.is_empty() {
|
||||||
@@ -586,21 +505,7 @@ impl PipelineOrchestrator {
|
|||||||
let Some(path) = ingest_set.get(&a.id).and_then(|ia| ia.working_path.clone()) else {
|
let Some(path) = ingest_set.get(&a.id).and_then(|ia| ia.working_path.clone()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut source_findings = crate::pipeline::plc::analyze_tree(&path, target_id);
|
all_findings.extend(crate::pipeline::plc::analyze_tree(&path, target_id));
|
||||||
// Control mapping for the PLC path (run_plc_scan is separate from
|
|
||||||
// run_pipeline, which does its own mapping). PLC findings carry
|
|
||||||
// file_path/line/cwe, so the semantic pass reads each region under this
|
|
||||||
// source's `path` and stamps master-control refs. The LUT + grounded
|
|
||||||
// surface passes are code-pattern / CRA-specific and don't apply to
|
|
||||||
// IEC 61131-3 control logic, so only the semantic pass runs here.
|
|
||||||
crate::controls::semantic_stamp_findings(
|
|
||||||
&self.config,
|
|
||||||
self.llm.clone(),
|
|
||||||
&path,
|
|
||||||
&mut source_findings,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
all_findings.extend(source_findings);
|
|
||||||
// Control-application SBOM: CODESYS libraries + runtime from a
|
// Control-application SBOM: CODESYS libraries + runtime from a
|
||||||
// `.projectarchive` (uploaded, or committed in the working tree).
|
// `.projectarchive` (uploaded, or committed in the working tree).
|
||||||
let archive = a
|
let archive = a
|
||||||
@@ -625,7 +530,6 @@ impl PipelineOrchestrator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut new_count = 0u32;
|
let mut new_count = 0u32;
|
||||||
let mut refreshed_count = 0u32;
|
|
||||||
for mut finding in all_findings {
|
for mut finding in all_findings {
|
||||||
finding.scan_run_id = Some(scan_run_id.to_string());
|
finding.scan_run_id = Some(scan_run_id.to_string());
|
||||||
if self
|
if self
|
||||||
@@ -637,27 +541,8 @@ impl PipelineOrchestrator {
|
|||||||
{
|
{
|
||||||
self.db.findings().insert_one(&finding).await?;
|
self.db.findings().insert_one(&finding).await?;
|
||||||
new_count += 1;
|
new_count += 1;
|
||||||
} else if !finding.control_refs.is_empty() {
|
|
||||||
// Re-scan refresh: mirror run_pipeline — persist newly-computed
|
|
||||||
// control_refs onto a PLC finding first seen before the semantic
|
|
||||||
// pass ran. The insert path alone never would, so without this a
|
|
||||||
// PLC re-scan can only pick up mappings via a delete + re-add.
|
|
||||||
self.db
|
|
||||||
.findings()
|
|
||||||
.update_one(
|
|
||||||
doc! { "fingerprint": &finding.fingerprint },
|
|
||||||
doc! { "$set": { "control_refs": finding.control_refs.clone() } },
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
refreshed_count += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if refreshed_count > 0 {
|
|
||||||
tracing::info!(
|
|
||||||
target_id,
|
|
||||||
"Refreshed control_refs on {refreshed_count} existing PLC findings"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !all_sbom.is_empty() {
|
if !all_sbom.is_empty() {
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
@@ -702,7 +587,7 @@ impl PipelineOrchestrator {
|
|||||||
let path = ingest_set
|
let path = ingest_set
|
||||||
.get(&a.id)
|
.get(&a.id)
|
||||||
.and_then(|ia| ia.working_path.clone())?;
|
.and_then(|ia| ia.working_path.clone())?;
|
||||||
werkbank_exec::plc::extract_program(&path)
|
crate::pipeline::plc::runtime::extract_program(&path)
|
||||||
});
|
});
|
||||||
let Some(program) = program else {
|
let Some(program) = program else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -712,9 +597,10 @@ impl PipelineOrchestrator {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
let http = werkbank_exec::plc::http_client()?;
|
let http = crate::pipeline::plc::runtime::http_client()?;
|
||||||
let provisioner = werkbank_exec::plc::DockerSoftPlc::new(self.config.plc_runtime.clone());
|
let provisioner =
|
||||||
let outcome = werkbank_exec::plc::provision_and_test(
|
crate::pipeline::plc::runtime::DockerSoftPlc::new(self.config.plc_runtime.clone());
|
||||||
|
let outcome = crate::pipeline::plc::runtime::provision_and_test(
|
||||||
&provisioner,
|
&provisioner,
|
||||||
&http,
|
&http,
|
||||||
&self.config.plc_runtime,
|
&self.config.plc_runtime,
|
||||||
@@ -777,7 +663,7 @@ impl PipelineOrchestrator {
|
|||||||
};
|
};
|
||||||
// Short per-request budget so an unreachable device doesn't stall the scan.
|
// Short per-request budget so an unreachable device doesn't stall the scan.
|
||||||
let budget = std::time::Duration::from_secs(5);
|
let budget = std::time::Duration::from_secs(5);
|
||||||
let findings = werkbank_exec::ics::probe_target(&endpoint, target_id, budget).await;
|
let findings = crate::pipeline::ics::probe_target(&endpoint, target_id, budget).await;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id,
|
target_id,
|
||||||
endpoint = %endpoint,
|
endpoint = %endpoint,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod lexer;
|
|||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod plcopen;
|
pub mod plcopen;
|
||||||
pub mod rules;
|
pub mod rules;
|
||||||
|
pub mod runtime;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use compliance_core::models::dast::{DastFinding, DastScanRun, DastTarget, DastTa
|
|||||||
use compliance_core::models::Finding;
|
use compliance_core::models::Finding;
|
||||||
use compliance_core::PlcRuntimeConfig;
|
use compliance_core::PlcRuntimeConfig;
|
||||||
|
|
||||||
use crate::error::ExecError;
|
use crate::error::AgentError;
|
||||||
|
|
||||||
pub use provision::{DockerSoftPlc, ProvisionedRuntime, SoftPlc};
|
pub use provision::{DockerSoftPlc, ProvisionedRuntime, SoftPlc};
|
||||||
|
|
||||||
@@ -61,12 +61,12 @@ pub struct PlcProgram {
|
|||||||
|
|
||||||
/// A cookie-aware HTTP client for the OpenPLC web UI. A fresh client per scan
|
/// A cookie-aware HTTP client for the OpenPLC web UI. A fresh client per scan
|
||||||
/// isolates the OpenPLC session (its Flask login cookie) from every other scan.
|
/// isolates the OpenPLC session (its Flask login cookie) from every other scan.
|
||||||
pub fn http_client() -> Result<reqwest::Client, ExecError> {
|
pub fn http_client() -> Result<reqwest::Client, AgentError> {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.cookie_store(true)
|
.cookie_store(true)
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
.map_err(ExecError::Http)
|
.map_err(AgentError::Http)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pick the control-logic program to run from an ingested PLC source tree.
|
/// Pick the control-logic program to run from an ingested PLC source tree.
|
||||||
@@ -151,7 +151,7 @@ pub async fn provision_and_test<P: SoftPlc>(
|
|||||||
cfg: &PlcRuntimeConfig,
|
cfg: &PlcRuntimeConfig,
|
||||||
program: &PlcProgram,
|
program: &PlcProgram,
|
||||||
target_id: &str,
|
target_id: &str,
|
||||||
) -> Result<ProvisionOutcome, ExecError> {
|
) -> Result<ProvisionOutcome, AgentError> {
|
||||||
let handle = provisioner.provision(target_id).await?;
|
let handle = provisioner.provision(target_id).await?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id,
|
target_id,
|
||||||
@@ -193,7 +193,7 @@ async fn run_dynamic_test(
|
|||||||
program: &PlcProgram,
|
program: &PlcProgram,
|
||||||
target_id: &str,
|
target_id: &str,
|
||||||
handle: &ProvisionedRuntime,
|
handle: &ProvisionedRuntime,
|
||||||
) -> Result<ProvisionOutcome, ExecError> {
|
) -> Result<ProvisionOutcome, AgentError> {
|
||||||
let ready_budget = Duration::from_secs((cfg.max_lifetime_secs / 3).clamp(10, 60));
|
let ready_budget = Duration::from_secs((cfg.max_lifetime_secs / 3).clamp(10, 60));
|
||||||
openplc::wait_ready(http, &handle.webvisu_url, ready_budget).await?;
|
openplc::wait_ready(http, &handle.webvisu_url, ready_budget).await?;
|
||||||
|
|
||||||
@@ -212,7 +212,8 @@ async fn run_dynamic_test(
|
|||||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
|
|
||||||
let probe_budget = Duration::from_secs(5);
|
let probe_budget = Duration::from_secs(5);
|
||||||
let findings = crate::ics::probe_target(&handle.modbus_endpoint, target_id, probe_budget).await;
|
let findings =
|
||||||
|
crate::pipeline::ics::probe_target(&handle.modbus_endpoint, target_id, probe_budget).await;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target_id,
|
target_id,
|
||||||
instance = %handle.name,
|
instance = %handle.name,
|
||||||
@@ -347,10 +348,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SoftPlc for FakeSoftPlc {
|
impl SoftPlc for FakeSoftPlc {
|
||||||
async fn provision(&self, _target_id: &str) -> Result<ProvisionedRuntime, ExecError> {
|
async fn provision(&self, _target_id: &str) -> Result<ProvisionedRuntime, AgentError> {
|
||||||
self.provisions.fetch_add(1, Ordering::SeqCst);
|
self.provisions.fetch_add(1, Ordering::SeqCst);
|
||||||
if self.fail_provision {
|
if self.fail_provision {
|
||||||
return Err(ExecError::Other("provision failed".into()));
|
return Err(AgentError::Other("provision failed".into()));
|
||||||
}
|
}
|
||||||
// Unreachable address so run_dynamic_test blocks on readiness until the
|
// Unreachable address so run_dynamic_test blocks on readiness until the
|
||||||
// deadline fires — exercising the teardown-on-deadline path.
|
// deadline fires — exercising the teardown-on-deadline path.
|
||||||
+15
-15
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::error::ExecError;
|
use crate::error::AgentError;
|
||||||
|
|
||||||
use super::PlcProgram;
|
use super::PlcProgram;
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ pub async fn wait_ready(
|
|||||||
http: &reqwest::Client,
|
http: &reqwest::Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
budget: Duration,
|
budget: Duration,
|
||||||
) -> Result<(), ExecError> {
|
) -> Result<(), AgentError> {
|
||||||
let login = format!("{base_url}/login");
|
let login = format!("{base_url}/login");
|
||||||
let outcome = tokio::time::timeout(budget, async {
|
let outcome = tokio::time::timeout(budget, async {
|
||||||
loop {
|
loop {
|
||||||
@@ -40,7 +40,7 @@ pub async fn wait_ready(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
outcome.map_err(|_| ExecError::Other(format!("OpenPLC at {base_url} did not become ready")))
|
outcome.map_err(|_| AgentError::Other(format!("OpenPLC at {base_url} did not become ready")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log in, upload the program, compile it, and start the runtime. On success the
|
/// Log in, upload the program, compile it, and start the runtime. On success the
|
||||||
@@ -52,7 +52,7 @@ pub async fn load_and_start(
|
|||||||
password: &str,
|
password: &str,
|
||||||
program: &PlcProgram,
|
program: &PlcProgram,
|
||||||
compile_budget: Duration,
|
compile_budget: Duration,
|
||||||
) -> Result<(), ExecError> {
|
) -> Result<(), AgentError> {
|
||||||
login(http, base_url, user, password).await?;
|
login(http, base_url, user, password).await?;
|
||||||
let prog_file = upload_program(http, base_url, program).await?;
|
let prog_file = upload_program(http, base_url, program).await?;
|
||||||
save_program(http, base_url, &prog_file).await?;
|
save_program(http, base_url, &prog_file).await?;
|
||||||
@@ -68,14 +68,14 @@ async fn login(
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
user: &str,
|
user: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<(), ExecError> {
|
) -> Result<(), AgentError> {
|
||||||
let resp = http
|
let resp = http
|
||||||
.post(format!("{base_url}/login"))
|
.post(format!("{base_url}/login"))
|
||||||
.form(&[("username", user), ("password", password)])
|
.form(&[("username", user), ("password", password)])
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
if resp.status().is_server_error() {
|
if resp.status().is_server_error() {
|
||||||
return Err(ExecError::Other(format!(
|
return Err(AgentError::Other(format!(
|
||||||
"OpenPLC login failed: HTTP {}",
|
"OpenPLC login failed: HTTP {}",
|
||||||
resp.status()
|
resp.status()
|
||||||
)));
|
)));
|
||||||
@@ -90,7 +90,7 @@ async fn upload_program(
|
|||||||
http: &reqwest::Client,
|
http: &reqwest::Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
program: &PlcProgram,
|
program: &PlcProgram,
|
||||||
) -> Result<String, ExecError> {
|
) -> Result<String, AgentError> {
|
||||||
let part = reqwest::multipart::Part::text(program.source.clone())
|
let part = reqwest::multipart::Part::text(program.source.clone())
|
||||||
.file_name(program.file_name.clone())
|
.file_name(program.file_name.clone())
|
||||||
.mime_str("application/octet-stream")?;
|
.mime_str("application/octet-stream")?;
|
||||||
@@ -102,7 +102,7 @@ async fn upload_program(
|
|||||||
.await?;
|
.await?;
|
||||||
let html = resp.text().await?;
|
let html = resp.text().await?;
|
||||||
parse_prog_file(&html).ok_or_else(|| {
|
parse_prog_file(&html).ok_or_else(|| {
|
||||||
ExecError::Other("OpenPLC upload did not return a prog_file handle".to_string())
|
AgentError::Other("OpenPLC upload did not return a prog_file handle".to_string())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ async fn save_program(
|
|||||||
http: &reqwest::Client,
|
http: &reqwest::Client,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
prog_file: &str,
|
prog_file: &str,
|
||||||
) -> Result<(), ExecError> {
|
) -> Result<(), AgentError> {
|
||||||
let epoch = std::time::SystemTime::now()
|
let epoch = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
@@ -130,7 +130,7 @@ async fn save_program(
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
if resp.status().is_server_error() {
|
if resp.status().is_server_error() {
|
||||||
return Err(ExecError::Other(format!(
|
return Err(AgentError::Other(format!(
|
||||||
"OpenPLC save-program failed: HTTP {}",
|
"OpenPLC save-program failed: HTTP {}",
|
||||||
resp.status()
|
resp.status()
|
||||||
)));
|
)));
|
||||||
@@ -146,7 +146,7 @@ async fn compile(
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
prog_file: &str,
|
prog_file: &str,
|
||||||
budget: Duration,
|
budget: Duration,
|
||||||
) -> Result<(), ExecError> {
|
) -> Result<(), AgentError> {
|
||||||
http.get(format!("{base_url}/compile-program"))
|
http.get(format!("{base_url}/compile-program"))
|
||||||
.query(&[("file", prog_file)])
|
.query(&[("file", prog_file)])
|
||||||
.send()
|
.send()
|
||||||
@@ -168,20 +168,20 @@ async fn compile(
|
|||||||
.await;
|
.await;
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(true) => Ok(()),
|
Ok(true) => Ok(()),
|
||||||
Ok(false) => Err(ExecError::Other(
|
Ok(false) => Err(AgentError::Other(
|
||||||
"OpenPLC compilation finished with errors".to_string(),
|
"OpenPLC compilation finished with errors".to_string(),
|
||||||
)),
|
)),
|
||||||
Err(_) => Err(ExecError::Other(
|
Err(_) => Err(AgentError::Other(
|
||||||
"OpenPLC compilation did not finish in time".to_string(),
|
"OpenPLC compilation did not finish in time".to_string(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /start_plc` — starts the runtime, opening Modbus/TCP on 502.
|
/// `GET /start_plc` — starts the runtime, opening Modbus/TCP on 502.
|
||||||
async fn start(http: &reqwest::Client, base_url: &str) -> Result<(), ExecError> {
|
async fn start(http: &reqwest::Client, base_url: &str) -> Result<(), AgentError> {
|
||||||
let resp = http.get(format!("{base_url}/start_plc")).send().await?;
|
let resp = http.get(format!("{base_url}/start_plc")).send().await?;
|
||||||
if resp.status().is_server_error() {
|
if resp.status().is_server_error() {
|
||||||
return Err(ExecError::Other(format!(
|
return Err(AgentError::Other(format!(
|
||||||
"OpenPLC start_plc failed: HTTP {}",
|
"OpenPLC start_plc failed: HTTP {}",
|
||||||
resp.status()
|
resp.status()
|
||||||
)));
|
)));
|
||||||
+6
-6
@@ -15,7 +15,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use compliance_core::PlcRuntimeConfig;
|
use compliance_core::PlcRuntimeConfig;
|
||||||
|
|
||||||
use crate::error::ExecError;
|
use crate::error::AgentError;
|
||||||
|
|
||||||
/// The Modbus/TCP port an OpenPLC instance opens once a program is running.
|
/// The Modbus/TCP port an OpenPLC instance opens once a program is running.
|
||||||
const MODBUS_PORT: u16 = 502;
|
const MODBUS_PORT: u16 = 502;
|
||||||
@@ -45,7 +45,7 @@ pub trait SoftPlc {
|
|||||||
fn provision(
|
fn provision(
|
||||||
&self,
|
&self,
|
||||||
target_id: &str,
|
target_id: &str,
|
||||||
) -> impl std::future::Future<Output = Result<ProvisionedRuntime, ExecError>> + Send;
|
) -> impl std::future::Future<Output = Result<ProvisionedRuntime, AgentError>> + Send;
|
||||||
|
|
||||||
/// Tear an instance down. Best-effort and idempotent — never fails the scan.
|
/// Tear an instance down. Best-effort and idempotent — never fails the scan.
|
||||||
fn teardown(&self, handle: &ProvisionedRuntime)
|
fn teardown(&self, handle: &ProvisionedRuntime)
|
||||||
@@ -65,7 +65,7 @@ impl DockerSoftPlc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SoftPlc for DockerSoftPlc {
|
impl SoftPlc for DockerSoftPlc {
|
||||||
async fn provision(&self, target_id: &str) -> Result<ProvisionedRuntime, ExecError> {
|
async fn provision(&self, target_id: &str) -> Result<ProvisionedRuntime, AgentError> {
|
||||||
// Best-effort sweep of any container leaked by a crashed earlier run
|
// Best-effort sweep of any container leaked by a crashed earlier run
|
||||||
// before we add another. Only removes instances past their max lifetime,
|
// before we add another. Only removes instances past their max lifetime,
|
||||||
// so it can never disturb a concurrent run.
|
// so it can never disturb a concurrent run.
|
||||||
@@ -75,7 +75,7 @@ impl SoftPlc for DockerSoftPlc {
|
|||||||
let args = run_args(&self.cfg, &name, target_id);
|
let args = run_args(&self.cfg, &name, target_id);
|
||||||
let out = run_docker(&args).await?;
|
let out = run_docker(&args).await?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return Err(ExecError::Other(format!(
|
return Err(AgentError::Other(format!(
|
||||||
"docker run for soft-PLC {name} failed: {}",
|
"docker run for soft-PLC {name} failed: {}",
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
)));
|
)));
|
||||||
@@ -215,12 +215,12 @@ async fn reap_stale(cfg: &PlcRuntimeConfig, now: u64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run a `docker` subcommand, capturing its output.
|
/// Run a `docker` subcommand, capturing its output.
|
||||||
async fn run_docker(args: &[String]) -> Result<std::process::Output, ExecError> {
|
async fn run_docker(args: &[String]) -> Result<std::process::Output, AgentError> {
|
||||||
tokio::process::Command::new("docker")
|
tokio::process::Command::new("docker")
|
||||||
.args(args)
|
.args(args)
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.map_err(ExecError::Io)
|
.map_err(AgentError::Io)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
use compliance_core::models::{Finding, ScanType, Severity};
|
use compliance_core::models::{Finding, ScanType, Severity};
|
||||||
use compliance_core::traits::{ScanOutput, Scanner};
|
use compliance_core::traits::{ScanOutput, Scanner};
|
||||||
@@ -6,30 +6,6 @@ use compliance_core::CoreError;
|
|||||||
|
|
||||||
use crate::pipeline::dedup;
|
use crate::pipeline::dedup;
|
||||||
|
|
||||||
/// Custom CRA-control detectors bundled into the binary and staged to a temp file
|
|
||||||
/// at scan time so semgrep can `--config` them alongside the auto ruleset. These
|
|
||||||
/// cover controls no off-the-shelf rule digs out (secure defaults, weak password
|
|
||||||
/// hashing, insecure session cookies, weak data-at-rest ciphers); each rule id is
|
|
||||||
/// keyed back to its control by the `control-map` LUT.
|
|
||||||
const CRA_RULES: &str = include_str!("../../rules/cra_semgrep.yaml");
|
|
||||||
|
|
||||||
/// Write the bundled CRA rules to a stable temp path (atomic: unique tmp +
|
|
||||||
/// rename). Returns `None` on failure — the scan then runs with auto rules only.
|
|
||||||
async fn stage_cra_rules() -> Option<PathBuf> {
|
|
||||||
let dir = std::env::temp_dir();
|
|
||||||
let path = dir.join("compliance-cra-semgrep.yaml");
|
|
||||||
let tmp = dir.join(format!("compliance-cra-semgrep.{}.tmp", std::process::id()));
|
|
||||||
if let Err(e) = tokio::fs::write(&tmp, CRA_RULES).await {
|
|
||||||
tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if let Err(e) = tokio::fs::rename(&tmp, &path).await {
|
|
||||||
tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SemgrepScanner;
|
pub struct SemgrepScanner;
|
||||||
|
|
||||||
impl Scanner for SemgrepScanner {
|
impl Scanner for SemgrepScanner {
|
||||||
@@ -43,26 +19,30 @@ impl Scanner for SemgrepScanner {
|
|||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
|
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
|
||||||
let cra_rules = stage_cra_rules().await;
|
let output = tokio::time::timeout(
|
||||||
let mut command = tokio::process::Command::new("semgrep");
|
std::time::Duration::from_secs(600),
|
||||||
command.arg("--config=auto");
|
tokio::process::Command::new("semgrep")
|
||||||
if let Some(path) = &cra_rules {
|
.args([
|
||||||
command.arg(format!("--config={}", path.display()));
|
"--config=auto",
|
||||||
}
|
"--json",
|
||||||
command
|
"--quiet",
|
||||||
.args(["--json", "--quiet", "--max-memory", "500", "--jobs", "1"])
|
"--max-memory",
|
||||||
.arg(repo_path);
|
"500",
|
||||||
|
"--jobs",
|
||||||
let output = tokio::time::timeout(std::time::Duration::from_secs(600), command.output())
|
"1",
|
||||||
.await
|
])
|
||||||
.map_err(|_| CoreError::Scanner {
|
.arg(repo_path)
|
||||||
scanner: "semgrep".to_string(),
|
.output(),
|
||||||
source: "timed out after 10 minutes".into(),
|
)
|
||||||
})?
|
.await
|
||||||
.map_err(|e| CoreError::Scanner {
|
.map_err(|_| CoreError::Scanner {
|
||||||
scanner: "semgrep".to_string(),
|
scanner: "semgrep".to_string(),
|
||||||
source: Box::new(e),
|
source: "timed out after 10 minutes".into(),
|
||||||
})?;
|
})?
|
||||||
|
.map_err(|e| CoreError::Scanner {
|
||||||
|
scanner: "semgrep".to_string(),
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
if !output.status.success() && output.stdout.is_empty() {
|
if !output.status.success() && output.stdout.is_empty() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
@@ -102,7 +82,10 @@ impl Scanner for SemgrepScanner {
|
|||||||
finding.file_path = Some(r.path);
|
finding.file_path = Some(r.path);
|
||||||
finding.line_number = Some(r.start.line);
|
finding.line_number = Some(r.start.line);
|
||||||
finding.code_snippet = Some(r.extra.lines);
|
finding.code_snippet = Some(r.extra.lines);
|
||||||
finding.cwe = r.extra.metadata.as_ref().and_then(extract_cwe);
|
finding.cwe = r
|
||||||
|
.extra
|
||||||
|
.metadata
|
||||||
|
.and_then(|m| m.get("cwe").and_then(|v| v.as_str()).map(|s| s.to_string()));
|
||||||
finding
|
finding
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -141,34 +124,10 @@ struct SemgrepExtra {
|
|||||||
metadata: Option<serde_json::Value>,
|
metadata: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// semgrep emits `metadata.cwe` as a list of strings like
|
|
||||||
/// `"CWE-798: Use of Hard-coded Credentials"` (occasionally a bare string). Take
|
|
||||||
/// the first entry and normalise it to just the `CWE-NNN` id.
|
|
||||||
fn extract_cwe(metadata: &serde_json::Value) -> Option<String> {
|
|
||||||
let raw = metadata.get("cwe")?;
|
|
||||||
let text = match raw {
|
|
||||||
serde_json::Value::Array(items) => items.first()?.as_str()?,
|
|
||||||
serde_json::Value::String(s) => s.as_str(),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let id = text.split(':').next().unwrap_or(text).trim();
|
|
||||||
(!id.is_empty()).then(|| id.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_cwe_handles_list_and_normalises() {
|
|
||||||
let md = serde_json::json!({"cwe": ["CWE-798: Use of Hard-coded Credentials"]});
|
|
||||||
assert_eq!(extract_cwe(&md).as_deref(), Some("CWE-798"));
|
|
||||||
let bare = serde_json::json!({"cwe": "CWE-89"});
|
|
||||||
assert_eq!(extract_cwe(&bare).as_deref(), Some("CWE-89"));
|
|
||||||
let none = serde_json::json!({"severity": "ERROR"});
|
|
||||||
assert_eq!(extract_cwe(&none), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deserialize_semgrep_output() {
|
fn deserialize_semgrep_output() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
//! C5 example 2 — exploratory (not a committed regression test). Four topically
|
|
||||||
//! distinct findings, to see whether tuned semantic retrieval maps each to the
|
|
||||||
//! right master-control family. Run:
|
|
||||||
//! export ... (LITELLM_* + BREAKPILOT_BASE_URL)
|
|
||||||
//! cargo test -p compliance-agent --test c5_example2 -- --ignored --nocapture
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use compliance_agent::llm::LlmClient;
|
|
||||||
use compliance_core::config::BreakpilotConfig;
|
|
||||||
use compliance_core::models::finding::{Finding, Severity};
|
|
||||||
use compliance_core::models::scan::ScanType;
|
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
fn env(k: &str) -> String {
|
|
||||||
std::env::var(k).unwrap_or_else(|_| panic!("env {k} must be set"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mk(file: &str, line: u32, title: &str, desc: &str) -> Finding {
|
|
||||||
let mut f = Finding::new(
|
|
||||||
"repo-c5b".into(),
|
|
||||||
format!("{file}:{line}"),
|
|
||||||
"semgrep".into(),
|
|
||||||
ScanType::Sast,
|
|
||||||
title.into(),
|
|
||||||
desc.into(),
|
|
||||||
Severity::High,
|
|
||||||
);
|
|
||||||
f.file_path = Some(file.into());
|
|
||||||
f.line_number = Some(line);
|
|
||||||
f
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write(repo: &std::path::Path, rel: &str, body: &str) {
|
|
||||||
let p = repo.join(rel);
|
|
||||||
if let Some(parent) = p.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap();
|
|
||||||
}
|
|
||||||
std::fs::write(p, body).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "live: api-dev + LiteLLM"]
|
|
||||||
async fn c5b_varied_findings() {
|
|
||||||
let llm = Arc::new(LlmClient::new(
|
|
||||||
env("LITELLM_URL"),
|
|
||||||
SecretString::from(env("LITELLM_API_KEY")),
|
|
||||||
env("LITELLM_MODEL"),
|
|
||||||
env("LITELLM_EMBED_MODEL"),
|
|
||||||
));
|
|
||||||
let mut config = common::dev_config("mongodb://unused".into(), "c5b".into());
|
|
||||||
config.breakpilot = BreakpilotConfig {
|
|
||||||
base_url: Some(env("BREAKPILOT_BASE_URL")),
|
|
||||||
token: None,
|
|
||||||
snapshot_dir: std::env::temp_dir()
|
|
||||||
.join("c5-oscal-snap")
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned(),
|
|
||||||
semantic_mapping: true,
|
|
||||||
grounded_control_checks: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let repo = std::env::temp_dir().join("c5b-fixture-repo");
|
|
||||||
let _ = std::fs::remove_dir_all(&repo);
|
|
||||||
write(
|
|
||||||
&repo,
|
|
||||||
"app/db.py",
|
|
||||||
"import sqlite3\n\ndef get_user(username):\n q = \"SELECT * FROM users WHERE name = '\" + username + \"'\"\n return conn.execute(q)\n",
|
|
||||||
);
|
|
||||||
write(
|
|
||||||
&repo,
|
|
||||||
"app/config.py",
|
|
||||||
"# service config\nAPI_KEY = \"sk_live_51H8xYz3kQ9v2bNmR7wT4uSpQ\"\nDB_HOST = \"db.internal\"\n",
|
|
||||||
);
|
|
||||||
write(
|
|
||||||
&repo,
|
|
||||||
"app/net.py",
|
|
||||||
"import requests\n\ndef fetch(url):\n return requests.get(url, verify=False, timeout=5)\n",
|
|
||||||
);
|
|
||||||
write(
|
|
||||||
&repo,
|
|
||||||
"app/ser.py",
|
|
||||||
"import pickle\n\ndef load_state(blob):\n return pickle.loads(blob)\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut findings = vec![
|
|
||||||
mk(
|
|
||||||
"app/db.py",
|
|
||||||
4,
|
|
||||||
"SQL injection via string-concatenated query",
|
|
||||||
"User input is concatenated directly into a SQL statement, allowing SQL injection.",
|
|
||||||
),
|
|
||||||
mk(
|
|
||||||
"app/config.py",
|
|
||||||
2,
|
|
||||||
"Hardcoded API credential in source",
|
|
||||||
"A live API key is hardcoded in source code instead of a secret store.",
|
|
||||||
),
|
|
||||||
mk(
|
|
||||||
"app/net.py",
|
|
||||||
4,
|
|
||||||
"TLS certificate verification disabled",
|
|
||||||
"requests is called with verify=False, disabling TLS certificate validation.",
|
|
||||||
),
|
|
||||||
mk(
|
|
||||||
"app/ser.py",
|
|
||||||
3,
|
|
||||||
"Insecure deserialization with pickle.loads",
|
|
||||||
"Untrusted data is deserialized with pickle.loads, allowing remote code execution.",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let tagged =
|
|
||||||
compliance_agent::controls::semantic_stamp_findings(&config, llm, &repo, &mut findings)
|
|
||||||
.await;
|
|
||||||
println!("\n=== C5 example 2: varied findings ===");
|
|
||||||
for f in &findings {
|
|
||||||
println!(" {:52} -> {:?}", f.title, f.control_refs);
|
|
||||||
}
|
|
||||||
println!("tagged: {tagged}/4");
|
|
||||||
let _ = std::fs::remove_dir_all(&repo);
|
|
||||||
assert!(tagged >= 1);
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
//! C5 live verification — the semantic master-controls path end to end against the
|
|
||||||
//! deployed api-dev catalog. Ignored (hits api-dev + LiteLLM). Run explicitly:
|
|
||||||
//!
|
|
||||||
//! set -a; . ./.env; set +a
|
|
||||||
//! BREAKPILOT_BASE_URL=https://api-dev.breakpilot.ai \
|
|
||||||
//! cargo test -p compliance-agent --test c5_semantic_live -- --ignored --nocapture
|
|
||||||
//!
|
|
||||||
//! Pulls the live master-controls catalog, embeds the corpus (chunked), then for a
|
|
||||||
//! couple of real vulnerable findings retrieves the nearest master controls and
|
|
||||||
//! grounded-judges them, stamping master-control refs.
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use compliance_agent::llm::LlmClient;
|
|
||||||
use compliance_core::config::BreakpilotConfig;
|
|
||||||
use compliance_core::models::finding::{Finding, Severity};
|
|
||||||
use compliance_core::models::scan::ScanType;
|
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
fn env(k: &str) -> String {
|
|
||||||
std::env::var(k).unwrap_or_else(|_| panic!("env {k} must be set for the live C5 test"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mk_finding(file: &str, line: u32, title: &str) -> Finding {
|
|
||||||
let mut f = Finding::new(
|
|
||||||
"repo-c5".into(),
|
|
||||||
format!("{file}:{line}"),
|
|
||||||
"semgrep".into(),
|
|
||||||
ScanType::Sast,
|
|
||||||
title.into(),
|
|
||||||
title.into(),
|
|
||||||
Severity::High,
|
|
||||||
);
|
|
||||||
f.file_path = Some(file.into());
|
|
||||||
f.line_number = Some(line);
|
|
||||||
f
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "live: requires deployed api-dev master-controls (fetch+parse only, no LLM)"]
|
|
||||||
async fn c5_ingest_master_controls_catalog() {
|
|
||||||
use compliance_agent::controls::OscalControlsProvider;
|
|
||||||
|
|
||||||
let provider = OscalControlsProvider::new(
|
|
||||||
reqwest::Client::new(),
|
|
||||||
env("BREAKPILOT_BASE_URL"),
|
|
||||||
None,
|
|
||||||
std::env::temp_dir().join("c5-ingest-snap"),
|
|
||||||
);
|
|
||||||
let doc = provider
|
|
||||||
.load_master_controls()
|
|
||||||
.await
|
|
||||||
.expect("pull + parse master-controls catalog");
|
|
||||||
let controls = doc.to_controls();
|
|
||||||
println!(
|
|
||||||
"\n=== C5 ingest: {} master controls parsed ===",
|
|
||||||
controls.len()
|
|
||||||
);
|
|
||||||
for c in controls.iter().take(4) {
|
|
||||||
let text: String = c.text.chars().take(90).collect();
|
|
||||||
println!(" {} | {} | {}", c.id, c.title, text);
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
!controls.is_empty(),
|
|
||||||
"expected a non-empty master-control corpus"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "live: requires deployed api-dev master-controls + LiteLLM"]
|
|
||||||
async fn c5_semantic_stamps_master_control_refs() {
|
|
||||||
let llm = Arc::new(LlmClient::new(
|
|
||||||
env("LITELLM_URL"),
|
|
||||||
SecretString::from(env("LITELLM_API_KEY")),
|
|
||||||
env("LITELLM_MODEL"),
|
|
||||||
env("LITELLM_EMBED_MODEL"),
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut config = common::dev_config("mongodb://unused".into(), "c5".into());
|
|
||||||
let snapshot = std::env::temp_dir().join("c5-oscal-snap");
|
|
||||||
config.breakpilot = BreakpilotConfig {
|
|
||||||
base_url: Some(env("BREAKPILOT_BASE_URL")),
|
|
||||||
token: None,
|
|
||||||
snapshot_dir: snapshot.to_string_lossy().into_owned(),
|
|
||||||
semantic_mapping: true,
|
|
||||||
grounded_control_checks: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fixture repo with recognizable code-checkable surfaces.
|
|
||||||
let repo = std::env::temp_dir().join("c5-fixture-repo");
|
|
||||||
let _ = std::fs::remove_dir_all(&repo);
|
|
||||||
std::fs::create_dir_all(repo.join("app")).expect("mkdir");
|
|
||||||
std::fs::write(
|
|
||||||
repo.join("app/auth.py"),
|
|
||||||
concat!(
|
|
||||||
"import hashlib\n",
|
|
||||||
"\n",
|
|
||||||
"def store_password(user, password):\n",
|
|
||||||
" # weak, unsalted password hashing\n",
|
|
||||||
" digest = hashlib.md5(password.encode()).hexdigest()\n",
|
|
||||||
" db.save(user, digest)\n",
|
|
||||||
"\n",
|
|
||||||
"@app.route('/login', methods=['POST'])\n",
|
|
||||||
"def login():\n",
|
|
||||||
" u = request.form['username']\n",
|
|
||||||
" p = request.form['password']\n",
|
|
||||||
" return 'ok' if check(u, p) else ('bad', 401)\n",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.expect("write fixture");
|
|
||||||
|
|
||||||
let mut findings = vec![
|
|
||||||
mk_finding("app/auth.py", 5, "Weak password hash (md5, unsalted)"),
|
|
||||||
mk_finding(
|
|
||||||
"app/auth.py",
|
|
||||||
9,
|
|
||||||
"Login endpoint without brute-force protection",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let tagged =
|
|
||||||
compliance_agent::controls::semantic_stamp_findings(&config, llm, &repo, &mut findings)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
println!("\n=== C5 semantic master-controls stamping ===");
|
|
||||||
for f in &findings {
|
|
||||||
println!(
|
|
||||||
" {:50} {}:{:?} -> {:?}",
|
|
||||||
f.title,
|
|
||||||
f.file_path.as_deref().unwrap_or(""),
|
|
||||||
f.line_number,
|
|
||||||
f.control_refs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!("findings that gained >=1 master-control ref: {tagged}");
|
|
||||||
let _ = std::fs::remove_dir_all(&repo);
|
|
||||||
|
|
||||||
// Live corpus — assert only that the path runs and stamps at least one ref.
|
|
||||||
assert!(
|
|
||||||
tagged >= 1,
|
|
||||||
"expected at least one finding to gain a master-control ref"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -60,7 +60,6 @@ pub fn dev_config(mongodb_uri: String, db_name: String) -> AgentConfig {
|
|||||||
tenant_registry_url: None,
|
tenant_registry_url: None,
|
||||||
plc_runtime: compliance_core::PlcRuntimeConfig::default(),
|
plc_runtime: compliance_core::PlcRuntimeConfig::default(),
|
||||||
werkbank_runner_token: Some(SecretString::from(TEST_RUNNER_TOKEN.to_string())),
|
werkbank_runner_token: Some(SecretString::from(TEST_RUNNER_TOKEN.to_string())),
|
||||||
breakpilot: compliance_core::config::BreakpilotConfig::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
//! Live validation of the grounded surface path (Stage 5d) for absence-based CRA
|
|
||||||
//! controls. Ignored (hits api-dev CRA catalog + LiteLLM). Run:
|
|
||||||
//! export ... (LITELLM_* + BREAKPILOT_BASE_URL)
|
|
||||||
//! cargo test -p compliance-agent --test grounded_surface_live -- --ignored --nocapture
|
|
||||||
//!
|
|
||||||
//! Builds a fixture whose code surfaces trigger several absence-based controls
|
|
||||||
//! (no rate limiting, no security logging, unverified update) and checks that the
|
|
||||||
//! grounded checker produces control-tagged findings.
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use compliance_agent::llm::LlmClient;
|
|
||||||
use compliance_core::config::BreakpilotConfig;
|
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
fn env(k: &str) -> String {
|
|
||||||
std::env::var(k).unwrap_or_else(|_| panic!("env {k} must be set"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write(repo: &std::path::Path, rel: &str, body: &str) {
|
|
||||||
let p = repo.join(rel);
|
|
||||||
if let Some(parent) = p.parent() {
|
|
||||||
std::fs::create_dir_all(parent).unwrap();
|
|
||||||
}
|
|
||||||
std::fs::write(p, body).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore = "live: api-dev CRA catalog + LiteLLM"]
|
|
||||||
async fn grounded_surface_flags_absence_controls() {
|
|
||||||
let llm = Arc::new(LlmClient::new(
|
|
||||||
env("LITELLM_URL"),
|
|
||||||
SecretString::from(env("LITELLM_API_KEY")),
|
|
||||||
env("LITELLM_MODEL"),
|
|
||||||
env("LITELLM_EMBED_MODEL"),
|
|
||||||
));
|
|
||||||
let mut config = common::dev_config("mongodb://unused".into(), "grounded".into());
|
|
||||||
config.breakpilot = BreakpilotConfig {
|
|
||||||
base_url: Some(env("BREAKPILOT_BASE_URL")),
|
|
||||||
token: None,
|
|
||||||
snapshot_dir: std::env::temp_dir()
|
|
||||||
.join("grounded-snap")
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned(),
|
|
||||||
semantic_mapping: false,
|
|
||||||
grounded_control_checks: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let repo = std::env::temp_dir().join("grounded-fixture-repo");
|
|
||||||
let _ = std::fs::remove_dir_all(&repo);
|
|
||||||
// cra-ai-11: login endpoint with no rate limiting / lockout
|
|
||||||
write(
|
|
||||||
&repo,
|
|
||||||
"app/auth.py",
|
|
||||||
"@app.route('/login', methods=['POST'])\ndef login():\n u = request.form['username']\n p = request.form['password']\n if authenticate(u, p):\n return redirect('/')\n return 'bad credentials', 401\n",
|
|
||||||
);
|
|
||||||
// cra-ai-24: privileged admin action with no security/audit logging
|
|
||||||
write(
|
|
||||||
&repo,
|
|
||||||
"app/admin.py",
|
|
||||||
"@app.route('/admin/delete_user', methods=['POST'])\ndef admin_delete_user():\n uid = request.form['uid']\n db.users.delete_one({'_id': uid})\n return 'ok', 200\n",
|
|
||||||
);
|
|
||||||
// cra-ai-28/29/30: firmware update applied without signature / checksum verification
|
|
||||||
write(
|
|
||||||
&repo,
|
|
||||||
"app/updater.py",
|
|
||||||
"def apply_firmware_update(url):\n blob = download(url)\n install_firmware(blob)\n reboot_device()\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
let findings =
|
|
||||||
compliance_agent::controls::grounded_surface_findings(&config, llm, &repo, "repo-grounded")
|
|
||||||
.await;
|
|
||||||
|
|
||||||
println!("\n=== Grounded surface findings ({}) ===", findings.len());
|
|
||||||
for f in &findings {
|
|
||||||
println!(
|
|
||||||
" {:24} {}:{:?} {}",
|
|
||||||
f.control_refs.join(","),
|
|
||||||
f.file_path.as_deref().unwrap_or(""),
|
|
||||||
f.line_number,
|
|
||||||
f.title
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_dir_all(&repo);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!findings.is_empty(),
|
|
||||||
"expected the grounded pass to flag at least one absence-based control"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ mod common;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::routing::{get, post};
|
use axum::routing::post;
|
||||||
use axum::{middleware, Extension, Router};
|
use axum::{middleware, Extension, Router};
|
||||||
|
|
||||||
use compliance_agent::agent::ComplianceAgent;
|
use compliance_agent::agent::ComplianceAgent;
|
||||||
@@ -19,9 +19,7 @@ use compliance_agent::api::handlers::werkbank_jobs;
|
|||||||
use compliance_agent::database::DatabasePool;
|
use compliance_agent::database::DatabasePool;
|
||||||
use compliance_agent::werkbank::JobQueue;
|
use compliance_agent::werkbank::JobQueue;
|
||||||
use compliance_core::models::werkbank::{InputRef, Job, JobResult, JobStatus, LeasedJob};
|
use compliance_core::models::werkbank::{InputRef, Job, JobResult, JobStatus, LeasedJob};
|
||||||
use compliance_core::models::{
|
use compliance_core::models::{Finding, ScanType, Severity};
|
||||||
Artifact, Finding, OnboardedTarget, PlcFormat, ScanType, Severity, TargetType,
|
|
||||||
};
|
|
||||||
|
|
||||||
use common::{dev_config, TEST_RUNNER_TOKEN};
|
use common::{dev_config, TEST_RUNNER_TOKEN};
|
||||||
|
|
||||||
@@ -60,14 +58,6 @@ async fn start() -> Option<Harness> {
|
|||||||
"/api/v1/werkbank/jobs/complete",
|
"/api/v1/werkbank/jobs/complete",
|
||||||
post(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(middleware::from_fn(werkbank_jobs::require_runner_token))
|
||||||
.layer(Extension(Arc::new(agent)));
|
.layer(Extension(Arc::new(agent)));
|
||||||
|
|
||||||
@@ -187,65 +177,6 @@ async fn lease_complete_persists_findings_against_the_target() {
|
|||||||
h.cleanup().await;
|
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]
|
#[tokio::test]
|
||||||
async fn empty_queue_leases_nothing() {
|
async fn empty_queue_leases_nothing() {
|
||||||
let Some(h) = start().await else { return };
|
let Some(h) = start().await else { return };
|
||||||
|
|||||||
@@ -58,46 +58,6 @@ pub struct AgentConfig {
|
|||||||
/// jobs — NOT a Keycloak JWT, since a runner acts across tenants. When
|
/// jobs — NOT a Keycloak JWT, since a runner acts across tenants. When
|
||||||
/// `None`, those endpoints are not mounted at all.
|
/// `None`, those endpoints are not mounted at all.
|
||||||
pub werkbank_runner_token: Option<SecretString>,
|
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,
|
|
||||||
/// Enable the master-controls **semantic** mapping pass (embed regions,
|
|
||||||
/// Enable the master-controls **semantic** mapping pass (embed regions,
|
|
||||||
/// retrieve nearest controls, grounded-judge). On by default — validated live
|
|
||||||
/// against the deployed master-controls catalog. Still a no-op unless
|
|
||||||
/// `base_url` is set and the catalog is reachable.
|
|
||||||
pub semantic_mapping: bool,
|
|
||||||
/// Enable the **grounded surface** pass for absence-based controls (retrieve
|
|
||||||
/// the code surface a control governs, judge whether it holds). On by default
|
|
||||||
/// — validated live; it covers the 8 absence-based CRA controls that no
|
|
||||||
/// syntactic rule can.
|
|
||||||
pub grounded_control_checks: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for BreakpilotConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
base_url: None,
|
|
||||||
token: None,
|
|
||||||
snapshot_dir: "/data/compliance-scanner/oscal".to_string(),
|
|
||||||
semantic_mapping: true,
|
|
||||||
grounded_control_checks: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration for the ephemeral soft-PLC "provision-and-test" path (#183).
|
/// Configuration for the ephemeral soft-PLC "provision-and-test" path (#183).
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
//! Grounded control-driven checking.
|
|
||||||
//!
|
|
||||||
//! Turns a *text* control into findings via an LLM used as a **pattern-recognizer**
|
|
||||||
//! whose output is grounded to real code — so a hallucinated finding cannot
|
|
||||||
//! survive. Determinism is structural, not a prompt plea:
|
|
||||||
//!
|
|
||||||
//! 1. the LLM only ever judges *retrieved* regions — it can't invent findings in
|
|
||||||
//! code it never saw;
|
|
||||||
//! 2. a verdict becomes a finding only if its quoted snippet appears **verbatim**
|
|
||||||
//! in the region, and the line is recomputed from that match — the model's own
|
|
||||||
//! line number is never trusted ([`ground`]);
|
|
||||||
//! 3. verdicts are cached by content hash ([`cache_key`]) so re-scans reproduce.
|
|
||||||
//!
|
|
||||||
//! The LLM supplies cross-language / cross-stack pattern recognition; this module
|
|
||||||
//! supplies the determinism.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::models::finding::{Finding, Severity};
|
|
||||||
use crate::models::scan::ScanType;
|
|
||||||
|
|
||||||
/// A control rendered as a check the LLM judges code against.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ControlCheckSpec {
|
|
||||||
/// Stable control id, e.g. `"cra-ai-8"`.
|
|
||||||
pub control_id: String,
|
|
||||||
/// Short control title (used in the finding title).
|
|
||||||
pub title: String,
|
|
||||||
/// The requirement text the LLM judges against (control objective/statement).
|
|
||||||
pub requirement: String,
|
|
||||||
/// CWE to fall back to when the model doesn't supply one.
|
|
||||||
pub default_cwe: Option<String>,
|
|
||||||
/// Severity for findings raised from this control.
|
|
||||||
pub severity: Severity,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A retrieved code region the LLM judges — never the whole repo.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct CandidateRegion {
|
|
||||||
/// Repo-relative path.
|
|
||||||
pub file: String,
|
|
||||||
/// 1-based line number of the region's first line in `file`.
|
|
||||||
pub start_line: u32,
|
|
||||||
/// The region's source text.
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The LLM's structured verdict for one (control, region). `snippet` is the
|
|
||||||
/// verbatim code the model claims proves the violation — it is the anchor the
|
|
||||||
/// grounding gate checks.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct LlmVerdict {
|
|
||||||
pub violates: bool,
|
|
||||||
pub snippet: String,
|
|
||||||
pub cwe: Option<String>,
|
|
||||||
pub confidence: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The grounding gate. A verdict becomes a [`Finding`] only if it claims a
|
|
||||||
/// violation AND its quoted `snippet` appears verbatim in `region.content`; the
|
|
||||||
/// finding's line is computed from the match, so a fabricated or mis-located
|
|
||||||
/// snippet is dropped. Pure — no LLM, no I/O.
|
|
||||||
pub fn ground(
|
|
||||||
spec: &ControlCheckSpec,
|
|
||||||
region: &CandidateRegion,
|
|
||||||
verdict: &LlmVerdict,
|
|
||||||
repo_id: &str,
|
|
||||||
) -> Option<Finding> {
|
|
||||||
if !verdict.violates {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let snippet = verdict.snippet.trim();
|
|
||||||
if snippet.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Grounding: the quoted snippet must literally exist in the retrieved region.
|
|
||||||
let pos = region.content.find(snippet)?;
|
|
||||||
// Recompute the real line from the match — never trust the model's number.
|
|
||||||
let newlines_before = region.content[..pos].matches('\n').count();
|
|
||||||
let line = region.start_line + newlines_before as u32;
|
|
||||||
|
|
||||||
let mut finding = Finding::new(
|
|
||||||
repo_id.to_string(),
|
|
||||||
control_finding_fingerprint(&spec.control_id, ®ion.file, snippet),
|
|
||||||
"control-check".to_string(),
|
|
||||||
ScanType::CodeReview,
|
|
||||||
format!("{}: {}", spec.control_id, spec.title),
|
|
||||||
format!(
|
|
||||||
"Control {} appears violated ({}) at {}:{line}",
|
|
||||||
spec.control_id, spec.requirement, region.file
|
|
||||||
),
|
|
||||||
spec.severity.clone(),
|
|
||||||
);
|
|
||||||
finding.cwe = verdict.cwe.clone().or_else(|| spec.default_cwe.clone());
|
|
||||||
finding.file_path = Some(region.file.clone());
|
|
||||||
finding.line_number = Some(line);
|
|
||||||
finding.code_snippet = Some(snippet.to_string());
|
|
||||||
finding.confidence = Some(verdict.confidence);
|
|
||||||
// Carry the control reference on the finding.
|
|
||||||
finding.control_refs = vec![spec.control_id.clone()];
|
|
||||||
Some(finding)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deterministic cache key for a (control, region, model, prompt-version) verdict
|
|
||||||
/// so identical inputs reproduce the same verdict without another LLM call.
|
|
||||||
pub fn cache_key(
|
|
||||||
control_id: &str,
|
|
||||||
region_content: &str,
|
|
||||||
model: &str,
|
|
||||||
prompt_version: &str,
|
|
||||||
) -> String {
|
|
||||||
hash_parts(&[control_id, region_content, model, prompt_version])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn control_finding_fingerprint(control_id: &str, file: &str, snippet: &str) -> String {
|
|
||||||
hash_parts(&[control_id, file, snippet])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hash_parts(parts: &[&str]) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
for part in parts {
|
|
||||||
hasher.update(part.as_bytes());
|
|
||||||
hasher.update([0u8]); // domain separator between parts
|
|
||||||
}
|
|
||||||
hex::encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn spec() -> ControlCheckSpec {
|
|
||||||
ControlCheckSpec {
|
|
||||||
control_id: "cra-ai-8".into(),
|
|
||||||
title: "No default passwords".into(),
|
|
||||||
requirement: "Products must not ship default credentials".into(),
|
|
||||||
default_cwe: Some("CWE-798".into()),
|
|
||||||
severity: Severity::High,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn region() -> CandidateRegion {
|
|
||||||
CandidateRegion {
|
|
||||||
file: "src/auth.py".into(),
|
|
||||||
start_line: 10,
|
|
||||||
content: "def login():\n PASSWORD = \"admin123\"\n return PASSWORD\n".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grounds_real_snippet_with_recomputed_line() {
|
|
||||||
let v = LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: "PASSWORD = \"admin123\"".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
};
|
|
||||||
let f = ground(&spec(), ®ion(), &v, "repo").expect("should ground");
|
|
||||||
assert_eq!(f.line_number, Some(11)); // 2nd line of a region starting at 10
|
|
||||||
assert_eq!(f.cwe.as_deref(), Some("CWE-798")); // fell back to the spec default
|
|
||||||
assert_eq!(f.control_refs, vec!["cra-ai-8".to_string()]); // control ref carried
|
|
||||||
assert_eq!(f.file_path.as_deref(), Some("src/auth.py"));
|
|
||||||
assert_eq!(f.code_snippet.as_deref(), Some("PASSWORD = \"admin123\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drops_fabricated_snippet_not_in_region() {
|
|
||||||
let v = LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: "SECRET = \"totally-made-up\"".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.99,
|
|
||||||
};
|
|
||||||
assert!(ground(&spec(), ®ion(), &v, "repo").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn drops_non_violation_and_empty_snippet() {
|
|
||||||
let no = LlmVerdict {
|
|
||||||
violates: false,
|
|
||||||
snippet: "PASSWORD = \"admin123\"".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
};
|
|
||||||
assert!(ground(&spec(), ®ion(), &no, "repo").is_none());
|
|
||||||
let empty = LlmVerdict {
|
|
||||||
violates: true,
|
|
||||||
snippet: " ".into(),
|
|
||||||
cwe: None,
|
|
||||||
confidence: 0.9,
|
|
||||||
};
|
|
||||||
assert!(ground(&spec(), ®ion(), &empty, "repo").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cache_key_and_fingerprint_are_deterministic() {
|
|
||||||
assert_eq!(cache_key("c", "x", "m", "v"), cache_key("c", "x", "m", "v"));
|
|
||||||
assert_ne!(cache_key("c", "x", "m", "v"), cache_key("c", "y", "m", "v"));
|
|
||||||
assert_eq!(
|
|
||||||
control_finding_fingerprint("c", "f", "s"),
|
|
||||||
control_finding_fingerprint("c", "f", "s")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod control_check;
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|||||||
@@ -76,10 +76,6 @@ pub struct Finding {
|
|||||||
pub triage_rationale: Option<String>,
|
pub triage_rationale: Option<String>,
|
||||||
/// Developer feedback on finding quality
|
/// Developer feedback on finding quality
|
||||||
pub developer_feedback: Option<String>,
|
pub developer_feedback: Option<String>,
|
||||||
/// Compliance control ids this finding is evidence for (stamped by control
|
|
||||||
/// triage against the `control-map` LUT). Empty when unmapped.
|
|
||||||
#[serde(default)]
|
|
||||||
pub control_refs: Vec<String>,
|
|
||||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||||
@@ -122,7 +118,6 @@ impl Finding {
|
|||||||
triage_action: None,
|
triage_action: None,
|
||||||
triage_rationale: None,
|
triage_rationale: None,
|
||||||
developer_feedback: None,
|
developer_feedback: None,
|
||||||
control_refs: Vec::new(),
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ pub mod mcp;
|
|||||||
pub mod mcp_token;
|
pub mod mcp_token;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
pub mod oscal;
|
|
||||||
pub mod oscal_assessment;
|
|
||||||
pub mod pentest;
|
pub mod pentest;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
@@ -41,8 +39,6 @@ pub use onboarding::{
|
|||||||
GitArtifactConfig, IssueTrackerConfig, OnboardedTarget, PlcArtifactConfig, PlcFormat,
|
GitArtifactConfig, IssueTrackerConfig, OnboardedTarget, PlcArtifactConfig, PlcFormat,
|
||||||
TargetScanConfig, TargetType, TargetTypeCandidate, WebArtifactConfig,
|
TargetScanConfig, TargetType, TargetTypeCandidate, WebArtifactConfig,
|
||||||
};
|
};
|
||||||
pub use oscal::OscalDocument;
|
|
||||||
pub use oscal_assessment::{assess, AssessmentResultsDoc, ControlLinker};
|
|
||||||
pub use pentest::{
|
pub use pentest::{
|
||||||
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
|
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
|
||||||
PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats,
|
PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats,
|
||||||
|
|||||||
@@ -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,424 +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, FindingStatus};
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a standard OSCAL assessment-results document from `findings`, using each
|
|
||||||
/// finding's stamped `control_refs` for control linkage. EVERY non-false-positive
|
|
||||||
/// finding is emitted as an observation — mapped findings additionally produce a
|
|
||||||
/// per-control `not-satisfied` finding; **unmapped findings are reported as-is**
|
|
||||||
/// (an observation carrying their CWE/tool/severity, with no control target) so
|
|
||||||
/// nothing is lost. `at` is the assessment timestamp.
|
|
||||||
pub fn assess(findings: &[Finding], 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();
|
|
||||||
let mut mapped = 0usize;
|
|
||||||
let mut unmapped = 0usize;
|
|
||||||
|
|
||||||
for finding in findings {
|
|
||||||
if finding.status == FindingStatus::FalsePositive {
|
|
||||||
continue; // flagged tool false positive — excluded from the report
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
let is_mapped = !finding.control_refs.is_empty();
|
|
||||||
if is_mapped {
|
|
||||||
mapped += 1;
|
|
||||||
} else {
|
|
||||||
unmapped += 1;
|
|
||||||
}
|
|
||||||
let mut props = vec![
|
|
||||||
ObsProp::new("tool", &finding.scanner),
|
|
||||||
ObsProp::new("severity", &finding.severity.to_string()),
|
|
||||||
ObsProp::new("mapping", if is_mapped { "mapped" } else { "unmapped" }),
|
|
||||||
];
|
|
||||||
if let Some(cwe) = &finding.cwe {
|
|
||||||
props.push(ObsProp::new("cwe", cwe));
|
|
||||||
}
|
|
||||||
observations.push(Observation {
|
|
||||||
uuid: obs_uuid.clone(),
|
|
||||||
title: finding.title.clone(),
|
|
||||||
description: finding.description.clone(),
|
|
||||||
methods: vec!["TEST".to_string()],
|
|
||||||
collected: ts.clone(),
|
|
||||||
props,
|
|
||||||
relevant_evidence: vec![RelevantEvidence {
|
|
||||||
href: location.map(|l| format!("file://{l}")),
|
|
||||||
description: format!("[{}] {}", finding.scanner, finding.title),
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
for control_id in &finding.control_refs {
|
|
||||||
obs_by_control
|
|
||||||
.entry(control_id.clone())
|
|
||||||
.or_default()
|
|
||||||
.push(obs_uuid.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut hit_controls: Vec<&String> = obs_by_control.keys().collect();
|
|
||||||
hit_controls.sort();
|
|
||||||
let ar_findings: Vec<ArFinding> = hit_controls
|
|
||||||
.iter()
|
|
||||||
.map(|control_id| ArFinding {
|
|
||||||
uuid: det_uuid(&format!("finding:{control_id}")),
|
|
||||||
title: format!("Findings affect {control_id}"),
|
|
||||||
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: Vec<SelectControlById> = hit_controls
|
|
||||||
.iter()
|
|
||||||
.map(|c| SelectControlById {
|
|
||||||
control_id: (*c).clone(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let result = ArResult {
|
|
||||||
uuid: det_uuid("result:cra"),
|
|
||||||
title: "Automated code-compliance assessment".to_string(),
|
|
||||||
description: format!(
|
|
||||||
"{} observation(s): {mapped} control-linked, {unmapped} unmapped (as-is); {} control(s) affected",
|
|
||||||
observations.len(),
|
|
||||||
include_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 title: String,
|
|
||||||
pub description: String,
|
|
||||||
pub methods: Vec<String>,
|
|
||||||
pub collected: String,
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub props: Vec<ObsProp>,
|
|
||||||
#[serde(rename = "relevant-evidence", skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub relevant_evidence: Vec<RelevantEvidence>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A name/value observation property (cwe, tool, severity, mapping status). Lets an
|
|
||||||
/// unmapped finding be reported fully as-is.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct ObsProp {
|
|
||||||
pub name: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ObsProp {
|
|
||||||
fn new(name: &str, value: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.to_string(),
|
|
||||||
value: value.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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::scan::ScanType;
|
|
||||||
|
|
||||||
fn finding(fp: &str, cwe: Option<&str>, refs: &[&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.control_refs = refs.iter().map(|s| s.to_string()).collect();
|
|
||||||
f
|
|
||||||
}
|
|
||||||
|
|
||||||
fn at() -> DateTime<Utc> {
|
|
||||||
DateTime::parse_from_rfc3339("2026-07-20T00:00:00Z")
|
|
||||||
.unwrap()
|
|
||||||
.with_timezone(&Utc)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mapped_finding_becomes_control_finding() {
|
|
||||||
let doc = assess(&[finding("f1", Some("CWE-798"), &["cra-ai-8"])], 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.reviewed_controls.control_selections[0]
|
|
||||||
.include_controls
|
|
||||||
.len(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unmapped_finding_is_reported_as_is() {
|
|
||||||
let doc = assess(&[finding("f1", Some("CWE-319"), &[])], at());
|
|
||||||
let r = &doc.assessment_results.results[0];
|
|
||||||
assert_eq!(r.observations.len(), 1); // still emitted...
|
|
||||||
assert!(r.findings.is_empty()); // ...but no control finding
|
|
||||||
assert!(r.reviewed_controls.control_selections[0]
|
|
||||||
.include_controls
|
|
||||||
.is_empty());
|
|
||||||
let props: Vec<(&str, &str)> = r.observations[0]
|
|
||||||
.props
|
|
||||||
.iter()
|
|
||||||
.map(|p| (p.name.as_str(), p.value.as_str()))
|
|
||||||
.collect();
|
|
||||||
assert!(props.contains(&("mapping", "unmapped")));
|
|
||||||
assert!(props.contains(&("cwe", "CWE-319")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn false_positive_is_excluded() {
|
|
||||||
let mut f = finding("f1", Some("CWE-798"), &["cra-ai-8"]);
|
|
||||||
f.status = FindingStatus::FalsePositive;
|
|
||||||
let doc = assess(&[f], at());
|
|
||||||
assert!(doc.assessment_results.results[0].observations.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deterministic_and_valid_oscal() {
|
|
||||||
let mk = || {
|
|
||||||
vec![
|
|
||||||
finding("f1", Some("CWE-798"), &["cra-ai-8"]),
|
|
||||||
finding("f2", Some("CWE-319"), &[]),
|
|
||||||
]
|
|
||||||
};
|
|
||||||
let a = serde_json::to_string(&assess(&mk(), at())).unwrap();
|
|
||||||
let b = serde_json::to_string(&assess(&mk(), at())).unwrap();
|
|
||||||
assert_eq!(a, b);
|
|
||||||
assert!(a.contains("\"oscal-version\":\"1.1.2\""));
|
|
||||||
assert!(a.contains("\"not-satisfied\""));
|
|
||||||
assert!(a.contains("\"mapping\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -42,19 +42,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let pool_for_factory = pool.clone();
|
let pool_for_factory = pool.clone();
|
||||||
let service = StreamableHttpService::new(
|
let service = StreamableHttpService::new(
|
||||||
move || {
|
move || Ok(ComplianceMcpServer::new(pool_for_factory.clone())),
|
||||||
// The factory runs in the request task, still inside the bearer
|
|
||||||
// middleware's `TENANT_ID` scope, and BEFORE rmcp spawns the
|
|
||||||
// session task (which would lose the task_local). So bind the
|
|
||||||
// tenant into the session's server instance here, once.
|
|
||||||
let tenant_id = auth::current_tenant_id().ok_or_else(|| {
|
|
||||||
std::io::Error::other("no tenant context when creating MCP session")
|
|
||||||
})?;
|
|
||||||
Ok(ComplianceMcpServer::new(
|
|
||||||
pool_for_factory.clone(),
|
|
||||||
tenant_id,
|
|
||||||
))
|
|
||||||
},
|
|
||||||
Arc::new(LocalSessionManager::default()),
|
Arc::new(LocalSessionManager::default()),
|
||||||
StreamableHttpServerConfig::default(),
|
StreamableHttpServerConfig::default(),
|
||||||
);
|
);
|
||||||
@@ -81,11 +69,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
tenant_id = %synth_tenant,
|
tenant_id = %synth_tenant,
|
||||||
"stdio transport — using synthetic tenant id; DO NOT use in production"
|
"stdio transport — using synthetic tenant id; DO NOT use in production"
|
||||||
);
|
);
|
||||||
let server = ComplianceMcpServer::new(pool, synth_tenant);
|
let server = ComplianceMcpServer::new(pool);
|
||||||
let transport = rmcp::transport::stdio();
|
let transport = rmcp::transport::stdio();
|
||||||
use rmcp::ServiceExt;
|
use rmcp::ServiceExt;
|
||||||
let handle = server.serve(transport).await?;
|
auth::TENANT_ID
|
||||||
handle.waiting().await?;
|
.scope(synth_tenant, async {
|
||||||
|
let handle = server.serve(transport).await?;
|
||||||
|
handle.waiting().await?;
|
||||||
|
Ok::<_, Box<dyn std::error::Error>>(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -2,33 +2,37 @@ use rmcp::{
|
|||||||
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler,
|
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::auth::current_tenant_id;
|
||||||
use crate::database::{Database, DatabasePool};
|
use crate::database::{Database, DatabasePool};
|
||||||
use crate::tools::{dast, findings, oscal, pentest, sbom};
|
use crate::tools::{dast, findings, pentest, sbom};
|
||||||
|
|
||||||
pub struct ComplianceMcpServer {
|
pub struct ComplianceMcpServer {
|
||||||
pool: DatabasePool,
|
pool: DatabasePool,
|
||||||
/// Tenant this session serves. Bound once at session creation (the HTTP
|
|
||||||
/// factory reads the bearer-set tenant while still in the request scope;
|
|
||||||
/// stdio passes a synthetic id) — NOT a per-request `task_local`, which is
|
|
||||||
/// lost across the `tokio::spawn` that runs the Streamable-HTTP session.
|
|
||||||
tenant_id: String,
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ComplianceMcpServer {
|
impl ComplianceMcpServer {
|
||||||
/// The per-tenant `Database` for this session.
|
/// Resolve the per-tenant `Database` from the bearer-set
|
||||||
|
/// `task_local`. Every tool handler calls this; missing context
|
||||||
|
/// surfaces as `internal_error` because it means the auth
|
||||||
|
/// middleware was misconfigured (handler ran without scope).
|
||||||
fn tenant_db(&self) -> Result<Database, rmcp::ErrorData> {
|
fn tenant_db(&self) -> Result<Database, rmcp::ErrorData> {
|
||||||
Ok(self.pool.for_tenant_id(&self.tenant_id))
|
let tenant_id = current_tenant_id().ok_or_else(|| {
|
||||||
|
rmcp::ErrorData::internal_error(
|
||||||
|
"no tenant context — bearer middleware not in chain".to_string(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(self.pool.for_tenant_id(&tenant_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool_router]
|
#[tool_router]
|
||||||
impl ComplianceMcpServer {
|
impl ComplianceMcpServer {
|
||||||
pub fn new(pool: DatabasePool, tenant_id: String) -> Self {
|
pub fn new(pool: DatabasePool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
tenant_id,
|
|
||||||
tool_router: Self::tool_router(),
|
tool_router: Self::tool_router(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,17 +68,6 @@ impl ComplianceMcpServer {
|
|||||||
findings::findings_summary(&db, params).await
|
findings::findings_summary(&db, params).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
|
||||||
description = "Emit an OSCAL 1.1 assessment-results document for a repo's findings (mapped findings target their compliance controls; unmapped findings are reported as-is)"
|
|
||||||
)]
|
|
||||||
async fn oscal_assessment(
|
|
||||||
&self,
|
|
||||||
Parameters(params): Parameters<oscal::OscalAssessmentParams>,
|
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
|
||||||
let db = self.tenant_db()?;
|
|
||||||
oscal::oscal_assessment(&db, params).await
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SBOM ──────────────────────────────────────────────
|
// ── SBOM ──────────────────────────────────────────────
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod dast;
|
pub mod dast;
|
||||||
pub mod findings;
|
pub mod findings;
|
||||||
pub mod oscal;
|
|
||||||
pub mod pentest;
|
pub mod pentest;
|
||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
//! OSCAL assessment MCP tool.
|
|
||||||
//!
|
|
||||||
//! Emits a standard OSCAL assessment-results document for a repo's findings —
|
|
||||||
//! what breakpilot's scanner MCP client pulls. Mapped findings target their
|
|
||||||
//! compliance controls (via the stamped `control_refs`); unmapped findings are
|
|
||||||
//! reported as-is, so nothing is lost.
|
|
||||||
|
|
||||||
use mongodb::bson::doc;
|
|
||||||
use rmcp::{model::*, ErrorData as McpError};
|
|
||||||
use schemars::JsonSchema;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use compliance_core::models::oscal_assessment::assess;
|
|
||||||
use compliance_core::models::Finding;
|
|
||||||
|
|
||||||
use crate::database::Database;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
|
||||||
pub struct OscalAssessmentParams {
|
|
||||||
/// Repository / target id to assess.
|
|
||||||
pub repo_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn oscal_assessment(
|
|
||||||
db: &Database,
|
|
||||||
params: OscalAssessmentParams,
|
|
||||||
) -> Result<CallToolResult, McpError> {
|
|
||||||
let mut cursor = db
|
|
||||||
.findings()
|
|
||||||
.find(doc! { "repo_id": ¶ms.repo_id })
|
|
||||||
.await
|
|
||||||
.map_err(|e| McpError::internal_error(format!("DB error: {e}"), None))?;
|
|
||||||
|
|
||||||
let mut findings: Vec<Finding> = Vec::new();
|
|
||||||
while cursor
|
|
||||||
.advance()
|
|
||||||
.await
|
|
||||||
.map_err(|e| McpError::internal_error(format!("cursor error: {e}"), None))?
|
|
||||||
{
|
|
||||||
findings.push(
|
|
||||||
cursor
|
|
||||||
.deserialize_current()
|
|
||||||
.map_err(|e| McpError::internal_error(format!("deserialize error: {e}"), None))?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let document = assess(&findings, chrono::Utc::now());
|
|
||||||
let json = serde_json::to_string_pretty(&document)
|
|
||||||
.map_err(|e| McpError::internal_error(format!("json error: {e}"), None))?;
|
|
||||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "control-map"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"framework": "cra",
|
|
||||||
"controls": [
|
|
||||||
{
|
|
||||||
"control": "cra-ai-1",
|
|
||||||
"title": "Secure-by-Default-Konfiguration",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": [
|
|
||||||
"cra-ai-1-flask-debug-enabled",
|
|
||||||
"cra-ai-1-django-debug-true",
|
|
||||||
"cra-ai-1-tls-verify-disabled",
|
|
||||||
"cra-ai-1-cors-wildcard"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-2",
|
|
||||||
"title": "Minimale Angriffsflaeche",
|
|
||||||
"scans": [],
|
|
||||||
"note": "design property (minimal attack surface) — not derivable from local code patterns; architecture/threat-model review",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-3",
|
|
||||||
"title": "Sichere Systemarchitektur",
|
|
||||||
"scans": [],
|
|
||||||
"note": "design property (secure system architecture) — architecture review, not statically code-checkable",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-4",
|
|
||||||
"title": "Least-Privilege-Prinzip",
|
|
||||||
"scans": [],
|
|
||||||
"note": "design property (least-privilege) — deployment/IAM & architecture review, not a local code pattern",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-5",
|
|
||||||
"title": "Manipulationsschutz",
|
|
||||||
"scans": [],
|
|
||||||
"note": "design property (tamper protection) — hardware/runtime & operational control, not statically code-checkable",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-6",
|
|
||||||
"title": "Integritaetspruefung",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "grounded-control-check",
|
|
||||||
"scan_type": "code_review",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "covered by the grounded surface check (retrieve code surface + grounded LLM judge decides presence/absence); validated live",
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-7",
|
|
||||||
"title": "Starke Authentifizierung",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": [
|
|
||||||
"cra-ai-7-weak-password-hash"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-8",
|
|
||||||
"title": "Keine Default-Passwoerter",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "gitleaks",
|
|
||||||
"scan_type": "secret_detection",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [
|
|
||||||
"CWE-798",
|
|
||||||
"CWE-259"
|
|
||||||
],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-9",
|
|
||||||
"title": "Sicheres Credential-Management",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "gitleaks",
|
|
||||||
"scan_type": "secret_detection",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [
|
|
||||||
"CWE-798",
|
|
||||||
"CWE-522"
|
|
||||||
],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-10",
|
|
||||||
"title": "Sitzungsmanagement",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": [
|
|
||||||
"cra-ai-10-session-cookie-insecure",
|
|
||||||
"cra-ai-10-express-cookie-insecure"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-11",
|
|
||||||
"title": "Brute-Force-Schutz",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "grounded-control-check",
|
|
||||||
"scan_type": "code_review",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "covered by the grounded surface check (retrieve code surface + grounded LLM judge decides presence/absence); validated live",
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-12",
|
|
||||||
"title": "Rollenbasierte Autorisierung",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "grounded-control-check",
|
|
||||||
"scan_type": "code_review",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "covered by the grounded surface check (retrieve code surface + grounded LLM judge decides presence/absence); validated live",
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-13",
|
|
||||||
"title": "Verschluesselung sensibler Daten",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [
|
|
||||||
"CWE-327",
|
|
||||||
"CWE-326"
|
|
||||||
],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-14",
|
|
||||||
"title": "Speicher-Schutz (Data at Rest)",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": [
|
|
||||||
"cra-ai-14-python-weak-cipher",
|
|
||||||
"cra-ai-14-node-weak-cipher"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-15",
|
|
||||||
"title": "Transport-Schutz (Data in Transit)",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [
|
|
||||||
"CWE-319",
|
|
||||||
"CWE-311"
|
|
||||||
],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-16",
|
|
||||||
"title": "Sicheres Schluesselmanagement",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "gitleaks",
|
|
||||||
"scan_type": "secret_detection",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [
|
|
||||||
"CWE-798",
|
|
||||||
"CWE-321"
|
|
||||||
],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-17",
|
|
||||||
"title": "Datenminimierung",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-18",
|
|
||||||
"title": "Strukturierter SSDLC",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-19",
|
|
||||||
"title": "Systematische Code Reviews",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-20",
|
|
||||||
"title": "Automatisierte Sicherheitstests",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "semgrep",
|
|
||||||
"scan_type": "sast",
|
|
||||||
"cwe": [
|
|
||||||
"CWE-89",
|
|
||||||
"CWE-78",
|
|
||||||
"CWE-79",
|
|
||||||
"CWE-22"
|
|
||||||
],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-21",
|
|
||||||
"title": "Supply-Chain-Security",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-22",
|
|
||||||
"title": "Dependency-Monitoring",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "osv",
|
|
||||||
"scan_type": "cve",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tool": "syft",
|
|
||||||
"scan_type": "sbom",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-23",
|
|
||||||
"title": "Software Bill of Materials (SBOM)",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "syft",
|
|
||||||
"scan_type": "sbom",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-24",
|
|
||||||
"title": "Security-Logging",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "grounded-control-check",
|
|
||||||
"scan_type": "code_review",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "covered by the grounded surface check (retrieve code surface + grounded LLM judge decides presence/absence); validated live",
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-25",
|
|
||||||
"title": "Ereignis-Monitoring",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-26",
|
|
||||||
"title": "Anomalie-Erkennung",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-27",
|
|
||||||
"title": "Log-Integritaet und -Aufbewahrung",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "grounded-control-check",
|
|
||||||
"scan_type": "code_review",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "covered by the grounded surface check (retrieve code surface + grounded LLM judge decides presence/absence); validated live",
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-28",
|
|
||||||
"title": "Sichere Update-Mechanismen",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "grounded-control-check",
|
|
||||||
"scan_type": "code_review",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "covered by the grounded surface check (retrieve code surface + grounded LLM judge decides presence/absence); validated live",
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-29",
|
|
||||||
"title": "Update-Authentizitaet",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "grounded-control-check",
|
|
||||||
"scan_type": "code_review",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "covered by the grounded surface check (retrieve code surface + grounded LLM judge decides presence/absence); validated live",
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-30",
|
|
||||||
"title": "Update-Integritaet",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "grounded-control-check",
|
|
||||||
"scan_type": "code_review",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": "covered by the grounded surface check (retrieve code surface + grounded LLM judge decides presence/absence); validated live",
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-31",
|
|
||||||
"title": "Lifecycle-Support",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-32",
|
|
||||||
"title": "Schwachstellen-Identifikation",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-33",
|
|
||||||
"title": "SBOM-Pflege und Analyse",
|
|
||||||
"scans": [
|
|
||||||
{
|
|
||||||
"tool": "syft",
|
|
||||||
"scan_type": "sbom",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tool": "osv",
|
|
||||||
"scan_type": "cve",
|
|
||||||
"cwe": [],
|
|
||||||
"rules": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"note": null,
|
|
||||||
"status": "covered"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-34",
|
|
||||||
"title": "Risikobasierte Priorisierung",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-35",
|
|
||||||
"title": "Coordinated Vulnerability Disclosure",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-36",
|
|
||||||
"title": "Incident-Response-Prozess",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-37",
|
|
||||||
"title": "Fruehwarnung (24h)",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-38",
|
|
||||||
"title": "Detaillierter Vorfallsbericht (72h)",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-39",
|
|
||||||
"title": "Patch-Bereitstellung",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"control": "cra-ai-40",
|
|
||||||
"title": "Dokumentation und Nachbereitung",
|
|
||||||
"scans": [],
|
|
||||||
"note": "process / document control — outside static-scan scope",
|
|
||||||
"status": "not_code_checkable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
//! `control-map` — the deterministic control → scan lookup table (LUT).
|
|
||||||
//!
|
|
||||||
//! The "transcribing" layer: it maps each compliance control to the static-scan
|
|
||||||
//! step(s) that check it, or marks it as needing custom tooling, or as not
|
|
||||||
//! code-checkable at all. The map is **authored and human-reviewed** — no LLM
|
|
||||||
//! decides coverage. The LLM only enters later, downstream, to triage/ground the
|
|
||||||
//! *tool's* findings (that lives in the agent, not here).
|
|
||||||
//!
|
|
||||||
//! This crate is intentionally tiny and standalone: types + an embedded JSON LUT
|
|
||||||
//! + query helpers.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Coverage bucket for a control under static (SAST-family) scanning.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum Coverage {
|
|
||||||
/// An existing tool's scan surfaces findings for this control.
|
|
||||||
Covered,
|
|
||||||
/// Code-checkable, but no existing tool digs it out — we must write tooling.
|
|
||||||
NeedsTooling,
|
|
||||||
/// Process / document control — out of static-scan scope.
|
|
||||||
NotCodeCheckable,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One tool binding: a scan step that (at least partially) checks a control.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ScanBinding {
|
|
||||||
/// Tool name, e.g. `"semgrep"`, `"gitleaks"`, `"syft"`, `"osv"`.
|
|
||||||
pub tool: String,
|
|
||||||
/// Scan family, e.g. `"sast"`, `"secret_detection"`, `"sbom"`, `"cve"`.
|
|
||||||
pub scan_type: String,
|
|
||||||
/// CWEs whose findings map to this control (used to attach findings back).
|
|
||||||
#[serde(default)]
|
|
||||||
pub cwe: Vec<String>,
|
|
||||||
/// Optional specific rule ids this control keys on.
|
|
||||||
#[serde(default)]
|
|
||||||
pub rules: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One control's entry in the LUT.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ControlEntry {
|
|
||||||
/// Control id, e.g. `"cra-ai-8"`.
|
|
||||||
pub control: String,
|
|
||||||
/// Human-readable title (for the reviewable view).
|
|
||||||
#[serde(default)]
|
|
||||||
pub title: String,
|
|
||||||
/// Coverage bucket.
|
|
||||||
pub status: Coverage,
|
|
||||||
/// Tool bindings (empty unless `status == Covered`).
|
|
||||||
#[serde(default)]
|
|
||||||
pub scans: Vec<ScanBinding>,
|
|
||||||
/// Reviewer note — why it needs tooling / isn't code-checkable.
|
|
||||||
#[serde(default)]
|
|
||||||
pub note: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The control → scan lookup table for one framework.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ControlMap {
|
|
||||||
pub version: String,
|
|
||||||
pub framework: String,
|
|
||||||
pub controls: Vec<ControlEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const CRA_MAP_JSON: &str = include_str!("../data/cra_control_map.json");
|
|
||||||
|
|
||||||
/// Whether an authored rule id `bound` matches a scanner's emitted rule id
|
|
||||||
/// `actual`. semgrep prefixes local-rule check_ids with a path
|
|
||||||
/// (`tmp.compliance-cra-semgrep.cra-ai-1-flask-debug-enabled`), so match the final
|
|
||||||
/// id segment rather than requiring exact equality.
|
|
||||||
fn rule_id_matches(bound: &str, actual: &str) -> bool {
|
|
||||||
actual == bound || actual.ends_with(&format!(".{bound}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ControlMap {
|
|
||||||
/// Load the built-in CRA control map (the embedded, authored LUT).
|
|
||||||
pub fn cra() -> Result<Self, MapError> {
|
|
||||||
Ok(serde_json::from_str(CRA_MAP_JSON)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The coverage entry for a control id, if present.
|
|
||||||
pub fn coverage(&self, control_id: &str) -> Option<&ControlEntry> {
|
|
||||||
self.controls.iter().find(|c| c.control == control_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Controls whose bindings include the given `tool` + `cwe` — used to attach a
|
|
||||||
/// raw tool finding back to the control(s) it's evidence for.
|
|
||||||
pub fn controls_for(&self, tool: &str, cwe: &str) -> Vec<&ControlEntry> {
|
|
||||||
self.controls_for_finding(tool, Some(cwe), None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Controls a tool finding is evidence for, matched by CWE and/or the specific
|
|
||||||
/// rule id that fired. Off-the-shelf findings bind by CWE; our custom detectors
|
|
||||||
/// bind by rule id (precise — a broad CWE would over-attribute and then the
|
|
||||||
/// grounded judge could drop a genuine finding as a control false positive).
|
|
||||||
pub fn controls_for_finding(
|
|
||||||
&self,
|
|
||||||
tool: &str,
|
|
||||||
cwe: Option<&str>,
|
|
||||||
rule_id: Option<&str>,
|
|
||||||
) -> Vec<&ControlEntry> {
|
|
||||||
self.controls
|
|
||||||
.iter()
|
|
||||||
.filter(|c| {
|
|
||||||
c.scans.iter().any(|s| {
|
|
||||||
s.tool == tool
|
|
||||||
&& (cwe.is_some_and(|w| s.cwe.iter().any(|x| x == w))
|
|
||||||
|| rule_id
|
|
||||||
.is_some_and(|r| s.rules.iter().any(|b| rule_id_matches(b, r))))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count of controls in each coverage bucket.
|
|
||||||
pub fn summary(&self) -> CoverageSummary {
|
|
||||||
let mut s = CoverageSummary::default();
|
|
||||||
for c in &self.controls {
|
|
||||||
match c.status {
|
|
||||||
Coverage::Covered => s.covered += 1,
|
|
||||||
Coverage::NeedsTooling => s.needs_tooling += 1,
|
|
||||||
Coverage::NotCodeCheckable => s.not_code_checkable += 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coverage bucket counts.
|
|
||||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct CoverageSummary {
|
|
||||||
pub covered: usize,
|
|
||||||
pub needs_tooling: usize,
|
|
||||||
pub not_code_checkable: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CoverageSummary {
|
|
||||||
pub fn total(&self) -> usize {
|
|
||||||
self.covered + self.needs_tooling + self.not_code_checkable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors loading a control map.
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum MapError {
|
|
||||||
#[error("failed to parse control map: {0}")]
|
|
||||||
Parse(#[from] serde_json::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cra_map_loads_all_40_controls() {
|
|
||||||
let map = ControlMap::cra().expect("CRA map should parse");
|
|
||||||
assert_eq!(map.framework, "cra");
|
|
||||||
assert_eq!(map.controls.len(), 40);
|
|
||||||
assert_eq!(map.summary().total(), 40);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hardcoded_password_control_is_tool_covered() {
|
|
||||||
let map = ControlMap::cra().unwrap();
|
|
||||||
let c = map.coverage("cra-ai-8").expect("cra-ai-8 present");
|
|
||||||
assert_eq!(c.status, Coverage::Covered);
|
|
||||||
assert!(c.scans.iter().any(|s| s.tool == "semgrep"));
|
|
||||||
assert!(c.scans.iter().any(|s| s.tool == "gitleaks"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn finding_attaches_back_to_control_via_tool_and_cwe() {
|
|
||||||
let map = ControlMap::cra().unwrap();
|
|
||||||
let hits = map.controls_for("semgrep", "CWE-798");
|
|
||||||
assert!(hits.iter().any(|c| c.control == "cra-ai-8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn covered_and_not_checkable_are_populated() {
|
|
||||||
let s = ControlMap::cra().unwrap().summary();
|
|
||||||
assert!(s.covered > 0);
|
|
||||||
assert!(s.not_code_checkable > 0);
|
|
||||||
// needs_tooling is now empty: every code-checkable control is either
|
|
||||||
// tool-covered or covered by the grounded surface pass.
|
|
||||||
assert_eq!(s.needs_tooling, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rule_id_matching_handles_semgrep_path_prefix() {
|
|
||||||
let bound = "cra-ai-1-flask-debug-enabled";
|
|
||||||
assert!(rule_id_matches(bound, bound)); // exact
|
|
||||||
assert!(rule_id_matches(
|
|
||||||
bound,
|
|
||||||
"tmp.compliance-cra-semgrep.cra-ai-1-flask-debug-enabled"
|
|
||||||
)); // semgrep path prefix
|
|
||||||
assert!(!rule_id_matches(
|
|
||||||
bound,
|
|
||||||
"cra-ai-1-flask-debug-enabled-extra"
|
|
||||||
)); // not a suffix segment
|
|
||||||
assert!(!rule_id_matches(
|
|
||||||
bound,
|
|
||||||
"python.lang.security.exec-detected"
|
|
||||||
)); // unrelated
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_rule_finding_attaches_to_control_by_rule_id() {
|
|
||||||
let map = ControlMap::cra().unwrap();
|
|
||||||
// cra-ai-1 is now tool-covered by custom rules.
|
|
||||||
assert_eq!(map.coverage("cra-ai-1").unwrap().status, Coverage::Covered);
|
|
||||||
// A prefixed check_id still maps back to cra-ai-1 by rule id.
|
|
||||||
let hits =
|
|
||||||
map.controls_for_finding("semgrep", None, Some("tmp.x.cra-ai-1-tls-verify-disabled"));
|
|
||||||
assert!(hits.iter().any(|c| c.control == "cra-ai-1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn coverage_after_grounded_promotion() {
|
|
||||||
let s = ControlMap::cra().unwrap().summary();
|
|
||||||
// 9 off-the-shelf + 4 custom-semgrep + 8 grounded surface controls (promoted
|
|
||||||
// after the grounded path was validated live).
|
|
||||||
assert_eq!(s.covered, 21);
|
|
||||||
// Nothing left as needs_tooling — every code-checkable control is covered.
|
|
||||||
assert_eq!(s.needs_tooling, 0);
|
|
||||||
// The 4 pure-architectural controls remain not code-checkable.
|
|
||||||
assert_eq!(s.not_code_checkable, 19);
|
|
||||||
assert_eq!(s.total(), 40);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn architectural_controls_are_not_code_checkable() {
|
|
||||||
let map = ControlMap::cra().unwrap();
|
|
||||||
for id in ["cra-ai-2", "cra-ai-3", "cra-ai-4", "cra-ai-5"] {
|
|
||||||
let c = map.coverage(id).unwrap();
|
|
||||||
assert_eq!(c.status, Coverage::NotCodeCheckable, "{id}");
|
|
||||||
assert!(c.scans.is_empty(), "{id} should carry no scan bindings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn custom_rule_controls_do_not_bind_by_broad_cwe() {
|
|
||||||
let map = ControlMap::cra().unwrap();
|
|
||||||
// cra-ai-1 rules emit CWE-489 in metadata, but the LUT binds by rule id
|
|
||||||
// only (cwe: []) — so a stray CWE-489 finding must NOT attach to it.
|
|
||||||
assert!(map.controls_for("semgrep", "CWE-489").is_empty());
|
|
||||||
// The CWE path for off-the-shelf findings is unchanged.
|
|
||||||
assert!(map
|
|
||||||
.controls_for("semgrep", "CWE-798")
|
|
||||||
.iter()
|
|
||||||
.any(|c| c.control == "cra-ai-8"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ export default withMermaid(defineConfig({
|
|||||||
{ text: 'Adding Repositories', link: '/guide/repositories' },
|
{ text: 'Adding Repositories', link: '/guide/repositories' },
|
||||||
{ text: 'Running Scans', link: '/guide/scanning' },
|
{ text: 'Running Scans', link: '/guide/scanning' },
|
||||||
{ text: 'PLC / SPS (CODESYS)', link: '/guide/plc' },
|
{ text: 'PLC / SPS (CODESYS)', link: '/guide/plc' },
|
||||||
{ text: 'Demo Targets', link: '/guide/demo-targets' },
|
|
||||||
{ text: 'Understanding Findings', link: '/guide/findings' },
|
{ text: 'Understanding Findings', link: '/guide/findings' },
|
||||||
{ text: 'SBOM & Licenses', link: '/guide/sbom' },
|
{ text: 'SBOM & Licenses', link: '/guide/sbom' },
|
||||||
{ text: 'Issues & Tracking', link: '/guide/issues' },
|
{ text: 'Issues & Tracking', link: '/guide/issues' },
|
||||||
@@ -37,7 +36,6 @@ export default withMermaid(defineConfig({
|
|||||||
{ text: 'Pentest Architecture', link: '/features/pentest-architecture' },
|
{ text: 'Pentest Architecture', link: '/features/pentest-architecture' },
|
||||||
{ text: 'AI Chat', link: '/features/ai-chat' },
|
{ text: 'AI Chat', link: '/features/ai-chat' },
|
||||||
{ text: 'Code Knowledge Graph', link: '/features/graph' },
|
{ text: 'Code Knowledge Graph', link: '/features/graph' },
|
||||||
{ text: 'Compliance Control Mapping', link: '/features/control-mapping' },
|
|
||||||
{ text: 'MCP Integration', link: '/features/mcp-server' },
|
{ text: 'MCP Integration', link: '/features/mcp-server' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
# Compliance Control Mapping
|
|
||||||
|
|
||||||
Control mapping connects the scanner's raw output — deterministic tool findings and the code itself — to the **compliance controls** each piece of evidence supports. A hardcoded credential stops being just "CWE-798 from semgrep" and becomes evidence for *"cra-ai-8: no default passwords"* and, at scale, master control *`mc-31761` hardcoded_secrets_detection*. Findings carry those references (`control_refs`) into the dashboard and out over the MCP server as OSCAL, so the compliance report is built from real, grounded findings rather than a questionnaire.
|
|
||||||
|
|
||||||
## The core principle: tools detect, the LLM judges
|
|
||||||
|
|
||||||
The design has one rule, borrowed from the ZeroFalse / IRIS line of research: **deterministic tools are the detectors; the LLM is only ever a grounded false-positive filter, never the thing that finds the issue.**
|
|
||||||
|
|
||||||
- A tool (semgrep, gitleaks, syft/osv, the PLC linter; the DAST agents today, with **Nuclei and ZAP planned** as deterministic web/OT detectors underneath them — see [Tools & Scanners](/reference/tools#planned-integrations-decided-2026-08-31-not-yet-in-the-code)) detects.
|
|
||||||
- An **authored, human-reviewed lookup table** (`control-map`) maps that detection to the control(s) it's evidence for.
|
|
||||||
- The LLM enters last, to *confirm or refute* the mapping against the actual code — and every surviving verdict is anchored to a verbatim snippet by the grounding gate.
|
|
||||||
|
|
||||||
This keeps hallucination out of detection. The LLM supplies cross-language, cross-stack pattern *recognition*; the surrounding machinery supplies determinism.
|
|
||||||
|
|
||||||
## Coverage model
|
|
||||||
|
|
||||||
Every control lands in one of three buckets, recorded in the `control-map` LUT (`control-map/data/cra_control_map.json`) and never decided by an LLM:
|
|
||||||
|
|
||||||
| Bucket | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `covered` | An existing tool's scan surfaces findings for this control |
|
|
||||||
| `needs_tooling` | Code-checkable, but no off-the-shelf tool digs it out — we author a detector or use the grounded surface check |
|
|
||||||
| `not_code_checkable` | A design/process property — out of static-scan scope |
|
|
||||||
|
|
||||||
For the **CRA** framework (40 controls) the split is **13 covered · 8 needs_tooling · 19 not_code_checkable**. The 16 originally-uncovered controls were resolved as a hybrid:
|
|
||||||
|
|
||||||
- **4 custom semgrep detectors** (`cra-ai-1`, `7`, `10`, `14`) — secure-by-default, weak password hashing, insecure session cookies, weak data-at-rest ciphers. Shipped in the binary and matched back to controls **by rule id** so a broad CWE can't over-attribute.
|
|
||||||
- **8 grounded surface checks** (`cra-ai-6`, `11`, `12`, `24`, `27`, `28`, `29`, `30`) — the absence-based controls (no rate limiting, no security logging, no update-signature check…) that have no syntactic pattern.
|
|
||||||
- **4 marked not_code_checkable** (`cra-ai-2`, `3`, `4`, `5`) — minimal attack surface, secure architecture, least privilege, tamper protection.
|
|
||||||
|
|
||||||
At scale, the **master-controls** corpus (breakpilot's deduped clusters, exported as OSCAL) currently provides **~2,882 code-checkable controls** (2,143 `network` + 739 `source_code`), matched semantically.
|
|
||||||
|
|
||||||
## The three mapping paths
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
T[Deterministic tools\nsemgrep · gitleaks · syft/osv · DAST · PLC linter] --> F[Findings]
|
|
||||||
F --> B["Stage 5b — LUT triage\ncontrols_for(tool, cwe / rule_id)"]
|
|
||||||
F --> C["Stage 5c — Semantic\nembed region+intent → top-K master controls"]
|
|
||||||
R[Repo source] --> D["Stage 5d — Grounded surface\nretrieve surface for absence-based controls"]
|
|
||||||
B --> J{{Grounded LLM judge\ntemp 0 · verbatim snippet}}
|
|
||||||
C --> J
|
|
||||||
D --> J
|
|
||||||
J -->|snippet grounds in region| S[Stamp control_refs]
|
|
||||||
J -->|refuted / ungrounded| X[Dropped]
|
|
||||||
```
|
|
||||||
|
|
||||||
All three paths converge on the same **grounded judge** and the same **grounding gate**. They differ only in how candidate (finding/region, control) pairs are produced.
|
|
||||||
|
|
||||||
### Stage 5b — deterministic LUT triage
|
|
||||||
|
|
||||||
The default path. A tool finding is matched to controls via `control_map.controls_for_finding(tool, cwe, rule_id)`; the judge then confirms each mapped control against the code region. Outcomes: `Confirmed([ids])` (stamp them), `FalsePositive` (drop the finding), or `Unmapped` (keep it untagged). Runs whenever `BREAKPILOT_BASE_URL` is set.
|
|
||||||
|
|
||||||
### Stage 5c — semantic retrieval (master-controls scale)
|
|
||||||
|
|
||||||
Master controls carry no CWE, so they can't be LUT-mapped. Instead we map by *similarity*: embed every control's requirement text once (cached), then for each finding retrieve the top-K nearest controls and hand them to the judge. Gated behind `BREAKPILOT_SEMANTIC_MAPPING` (default off). See [Semantic retrieval](#semantic-retrieval-in-detail).
|
|
||||||
|
|
||||||
### Stage 5d — grounded surface checks (absence-based controls)
|
|
||||||
|
|
||||||
Some controls are violated by an *absence* — no rate limiting on login, no security logging, no signature check on an update. There's no pattern for semgrep to match, so we deterministically retrieve the code **surface** the control governs (a login route, a logging setup, update/download code) by identifier/route terms, and let the judge decide whether the control holds there. Produces net-new, already-grounded findings. Gated behind `BREAKPILOT_GROUNDED_CHECKS` (default off).
|
|
||||||
|
|
||||||
## The grounding gate
|
|
||||||
|
|
||||||
No matter the path, a verdict becomes a finding only if it survives `compliance_core::control_check::ground`:
|
|
||||||
|
|
||||||
1. The judge runs at **temperature 0** with a closed prompt and must quote the offending code **verbatim** into `snippet`.
|
|
||||||
2. That snippet must appear **literally** in the retrieved region — otherwise the verdict is dropped.
|
|
||||||
3. The finding's line is **recomputed from the match**; the model's own line number is never trusted.
|
|
||||||
4. Verdicts are cached by content hash, so re-scans reproduce.
|
|
||||||
|
|
||||||
The model is allowed to be smart; it is never trusted.
|
|
||||||
|
|
||||||
## Semantic retrieval in detail
|
|
||||||
|
|
||||||
1. **Embed the corpus once.** Each control's requirement text is embedded with `bge-multilingual-gemma2` (3584-dim — multilingual matters, the master controls are in German while code is English). The embedding backend caps input arrays at 25 per request, so `embed()` chunks at 16; the whole `ControlIndex` is persisted to `snapshot_dir` keyed by a **corpus hash**, so only the first scan after a catalog change pays the embedding cost.
|
|
||||||
2. **Build the query from the finding's intent, not just the code.** The retrieval query is `finding.title + finding.description + region`, not the raw region. This is the single most important tuning: two findings in one file share overlapping windows and, on the code alone, embed alike and collapse onto the same controls. The finding's own words ("brute-force protection" vs "weak hash") carry the discriminating signal. The raw region still goes to the judge for grounding.
|
|
||||||
3. **Retrieve → judge → ground.** Top-K nearest by cosine, each judged against the region, each grounded.
|
|
||||||
|
|
||||||
## Worked examples
|
|
||||||
|
|
||||||
Both examples are from the live end-to-end verification (`c5_semantic_live.rs`) against the real ~2,882-control corpus.
|
|
||||||
|
|
||||||
### Example 1 — a small auth file (the tuning story)
|
|
||||||
|
|
||||||
Two findings in one `auth.py`: a weak `hashlib.md5(password)` hash and a login endpoint with no brute-force protection.
|
|
||||||
|
|
||||||
| Finding | Region-only retrieval | Intent-enriched retrieval |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Weak md5 hash | 19874, 20683, 23149, 29985 | **`mc-23149`** (eliminate weak unsalted hashes) at rank 1, + `mc-21634` salted hashing |
|
|
||||||
| Login w/o brute-force protection | *identical 4, reordered* | newly surfaces **`mc-19984`** brute_force_protection + **`mc-23186`** account_lockout |
|
|
||||||
|
|
||||||
Region-only retrieval gave both findings the *same* four password-hashing controls — the brute-force finding never found its real controls because its window is saturated with `password` tokens. Enriching the query with the finding's intent fixed it: the brute-force finding now pulls the correct rate-limiting / lockout controls out of the 2,882.
|
|
||||||
|
|
||||||
### Example 2 — four topically distinct vulnerabilities
|
|
||||||
|
|
||||||
| Finding | Top matched controls | Family |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| SQL injection (string-concat query) | `sql_injection_prevention`, `sql_injection`, `parameterized_queries`, input_sanitization | input-validation ✓ |
|
|
||||||
| Hardcoded API credential | `hardcoded_secrets_detection`, credential_scanning, secrets_detection | credentials ✓ |
|
|
||||||
| TLS verification disabled (`verify=False`) | `https_enforcement`, `configuration_verification`, transport config | transport-encryption ✓ |
|
|
||||||
| Insecure deserialization (`pickle.loads`) | `deserialization`, `deserialization_testing`, `deserialization_security` | deserialization ✓ |
|
|
||||||
|
|
||||||
Every finding maps to its exact control family, with the most specific control often at the top, and the four sets are distinct.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- **Absence findings are weak for semantic retrieval.** Similarity matches what code *is about*, not what it *lacks*; a "missing rate limiting" finding embeds like login code. This is exactly why the grounded surface path (Stage 5d) exists — it decides presence/absence at a retrieved surface rather than by embedding distance.
|
|
||||||
- **Generic catch-all controls co-occur.** `mc-20890 secure_development_security_code_review` appears in the top-K for many code-security findings because it is semantically near almost all of them. It's harmless (the judge grounds it, and it never crowds out the specific controls — the SQLi example didn't get it) but is a candidate for future down-weighting.
|
|
||||||
- **Corpus classification noise.** The master-controls `verification_method` classification is imperfect — e.g. a documentation control (`eu_declaration_accuracy`) is currently tagged `source_code`. That's a corpus-side data-quality issue, separate from the mapping engine.
|
|
||||||
|
|
||||||
## Emitting over MCP — closing the loop
|
|
||||||
|
|
||||||
Findings don't just land in the dashboard; they flow to breakpilot-compliance as OSCAL over the scanner's MCP server, so the compliance report is assembled from real, control-tagged findings.
|
|
||||||
|
|
||||||
- The MCP server exposes an **`oscal_assessment`** tool: given a `repo_id`, it emits a standard OSCAL 1.1 assessment-results document for that repo's findings — mapped findings target their controls via the stamped `control_refs`, and unmapped findings are reported **as-is** (as observations), so nothing is lost.
|
|
||||||
- breakpilot pulls it: `POST /v1/cra/oscal-from-scanner` calls `oscal_assessment` over MCP (Streamable HTTP + bearer) and consumes the pre-computed OSCAL — rather than pulling raw findings and re-assessing.
|
|
||||||
|
|
||||||
**Operational note — tenant context over HTTP.** The MCP server is multi-tenant; the bearer token resolves a tenant whose per-tenant database the tools query. rmcp's Streamable HTTP transport runs each session's tool calls in a `tokio::spawn`ed task, and `task_local`s do **not** cross a spawn — so binding the tenant in a per-request middleware `task_local` leaves tool handlers with no context (every call fails `no tenant context`). The fix is to bind the tenant to the **per-session server instance** at creation (the factory runs in the request scope before the spawn), not to a per-request task_local. Until this was fixed, the loop silently failed over HTTP and consumers fell back to demo data.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| Variable | Effect |
|
|
||||||
| --- | --- |
|
|
||||||
| `BREAKPILOT_BASE_URL` | breakpilot-compliance root; enables control ingest + all mapping passes. **Unset disables all control mapping** — findings are produced without `control_refs`. |
|
|
||||||
| `BREAKPILOT_SEMANTIC_MAPPING` | Stage 5c (semantic master-controls mapping). **Default on** (validated live). |
|
|
||||||
| `BREAKPILOT_GROUNDED_CHECKS` | Stage 5d (grounded surface checks). **Default on** (validated live). |
|
|
||||||
| `BREAKPILOT_SNAPSHOT_DIR` | Where OSCAL catalog snapshots and the cached control-embedding index live. |
|
|
||||||
|
|
||||||
The semantic and grounded passes default **on** now that both are validated live; each is still a no-op if `BREAKPILOT_BASE_URL` is unset or the catalog is unreachable, so they only ever add coverage. The live verifications live in `compliance-agent/tests/c5_semantic_live.rs` and `grounded_surface_live.rs` (ignored; run with `--ignored`).
|
|
||||||
|
|
||||||
## Appendix — the master-controls data pipeline
|
|
||||||
|
|
||||||
The master-controls corpus is produced by breakpilot-compliance and pulled as an OSCAL catalog from `GET /api/compliance/v1/oscal/catalog?framework=master-controls`. Two operational lessons are worth recording, because they cost real time to diagnose:
|
|
||||||
|
|
||||||
- **The catalog is served from `breakpilot_db`, not `postgres`.** Diagnostics run against the wrong database will look clean while the app serves something else entirely. Confirm the app's datname (`pg_stat_activity`) before trusting any count or `EXPLAIN`.
|
|
||||||
- **A constraint-less dump triplicated the master-control tables.** Restored without their PK/unique constraints, `master_controls` / `mc_verification` / `master_control_members` accumulated identical rows 3× (the same artifact migration `158` fixed for `doc_check_controls`). That inflated the catalog to ~26k dup'd controls and, with the indexes also missing, drove the export query to a >120s / 502. The fix (breakpilot migration `160`) ctid-dedups each table by its natural key and restores the constraints + indexes so it can't recur; the export query was also rewritten set-based (a single windowed pass instead of a per-row correlated subquery). After dedup: 41,850 → 13,950 master controls, catalog **25,938 → 2,882** code-checkable, endpoint **502 → 200 in ~3s**.
|
|
||||||
@@ -92,7 +92,3 @@ Filters can be combined. A count indicator shows how many findings match the cur
|
|||||||
::: tip
|
::: tip
|
||||||
Findings marked as **Confirmed** exploitable were verified with a successful attack payload. **Unconfirmed** findings show suspicious behavior that may indicate a vulnerability but could not be fully exploited.
|
Findings marked as **Confirmed** exploitable were verified with a successful attack payload. **Unconfirmed** findings show suspicious behavior that may indicate a vulnerability but could not be fully exploited.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Deterministic detectors (planned)
|
|
||||||
|
|
||||||
The DAST engine above is agentic: an LLM drives crawler, browser and testing tools and decides what to try next. That gives depth and code-aware exploitation, but not run-to-run reproducibility. The next step (decided 2026-08-31, not yet implemented) adds two deterministic open-source detectors **under** the agents: **Nuclei** (template checks incl. ICS/OT and default-credential templates) first, then an **OWASP ZAP** baseline scan. Their findings will appear alongside agent findings, carry CWE + compliance `control_refs`, and seed the agent's context so it verifies and chains instead of rediscovering. See [Tools & Scanners](/reference/tools#planned-integrations-decided-2026-08-31-not-yet-in-the-code).
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Demo Targets
|
|
||||||
|
|
||||||
Certifai ships a small, versioned set of **demo targets** — representative
|
|
||||||
inputs that exercise every scan path repeatably. They double as the fixture
|
|
||||||
set for the nightly regression and as a ready-made walkthrough for demos.
|
|
||||||
|
|
||||||
The set lives in [`fixtures/demo-targets/targets.json`](https://git.breakpilot.com/sharang/compliance-scanner-agent/src/branch/main/fixtures/demo-targets/targets.json).
|
|
||||||
|
|
||||||
## What is in the set
|
|
||||||
|
|
||||||
| Key | Target type | Artifacts | What it exercises |
|
|
||||||
|-----|-------------|-----------|-------------------|
|
|
||||||
| `plc-pump-station` | PLC / SPS (composite) | `pump_station.st`, `pump_fbd.xml`, optional Modbus live URL, optional firmware image | PLC control-logic SAST (ST **and** FBD-as-XML), ICS probe, semantic CRA/master-control mapping |
|
|
||||||
| `plc-conveyor-line` | PLC / SPS | `conveyor.xml`, `traffic_light.st` | Pure control-logic SAST on a PLCopen-XML program plus a realistic OpenPLC-style sample with three planted defects |
|
|
||||||
| `git-cra-vuln-demo` | Backend service | git `sharang/cra-vuln-demo` | Plain git SAST: Semgrep → CWE → CRA control refs (`cra-ai-8/13/20`) |
|
|
||||||
| `web-juice-shop` | Web app | git `juice-shop/juice-shop` @ v19.2.1 + live URL | SAST + SBOM/CVE on a large Node app, DAST + pentest against the running instance |
|
|
||||||
| `firmware-zephyr-example` | Firmware (RTOS) | git `zephyrproject-rtos/example-application` | Tramiton detect handoff (`build_system = zephyr`), firmware source SBOM |
|
|
||||||
|
|
||||||
The PLC files are the same ones under `examples/plc-demo/` that the PLC rule
|
|
||||||
tests already run against, so their expected rule ids are enforced offline on
|
|
||||||
every CI run (`compliance-agent::fixtures` tests).
|
|
||||||
|
|
||||||
## Reproducibility
|
|
||||||
|
|
||||||
* Git artifacts carry a `pin` (commit SHA, plus `pin_tag` where a release tag
|
|
||||||
exists). The agent clones `branch`; the pin records **which commit the
|
|
||||||
baseline was recorded against**. When a baseline drifts, re-pin and update
|
|
||||||
`expect` in the same change.
|
|
||||||
* Uploaded artifacts are checked into this repository.
|
|
||||||
* Anything that needs infrastructure is **optional** and env-driven, so the
|
|
||||||
set seeds cleanly on a laptop and gains the dynamic pieces on `comp-dev`:
|
|
||||||
|
|
||||||
| Env var | Used by | Meaning |
|
|
||||||
|---------|---------|---------|
|
|
||||||
| `DEMO_WEB_URL` | `web-juice-shop` | Live URL for DAST/pentest. Default `http://localhost:3000` — run `docker run -d -p 3000:3000 bkimminich/juice-shop:v19.2.1`. |
|
|
||||||
| `DEMO_PLC_MODBUS_URL` | `plc-pump-station` | Modbus endpoint for the ICS probe (in-cluster default `modbus://plc-sim:502`). Skipped when unset. |
|
|
||||||
| `DEMO_PLC_FIRMWARE_IMAGE` | `plc-pump-station` | Absolute path to a device firmware image to attach. Skipped when unset. |
|
|
||||||
|
|
||||||
## Seeding the targets
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# against a local dev agent (no Keycloak → dev tenant)
|
|
||||||
scripts/seed-demo-targets.sh
|
|
||||||
|
|
||||||
# seed and trigger the first scan of each
|
|
||||||
scripts/seed-demo-targets.sh --scan
|
|
||||||
|
|
||||||
# only some targets
|
|
||||||
scripts/seed-demo-targets.sh --only web-juice-shop,git-cra-vuln-demo
|
|
||||||
|
|
||||||
# wipe every "Demo · " target and reseed
|
|
||||||
scripts/seed-demo-targets.sh --reset --scan
|
|
||||||
|
|
||||||
# a deployed agent
|
|
||||||
AGENT_URL=https://comp-dev.breakpilot.com AGENT_TOKEN=$TOKEN \
|
|
||||||
DEMO_PLC_MODBUS_URL=modbus://plc-sim:502 scripts/seed-demo-targets.sh --scan
|
|
||||||
```
|
|
||||||
|
|
||||||
The script uses only `curl` + `jq` and the public onboarding API:
|
|
||||||
`POST /api/v1/targets`, `POST /api/v1/targets/{id}/artifacts/upload`,
|
|
||||||
`POST /api/v1/targets/{id}/detect`, `POST /api/v1/targets/{id}/scan`.
|
|
||||||
Every seeded target is named `Demo · <name>`; `--reset` deletes exactly that
|
|
||||||
prefix and nothing else.
|
|
||||||
|
|
||||||
## Manual (re)onboarding
|
|
||||||
|
|
||||||
Each target can also be created through the onboarding wizard:
|
|
||||||
|
|
||||||
1. **PLC targets** — pick *PLC / SPS*, upload the `.st` / `.xml` files from
|
|
||||||
`examples/plc-demo/`, optionally add a `modbus://` live URL. See
|
|
||||||
[PLC / SPS (CODESYS)](/guide/plc).
|
|
||||||
2. **Git targets** — pick the type, add the git URL and branch from the
|
|
||||||
manifest. Public repos need no credentials.
|
|
||||||
3. **Web app** — add the git repo *and* the live URL; enable DAST and, if
|
|
||||||
wanted, pentest on the scan-selection step.
|
|
||||||
|
|
||||||
## Golden baselines
|
|
||||||
|
|
||||||
Each target's `expect` block states what a healthy scan must produce
|
|
||||||
(`min_findings`, required `sast_rule_ids`, `cwes`, `control_refs`,
|
|
||||||
`min_sbom_components`, `scans_offered`, `pentest_supported`,
|
|
||||||
`detected_facts`). The PLC baselines are asserted in unit tests today; the
|
|
||||||
nightly regression story (#188) runs the full set against a live agent and
|
|
||||||
alerts on drift.
|
|
||||||
@@ -26,7 +26,7 @@ Filters can be combined. Results are paginated with 20 findings per page.
|
|||||||
| Severity | Color-coded badge: Critical (red), High (orange), Medium (yellow), Low (green), Info (blue) |
|
| Severity | Color-coded badge: Critical (red), High (orange), Medium (yellow), Low (green), Info (blue) |
|
||||||
| Title | Short description of the vulnerability (clickable) |
|
| Title | Short description of the vulnerability (clickable) |
|
||||||
| Type | SAST, SBOM, CVE, GDPR, OAuth, Secrets, or Code Review |
|
| Type | SAST, SBOM, CVE, GDPR, OAuth, Secrets, or Code Review |
|
||||||
| Scanner | Tool that found the issue (e.g. Semgrep, Syft/OSV) |
|
| Scanner | Tool that found the issue (e.g. Semgrep, Grype) |
|
||||||
| File | Source file path where the issue was found |
|
| File | Source file path where the issue was found |
|
||||||
| Status | Current triage status |
|
| Status | Current triage status |
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ If the finding has been pushed to an issue tracker (GitHub, GitLab, Gitea, Jira)
|
|||||||
| Type | Source | Description |
|
| Type | Source | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| **SAST** | Semgrep | Code-level vulnerabilities found through static analysis |
|
| **SAST** | Semgrep | Code-level vulnerabilities found through static analysis |
|
||||||
| **SBOM** | Syft + OSV.dev/NVD | Vulnerable dependencies identified in your software bill of materials |
|
| **SBOM** | Syft + Grype | Vulnerable dependencies identified in your software bill of materials |
|
||||||
| **CVE** | NVD | Known CVEs matching your dependency versions |
|
| **CVE** | NVD | Known CVEs matching your dependency versions |
|
||||||
| **GDPR** | Custom rules | Personal data handling and consent issues |
|
| **GDPR** | Custom rules | Personal data handling and consent issues |
|
||||||
| **OAuth** | Custom rules | OAuth/OIDC misconfigurations and insecure token handling |
|
| **OAuth** | Custom rules | OAuth/OIDC misconfigurations and insecure token handling |
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ The SBOM (Software Bill of Materials) feature provides a complete inventory of a
|
|||||||
|
|
||||||
A Software Bill of Materials is a list of every component (library, package, framework) that your software depends on, along with version numbers, licenses, and known vulnerabilities. SBOMs are increasingly required for compliance audits, customer security questionnaires, and supply chain transparency.
|
A Software Bill of Materials is a list of every component (library, package, framework) that your software depends on, along with version numbers, licenses, and known vulnerabilities. SBOMs are increasingly required for compliance audits, customer security questionnaires, and supply chain transparency.
|
||||||
|
|
||||||
Certifai generates SBOMs automatically during each scan using Syft for dependency extraction and OSV.dev + NVD for vulnerability matching.
|
Certifai generates SBOMs automatically during each scan using Syft for dependency extraction and Grype for vulnerability matching.
|
||||||
|
|
||||||
## Packages Tab
|
## Packages Tab
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ When a scan is triggered, Certifai runs through these phases in order:
|
|||||||
|
|
||||||
1. **Clone** -- pulls the latest code from the Git remote (or clones it for the first time)
|
1. **Clone** -- pulls the latest code from the Git remote (or clones it for the first time)
|
||||||
2. **SAST** -- runs static analysis using Semgrep with rules covering OWASP, GDPR, OAuth, secrets, and general security patterns
|
2. **SAST** -- runs static analysis using Semgrep with rules covering OWASP, GDPR, OAuth, secrets, and general security patterns
|
||||||
3. **SBOM** -- extracts all dependencies using Syft, identifying packages, versions, licenses, and known vulnerabilities via OSV.dev + NVD
|
3. **SBOM** -- extracts all dependencies using Syft, identifying packages, versions, licenses, and known vulnerabilities via Grype
|
||||||
4. **CVE Check** -- cross-references dependencies against the NVD database for known CVEs
|
4. **CVE Check** -- cross-references dependencies against the NVD database for known CVEs
|
||||||
5. **Graph Build** -- parses the codebase to construct a code knowledge graph of functions, classes, and their relationships
|
5. **Graph Build** -- parses the codebase to construct a code knowledge graph of functions, classes, and their relationships
|
||||||
6. **AI Triage** -- new findings are reviewed by an LLM that assesses severity, considers blast radius using the code graph, and generates remediation guidance
|
6. **AI Triage** -- new findings are reviewed by an LLM that assesses severity, considers blast radius using the code graph, and generates remediation guidance
|
||||||
@@ -52,7 +52,7 @@ A full scan runs multiple analysis engines, each producing different types of fi
|
|||||||
| Scan Type | What It Detects | Scanner |
|
| Scan Type | What It Detects | Scanner |
|
||||||
|-----------|----------------|---------|
|
|-----------|----------------|---------|
|
||||||
| **SAST** | Code-level vulnerabilities (injection, XSS, insecure crypto, etc.) | Semgrep |
|
| **SAST** | Code-level vulnerabilities (injection, XSS, insecure crypto, etc.) | Semgrep |
|
||||||
| **SBOM** | Dependency inventory, outdated packages, known vulnerabilities | Syft + OSV.dev/NVD |
|
| **SBOM** | Dependency inventory, outdated packages, known vulnerabilities | Syft + Grype |
|
||||||
| **CVE** | Known CVEs in dependencies cross-referenced against NVD | NVD API |
|
| **CVE** | Known CVEs in dependencies cross-referenced against NVD | NVD API |
|
||||||
| **GDPR** | Personal data handling issues, consent violations | Custom rules |
|
| **GDPR** | Personal data handling issues, consent violations | Custom rules |
|
||||||
| **OAuth** | OAuth/OIDC misconfigurations, insecure token handling | Custom rules |
|
| **OAuth** | OAuth/OIDC misconfigurations, insecure token handling | Custom rules |
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ An open-source static analysis tool that finds bugs and enforces code standards
|
|||||||
**Syft**
|
**Syft**
|
||||||
An open-source tool for generating SBOMs from container images and filesystems. Used by Certifai to extract dependency information.
|
An open-source tool for generating SBOMs from container images and filesystems. Used by Certifai to extract dependency information.
|
||||||
|
|
||||||
**OSV.dev**
|
**Grype**
|
||||||
Google's open distributed vulnerability database, queried by package URL. Certifai uses it (together with NVD) to match SBOM components against known vulnerabilities.
|
An open-source vulnerability scanner for container images and filesystems. Used by Certifai to match dependencies against known vulnerabilities.
|
||||||
|
|
||||||
## Protocols
|
## Protocols
|
||||||
|
|
||||||
|
|||||||
+6
-16
@@ -24,14 +24,15 @@ Semgrep produces SAST-type findings with file paths, line numbers, and rule desc
|
|||||||
|
|
||||||
Syft output feeds into both the SBOM feature and the vulnerability scanning pipeline.
|
Syft output feeds into both the SBOM feature and the vulnerability scanning pipeline.
|
||||||
|
|
||||||
## OSV.dev + NVD -- Vulnerability Matching
|
## Grype -- Vulnerability Scanning
|
||||||
|
|
||||||
Certifai matches every SBOM component directly against two public vulnerability sources (no separate scanner binary):
|
[Grype](https://github.com/anchore/grype) is an open-source vulnerability scanner that matches your dependencies against known vulnerability databases. It takes Syft's SBOM output and cross-references it against:
|
||||||
|
|
||||||
- [OSV.dev](https://osv.dev/) -- batch queried by package URL (purl) for ecosystem advisories (npm, PyPI, crates.io, Go, Maven, ...)
|
- National Vulnerability Database (NVD)
|
||||||
- [NVD](https://nvd.nist.gov/) -- queried per CVE for the CVSS v3.1 base score, and by CPE for CODESYS runtime versions found in PLC projects
|
- GitHub Advisory Database
|
||||||
|
- OS-specific advisory databases
|
||||||
|
|
||||||
Matches are stored as CVE alerts with CVSS scores and re-checked hourly, so newly published CVEs against an unchanged dependency still raise a notification.
|
Grype produces SBOM-type findings with CVE identifiers, severity ratings, and links to advisories.
|
||||||
|
|
||||||
## Custom OAuth Scanner
|
## Custom OAuth Scanner
|
||||||
|
|
||||||
@@ -96,14 +97,3 @@ When you mark findings as false positives or provide developer feedback, this in
|
|||||||
::: tip
|
::: tip
|
||||||
The AI triage is a starting point, not a final verdict. Always review the rationale and code evidence before acting on a finding. See [Understanding Findings](/guide/findings#human-in-the-loop) for more on the human-in-the-loop workflow.
|
The AI triage is a starting point, not a final verdict. Always review the rationale and code evidence before acting on a finding. See [Understanding Findings](/guide/findings#human-in-the-loop) for more on the human-in-the-loop workflow.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Planned integrations (decided 2026-08-31, not yet in the code)
|
|
||||||
|
|
||||||
The product spec keeps an **OSS-only** tooling policy and a control-mapping rule of *tools detect, the LLM judges*. Two deterministic detectors are therefore being added **underneath** the agentic DAST/pentest layer — the agents stay on top for context-seeded exploitation, chaining and explanation:
|
|
||||||
|
|
||||||
| Tool | Role | Status |
|
|
||||||
|------|------|--------|
|
|
||||||
| [Nuclei](https://github.com/projectdiscovery/nuclei) | Template-driven checks (CVE probes, default credentials, exposed panels, misconfigurations) including ICS/OT templates for WebVisu / OpenPLC / HMI endpoints. Runs as a DAST phase and as a Werkbank job with vendored templates so it works on-prem. | Planned — tracked as an issue |
|
|
||||||
| [OWASP ZAP](https://www.zaproxy.org/) | Baseline (passive) and, behind the destructive-tests flag, active scan for reproducible spider + rule coverage; results seed the pentest agent. | Planned — follows Nuclei |
|
|
||||||
|
|
||||||
Both feed the same `control-map` lookup table as Semgrep, so their findings receive compliance `control_refs` through the grounded judge. An **offline vulnerability database** (Trivy preferred, Grype as alternative) is planned for the on-prem Werkbank runner, which cannot reach the OSV.dev / NVD APIs. Until these land, DAST findings come exclusively from the in-house agents described above.
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"name_prefix": "Demo · ",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"key": "plc-pump-station",
|
|
||||||
"name": "PLC pump station (ST + FBD, composite)",
|
|
||||||
"target_type": "plc_sps",
|
|
||||||
"description": "Composite PlcSps demo: Structured Text + FBD-as-PLCopen-XML control logic, plus an optional live Modbus endpoint (in-cluster plc-sim) and an optional device firmware image. Exercises PLC SAST, ICS probe, semantic control mapping.",
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"kind": "plc_project",
|
|
||||||
"upload": "examples/plc-demo/pump_station.st",
|
|
||||||
"plc_format": "structured_text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "plc_project",
|
|
||||||
"upload": "examples/plc-demo/pump_fbd.xml",
|
|
||||||
"plc_format": "plcopen_xml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "live_url",
|
|
||||||
"source_ref_env": "DEMO_PLC_MODBUS_URL",
|
|
||||||
"source_ref": "modbus://plc-sim:502",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "firmware_image",
|
|
||||||
"upload_env": "DEMO_PLC_FIRMWARE_IMAGE",
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expect": {
|
|
||||||
"min_findings": 16,
|
|
||||||
"sast_rule_ids": [
|
|
||||||
"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"
|
|
||||||
],
|
|
||||||
"cwes": [
|
|
||||||
"CWE-798",
|
|
||||||
"CWE-319",
|
|
||||||
"CWE-1384"
|
|
||||||
],
|
|
||||||
"control_refs_any": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "plc-conveyor-line",
|
|
||||||
"name": "PLC conveyor + traffic light (PLCopen XML + ST)",
|
|
||||||
"target_type": "plc_sps",
|
|
||||||
"description": "Pure PLC SAST demo: a PLCopen-XML conveyor program and a realistic OpenPLC-style traffic-light program with three planted defects. Exercises the control-logic rules without any dynamic infra.",
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"kind": "plc_project",
|
|
||||||
"upload": "examples/plc-demo/conveyor.xml",
|
|
||||||
"plc_format": "plcopen_xml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "plc_project",
|
|
||||||
"upload": "examples/plc-demo/traffic_light.st",
|
|
||||||
"plc_format": "structured_text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expect": {
|
|
||||||
"min_findings": 8,
|
|
||||||
"sast_rule_ids": [
|
|
||||||
"plc-hardcoded-credential",
|
|
||||||
"plc-default-password",
|
|
||||||
"plc-safety-bypass",
|
|
||||||
"plc-insecure-comm",
|
|
||||||
"plc-insecure-protocol-port"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "git-cra-vuln-demo",
|
|
||||||
"name": "Git SAST · cra-vuln-demo",
|
|
||||||
"target_type": "backend_service",
|
|
||||||
"description": "Plain git SAST target. Small deliberately-vulnerable Python service (hardcoded credential, weak cipher, SQL injection, cleartext transport) used to prove the CWE → CRA control mapping.",
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"kind": "git_repo",
|
|
||||||
"source_ref": "https://git.breakpilot.com/sharang/cra-vuln-demo.git",
|
|
||||||
"branch": "main",
|
|
||||||
"pin": "21951249b9977d0c7feb555a9d5ce42c70ab5ae4"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expect": {
|
|
||||||
"min_findings": 3,
|
|
||||||
"cwes": [
|
|
||||||
"CWE-798",
|
|
||||||
"CWE-327",
|
|
||||||
"CWE-89"
|
|
||||||
],
|
|
||||||
"control_refs": [
|
|
||||||
"cra-ai-8",
|
|
||||||
"cra-ai-13",
|
|
||||||
"cra-ai-20"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "web-juice-shop",
|
|
||||||
"name": "Web app · OWASP Juice Shop",
|
|
||||||
"target_type": "web_app",
|
|
||||||
"description": "WebApp target: git repo for SAST/SBOM/CVE plus a live URL for DAST + pentest. Run the instance locally with `docker run -d -p 3000:3000 bkimminich/juice-shop:v19.2.1` or point DEMO_WEB_URL at a deployed copy.",
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"kind": "git_repo",
|
|
||||||
"source_ref": "https://github.com/juice-shop/juice-shop.git",
|
|
||||||
"branch": "master",
|
|
||||||
"pin": "f87c6f58c49b61c9de20e4d69a9bdb1fbd4f3bd3",
|
|
||||||
"pin_tag": "v19.2.1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "live_url",
|
|
||||||
"source_ref_env": "DEMO_WEB_URL",
|
|
||||||
"source_ref": "http://localhost:3000"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expect": {
|
|
||||||
"min_findings": 10,
|
|
||||||
"min_sbom_components": 500,
|
|
||||||
"scans_offered": [
|
|
||||||
"sast",
|
|
||||||
"dast"
|
|
||||||
],
|
|
||||||
"pentest_supported": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "firmware-zephyr-example",
|
|
||||||
"name": "Firmware RTOS · Zephyr example-application",
|
|
||||||
"target_type": "firmware_rtos",
|
|
||||||
"description": "Upstream Zephyr example application (Apache-2.0). Exercises the tramiton detect handoff (build system = zephyr) and the firmware source SBOM path.",
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"kind": "git_repo",
|
|
||||||
"source_ref": "https://github.com/zephyrproject-rtos/example-application.git",
|
|
||||||
"branch": "main",
|
|
||||||
"pin": "38a6d9b276ed434454900130cacc87d058d3ac62"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"expect": {
|
|
||||||
"detected_facts": {
|
|
||||||
"build_system": "zephyr"
|
|
||||||
},
|
|
||||||
"scans_offered": [
|
|
||||||
"sast"
|
|
||||||
],
|
|
||||||
"pentest_supported": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Seed the curated demo targets (#187) into a running compliance-agent.
|
|
||||||
#
|
|
||||||
# Reads fixtures/demo-targets/targets.json and, for every target:
|
|
||||||
# 1. POST /api/v1/targets (name = name_prefix + name)
|
|
||||||
# 2. POST /api/v1/targets/{id}/artifacts/upload for each `upload` artifact
|
|
||||||
# 3. POST /api/v1/targets/{id}/detect (surface detected facts)
|
|
||||||
# 4. POST /api/v1/targets/{id}/scan (only with --scan)
|
|
||||||
#
|
|
||||||
# Artifact env overrides (see the manifest): DEMO_WEB_URL, DEMO_PLC_MODBUS_URL,
|
|
||||||
# DEMO_PLC_FIRMWARE_IMAGE. Optional artifacts whose env var is unset are
|
|
||||||
# skipped, so the set seeds on a bare laptop; set them on comp-dev / Orca.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# scripts/seed-demo-targets.sh # seed all
|
|
||||||
# scripts/seed-demo-targets.sh --scan # seed + trigger first scan
|
|
||||||
# scripts/seed-demo-targets.sh --only web-juice-shop,git-cra-vuln-demo
|
|
||||||
# scripts/seed-demo-targets.sh --reset # delete every "Demo · " target
|
|
||||||
# scripts/seed-demo-targets.sh --reset --scan # reset, reseed, scan
|
|
||||||
#
|
|
||||||
# Env:
|
|
||||||
# AGENT_URL base URL of the agent (default http://localhost:3011)
|
|
||||||
# AGENT_TOKEN bearer token; omit for dev mode (no Keycloak → dev tenant)
|
|
||||||
# MANIFEST alternative manifest path
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
AGENT_URL="${AGENT_URL:-http://localhost:3011}"
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
MANIFEST="${MANIFEST:-$ROOT/fixtures/demo-targets/targets.json}"
|
|
||||||
|
|
||||||
DO_SCAN=0
|
|
||||||
DO_RESET=0
|
|
||||||
ONLY=""
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--scan) DO_SCAN=1 ;;
|
|
||||||
--reset) DO_RESET=1 ;;
|
|
||||||
--only) ONLY="$2"; shift ;;
|
|
||||||
-h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
||||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
|
||||||
esac
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
|
|
||||||
for bin in jq curl; do
|
|
||||||
command -v "$bin" >/dev/null || { echo "need $bin" >&2; exit 1; }
|
|
||||||
done
|
|
||||||
[[ -f "$MANIFEST" ]] || { echo "manifest not found: $MANIFEST" >&2; exit 1; }
|
|
||||||
|
|
||||||
AUTH=()
|
|
||||||
[[ -n "${AGENT_TOKEN:-}" ]] && AUTH=(-H "Authorization: Bearer ${AGENT_TOKEN}")
|
|
||||||
|
|
||||||
green() { printf '\033[32m%s\033[0m' "$*"; }
|
|
||||||
yellow() { printf '\033[33m%s\033[0m' "$*"; }
|
|
||||||
red() { printf '\033[31m%s\033[0m' "$*"; }
|
|
||||||
|
|
||||||
# api METHOD PATH [curl args...] → body on stdout; non-2xx → exit 1 with body.
|
|
||||||
api() {
|
|
||||||
local method="$1" path="$2"; shift 2
|
|
||||||
local out code
|
|
||||||
out=$(curl -sS -X "$method" "${AGENT_URL}${path}" "${AUTH[@]}" -w '\n%{http_code}' "$@")
|
|
||||||
code="${out##*$'\n'}"
|
|
||||||
out="${out%$'\n'*}"
|
|
||||||
if [[ "$code" != 2* ]]; then
|
|
||||||
echo "$(red "HTTP $code") $method $path" >&2
|
|
||||||
echo "$out" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
printf '%s' "$out"
|
|
||||||
}
|
|
||||||
|
|
||||||
PREFIX="$(jq -r '.name_prefix' "$MANIFEST")"
|
|
||||||
|
|
||||||
reset_targets() {
|
|
||||||
echo "== reset: deleting targets named '${PREFIX}*'"
|
|
||||||
local list ids
|
|
||||||
list=$(api GET "/api/v1/targets?limit=500")
|
|
||||||
ids=$(jq -r --arg p "$PREFIX" '.data[] | select(.name | startswith($p)) | ._id."$oid"' <<<"$list")
|
|
||||||
local n=0
|
|
||||||
for id in $ids; do
|
|
||||||
api DELETE "/api/v1/targets/${id}" >/dev/null && n=$((n + 1))
|
|
||||||
done
|
|
||||||
echo " deleted $n"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build the JSON artifact list for reference-style (non-upload) artifacts.
|
|
||||||
# Prints one JSON object per artifact that should be created inline.
|
|
||||||
inline_artifacts() {
|
|
||||||
local target_json="$1"
|
|
||||||
jq -c '.artifacts[] | select(.upload == null and .upload_env == null)' <<<"$target_json" |
|
|
||||||
while IFS= read -r a; do
|
|
||||||
local kind env ref optional branch
|
|
||||||
kind=$(jq -r '.kind' <<<"$a")
|
|
||||||
env=$(jq -r '.source_ref_env // empty' <<<"$a")
|
|
||||||
ref=$(jq -r '.source_ref // empty' <<<"$a")
|
|
||||||
optional=$(jq -r '.optional // false' <<<"$a")
|
|
||||||
branch=$(jq -r '.branch // empty' <<<"$a")
|
|
||||||
if [[ -n "$env" && -n "${!env:-}" ]]; then
|
|
||||||
ref="${!env}"
|
|
||||||
elif [[ -n "$env" && "$optional" == "true" ]]; then
|
|
||||||
echo " $(yellow skip) $kind (set \$$env to include)" >&2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
[[ -n "$ref" ]] || continue
|
|
||||||
jq -cn --arg k "$kind" --arg r "$ref" --arg b "$branch" \
|
|
||||||
'{kind:$k, source_ref:$r} + (if $b != "" then {branch:$b} else {} end)'
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# Upload every `upload` / `upload_env` artifact of the target.
|
|
||||||
upload_artifacts() {
|
|
||||||
local id="$1" target_json="$2"
|
|
||||||
jq -c '.artifacts[] | select(.upload != null or .upload_env != null)' <<<"$target_json" |
|
|
||||||
while IFS= read -r a; do
|
|
||||||
local kind path env fmt optional
|
|
||||||
kind=$(jq -r '.kind' <<<"$a")
|
|
||||||
env=$(jq -r '.upload_env // empty' <<<"$a")
|
|
||||||
path=$(jq -r '.upload // empty' <<<"$a")
|
|
||||||
fmt=$(jq -r '.plc_format // empty' <<<"$a")
|
|
||||||
optional=$(jq -r '.optional // false' <<<"$a")
|
|
||||||
if [[ -n "$env" && -n "${!env:-}" ]]; then
|
|
||||||
path="${!env}"
|
|
||||||
elif [[ -n "$path" ]]; then
|
|
||||||
path="$ROOT/$path"
|
|
||||||
elif [[ "$optional" == "true" ]]; then
|
|
||||||
echo " $(yellow skip) $kind (set \$$env to include)" >&2
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
[[ -f "$path" ]] || { echo " $(red missing) $path" >&2; return 1; }
|
|
||||||
local form=(-F "file=@${path}" -F "kind=${kind}")
|
|
||||||
[[ -n "$fmt" ]] && form+=(-F "plc_format=${fmt}")
|
|
||||||
api POST "/api/v1/targets/${id}/artifacts/upload" "${form[@]}" >/dev/null
|
|
||||||
echo " $(green upload) $kind $(basename "$path")"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
seed_target() {
|
|
||||||
local t="$1"
|
|
||||||
local key name type desc
|
|
||||||
key=$(jq -r '.key' <<<"$t")
|
|
||||||
name="${PREFIX}$(jq -r '.name' <<<"$t")"
|
|
||||||
type=$(jq -r '.target_type' <<<"$t")
|
|
||||||
desc=$(jq -r '.description // ""' <<<"$t")
|
|
||||||
echo "== $key ($type)"
|
|
||||||
|
|
||||||
local arts body resp id
|
|
||||||
arts=$(inline_artifacts "$t" | jq -cs '.')
|
|
||||||
body=$(jq -cn --arg n "$name" --arg tt "$type" --arg d "$desc" --argjson a "$arts" \
|
|
||||||
'{name:$n, target_type:$tt, description:$d, artifacts:$a}')
|
|
||||||
resp=$(api POST "/api/v1/targets" -H 'Content-Type: application/json' -d "$body")
|
|
||||||
id=$(jq -r '.data._id."$oid"' <<<"$resp")
|
|
||||||
echo " $(green created) $id $(jq -r '.data.artifacts|length' <<<"$resp") inline artifact(s)"
|
|
||||||
|
|
||||||
upload_artifacts "$id" "$t"
|
|
||||||
|
|
||||||
local det
|
|
||||||
det=$(api POST "/api/v1/targets/${id}/detect" -H 'Content-Type: application/json' -d '{}' || true)
|
|
||||||
if [[ -n "$det" ]]; then
|
|
||||||
echo " detect → $(jq -r '.data.classification.suggested // "n/a"' <<<"$det")"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$DO_SCAN" == 1 ]]; then
|
|
||||||
api POST "/api/v1/targets/${id}/scan" -H 'Content-Type: application/json' -d '{}' >/dev/null
|
|
||||||
echo " $(green scan) triggered"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
[[ "$DO_RESET" == 1 ]] && reset_targets
|
|
||||||
|
|
||||||
echo "== seeding from $MANIFEST → $AGENT_URL"
|
|
||||||
jq -c '.targets[]' "$MANIFEST" | while IFS= read -r t; do
|
|
||||||
key=$(jq -r '.key' <<<"$t")
|
|
||||||
if [[ -n "$ONLY" && ",$ONLY," != *",$key,"* ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
seed_target "$t"
|
|
||||||
done
|
|
||||||
echo "== done"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "werkbank-exec"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Shared dynamic-execution logic: soft-PLC provisioning + industrial-protocol probing, used by the compliance agent and the Werkbank runner."
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
compliance-core = { workspace = true }
|
|
||||||
compliance-dast = { path = "../compliance-dast" }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
regex = { workspace = true }
|
|
||||||
secrecy = { workspace = true }
|
|
||||||
sha2 = { workspace = true }
|
|
||||||
hex = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
walkdir = "2"
|
|
||||||
futures-util = "0.3"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//! Error type for the dynamic-execution logic.
|
|
||||||
|
|
||||||
/// Anything that can go wrong provisioning and testing a soft-PLC. The compliance
|
|
||||||
/// agent maps this into its own `AgentError` at the call boundary.
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
pub enum ExecError {
|
|
||||||
/// An HTTP request (to OpenPLC) failed.
|
|
||||||
#[error("HTTP error: {0}")]
|
|
||||||
Http(#[from] reqwest::Error),
|
|
||||||
/// A local IO / process error (e.g. invoking `docker`).
|
|
||||||
#[error("IO error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
/// Any other failure, with a message.
|
|
||||||
#[error("{0}")]
|
|
||||||
Other(String),
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
//! Finding fingerprint helper (a SHA-256 over the salient parts), shared by the
|
|
||||||
//! probe modules for stable dedup keys. Mirrors the agent's `dedup` helper.
|
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
/// A stable fingerprint over the given parts (order-sensitive, separated so
|
|
||||||
/// `["ab","c"]` and `["a","bc"]` differ).
|
|
||||||
pub fn compute_fingerprint(parts: &[&str]) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
for part in parts {
|
|
||||||
hasher.update(part.as_bytes());
|
|
||||||
hasher.update(b"|");
|
|
||||||
}
|
|
||||||
hex::encode(hasher.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deterministic_and_hex() {
|
|
||||||
let a = compute_fingerprint(&["repo", "rule", "1"]);
|
|
||||||
assert_eq!(a, compute_fingerprint(&["repo", "rule", "1"]));
|
|
||||||
assert_eq!(a.len(), 64);
|
|
||||||
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
assert_ne!(
|
|
||||||
compute_fingerprint(&["ab", "c"]),
|
|
||||||
compute_fingerprint(&["a", "bc"])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
//! Shared dynamic-execution logic for Werkbank.
|
|
||||||
//!
|
|
||||||
//! The soft-PLC provisioning + industrial-protocol probing that turns a control-
|
|
||||||
//! logic artifact into findings: provision an ephemeral OpenPLC, load the program,
|
|
||||||
//! start it, probe it over Modbus/OPC-UA/EtherNet-IP, DAST its web endpoint, tear
|
|
||||||
//! it down. Extracted from the compliance agent (#183) so both the agent (in
|
|
||||||
//! process) and the Werkbank runner (WB-04) run identical logic.
|
|
||||||
//!
|
|
||||||
//! - [`ics`] — read-only industrial-protocol probing.
|
|
||||||
//! - [`plc`] — ephemeral soft-PLC provisioning + the provision-and-test loop.
|
|
||||||
|
|
||||||
pub mod error;
|
|
||||||
pub mod ics;
|
|
||||||
pub mod plc;
|
|
||||||
|
|
||||||
mod fingerprint;
|
|
||||||
|
|
||||||
pub use error::ExecError;
|
|
||||||
Reference in New Issue
Block a user