Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20067fcf44 | ||
|
|
b5c33bdd52 | ||
|
|
8dcdad9fe7 | ||
|
|
53bd93c96e | ||
|
|
0834547a74 | ||
|
|
effc3080e0 | ||
|
|
51bab61f77 | ||
|
|
5bdc35ee92 | ||
|
|
ea516cc054 | ||
|
|
7d5c95ddb8 | ||
|
|
3e233da128 | ||
|
|
a72f79e557 |
+26
-15
@@ -7,6 +7,13 @@ on:
|
||||
pull_request:
|
||||
|
||||
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
|
||||
RUSTFLAGS: "-D warnings"
|
||||
# Compile cache: sccache -> Hetzner S3 (breakpilot-sccache), runner-independent
|
||||
@@ -65,7 +72,7 @@ jobs:
|
||||
echo '[source.crates-io]'
|
||||
echo 'replace-with = "kellnr"'
|
||||
echo '[registries.kellnr]'
|
||||
echo 'index = "sparse+https://crates.meghsakha.com/api/v1/cratesio/"'
|
||||
echo 'index = "sparse+https://crates.breakpilot.com/api/v1/cratesio/"'
|
||||
} >> "$CARGO_HOME/config.toml"
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
@@ -87,8 +94,8 @@ jobs:
|
||||
- name: Configure git auth for private tramiton dependency
|
||||
run: |
|
||||
git config --global \
|
||||
url."https://sharang:${{ secrets.TRAMITON_FETCH_TOKEN }}@gitea.meghsakha.com/".insteadOf \
|
||||
"ssh://git@gitea.meghsakha.com:22222/"
|
||||
url."https://sharang:${{ secrets.TRAMITON_FETCH_TOKEN }}@git.breakpilot.com/".insteadOf \
|
||||
"ssh://git@git.breakpilot.com:22222/"
|
||||
env:
|
||||
RUSTC_WRAPPER: ""
|
||||
|
||||
@@ -206,12 +213,13 @@ jobs:
|
||||
apk add --no-cache git curl openssl
|
||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||
IMAGE=repo.meghsakha.com/certifai/compliance-agent
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login repo.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
IMAGE=repo.breakpilot.com/certifai/compliance-agent
|
||||
echo "$REGISTRY_PASSWORD" | docker login repo.breakpilot.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||
-f Dockerfile.agent -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker 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 && chmod +x /usr/local/bin/cosign; }
|
||||
{ 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}")
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||
@@ -232,12 +240,13 @@ jobs:
|
||||
apk add --no-cache git curl openssl
|
||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||
IMAGE=repo.meghsakha.com/certifai/compliance-dashboard
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login repo.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
IMAGE=repo.breakpilot.com/certifai/compliance-dashboard
|
||||
echo "$REGISTRY_PASSWORD" | docker login repo.breakpilot.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||
-f Dockerfile.dashboard -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker 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 && chmod +x /usr/local/bin/cosign; }
|
||||
{ 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}")
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||
@@ -256,11 +265,12 @@ jobs:
|
||||
apk add --no-cache git curl openssl
|
||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||
IMAGE=repo.meghsakha.com/certifai/compliance-docs
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login repo.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
IMAGE=repo.breakpilot.com/certifai/compliance-docs
|
||||
echo "$REGISTRY_PASSWORD" | docker login repo.breakpilot.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
docker build -f Dockerfile.docs -t "$IMAGE:latest" -t "$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 && chmod +x /usr/local/bin/cosign; }
|
||||
{ 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}")
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||
@@ -281,12 +291,13 @@ jobs:
|
||||
apk add --no-cache git curl openssl
|
||||
git init && git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --depth=1 origin "${GITHUB_SHA}" && git checkout FETCH_HEAD
|
||||
IMAGE=repo.meghsakha.com/certifai/compliance-mcp
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login repo.meghsakha.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
IMAGE=repo.breakpilot.com/certifai/compliance-mcp
|
||||
echo "$REGISTRY_PASSWORD" | docker login repo.breakpilot.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN \
|
||||
-f Dockerfile.mcp -t "$IMAGE:latest" -t "$IMAGE:${GITHUB_SHA}" .
|
||||
docker 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 && chmod +x /usr/local/bin/cosign; }
|
||||
{ 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}")
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "${{ secrets.ORCA_WEBHOOK_SECRET }}" | awk '{print $2}')
|
||||
|
||||
Generated
+14
-14
@@ -2116,7 +2116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2474,9 +2474,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2796,7 +2796,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
@@ -3711,7 +3711,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4298,7 +4298,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4335,9 +4335,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4711,7 +4711,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5598,7 +5598,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6176,7 +6176,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "tramiton-core"
|
||||
version = "0.4.1"
|
||||
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
source = "git+ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tempfile",
|
||||
@@ -6188,7 +6188,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "tramiton-repro"
|
||||
version = "0.4.1"
|
||||
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
source = "git+ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6203,7 +6203,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "tramiton-sbom"
|
||||
version = "0.4.1"
|
||||
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
source = "git+ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
|
||||
dependencies = [
|
||||
"object",
|
||||
"serde",
|
||||
@@ -6791,7 +6791,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ COPY . .
|
||||
RUN --mount=type=secret,id=tramiton_token \
|
||||
if [ -s /run/secrets/tramiton_token ]; then \
|
||||
git config --global \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@git.breakpilot.com/".insteadOf \
|
||||
"ssh://git@git.breakpilot.com:22222/"; \
|
||||
fi && \
|
||||
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 \
|
||||
if [ -s /run/secrets/tramiton_token ]; then \
|
||||
git config --global \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@git.breakpilot.com/".insteadOf \
|
||||
"ssh://git@git.breakpilot.com:22222/"; \
|
||||
fi && \
|
||||
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 \
|
||||
if [ -s /run/secrets/tramiton_token ]; then \
|
||||
git config --global \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
|
||||
"ssh://git@gitea.meghsakha.com:22222/"; \
|
||||
url."https://sharang:$(cat /run/secrets/tramiton_token)@git.breakpilot.com/".insteadOf \
|
||||
"ssh://git@git.breakpilot.com:22222/"; \
|
||||
fi && \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-mcp
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@ compliance-dast = { path = "../compliance-dast" }
|
||||
werkbank-exec = { path = "../werkbank-exec" }
|
||||
# Native firmware build/target detection for bare-metal & RTOS artifacts.
|
||||
# Same-company IP, used directly (not via CLI) so the whole tramiton suite is
|
||||
# available to the onboarding classifier. NOTE: CI must be able to fetch this
|
||||
# available to the onboarding classifier. Repo renamed tramiton -> firmwerk
|
||||
# (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).
|
||||
tramiton-core = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
|
||||
tramiton-core = { git = "ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git", tag = "v0.4.1" }
|
||||
# tramiton-repro drives the reproducible build (NixBackend seal_and_build) that
|
||||
# yields a sealed lock; `libraries_from_inputs` is the analysis-only fallback.
|
||||
tramiton-repro = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
|
||||
tramiton-repro = { git = "ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git", tag = "v0.4.1" }
|
||||
# tramiton-sbom renders the bill of materials from a sealed lock (+ binary SCA).
|
||||
tramiton-sbom = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
|
||||
tramiton-sbom = { git = "ssh://git@git.breakpilot.com:22222/sharang/firmwerk.git", tag = "v0.4.1" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -112,9 +112,9 @@ async fn build_specs(provider: &OscalControlsProvider) -> HashMap<String, Contro
|
||||
/// 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.
|
||||
///
|
||||
/// Gated: the orchestrator runs this only when `breakpilot.grounded_control_checks`
|
||||
/// is set. Absence detection is the least deterministic path (the judge decides
|
||||
/// presence/absence, not a syntactic pattern), so it stays off until tuned live.
|
||||
/// 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>,
|
||||
@@ -173,11 +173,10 @@ fn fetch_region(repo_path: &Path, file: &str, line: u32) -> Option<CandidateRegi
|
||||
/// ~13.6k master-control corpus (which has no CWE to LUT on). Returns the number
|
||||
/// of findings that gained a master-control ref.
|
||||
///
|
||||
/// Gated: the orchestrator runs this only when `breakpilot.semantic_mapping` is
|
||||
/// set (default off, flipped on once the master-controls catalog is live). 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.
|
||||
/// 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>,
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod config;
|
||||
pub mod controls;
|
||||
pub mod database;
|
||||
pub mod error;
|
||||
pub mod fixtures;
|
||||
pub mod ingest;
|
||||
pub mod llm;
|
||||
pub mod pentest;
|
||||
|
||||
@@ -232,9 +232,9 @@ impl PipelineOrchestrator {
|
||||
|
||||
// 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. Gated
|
||||
// (default off) as the corpus embedding + per-finding judging is the heavy
|
||||
// path; enabled once verified live against a deployed master-controls catalog.
|
||||
// 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;
|
||||
@@ -256,8 +256,8 @@ impl PipelineOrchestrator {
|
||||
// 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. Gated (default off): absence
|
||||
// detection is the least deterministic path, kept off until tuned live.
|
||||
// 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;
|
||||
@@ -277,8 +277,10 @@ impl PipelineOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup against existing findings and insert new ones
|
||||
// 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 refreshed_count = 0u32;
|
||||
let mut new_findings: Vec<Finding> = Vec::new();
|
||||
for mut finding in all_findings {
|
||||
finding.scan_run_id = Some(scan_run_id.to_string());
|
||||
@@ -293,8 +295,25 @@ impl PipelineOrchestrator {
|
||||
finding.id = result.inserted_id.as_object_id();
|
||||
new_findings.push(finding);
|
||||
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
|
||||
if !sbom_entries.is_empty() {
|
||||
@@ -567,7 +586,21 @@ impl PipelineOrchestrator {
|
||||
let Some(path) = ingest_set.get(&a.id).and_then(|ia| ia.working_path.clone()) else {
|
||||
continue;
|
||||
};
|
||||
all_findings.extend(crate::pipeline::plc::analyze_tree(&path, target_id));
|
||||
let mut source_findings = 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
|
||||
// `.projectarchive` (uploaded, or committed in the working tree).
|
||||
let archive = a
|
||||
@@ -592,6 +625,7 @@ impl PipelineOrchestrator {
|
||||
);
|
||||
|
||||
let mut new_count = 0u32;
|
||||
let mut refreshed_count = 0u32;
|
||||
for mut finding in all_findings {
|
||||
finding.scan_run_id = Some(scan_run_id.to_string());
|
||||
if self
|
||||
@@ -603,8 +637,27 @@ impl PipelineOrchestrator {
|
||||
{
|
||||
self.db.findings().insert_one(&finding).await?;
|
||||
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 let Err(e) = self
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
@@ -76,14 +76,15 @@ pub struct BreakpilotConfig {
|
||||
/// Directory for catalog snapshots.
|
||||
pub snapshot_dir: String,
|
||||
/// Enable the master-controls **semantic** mapping pass (embed regions,
|
||||
/// retrieve nearest controls, grounded-judge). Off by default: it is the
|
||||
/// scale path and stays gated until verified live against a deployed
|
||||
/// master-controls catalog.
|
||||
/// 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). Off by
|
||||
/// default: absence detection is the least deterministic path and stays gated
|
||||
/// until tuned against live scans.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -93,8 +94,8 @@ impl Default for BreakpilotConfig {
|
||||
base_url: None,
|
||||
token: None,
|
||||
snapshot_dir: "/data/compliance-scanner/oscal".to_string(),
|
||||
semantic_mapping: false,
|
||||
grounded_control_checks: false,
|
||||
semantic_mapping: true,
|
||||
grounded_control_checks: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let pool_for_factory = pool.clone();
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok(ComplianceMcpServer::new(pool_for_factory.clone())),
|
||||
move || {
|
||||
// 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()),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
@@ -69,16 +81,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tenant_id = %synth_tenant,
|
||||
"stdio transport — using synthetic tenant id; DO NOT use in production"
|
||||
);
|
||||
let server = ComplianceMcpServer::new(pool);
|
||||
let server = ComplianceMcpServer::new(pool, synth_tenant);
|
||||
let transport = rmcp::transport::stdio();
|
||||
use rmcp::ServiceExt;
|
||||
auth::TENANT_ID
|
||||
.scope(synth_tenant, async {
|
||||
let handle = server.serve(transport).await?;
|
||||
handle.waiting().await?;
|
||||
Ok::<_, Box<dyn std::error::Error>>(())
|
||||
})
|
||||
.await?;
|
||||
let handle = server.serve(transport).await?;
|
||||
handle.waiting().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -2,37 +2,33 @@ use rmcp::{
|
||||
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler,
|
||||
};
|
||||
|
||||
use crate::auth::current_tenant_id;
|
||||
use crate::database::{Database, DatabasePool};
|
||||
use crate::tools::{dast, findings, oscal, pentest, sbom};
|
||||
|
||||
pub struct ComplianceMcpServer {
|
||||
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)]
|
||||
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl ComplianceMcpServer {
|
||||
/// 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).
|
||||
/// The per-tenant `Database` for this session.
|
||||
fn tenant_db(&self) -> Result<Database, rmcp::ErrorData> {
|
||||
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))
|
||||
Ok(self.pool.for_tenant_id(&self.tenant_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl ComplianceMcpServer {
|
||||
pub fn new(pool: DatabasePool) -> Self {
|
||||
pub fn new(pool: DatabasePool, tenant_id: String) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
tenant_id,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,16 @@
|
||||
{
|
||||
"control": "cra-ai-6",
|
||||
"title": "Integritaetspruefung",
|
||||
"scans": [],
|
||||
"note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning",
|
||||
"status": "needs_tooling"
|
||||
"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",
|
||||
@@ -138,16 +145,30 @@
|
||||
{
|
||||
"control": "cra-ai-11",
|
||||
"title": "Brute-Force-Schutz",
|
||||
"scans": [],
|
||||
"note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning",
|
||||
"status": "needs_tooling"
|
||||
"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": [],
|
||||
"note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning",
|
||||
"status": "needs_tooling"
|
||||
"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",
|
||||
@@ -307,9 +328,16 @@
|
||||
{
|
||||
"control": "cra-ai-24",
|
||||
"title": "Security-Logging",
|
||||
"scans": [],
|
||||
"note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning",
|
||||
"status": "needs_tooling"
|
||||
"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",
|
||||
@@ -328,30 +356,58 @@
|
||||
{
|
||||
"control": "cra-ai-27",
|
||||
"title": "Log-Integritaet und -Aufbewahrung",
|
||||
"scans": [],
|
||||
"note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning",
|
||||
"status": "needs_tooling"
|
||||
"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": [],
|
||||
"note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning",
|
||||
"status": "needs_tooling"
|
||||
"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": [],
|
||||
"note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning",
|
||||
"status": "needs_tooling"
|
||||
"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": [],
|
||||
"note": "absence-based — no syntactic pattern; covered by the grounded surface check (retrieve surface + LLM judge), gated (BREAKPILOT_GROUNDED_CHECKS) pending live tuning",
|
||||
"status": "needs_tooling"
|
||||
"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",
|
||||
|
||||
+12
-8
@@ -178,11 +178,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_bucket_is_represented() {
|
||||
fn covered_and_not_checkable_are_populated() {
|
||||
let s = ControlMap::cra().unwrap().summary();
|
||||
assert!(s.covered > 0);
|
||||
assert!(s.needs_tooling > 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]
|
||||
@@ -215,14 +217,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_reflects_the_b_track_split() {
|
||||
fn coverage_after_grounded_promotion() {
|
||||
let s = ControlMap::cra().unwrap().summary();
|
||||
// 9 already tool-covered + B1's 4 custom-semgrep controls.
|
||||
assert_eq!(s.covered, 13);
|
||||
// The 8 grounded surface controls stay needs_tooling until live-tuned.
|
||||
assert_eq!(s.needs_tooling, 8);
|
||||
// B3 marked the 4 pure-architectural controls not code-checkable.
|
||||
// 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]
|
||||
|
||||
@@ -21,6 +21,7 @@ export default withMermaid(defineConfig({
|
||||
{ text: 'Adding Repositories', link: '/guide/repositories' },
|
||||
{ text: 'Running Scans', link: '/guide/scanning' },
|
||||
{ text: 'PLC / SPS (CODESYS)', link: '/guide/plc' },
|
||||
{ text: 'Demo Targets', link: '/guide/demo-targets' },
|
||||
{ text: 'Understanding Findings', link: '/guide/findings' },
|
||||
{ text: 'SBOM & Licenses', link: '/guide/sbom' },
|
||||
{ text: 'Issues & Tracking', link: '/guide/issues' },
|
||||
|
||||
@@ -6,7 +6,7 @@ Control mapping connects the scanner's raw output — deterministic tool finding
|
||||
|
||||
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, ZAP, nuclei) detects deterministically.
|
||||
- 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.
|
||||
|
||||
@@ -34,7 +34,7 @@ At scale, the **master-controls** corpus (breakpilot's deduped clusters, exporte
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
T[Deterministic tools\nsemgrep · gitleaks · syft/osv · ZAP] --> F[Findings]
|
||||
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"]
|
||||
@@ -108,16 +108,25 @@ Every finding maps to its exact control family, with the most specific control o
|
||||
- **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 + Stage 5b. Unset disables all control mapping. |
|
||||
| `BREAKPILOT_SEMANTIC_MAPPING` | Enables Stage 5c (semantic master-controls mapping). Default off. |
|
||||
| `BREAKPILOT_GROUNDED_CHECKS` | Enables Stage 5d (grounded surface checks). Default off. |
|
||||
| `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 are gated because they are the heavier, less deterministic paths; they stay off until verified live against a deployed catalog. The live verification lives in `compliance-agent/tests/c5_semantic_live.rs` (ignored; run with `--ignored`).
|
||||
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
|
||||
|
||||
|
||||
@@ -92,3 +92,7 @@ Filters can be combined. A count indicator shows how many findings match the cur
|
||||
::: 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.
|
||||
:::
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,84 @@
|
||||
# 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) |
|
||||
| Title | Short description of the vulnerability (clickable) |
|
||||
| Type | SAST, SBOM, CVE, GDPR, OAuth, Secrets, or Code Review |
|
||||
| Scanner | Tool that found the issue (e.g. Semgrep, Grype) |
|
||||
| Scanner | Tool that found the issue (e.g. Semgrep, Syft/OSV) |
|
||||
| File | Source file path where the issue was found |
|
||||
| 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 |
|
||||
|------|--------|-------------|
|
||||
| **SAST** | Semgrep | Code-level vulnerabilities found through static analysis |
|
||||
| **SBOM** | Syft + Grype | Vulnerable dependencies identified in your software bill of materials |
|
||||
| **SBOM** | Syft + OSV.dev/NVD | Vulnerable dependencies identified in your software bill of materials |
|
||||
| **CVE** | NVD | Known CVEs matching your dependency versions |
|
||||
| **GDPR** | Custom rules | Personal data handling and consent issues |
|
||||
| **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.
|
||||
|
||||
Certifai generates SBOMs automatically during each scan using Syft for dependency extraction and Grype for vulnerability matching.
|
||||
Certifai generates SBOMs automatically during each scan using Syft for dependency extraction and OSV.dev + NVD for vulnerability matching.
|
||||
|
||||
## 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)
|
||||
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 Grype
|
||||
3. **SBOM** -- extracts all dependencies using Syft, identifying packages, versions, licenses, and known vulnerabilities via OSV.dev + NVD
|
||||
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
|
||||
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 |
|
||||
|-----------|----------------|---------|
|
||||
| **SAST** | Code-level vulnerabilities (injection, XSS, insecure crypto, etc.) | Semgrep |
|
||||
| **SBOM** | Dependency inventory, outdated packages, known vulnerabilities | Syft + Grype |
|
||||
| **SBOM** | Dependency inventory, outdated packages, known vulnerabilities | Syft + OSV.dev/NVD |
|
||||
| **CVE** | Known CVEs in dependencies cross-referenced against NVD | NVD API |
|
||||
| **GDPR** | Personal data handling issues, consent violations | 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**
|
||||
An open-source tool for generating SBOMs from container images and filesystems. Used by Certifai to extract dependency information.
|
||||
|
||||
**Grype**
|
||||
An open-source vulnerability scanner for container images and filesystems. Used by Certifai to match dependencies against known vulnerabilities.
|
||||
**OSV.dev**
|
||||
Google's open distributed vulnerability database, queried by package URL. Certifai uses it (together with NVD) to match SBOM components against known vulnerabilities.
|
||||
|
||||
## Protocols
|
||||
|
||||
|
||||
+16
-6
@@ -24,15 +24,14 @@ 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.
|
||||
|
||||
## Grype -- Vulnerability Scanning
|
||||
## OSV.dev + NVD -- Vulnerability Matching
|
||||
|
||||
[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:
|
||||
Certifai matches every SBOM component directly against two public vulnerability sources (no separate scanner binary):
|
||||
|
||||
- National Vulnerability Database (NVD)
|
||||
- GitHub Advisory Database
|
||||
- OS-specific advisory databases
|
||||
- [OSV.dev](https://osv.dev/) -- batch queried by package URL (purl) for ecosystem advisories (npm, PyPI, crates.io, Go, Maven, ...)
|
||||
- [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
|
||||
|
||||
Grype produces SBOM-type findings with CVE identifiers, severity ratings, and links to advisories.
|
||||
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.
|
||||
|
||||
## Custom OAuth Scanner
|
||||
|
||||
@@ -97,3 +96,14 @@ When you mark findings as false positives or provide developer feedback, this in
|
||||
::: 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.
|
||||
:::
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user