Compare commits

..
Author SHA1 Message Date
Sharang ParnerkarandClaude Fable 5 02c7552725 fix(mcp): bind tenant to session — bearer context was lost over HTTP
CI / Check (push) Skipped
CI / Check (pull_request) Successful in 5m44s
CI / Detect Changes (pull_request) Skipped
CI / Deploy Agent (pull_request) Skipped
CI / Deploy Dashboard (pull_request) Skipped
CI / Deploy Docs (pull_request) Skipped
CI / Deploy MCP (pull_request) Skipped
Root cause (found while wiring the breakpilot MCP loop, A): every MCP tool call
over Streamable HTTP failed with '-32603 no tenant context — bearer middleware
not in chain'. The bearer middleware sets the tenant in a tokio task_local scoped
around the request, but rmcp's StreamableHttpService spawns the session's
tool-serving task (tower.rs: 'spawn a task to serve the session'), and task_locals
do NOT cross tokio::spawn. So initialize/tools-list worked but every real tool call
(list_findings, oscal_assessment, ...) failed — meaning the loop never worked over
HTTP and breakpilot's client fell back to demo data.

Fix: bind the tenant to the per-session server instance instead of a per-request
task_local. rmcp calls the service factory in the request task (before the spawn)
and still inside the middleware's scope, so the factory reads the bearer-set
tenant once and bakes it into ComplianceMcpServer; tool_db() then reads
self.tenant_id. stdio passes its synthetic tenant the same way. No tool-handler
changes; the middleware still validates (and can revoke) the token per request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:23:25 +02:00
sharang 3e233da128 feat(controls): promote grounded controls to covered + enable LLM passes by default (#224)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Docs (push) Skipped
CI / Deploy Agent (push) Successful in 7m53s
CI / Deploy Dashboard (push) Successful in 7m10s
CI / Deploy MCP (push) Successful in 1m50s
2026-07-22 07:48:51 +00:00
sharang a72f79e557 ci: fix cosign signing (install fallback, env-style login, portal sign step) (#225)
CI / Deploy Docs (push) Canceled after 0s
CI / Check (push) Canceled after 0s
CI / Detect Changes (push) Canceled after 0s
CI / Deploy Agent (push) Canceled after 0s
CI / Deploy Dashboard (push) Canceled after 0s
CI / Deploy MCP (push) Canceled after 0s
2026-07-22 07:46:34 +00:00
sharang 182dec69b8 docs(features): compliance control-mapping pipeline (#223)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Skipped
CI / Deploy Dashboard (push) Skipped
CI / Deploy MCP (push) Skipped
CI / Deploy Docs (push) Failing after 4s
2026-07-22 07:30:10 +00:00
sharang a25d41c3e5 feat(controls): enrich semantic retrieval query with finding intent + C5 live test (#222)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Dashboard (push) Skipped
CI / Deploy Docs (push) Skipped
CI / Deploy MCP (push) Skipped
CI / Deploy Agent (push) Failing after 4s
2026-07-22 07:14:49 +00:00
sharang 60601d8215 fix(llm): chunk embed() requests under the backend batch cap (#221)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Dashboard (push) Skipped
CI / Deploy Docs (push) Skipped
CI / Deploy MCP (push) Skipped
CI / Deploy Agent (push) Failing after 5s
2026-07-21 15:41:07 +00:00
sharang a7ff36edf3 feat(controls): B3 — categorize the rest of needs_tooling (architectural + RBAC) (#220)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 2s
CI / Deploy Dashboard (push) Skipped
CI / Deploy Docs (push) Skipped
CI / Deploy MCP (push) Skipped
CI / Deploy Agent (push) Failing after 4s
2026-07-21 13:15:27 +00:00
sharang 4ef257bfe2 feat(controls): B2 — grounded surface checks for absence-based CRA controls (#219)
CI / Check (push) Skipped
CI / Deploy MCP (push) Failing after 5s
CI / Detect Changes (push) Successful in 3s
CI / Deploy Docs (push) Skipped
CI / Deploy Agent (push) Failing after 5s
CI / Deploy Dashboard (push) Failing after 4s
2026-07-21 12:48:12 +00:00
sharang 38fedc661b feat(controls): B1 — custom semgrep detectors for 4 CRA controls (#218)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Dashboard (push) Skipped
CI / Deploy Docs (push) Skipped
CI / Deploy MCP (push) Skipped
CI / Deploy Agent (push) Failing after 4s
2026-07-21 12:31:20 +00:00
sharang 36a49cdeac ci: push images to Harbor (repo.meghsakha.com) + cosign (#216)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Skipped
CI / Deploy Dashboard (push) Skipped
CI / Deploy Docs (push) Skipped
CI / Deploy MCP (push) Skipped
2026-07-21 12:08:04 +00:00
sharang 0ef2cd1b23 feat(agent): cache control embedding index + auto-wire semantic pass (gated) (#217)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Docs (push) Skipped
CI / Deploy Agent (push) Failing after 3s
CI / Deploy Dashboard (push) Failing after 3s
CI / Deploy MCP (push) Failing after 2s
2026-07-21 11:29:16 +00:00
sharang f516ecf3b5 feat(agent): semantic control mapping — embedding index + region->control retrieval (#215)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Dashboard (push) Skipped
CI / Deploy Docs (push) Skipped
CI / Deploy MCP (push) Skipped
CI / Deploy Agent (push) Failing after 5s
2026-07-21 10:50:21 +00:00
sharang 18a23403a1 feat(oscal): emit unmapped findings as-is + MCP oscal_assessment tool (#214)
CI / Deploy Dashboard (push) Failing after 3s
CI / Deploy MCP (push) Failing after 2s
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Docs (push) Skipped
CI / Deploy Agent (push) Failing after 3s
2026-07-21 09:48:37 +00:00
sharang c6baf72c6d feat: control-driven SAST — LUT + grounded LLM triage over tool findings (#213)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m45s
CI / Deploy Docs (push) Skipped
CI / Deploy Dashboard (push) Successful in 2m40s
CI / Deploy MCP (push) Successful in 1m46s
2026-07-21 09:01:51 +00:00
sharang 5285fb67ae feat(oscal): live assessment endpoint POST /api/v1/oscal/assess (#212)
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 2s
CI / Deploy Docs (push) Skipped
CI / Deploy Agent (push) Successful in 3m43s
CI / Deploy Dashboard (push) Successful in 2m36s
CI / Deploy MCP (push) Successful in 1m48s
2026-07-20 19:13:47 +00:00
sharang ca4e31cb65 feat(oscal): assessment-results emitter + finding->control linker (#211)
CI / Deploy MCP (push) Successful in 1m47s
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Docs (push) Skipped
CI / Deploy Agent (push) Successful in 3m44s
CI / Deploy Dashboard (push) Successful in 2m39s
2026-07-20 16:53:39 +00:00
sharang d4884cfe4f feat(oscal): ingest breakpilot OSCAL catalog via OscalControlsProvider (#210)
CI / Deploy Agent (push) Successful in 3m40s
CI / Check (push) Skipped
CI / Detect Changes (push) Successful in 2s
CI / Deploy Docs (push) Skipped
CI / Deploy Dashboard (push) Successful in 2m34s
CI / Deploy MCP (push) Successful in 2m0s
2026-07-20 16:19:09 +00:00
sharang 94c0d51a11 feat(werkbank): make the loop runnable — enqueue + artifact serve/fetch (WB-05b) (#209)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m43s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-17 14:34:00 +00:00
sharang 70a4ee55ab refactor(werkbank): extract soft-PLC provisioning + ICS probe into werkbank-exec (WB-04a) (#208)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m46s
CI / Deploy Dashboard (push) Successful in 2m53s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
2026-07-17 12:56:15 +00:00
sharang 91a87677bc feat(werkbank): runner queue endpoints + result persistence (WB-05) (#207)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Successful in 3m44s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m49s
2026-07-17 11:42:29 +00:00
sharang 633f945a1e feat(werkbank): Mongo-backed job queue with lease + visibility timeout (WB-02) (#206)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m31s
CI / Deploy Dashboard (push) Successful in 2m38s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m47s
2026-07-17 09:15:23 +00:00
sharang 7b218fffef feat(werkbank): job/result contract in compliance-core (WB-01) (#205)
CI / Check (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 2m38s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m47s
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m51s
2026-07-17 08:54:11 +00:00
sharang 7ad7bce9db feat(plc): dynamic PLC testing via ephemeral soft-PLC (#193)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy MCP (push) Successful in 1m49s
CI / Deploy Agent (push) Successful in 3m55s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
2026-07-17 07:47:43 +00:00
sharang 1aba85b28e docs(plc): PLC Runtime Landscape reference + support watch-list (#185)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Successful in 49s
CI / Deploy MCP (push) Has been skipped
2026-07-16 22:05:39 +00:00
sharang c6a84b327f docs(plc): soft-PLC architecture + CODESYS/Yocto lifecycle diagram (#184)
CI / Check (push) Waiting to run
CI / Detect Changes (push) Waiting to run
CI / Deploy Agent (push) Blocked by required conditions
CI / Deploy Dashboard (push) Blocked by required conditions
CI / Deploy Docs (push) Blocked by required conditions
CI / Deploy MCP (push) Blocked by required conditions
2026-07-16 22:00:17 +00:00
sharang 0f4c75b6b3 fix(matrix): DAST needs an http(s) endpoint; don't offer/run it on modbus:// (#182)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Successful in 3m34s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m0s
2026-07-16 21:28:47 +00:00
sharang cd65fa345c feat(onboarding): enable opt-in scans from the wizard success step (#181)
CI / Check (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 2m38s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Has been skipped
2026-07-16 20:52:50 +00:00
sharang 097f86243e feat(cve): match the CODESYS runtime against NVD by CPE (#180)
CI / Detect Changes (push) Successful in 3s
CI / Check (push) Has been skipped
CI / Deploy Agent (push) Successful in 3m45s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-16 20:10:18 +00:00
sharang b7406eca15 feat(onboarding): Project-archive PLC format + auto-detect (#179)
CI / Check (push) Waiting to run
CI / Detect Changes (push) Waiting to run
CI / Deploy Agent (push) Blocked by required conditions
CI / Deploy Dashboard (push) Blocked by required conditions
CI / Deploy Docs (push) Blocked by required conditions
CI / Deploy MCP (push) Blocked by required conditions
2026-07-16 20:05:05 +00:00
sharang 4f9e2c2828 fix(plc): scan every PLC-source artifact, not just the first (#178)
CI / Check (push) Has been skipped
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Detect Changes (push) Successful in 3s
CI / Deploy MCP (push) Has been cancelled
2026-07-16 19:59:58 +00:00
sharang 98357cae10 fix(upload): raise body-size limit to 512 MiB + surface upload errors (#177)
CI / Deploy Agent (push) Successful in 3m33s
CI / Deploy Dashboard (push) Successful in 2m39s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
2026-07-16 18:03:40 +00:00
sharang 55e4117369 fix(dashboard): preselect saved type/kind in edit-target form (#176)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Dashboard (push) Successful in 2m36s
CI / Deploy Docs (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-16 17:52:27 +00:00
sharang 7932f60ab9 feat(ics): EtherNet/IP probe + OT service-discovery port scan (#175)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m29s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-16 16:48:22 +00:00
sharang b96c4d594c feat(ics): OPC UA reachability probe (#174)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m27s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-16 16:35:54 +00:00
sharang d2003eb3c0 feat(cve): notifications for the PLC control-app SBOM (#173)
CI / Check (push) Waiting to run
CI / Detect Changes (push) Waiting to run
CI / Deploy Agent (push) Blocked by required conditions
CI / Deploy Dashboard (push) Blocked by required conditions
CI / Deploy Docs (push) Blocked by required conditions
CI / Deploy MCP (push) Blocked by required conditions
2026-07-16 16:30:19 +00:00
sharang 77eced8314 feat(ics): dynamic Modbus/TCP probe for PLC/SPS devices (#172)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
2026-07-16 16:25:04 +00:00
sharang a3f3f1d4f5 feat(plc): ingest CODESYS projects from a git repo (SAST + SBOM) (#171)
CI / Deploy Agent (push) Successful in 11m7s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Dashboard (push) Successful in 7m0s
CI / Deploy Docs (push) Successful in 57s
CI / Deploy MCP (push) Successful in 1m48s
2026-07-16 15:47:04 +00:00
sharang 7369e031c4 feat(plc): control-application SBOM from CODESYS .projectarchive (#170)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m28s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-16 15:28:42 +00:00
sharang 386d8457d6 feat(plc): analyse graphical logic (FBD/LD) from PLCopen XML (#169)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m23s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-16 11:43:49 +00:00
sharang 3a53a1d7f2 feat(matrix): PlcSps composite device target (SBOM/CVE/DAST/pentest) (#168)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy MCP (push) Successful in 2m9s
CI / Deploy Agent (push) Successful in 3m51s
CI / Deploy Dashboard (push) Successful in 3m1s
CI / Deploy Docs (push) Has been skipped
2026-07-16 10:56:36 +00:00
sharang afa5c985ee feat(onboarding): PLC/blob file upload (multipart) + single-file ingest fix (#163)
CI / Deploy Agent (push) Successful in 5m39s
CI / Deploy Dashboard (push) Successful in 4m7s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m38s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
2026-07-16 09:41:12 +00:00
sharang 6d02b138c6 feat(pipeline): PLC/SPS control-logic security scanner (IEC 61131-3) (#162)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m48s
CI / Deploy Dashboard (push) Successful in 2m51s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m2s
2026-07-16 08:31:43 +00:00
sharang a981311413 refactor: rip out the legacy TrackedRepository / repositories path (#161)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 8m20s
CI / Deploy Dashboard (push) Successful in 3m13s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m7s
2026-07-16 07:25:45 +00:00
sharang 87f240b26f feat(onboarding): input validation + editable targets (#160)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 4m6s
CI / Deploy Dashboard (push) Successful in 3m11s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-13 17:57:48 +00:00
sharang 613847d4d3 feat(agent): real nix (sandbox=false) for firmware SBOM, replacing nix-portable (#159)
CI / Deploy Agent (push) Successful in 4m7s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
2026-07-13 16:01:09 +00:00
sharang 71aceafa26 fix(deps): bump tramiton to v0.4.1 (proot-safe firmware build) (#158)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy MCP (push) Successful in 1m44s
CI / Deploy Agent (push) Successful in 3m44s
CI / Deploy Dashboard (push) Successful in 2m34s
CI / Deploy Docs (push) Has been skipped
2026-07-13 11:21:43 +00:00
sharang b12d18d99d feat(pipeline): firmware SBOM via tramiton reproducible build (phase 2) (#157)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Successful in 4m53s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m45s
2026-07-13 10:15:06 +00:00
sharang eaaafc0621 feat(pipeline): analysis-based firmware SBOM from tramiton (#156)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m3s
CI / Deploy Dashboard (push) Successful in 3m46s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m14s
2026-07-13 08:31:33 +00:00
sharang 0ec5fd8295 fix(dashboard): Findings/SBOM filter by targets + accurate target findings_count (#155)
CI / Deploy Agent (push) Successful in 5m7s
CI / Deploy Dashboard (push) Successful in 2m57s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
2026-07-13 07:59:45 +00:00
sharang bf32b9939a fix(onboarding): targets visibility + unified pipeline by default (#154)
CI / Detect Changes (push) Successful in 3s
CI / Check (push) Has been skipped
CI / Deploy Agent (push) Successful in 3m41s
CI / Deploy Dashboard (push) Successful in 2m46s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m46s
2026-07-13 07:29:04 +00:00
sharang 669e1f1b03 feat(onboarding): scan-trigger endpoint + wizard Run-Scan button (#153)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Successful in 3m34s
CI / Deploy Dashboard (push) Successful in 2m56s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-12 22:19:32 +00:00
sharang 9e70bd1c8e ci: don't cancel-in-progress for main-branch runs (only pull_request) (#152)
CI / Detect Changes (push) Successful in 3s
CI / Check (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-12 22:08:58 +00:00
sharang 9ec07ff7a1 fix(ci): authenticate tramiton fetch in dashboard + mcp image builds (#147)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m47s
CI / Deploy Dashboard (push) Successful in 2m37s
CI / Deploy Docs (push) Has been skipped
2026-07-12 21:52:38 +00:00
sharang 0e57c2d7a7 feat(pipeline): run tramiton classification + provision DAST in run_target (#146)
CI / Deploy Agent (push) Successful in 3m40s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy MCP (push) Has been skipped
2026-07-12 21:49:22 +00:00
sharang c6a02f0ac3 feat(pipeline): unified run_target execution behind UNIFIED_PIPELINE (#145)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m47s
CI / Deploy Dashboard (push) Failing after 4m28s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Failing after 5s
2026-07-12 21:31:21 +00:00
sharang 17f788858d feat(dashboard): onboarding wizard UI (#144)
CI / Detect Changes (push) Successful in 3s
CI / Check (push) Has been skipped
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Agent (push) Has been skipped
2026-07-12 21:28:52 +00:00
sharangandSharang Parnerkar c6b5622113 fix(ci): authenticate tramiton-core fetch in the agent image build (main deploys) (#143)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 7m46s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
The PR `check` job authenticates the private tramiton-core git fetch, but the main-branch **`deploy-agent`** job builds `Dockerfile.agent` where cargo also fetches tramiton-core — inside the image build, with no credentials — so **agent image builds on main fail**.

Fix: inject the PAT as a **BuildKit secret** (never baked into an image layer).
- `Dockerfile.agent`: `RUN --mount=type=secret,id=tramiton_token …` applies the same `https`-insteadOf rewrite + `CARGO_NET_GIT_FETCH_WITH_CLI=true` before `cargo build`.
- `deploy-agent`: `DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN …`, reusing the existing `TRAMITON_FETCH_TOKEN` secret.

Only the agent image depends on tramiton-core (dashboard/mcp/docs unaffected). **Self-tests on merge** — changing `Dockerfile.agent` makes `detect-changes` run `deploy-agent`.

Note: couldn't fully run the image build locally (no PAT value on hand + no `.dockerignore` so the context is large), but this mirrors the working PR-stage auth and uses standard BuildKit secret injection. Assumes the deploy runner's Docker daemon supports BuildKit (docker:27-cli → yes).
---------

Co-authored-by: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com>
Reviewed-on: #143
2026-07-12 20:25:39 +00:00
sharang a204f0c59c feat(api): onboarding endpoints for unified targets (#142)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Failing after 5s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-12 20:15:22 +00:00
sharang cf4afdda1b feat(migrate): onboarding backfill (repositories + dast_targets -> onboarded_targets) (#141)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Failing after 5s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-12 20:11:16 +00:00
sharang a074efd0b4 ci: Kellnr crates.io mirror + persistent S3-backed sccache (#140)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-07-10 16:30:51 +00:00
sharang ef6ee3dcd1 feat(onboarding): artifact ingest + classifier + native tramiton + suite seams (#138)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Failing after 5s
CI / Deploy Dashboard (push) Failing after 5s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Failing after 4s
2026-07-10 16:00:51 +00:00
sharang 675c4ef699 feat(onboarding): unified multi-target model + scan matrix foundation (#134)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 7m36s
CI / Deploy Dashboard (push) Successful in 7m9s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m44s
2026-07-10 13:41:25 +00:00
sharang aed551231c feat(dashboard): UI for managing MCP tokens (#94)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 3m3s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
Adds /mcp-tokens page so a logged-in user can mint, list, and revoke bearer tokens for the MCP server without curl. Pairs with #92's tenant-scoped MCP middleware — copy a token from the dashboard straight into an LLM client config.
2026-06-30 16:32:54 +00:00
sharang b851f4267a feat(m7.3): scheduler pulls tenants from registry, env as fallback (#96)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
Replaces M7.2-C static SCHEDULER_TENANT_IDS with a live query to the tenant-registry at every tick. New tenants picked up without an agent restart; env stays as fallback so a registry outage never silences the scheduler. Resolution order: registry -> SCHEDULER_TENANT_IDS env -> DEFAULT_SCHEDULER_TENANT_ID. Logs the active source on startup.
2026-06-30 16:32:35 +00:00
sharang e9536b6d98 fix(audit): bump quinn-proto + ignore rmcp DNS-rebinding advisory (#97)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3m55s
CI / Deploy Dashboard (push) Successful in 2m44s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m50s
RUSTSEC-2026-0185 (quinn-proto 0.11.14): patch-bump to 0.11.15. RUSTSEC-2026-0189 (rmcp 0.16 DNS rebinding): added to ignore with public-hostname + bearer-auth threat-model justification; rmcp 0.16->2.x migration tracked as a separate multi-hour PR.
2026-06-30 16:07:01 +00:00
sharang a3a96fe2cc feat(m7.3): MCP tenant-scoped bearer tokens (#92)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 8m13s
CI / Deploy Dashboard (push) Successful in 7m3s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m50s
MCP server validates per-tenant bearer tokens on incoming calls and routes each tool to the caller's tenant DB. Closes the cross-tenant data leak in the MCP path identified in M7.3.
2026-06-30 15:27:21 +00:00
sharang ac24ca766a feat(m7.3): cross-tenant admin HTTP endpoints (#95)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
GET /api/admin/tenants lists tenant DBs; DELETE /api/admin/tenants/{tenant_id} drops them (GDPR). Behind a separate auth path that rejects customer realm tokens.
2026-06-30 15:23:39 +00:00
sharang 485c3ff45e chore(agent): remove stale unscoped webhook routes from API router (#93)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 4s
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
Webhook routes live on the separate webhook server (port 3002). M7.2-C URL form is /webhook/{tenant_id}/{platform}/{repo_id}; mounting unscoped variants on the API router would mismatch handler signatures.
2026-06-30 15:18:31 +00:00
sharang 69c4f7bb78 feat(dashboard): proactively refresh expired Keycloak tokens (#91)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 8s
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 2m55s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-06-17 20:01:37 +00:00
sharang 56482911b8 fix(dashboard): attach Keycloak token on agent API calls (#90)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 6s
CI / Deploy Agent (push) Successful in 4m8s
CI / Deploy Dashboard (push) Successful in 4m58s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-06-17 18:35:59 +00:00
sharang 183234f9af feat(m7.1): wire compliance-agent to compliance-core auth + status gate (#85)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 8m38s
CI / Deploy Dashboard (push) Successful in 7m30s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m55s
2026-06-17 09:36:52 +00:00
sharang dbadff0aac fix(m7.1): JWKS refresh-on-failure in auth middleware (#84)
CI / Deploy Agent (push) Successful in 11m44s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Dashboard (push) Successful in 13m1s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m53s
2026-06-04 14:46:14 +00:00
sharang 116293519d M7.1 smoke harness: lift auth to compliance-core + compliance-smoke service (#83)
CI / Check (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
2026-06-04 14:38:35 +00:00
sharang a8cef58e02 feat(dashboard): add light/dark theme with sidebar toggle (#81)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Dashboard (push) Successful in 9m46s
CI / Deploy Docs (push) Has been skipped
CI / Deploy Agent (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-05-13 11:44:22 +00:00
sharang 927fbc8ecb fix: live progress + concurrency for embedding builds (#80)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 7m59s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-05-13 10:01:05 +00:00
sharang e67a13535a fix: add HTTP timeout to reqwest client and CVE stage timeout (#79)
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 8m26s
CI / Deploy Dashboard (push) Has been skipped
CI / Check (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-05-13 07:30:26 +00:00
sharangandSharang Parnerkar df0063abc0 fix: scanner timeouts, semgrep memory cap, syft remote lookups, Script error (#78)
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 9m41s
CI / Check (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 15m19s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 3m7s
## Summary

- **Scan produces no results in Orca** — semgrep (`--config=auto`, unbounded memory) and syft (remote license network calls) were getting OOM-killed or hanging in resource-constrained Orca containers. Scan would "complete" with 0 findings/SBOMs silently because each scanner failure is caught and logged as a warning.
- **Dashboard Script error spam** — `document::Script` in Dioxus 0.7 needs a single text node child for inline scripts; `dangerous_inner_html` was invalid and spammed the error log on every unauthenticated page load.

## Changes

| File | Change |
|------|--------|
| `semgrep.rs` | Add `--max-memory 500 --jobs 1`; 10-minute timeout |
| `syft.rs` | Remove remote license lookup env vars; 5-minute timeout |
| `gitleaks.rs` | 5-minute timeout |
| `app_shell.rs` | Fix `dangerous_inner_html` → text child in `document::Script` |

## Test plan

- [ ] Trigger a scan on a repo in Orca — findings and SBOM entries should now appear
- [ ] Agent logs should show timeout/error warnings rather than silent empty results when tools are killed
- [ ] Navigate to dashboard unauthenticated — Script error gone from logs
- [ ] Verify scans work end-to-end with `docker compose up`

---------

Co-authored-by: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com>
Reviewed-on: #78
2026-05-12 11:27:24 +00:00
Sharang Parnerkar 5cafd13f44 ci: log orca webhook response so deploy steps arent silent
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
Nightly E2E Tests / E2E Tests (push) Failing after 2m59s
2026-04-08 15:09:27 +02:00
Sharang ParnerkarandClaude Opus 4.6 69209649a5 ci: trigger first orca build for all services
CI / Check (push) Has been skipped
CI / Deploy Agent (push) Successful in 7m5s
CI / Deploy Dashboard (push) Failing after 21m28s
CI / Detect Changes (push) Successful in 4s
CI / Deploy Docs (push) Successful in 30s
CI / Deploy MCP (push) Successful in 1m31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:10:07 +02:00
Sharang ParnerkarandClaude Opus 4.6 d5439adc0d ci: trigger build of dashboard, docs, mcp images for orca
CI / Check (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:09:49 +02:00
Sharang ParnerkarandClaude Opus 4.6 bc7cdd35e4 ci: replace coolify webhook with orca deploy
CI / Check (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
Each deploy job now builds the per-service image, pushes to the
private registry as :latest and :sha, then triggers an HMAC-signed
orca redeploy webhook. Coolify webhooks are no longer used.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:06:11 +02:00
Sharang ParnerkarandClaude Opus 4.6 c062d834a1 fix: downgrade dotenv missing file from FAILED to info message
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Has been skipped
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
Nightly E2E Tests / E2E Tests (push) Failing after 2m16s
Non-fatal in Docker where env vars come from container config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:33:24 +02:00
sharang 23cf37b6c3 fix: CVE notifications during scan + help chat doc loading + Dockerfile (#55)
CI / Check (push) Has been skipped
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy MCP (push) Has been skipped
2026-03-30 13:10:56 +00:00
sharang 49d5cd4e0a feat: hourly CVE alerting with notification bell and API (#53)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
2026-03-30 10:39:39 +00:00
sharang 4388e98b5b feat: add E2E test suite with nightly CI, fix dashboard Dockerfile (#52)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 2s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
2026-03-30 10:04:07 +00:00
sharang a8bb05d7b1 feat: add floating help chat widget, remove settings page (#51)
CI / Deploy Docs (push) Successful in 2s
CI / Deploy MCP (push) Has been skipped
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 3s
CI / Deploy Dashboard (push) Successful in 2s
2026-03-30 08:05:29 +00:00
204 changed files with 28012 additions and 2507 deletions
+23
View File
@@ -0,0 +1,23 @@
[advisories]
ignore = [
# hickory-proto 0.25.x pulled in transitively via mongodb → hickory-resolver.
# MongoDB 3.x has not yet released with hickory-resolver 0.26.x, so we cannot
# upgrade past this without a mongodb release. Both are DNS-layer DoS vectors
# requiring a MITM/controlled DNS server against MongoDB's hostname resolution —
# not a realistic attack surface here. Revisit when mongodb bumps hickory.
"RUSTSEC-2026-0118", # NSEC3 loop, no fix available upstream
"RUSTSEC-2026-0119", # O(n²) name compression, fixed in hickory-proto >=0.26.1
# rmcp 0.16.0 — DNS rebinding in Streamable HTTP server transport (missing
# Host header validation). Patched in rmcp >= 1.4.0, which is a major API
# version jump from our pin; rmcp shipped 0.x → 1.x → 2.x in three months
# and the migration touches every tool handler + the auth middleware we
# just landed in #92. Threat model in our deployment: the MCP server is
# exposed at a public hostname (comp-mcp-dev.meghsakha.com) behind orca's
# TLS-terminating ingress with per-tenant bearer auth — the attack model
# (browser DNS-rebinding into localhost MCP server) doesn't directly apply.
# Defense-in-depth Host-header check is still a worthwhile follow-up.
# FOLLOW-UP: bump rmcp to 2.x in a dedicated PR (M7.3 follow-up, sized
# multi-hour due to API surface change).
"RUSTSEC-2026-0189",
]
+18
View File
@@ -34,6 +34,24 @@ SCAN_SCHEDULE=0 0 */6 * * *
CVE_MONITOR_SCHEDULE=0 0 0 * * *
GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos
# Dynamic PLC testing — ephemeral soft-PLC provisioning (#183). Off unless
# enabled; requires the agent container to have Docker access (socket mount).
# When on, a PLC/SPS target with control logic but no reachable device gets its
# logic instantiated on a throwaway OpenPLC, probed, then torn down.
PLC_RUNTIME_ENABLED=0
PLC_RUNTIME_IMAGE=registry.meghsakha.com/openplc:latest
PLC_RUNTIME_NETWORK=certifai
PLC_RUNTIME_MEMORY=512m
PLC_RUNTIME_CPUS=0.5
PLC_RUNTIME_MAX_LIFETIME_SECS=180
PLC_RUNTIME_OPENPLC_USER=openplc
PLC_RUNTIME_OPENPLC_PASSWORD=openplc
# Werkbank runner API (/api/v1/werkbank/jobs/*, /api/v1/werkbank/artifacts/*).
# When set, mounts the runner-facing queue + artifact endpoints behind this
# bearer token; runners present the same token. Unset = endpoints not mounted.
WERKBANK_RUNNER_TOKEN=
# Dashboard
DASHBOARD_PORT=8080
AGENT_API_URL=http://localhost:3001
+137 -29
View File
@@ -7,17 +7,42 @@ 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"
# sccache caches compilation artifacts within a job so that compiling
# both --features server and --features web shares common crate work.
# Compile cache: sccache -> Hetzner S3 (breakpilot-sccache), runner-independent
# and persistent across CI runs (own key prefix). Reuses the shared cluster S3
# creds (same bucket as werkpilot). Requires repo secrets HETZNER_S3_ACCESS_KEY
# and HETZNER_S3_SECRET_KEY.
RUSTC_WRAPPER: /usr/local/bin/sccache
SCCACHE_DIR: /tmp/sccache
SCCACHE_BUCKET: breakpilot-sccache
SCCACHE_ENDPOINT: https://nbg1.your-objectstorage.com
SCCACHE_REGION: auto
SCCACHE_S3_USE_SSL: "true"
SCCACHE_S3_KEY_PREFIX: compliance-scanner
AWS_ACCESS_KEY_ID: ${{ secrets.HETZNER_S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.HETZNER_S3_SECRET_KEY }}
# compliance-agent depends on tramiton-core via git; use the system git so the
# credential rewrite below (see "Configure git auth ...") is honored on fetch.
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
# Throttle cargo so a ~670-crate concurrent download burst doesn't 429 the
# Kellnr mirror: fewer concurrent connections (HTTP/1.1) + more retries.
CARGO_NET_RETRY: "10"
CARGO_HTTP_MULTIPLEXING: "false"
# Cancel in-progress runs for the same branch/PR
# Cancel superseded PR runs, but NEVER cancel main-branch runs — those build and
# deploy per-service images, and cancelling one merge's deploy when the next
# merge lands leaves a service un-deployed (as happened between two back-to-back
# merges). So cancel-in-progress only for pull_request events.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# ---------------------------------------------------------------------------
@@ -36,16 +61,44 @@ jobs:
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout FETCH_HEAD
# Resolve crates.io deps through the self-hosted Kellnr mirror (cached,
# crates.io-independent). Git deps (tramiton-core) are unaffected — source
# replacement only applies to crates.io-sourced crates.
- name: Use Kellnr crates.io mirror
run: |
: "${CARGO_HOME:=/usr/local/cargo}"
mkdir -p "$CARGO_HOME"
{
echo '[source.crates-io]'
echo 'replace-with = "kellnr"'
echo '[registries.kellnr]'
echo 'index = "sparse+https://crates.meghsakha.com/api/v1/cratesio/"'
} >> "$CARGO_HOME/config.toml"
env:
RUSTC_WRAPPER: ""
- name: Install tools
run: |
rustup component add rustfmt clippy
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.10.0-x86_64-unknown-linux-musl/sccache
chmod +x /usr/local/bin/sccache
cargo install cargo-audit --locked
env:
RUSTC_WRAPPER: ""
# compliance-agent has a git dependency on tramiton-core (a private repo on
# this Gitea instance). Rewrite its SSH URL to HTTPS + a PAT so the runner
# can fetch it. Requires the repo secret TRAMITON_FETCH_TOKEN (a Gitea PAT
# with read:repository, owned by a user with access to sharang/tramiton).
# (Honored on fetch because CARGO_NET_GIT_FETCH_WITH_CLI=true uses system git.)
- name: Configure git auth for private tramiton dependency
run: |
git config --global \
url."https://sharang:${{ secrets.TRAMITON_FETCH_TOKEN }}@gitea.meghsakha.com/".insteadOf \
"ssh://git@gitea.meghsakha.com:22222/"
env:
RUSTC_WRAPPER: ""
# Format (no compilation needed)
- name: Format
run: cargo fmt --all --check
@@ -61,6 +114,10 @@ jobs:
run: cargo clippy -p compliance-dashboard --features web --no-default-features -- -D warnings
- name: Clippy (mcp)
run: cargo clippy -p compliance-mcp -- -D warnings
- name: Clippy (werkbank-exec)
run: cargo clippy -p werkbank-exec -- -D warnings
- name: Clippy (control-map)
run: cargo clippy -p control-map -- -D warnings
# Security audit
- name: Security Audit
@@ -69,8 +126,8 @@ jobs:
RUSTC_WRAPPER: ""
# Tests (reuses compilation artifacts from clippy)
- name: Tests (core + agent)
run: cargo test -p compliance-core -p compliance-agent
- name: Tests (core + agent + werkbank-exec + control-map)
run: cargo test -p compliance-core -p compliance-agent -p werkbank-exec -p control-map --lib
- name: Tests (dashboard server)
run: cargo test -p compliance-dashboard --features server --no-default-features
- name: Tests (dashboard web)
@@ -145,13 +202,28 @@ jobs:
needs: [detect-changes]
if: needs.detect-changes.outputs.agent == 'true'
container:
image: alpine:latest
image: docker:27-cli
steps:
- name: Trigger Coolify deploy
- name: Build, push and trigger orca redeploy
env:
# PAT for fetching the private tramiton-core git dependency during the
# image build (injected as a BuildKit secret, never baked into a layer).
TRAMITON_FETCH_TOKEN: ${{ secrets.TRAMITON_FETCH_TOKEN }}
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK_AGENT }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
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 "$REGISTRY_PASSWORD" | docker login repo.meghsakha.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 || 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}')
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"
deploy-dashboard:
name: Deploy Dashboard
@@ -159,13 +231,26 @@ jobs:
needs: [detect-changes]
if: needs.detect-changes.outputs.dashboard == 'true'
container:
image: alpine:latest
image: docker:27-cli
steps:
- name: Trigger Coolify deploy
- name: Build, push and trigger orca redeploy
env:
TRAMITON_FETCH_TOKEN: ${{ secrets.TRAMITON_FETCH_TOKEN }}
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK_DASHBOARD }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
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 "$REGISTRY_PASSWORD" | docker login repo.meghsakha.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 || 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}')
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"
deploy-docs:
name: Deploy Docs
@@ -173,13 +258,23 @@ jobs:
needs: [detect-changes]
if: needs.detect-changes.outputs.docs == 'true'
container:
image: alpine:latest
image: docker:27-cli
steps:
- name: Trigger Coolify deploy
- name: Build, push and trigger orca redeploy
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK_DOCS }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
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 "$REGISTRY_PASSWORD" | docker login repo.meghsakha.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 || 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}')
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"
deploy-mcp:
name: Deploy MCP
@@ -187,10 +282,23 @@ jobs:
needs: [detect-changes]
if: needs.detect-changes.outputs.mcp == 'true'
container:
image: alpine:latest
image: docker:27-cli
steps:
- name: Trigger Coolify deploy
- name: Build, push and trigger orca redeploy
env:
TRAMITON_FETCH_TOKEN: ${{ secrets.TRAMITON_FETCH_TOKEN }}
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK_MCP }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
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 "$REGISTRY_PASSWORD" | docker login repo.meghsakha.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 || 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}')
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"
+52
View File
@@ -0,0 +1,52 @@
name: Nightly E2E Tests
on:
schedule:
- cron: '0 3 * * *' # 3 AM UTC daily
workflow_dispatch: # Allow manual trigger
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
RUSTC_WRAPPER: /usr/local/bin/sccache
SCCACHE_DIR: /tmp/sccache
TEST_MONGODB_URI: "mongodb://root:example@mongo:27017/?authSource=admin"
concurrency:
group: nightly-e2e
cancel-in-progress: true
jobs:
e2e:
name: E2E Tests
runs-on: docker
container:
image: rust:1.94-bookworm
services:
mongo:
image: mongo:7
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
steps:
- name: Checkout
run: |
git init
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git fetch --depth=1 origin "${GITHUB_SHA:-refs/heads/main}"
git checkout FETCH_HEAD
- name: Install sccache
run: |
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \
| tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache
chmod +x /usr/local/bin/sccache
env:
RUSTC_WRAPPER: ""
- name: Run E2E tests
run: cargo test -p compliance-agent --test e2e -- --test-threads=4
- name: Show sccache stats
run: sccache --show-stats
if: always()
Generated
+183 -13
View File
@@ -666,6 +666,7 @@ dependencies = [
"compliance-core",
"compliance-dast",
"compliance-graph",
"control-map",
"dashmap",
"dotenvy",
"futures-core",
@@ -676,8 +677,10 @@ dependencies = [
"jsonwebtoken",
"mongodb",
"octocrab",
"rand 0.9.2",
"regex",
"reqwest",
"roxmltree",
"secrecy",
"serde",
"serde_json",
@@ -687,12 +690,17 @@ dependencies = [
"tokio-cron-scheduler",
"tokio-stream",
"tokio-tungstenite 0.26.2",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"tramiton-core",
"tramiton-repro",
"tramiton-sbom",
"urlencoding",
"uuid",
"walkdir",
"werkbank-exec",
"zip",
]
@@ -700,19 +708,24 @@ dependencies = [
name = "compliance-core"
version = "0.1.0"
dependencies = [
"axum",
"bson",
"chrono",
"hex",
"jsonwebtoken",
"mongodb",
"opentelemetry",
"opentelemetry-appender-tracing",
"opentelemetry-otlp",
"opentelemetry_sdk",
"reqwest",
"secrecy",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
"toml",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
@@ -813,12 +826,15 @@ dependencies = [
"bson",
"chrono",
"compliance-core",
"dashmap",
"dotenvy",
"hex",
"mongodb",
"rmcp",
"schemars 1.2.1",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
"tower-http",
@@ -826,6 +842,20 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "compliance-smoke"
version = "0.1.0"
dependencies = [
"axum",
"compliance-core",
"reqwest",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
@@ -939,6 +969,15 @@ dependencies = [
"charset",
]
[[package]]
name = "control-map"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "convert_case"
version = "0.8.0"
@@ -1095,9 +1134,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -3524,9 +3563,9 @@ checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9"
[[package]]
name = "mongodb"
version = "3.5.1"
version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803dd859e8afa084c255a8effd8000ff86f7c8076a50cd6d8c99e8f3496f75c2"
checksum = "1ef2c933617431ad0246fb5b43c425ebdae18c7f7259c87de0726d93b0e7e91b"
dependencies = [
"base64",
"bitflags",
@@ -3570,9 +3609,9 @@ dependencies = [
[[package]]
name = "mongodb-internal-macros"
version = "3.5.1"
version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a973ef3dd3dbc6f6e65bbdecfd9ec5e781b9e7493b0f369a7c62e35d8e5ae2c8"
checksum = "9e5758dc828eb2d02ec30563cba365609d56ddd833190b192beaee2b475a7bb3"
dependencies = [
"macro_magic",
"proc-macro2",
@@ -3743,6 +3782,15 @@ dependencies = [
"syn",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "octocrab"
version = "0.44.1"
@@ -4174,7 +4222,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit",
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
@@ -4259,9 +4307,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
dependencies = [
"bytes",
"getrandom 0.3.4",
@@ -4593,6 +4641,12 @@ dependencies = [
"syn",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rust-stemmers"
version = "1.2.0"
@@ -4699,9 +4753,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
@@ -4973,6 +5027,15 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -5036,6 +5099,12 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -5171,7 +5240,7 @@ version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [
"heck 0.4.1",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn",
@@ -5787,6 +5856,27 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -5796,6 +5886,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.13.0",
"serde",
"serde_spanned",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
[[package]]
name = "toml_edit"
version = "0.23.10+spec-1.0.0"
@@ -5803,7 +5907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
dependencies = [
"indexmap 2.13.0",
"toml_datetime",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"winnow",
]
@@ -5817,6 +5921,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tonic"
version = "0.12.3"
@@ -6063,6 +6173,46 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "tramiton-core"
version = "0.4.1"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
dependencies = [
"serde",
"tempfile",
"thiserror 1.0.69",
"toml",
"walkdir",
]
[[package]]
name = "tramiton-repro"
version = "0.4.1"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
dependencies = [
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 1.0.69",
"toml",
"tramiton-core",
"walkdir",
]
[[package]]
name = "tramiton-sbom"
version = "0.4.1"
source = "git+ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git?tag=v0.4.1#ae4fc1376279f9edb9882605b20877335e7ba8ba"
dependencies = [
"object",
"serde",
"serde_json",
"sha2",
"tramiton-core",
"tramiton-repro",
]
[[package]]
name = "tree-sitter"
version = "0.24.7"
@@ -6338,6 +6488,7 @@ dependencies = [
"getrandom 0.4.1",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
@@ -6581,6 +6732,25 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "werkbank-exec"
version = "0.1.0"
dependencies = [
"compliance-core",
"compliance-dast",
"futures-util",
"hex",
"regex",
"reqwest",
"secrecy",
"sha2",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
"walkdir",
]
[[package]]
name = "which"
version = "6.0.3"
+8 -2
View File
@@ -6,6 +6,9 @@ members = [
"compliance-graph",
"compliance-dast",
"compliance-mcp",
"compliance-smoke",
"werkbank-exec",
"control-map",
]
resolver = "2"
@@ -15,6 +18,7 @@ expect_used = "deny"
[workspace.dependencies]
compliance-core = { path = "compliance-core", default-features = false }
control-map = { path = "control-map" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
@@ -22,14 +26,16 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", features = ["serde"] }
mongodb = { version = "3", features = ["rustls-tls", "compat-3-0-0"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart", "cookies"], default-features = false }
thiserror = "2"
sha2 = "0.10"
hex = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
secrecy = { version = "0.10", features = ["serde"] }
regex = "1"
zip = { version = "2", features = ["aes-crypto", "deflate"] }
dashmap = "6"
tokio-stream = { version = "0.1", features = ["sync"] }
aes-gcm = "0.10"
rand = "0.9"
base64 = "0.22"
+47 -2
View File
@@ -2,7 +2,22 @@ FROM rust:1.94-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release -p compliance-agent
# compliance-agent depends on the private tramiton-core git repo. Authenticate
# the fetch with a PAT passed as a BuildKit secret (never baked into a layer).
# Build with: DOCKER_BUILDKIT=1 docker build --secret id=tramiton_token,env=TRAMITON_FETCH_TOKEN ...
RUN --mount=type=secret,id=tramiton_token \
if [ -s /run/secrets/tramiton_token ]; then \
git config --global \
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
"ssh://git@gitea.meghsakha.com:22222/"; \
fi && \
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-agent
# A throwaway stage that packs a real nix store (store paths + the validity DB)
# into a compressed bootstrap tarball. Only the tarball is copied into the final
# image, so we don't carry a raw /nix copy layer.
FROM nixos/nix:latest AS nixseed
RUN tar -C / -czf /nix-bootstrap.tar.gz nix
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl python3 python3-pip npm golang-go php-cli && rm -rf /var/lib/apt/lists/*
@@ -31,11 +46,41 @@ RUN pip3 install --break-system-packages semgrep
# Install ruff for Python linting
RUN pip3 install --break-system-packages ruff
# Real nix for the tramiton reproducible-build firmware SBOM.
#
# nix-portable's proot fallback can't run here: user namespaces are blocked by
# the container's default seccomp/apparmor profile, and orca exposes no way to
# relax it. So ship a *real* nix and disable its build sandbox
# (`sandbox = false`) — a plain gcc/make firmware build needs no user namespace,
# so it runs fine under the locked-down profile with no proot involved.
#
# The store is shipped as a bootstrap tarball and seeded onto /nix at first
# start (see docker/agent-entrypoint.sh), so a persistent /nix volume survives
# redeploys. A missing/broken nix just falls back to the analysis-only SBOM.
COPY --from=nixseed /nix-bootstrap.tar.gz /opt/nix-bootstrap.tar.gz
ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}"
RUN mkdir -p /etc/nix && printf '%s\n' \
'experimental-features = nix-command flakes' \
'sandbox = false' \
'build-users-group =' \
'substituters = https://cache.nixos.org' \
'trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=' \
> /etc/nix/nix.conf
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
COPY docker/agent-entrypoint.sh /usr/local/bin/agent-entrypoint.sh
RUN chmod +x /usr/local/bin/agent-entrypoint.sh
# Copy documentation for the help chat assistant
COPY --from=builder /app/README.md /app/README.md
COPY --from=builder /app/docs /app/docs
ENV HELP_DOCS_PATH=/app
# Ensure SSH key directory exists
RUN mkdir -p /data/compliance-scanner/ssh
EXPOSE 3001 3002
ENTRYPOINT ["compliance-agent"]
# Seeds /nix (fresh volume) from the bootstrap tarball, then runs the agent.
ENTRYPOINT ["/usr/local/bin/agent-entrypoint.sh"]
+12 -2
View File
@@ -1,13 +1,22 @@
FROM rust:1.94-bookworm AS builder
RUN cargo install dioxus-cli --version 0.7.3
RUN cargo install dioxus-cli --version 0.7.3 --locked
ARG DOCS_URL=/docs
WORKDIR /app
COPY . .
ENV DOCS_URL=${DOCS_URL}
RUN dx build --release --package compliance-dashboard
# compliance-agent (a workspace member) depends on the private tramiton-core git
# repo, so the workspace resolve needs it even to build the dashboard.
# Authenticate the fetch with a PAT passed as a BuildKit secret.
RUN --mount=type=secret,id=tramiton_token \
if [ -s /run/secrets/tramiton_token ]; then \
git config --global \
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
"ssh://git@gitea.meghsakha.com:22222/"; \
fi && \
CARGO_NET_GIT_FETCH_WITH_CLI=true dx build --release --package compliance-dashboard
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
@@ -20,3 +29,4 @@ ENV IP=0.0.0.0
EXPOSE 8080
ENTRYPOINT ["./compliance-dashboard"]
+1
View File
@@ -12,3 +12,4 @@ RUN rm /etc/nginx/conf.d/default.conf
COPY docs/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/.vitepress/dist /usr/share/nginx/html
EXPOSE 80
+11 -1
View File
@@ -2,7 +2,16 @@ FROM rust:1.94-bookworm AS builder
WORKDIR /app
COPY . .
RUN cargo build --release -p compliance-mcp
# compliance-agent (a workspace member) depends on the private tramiton-core git
# repo, so the workspace resolve needs it even to build the mcp binary.
# Authenticate the fetch with a PAT passed as a BuildKit secret.
RUN --mount=type=secret,id=tramiton_token \
if [ -s /run/secrets/tramiton_token ]; then \
git config --global \
url."https://sharang:$(cat /run/secrets/tramiton_token)@gitea.meghsakha.com/".insteadOf \
"ssh://git@gitea.meghsakha.com:22222/"; \
fi && \
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p compliance-mcp
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
@@ -14,3 +23,4 @@ EXPOSE 8090
ENV MCP_PORT=8090
ENTRYPOINT ["compliance-mcp"]
+32 -3
View File
@@ -7,9 +7,23 @@ edition = "2021"
workspace = true
[dependencies]
compliance-core = { workspace = true, features = ["mongodb", "telemetry"] }
compliance-core = { workspace = true, features = ["mongodb", "telemetry", "axum"] }
control-map = { workspace = true }
compliance-graph = { path = "../compliance-graph" }
compliance-dast = { path = "../compliance-dast" }
# Shared dynamic-execution logic (soft-PLC provisioning + ICS probing), also
# used by the Werkbank runner.
werkbank-exec = { path = "../werkbank-exec" }
# Native firmware build/target detection for bare-metal & RTOS artifacts.
# Same-company IP, used directly (not via CLI) so the whole tramiton suite is
# available to the onboarding classifier. NOTE: CI must be able to fetch this
# private repo (see the git-auth step in .gitea/workflows/ci.yml).
tramiton-core = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
# tramiton-repro drives the reproducible build (NixBackend seal_and_build) that
# yields a sealed lock; `libraries_from_inputs` is the analysis-only fallback.
tramiton-repro = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
# tramiton-sbom renders the bill of materials from a sealed lock (+ binary SCA).
tramiton-sbom = { git = "ssh://git@gitea.meghsakha.com:22222/sharang/tramiton.git", tag = "v0.4.1" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
@@ -24,14 +38,16 @@ hex = { workspace = true }
uuid = { workspace = true }
secrecy = { workspace = true }
regex = { workspace = true }
axum = "0.8"
tower-http = { version = "0.6", features = ["cors", "trace"] }
axum = { version = "0.8", features = ["multipart"] }
tower-http = { version = "0.6", features = ["cors", "trace", "set-header"] }
git2 = "0.20"
octocrab = "0.44"
tokio-cron-scheduler = "0.13"
dotenvy = "0.15"
hmac = "0.12"
walkdir = "2"
# Read-only XML tree parsing for PLCopen project files (POU extraction).
roxmltree = "0.20"
base64 = "0.22"
urlencoding = "2"
futures-util = "0.3"
@@ -42,3 +58,16 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
futures-core = "0.3"
dashmap = { workspace = true }
tokio-stream = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
compliance-core = { workspace = true, features = ["mongodb", "axum"] }
tower = { version = "0.5", features = ["util"] }
reqwest = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
mongodb = { workspace = true }
uuid = { workspace = true }
secrecy = { workspace = true }
axum = { version = "0.8", features = ["multipart"] }
tower-http = { version = "0.6", features = ["cors"] }
+117
View File
@@ -0,0 +1,117 @@
# 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(...)'
+47 -26
View File
@@ -6,7 +6,7 @@ use tokio::sync::{broadcast, watch, Semaphore};
use compliance_core::models::pentest::PentestEvent;
use compliance_core::AgentConfig;
use crate::database::Database;
use crate::database::DatabasePool;
use crate::llm::LlmClient;
use crate::pipeline::orchestrator::PipelineOrchestrator;
@@ -16,7 +16,10 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS: usize = 5;
#[derive(Clone)]
pub struct ComplianceAgent {
pub config: AgentConfig,
pub db: Database,
/// Per-tenant Mongo broker. Every code path must obtain a
/// tenant-scoped [`crate::database::Database`] from this pool —
/// there is no single shared database any more.
pub db_pool: DatabasePool,
pub llm: Arc<LlmClient>,
pub http: reqwest::Client,
/// Per-session broadcast senders for SSE streaming.
@@ -28,18 +31,23 @@ pub struct ComplianceAgent {
}
impl ComplianceAgent {
pub fn new(config: AgentConfig, db: Database) -> Self {
pub fn new(config: AgentConfig, db_pool: DatabasePool) -> Self {
let llm = Arc::new(LlmClient::new(
config.litellm_url.clone(),
config.litellm_api_key.clone(),
config.litellm_model.clone(),
config.litellm_embed_model.clone(),
));
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default();
Self {
config,
db,
db_pool,
llm,
http: reqwest::Client::new(),
http,
session_streams: Arc::new(DashMap::new()),
session_pause: Arc::new(DashMap::new()),
session_semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_SESSIONS)),
@@ -48,44 +56,57 @@ impl ComplianceAgent {
pub async fn run_scan(
&self,
tenant_id: &str,
repo_id: &str,
trigger: compliance_core::models::ScanTrigger,
) -> Result<(), crate::error::AgentError> {
let orchestrator = PipelineOrchestrator::new(
self.config.clone(),
self.db.clone(),
self.llm.clone(),
self.http.clone(),
);
orchestrator.run(repo_id, trigger).await
let db = self.db_pool.for_tenant_id(tenant_id).await?;
let orchestrator =
PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone());
orchestrator.run_target(repo_id, trigger).await
}
/// Alias for [`Self::run_scan`] — every scan runs the unified onboarded-target
/// pipeline. Kept as a distinct name for the `/targets/{id}/scan` endpoint's
/// intent.
pub async fn run_target_scan(
&self,
tenant_id: &str,
target_id: &str,
trigger: compliance_core::models::ScanTrigger,
) -> Result<(), crate::error::AgentError> {
let db = self.db_pool.for_tenant_id(tenant_id).await?;
let orchestrator =
PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone());
orchestrator.run_target(target_id, trigger).await
}
/// Run a PR review: scan the diff and post review comments.
pub async fn run_pr_review(
&self,
tenant_id: &str,
repo_id: &str,
pr_number: u64,
base_sha: &str,
head_sha: &str,
) -> Result<(), crate::error::AgentError> {
let repo = self
.db
.repositories()
.find_one(mongodb::bson::doc! {
"_id": mongodb::bson::oid::ObjectId::parse_str(repo_id)
.map_err(|e| crate::error::AgentError::Other(e.to_string()))?
})
let db = self.db_pool.for_tenant_id(tenant_id).await?;
let oid = mongodb::bson::oid::ObjectId::parse_str(repo_id)
.map_err(|e| crate::error::AgentError::Other(e.to_string()))?;
let target = db
.onboarded_targets()
.find_one(mongodb::bson::doc! { "_id": oid })
.await?
.ok_or_else(|| {
crate::error::AgentError::Other(format!("Repository {repo_id} not found"))
crate::error::AgentError::Other(format!("Target {repo_id} not found"))
})?;
let code = target.code_artifact().ok_or_else(|| {
crate::error::AgentError::Other(format!("Target {repo_id} has no code artifact"))
})?;
let repo = crate::pipeline::repo_view::RepoView::from_target(&target, code);
let orchestrator = PipelineOrchestrator::new(
self.config.clone(),
self.db.clone(),
self.llm.clone(),
self.http.clone(),
);
let orchestrator =
PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone());
orchestrator
.run_pr_review(&repo, repo_id, pr_number, base_sha, head_sha)
.await
-113
View File
@@ -1,113 +0,0 @@
use std::sync::Arc;
use axum::{
extract::Request,
middleware::Next,
response::{IntoResponse, Response},
};
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
use reqwest::StatusCode;
use serde::Deserialize;
use tokio::sync::RwLock;
/// Cached JWKS from Keycloak for token validation.
#[derive(Clone)]
pub struct JwksState {
pub jwks: Arc<RwLock<Option<JwkSet>>>,
pub jwks_url: String,
}
#[derive(Debug, Deserialize)]
struct Claims {
#[allow(dead_code)]
sub: String,
}
const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"];
/// Middleware that validates Bearer JWT tokens against Keycloak's JWKS.
///
/// Skips validation for health check endpoints.
/// If `JwksState` is not present as an extension (keycloak not configured),
/// all requests pass through.
pub async fn require_jwt_auth(request: Request, next: Next) -> Response {
let path = request.uri().path();
if PUBLIC_ENDPOINTS.contains(&path) {
return next.run(request).await;
}
let jwks_state = match request.extensions().get::<JwksState>() {
Some(s) => s.clone(),
None => return next.run(request).await,
};
let auth_header = match request.headers().get("authorization") {
Some(h) => h,
None => return (StatusCode::UNAUTHORIZED, "Missing authorization header").into_response(),
};
let token = match auth_header.to_str() {
Ok(s) if s.starts_with("Bearer ") => &s[7..],
_ => return (StatusCode::UNAUTHORIZED, "Invalid authorization header").into_response(),
};
match validate_token(token, &jwks_state).await {
Ok(()) => next.run(request).await,
Err(e) => {
tracing::warn!("JWT validation failed: {e}");
(StatusCode::UNAUTHORIZED, "Invalid token").into_response()
}
}
}
async fn validate_token(token: &str, state: &JwksState) -> Result<(), String> {
let header = decode_header(token).map_err(|e| format!("failed to decode JWT header: {e}"))?;
let kid = header
.kid
.ok_or_else(|| "JWT missing kid header".to_string())?;
let jwks = fetch_or_get_jwks(state).await?;
let jwk = jwks
.keys
.iter()
.find(|k| k.common.key_id.as_deref() == Some(&kid))
.ok_or_else(|| "no matching key found in JWKS".to_string())?;
let decoding_key =
DecodingKey::from_jwk(jwk).map_err(|e| format!("failed to create decoding key: {e}"))?;
let mut validation = Validation::new(header.alg);
validation.validate_exp = true;
validation.validate_aud = false;
decode::<Claims>(token, &decoding_key, &validation)
.map_err(|e| format!("token validation failed: {e}"))?;
Ok(())
}
async fn fetch_or_get_jwks(state: &JwksState) -> Result<JwkSet, String> {
{
let cached = state.jwks.read().await;
if let Some(ref jwks) = *cached {
return Ok(jwks.clone());
}
}
let resp = reqwest::get(&state.jwks_url)
.await
.map_err(|e| format!("failed to fetch JWKS: {e}"))?;
let jwks: JwkSet = resp
.json()
.await
.map_err(|e| format!("failed to parse JWKS: {e}"))?;
let mut cached = state.jwks.write().await;
*cached = Some(jwks.clone());
Ok(jwks)
}
+115
View File
@@ -0,0 +1,115 @@
//! Cross-tenant admin endpoints (`/api/v1/admin/*`).
//!
//! Operator-only. Auth is a **static bearer token** (`ADMIN_API_TOKEN`
//! env on the agent) — explicitly NOT a Keycloak JWT, because the
//! whole point of these endpoints is to operate ACROSS tenants. A
//! customer JWT (which always carries a single tenant_id) has no
//! business mounting them.
//!
//! Routes are only registered when `ADMIN_API_TOKEN` is set. With no
//! token, the endpoints don't exist at all (404), which is a stronger
//! guarantee than "401 if you guess the path".
//!
//! Operations:
//! - `GET /api/v1/admin/tenants` — list tenant DBs
//! - `DELETE /api/v1/admin/tenants/{tenant_id}` — GDPR delete
//!
//! Tenant ids in URLs are passed as-is to `DatabasePool::drop_tenant`,
//! which sanitises them the same way it does for creation. Listing
//! returns the raw DB names from `list_tenant_db_names` — operators
//! can reverse-derive the tenant_id from the prefix.
use axum::extract::{Extension, Path, Request};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use secrecy::ExposeSecret;
use serde::Serialize;
use super::dto::AgentExt;
#[derive(Serialize)]
pub struct ListTenantDbsResponse {
pub tenant_db_names: Vec<String>,
}
#[tracing::instrument(skip_all)]
pub async fn list_tenant_dbs(
Extension(agent): AgentExt,
) -> Result<Json<ListTenantDbsResponse>, StatusCode> {
let names = agent.db_pool.list_tenant_db_names().await.map_err(|e| {
tracing::error!("admin: list_tenant_db_names failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(ListTenantDbsResponse {
tenant_db_names: names,
}))
}
#[tracing::instrument(skip_all, fields(tenant_id = %tenant_id))]
pub async fn drop_tenant_db(
Extension(agent): AgentExt,
Path(tenant_id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
agent.db_pool.drop_tenant(&tenant_id).await.map_err(|e| {
tracing::error!("admin: drop_tenant failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(serde_json::json!({ "status": "dropped" })))
}
/// Constant-time-ish comparison of the configured admin token against
/// the incoming bearer. Uses `subtle`-style byte equality so timing
/// attacks can't probe the token character by character.
fn tokens_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
diff |= x ^ y;
}
diff == 0
}
/// Middleware enforcing the static `ADMIN_API_TOKEN`. Mounted only on
/// the admin sub-router, so this never runs on customer routes.
pub async fn require_admin_token(
Extension(agent): AgentExt,
request: Request,
next: Next,
) -> Response {
let Some(expected) = agent.config.admin_api_token.as_ref() else {
// Belt-and-braces — if the routes were somehow mounted without
// a token configured, refuse rather than no-op-pass.
return (StatusCode::NOT_FOUND, "admin disabled").into_response();
};
let presented = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(|s| s.trim());
let Some(presented) = presented.filter(|s| !s.is_empty()) else {
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
};
if !tokens_eq(presented, expected.expose_secret()) {
return (StatusCode::UNAUTHORIZED, "Invalid admin token").into_response();
}
next.run(request).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokens_eq_basic() {
assert!(tokens_eq("abc", "abc"));
assert!(!tokens_eq("abc", "abd"));
assert!(!tokens_eq("abc", "abcd"));
assert!(!tokens_eq("", "x"));
assert!(tokens_eq("", ""));
}
}
+40 -28
View File
@@ -7,11 +7,13 @@ use mongodb::bson::doc;
use compliance_core::models::chat::{ChatRequest, ChatResponse, SourceReference};
use compliance_core::models::embedding::EmbeddingBuildRun;
use compliance_core::tenant_ctx::TenantCtx;
use compliance_graph::graph::embedding_store::EmbeddingStore;
use crate::agent::ComplianceAgent;
use crate::rag::pipeline::RagPipeline;
use super::dto::tenant_db;
use super::ApiResponse;
type AgentExt = Extension<Arc<ComplianceAgent>>;
@@ -20,10 +22,12 @@ type AgentExt = Extension<Arc<ComplianceAgent>>;
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub async fn chat(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
Json(req): Json<ChatRequest>,
) -> Result<Json<ApiResponse<ChatResponse>>, StatusCode> {
let pipeline = RagPipeline::new(agent.llm.clone(), agent.db.inner());
let db = tenant_db(&agent, &tenant).await?;
let pipeline = RagPipeline::new(agent.llm.clone(), db.inner());
// Step 1: Embed the user's message
let query_vectors = agent
@@ -133,13 +137,16 @@ pub async fn chat(
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub async fn build_embeddings(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
// Resolve the tenant DB up front so we can move it into the spawn;
// the JWT/dev context isn't available inside detached tasks.
let db = tenant_db(&agent, &tenant).await?;
let agent_clone = (*agent).clone();
tokio::spawn(async move {
let repo = match agent_clone
.db
.repositories()
let repo = match db
.onboarded_targets()
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
.await
{
@@ -151,8 +158,7 @@ pub async fn build_embeddings(
};
// Get latest graph build
let build = match agent_clone
.db
let build = match db
.graph_builds()
.find_one(doc! { "repo_id": &repo_id })
.sort(doc! { "started_at": -1 })
@@ -171,35 +177,39 @@ pub async fn build_embeddings(
.unwrap_or_else(|| "unknown".to_string());
// Get nodes
let nodes: Vec<compliance_core::models::graph::CodeNode> = match agent_clone
.db
.graph_nodes()
.find(doc! { "repo_id": &repo_id })
.await
{
Ok(cursor) => {
use futures_util::StreamExt;
let mut items = Vec::new();
let mut cursor = cursor;
while let Some(Ok(item)) = cursor.next().await {
items.push(item);
let nodes: Vec<compliance_core::models::graph::CodeNode> =
match db.graph_nodes().find(doc! { "repo_id": &repo_id }).await {
Ok(cursor) => {
use futures_util::StreamExt;
let mut items = Vec::new();
let mut cursor = cursor;
while let Some(Ok(item)) = cursor.next().await {
items.push(item);
}
items
}
items
}
Err(e) => {
tracing::error!("[{repo_id}] Failed to fetch nodes: {e}");
Err(e) => {
tracing::error!("[{repo_id}] Failed to fetch nodes: {e}");
return;
}
};
let code = match repo.code_artifact() {
Some(c) => c,
None => {
tracing::error!("Target {repo_id} has no code artifact for embedding build");
return;
}
};
let view = crate::pipeline::repo_view::RepoView::from_target(&repo, code);
let creds = crate::pipeline::git::RepoCredentials {
ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()),
auth_token: repo.auth_token.clone(),
auth_username: repo.auth_username.clone(),
auth_token: view.auth_token.clone(),
auth_username: view.auth_username.clone(),
};
let git_ops =
crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds);
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
let repo_path = match git_ops.clone_or_fetch(&view.git_url, &view.name) {
Ok(p) => p,
Err(e) => {
tracing::error!("Failed to clone repo for embedding build: {e}");
@@ -207,7 +217,7 @@ pub async fn build_embeddings(
}
};
let pipeline = RagPipeline::new(agent_clone.llm.clone(), agent_clone.db.inner());
let pipeline = RagPipeline::new(agent_clone.llm.clone(), db.inner());
match pipeline
.build_embeddings(&repo_id, &repo_path, &graph_build_id, &nodes)
.await
@@ -234,9 +244,11 @@ pub async fn build_embeddings(
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub async fn embedding_status(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
) -> Result<Json<ApiResponse<Option<EmbeddingBuildRun>>>, StatusCode> {
let store = EmbeddingStore::new(agent.db.inner());
let db = tenant_db(&agent, &tenant).await?;
let store = EmbeddingStore::new(db.inner());
let build = store.get_latest_build(&repo_id).await.map_err(|e| {
tracing::error!("Failed to get embedding status: {e}");
StatusCode::INTERNAL_SERVER_ERROR
+20 -11
View File
@@ -7,9 +7,11 @@ use mongodb::bson::doc;
use serde::Deserialize;
use compliance_core::models::dast::{DastFinding, DastScanRun, DastTarget, DastTargetType};
use compliance_core::tenant_ctx::TenantCtx;
use crate::agent::ComplianceAgent;
use super::dto::tenant_db;
use super::{collect_cursor_async, ApiResponse, PaginationParams};
type AgentExt = Extension<Arc<ComplianceAgent>>;
@@ -45,9 +47,11 @@ fn default_rate_limit() -> u32 {
#[tracing::instrument(skip_all)]
pub async fn list_targets(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<Vec<DastTarget>>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = db
.dast_targets()
@@ -80,6 +84,7 @@ pub async fn list_targets(
#[tracing::instrument(skip_all)]
pub async fn add_target(
Extension(agent): AgentExt,
tenant: TenantCtx,
Json(req): Json<AddTargetRequest>,
) -> Result<Json<ApiResponse<DastTarget>>, StatusCode> {
let mut target = DastTarget::new(req.name, req.base_url, req.target_type);
@@ -89,9 +94,8 @@ pub async fn add_target(
target.rate_limit = req.rate_limit;
target.allow_destructive = req.allow_destructive;
agent
.db
.dast_targets()
let db = tenant_db(&agent, &tenant).await?;
db.dast_targets()
.insert_one(&target)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -107,19 +111,19 @@ pub async fn add_target(
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn trigger_scan(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
let target = agent
.db
let target = db
.dast_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let db = agent.db.clone();
tokio::spawn(async move {
let orchestrator = compliance_dast::DastOrchestrator::new(100);
match orchestrator.run_scan(&target, Vec::new()).await {
@@ -147,9 +151,11 @@ pub async fn trigger_scan(
#[tracing::instrument(skip_all)]
pub async fn list_scan_runs(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<Vec<DastScanRun>>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = db
.dast_scan_runs()
@@ -183,9 +189,11 @@ pub async fn list_scan_runs(
#[tracing::instrument(skip_all)]
pub async fn list_findings(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<Vec<DastFinding>>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = db
.dast_findings()
@@ -219,12 +227,13 @@ pub async fn list_findings(
#[tracing::instrument(skip_all, fields(finding_id = %id))]
pub async fn get_finding(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<DastFinding>>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
let finding = agent
.db
let finding = db
.dast_findings()
.find_one(doc! { "_id": oid })
.await
+21
View File
@@ -180,6 +180,27 @@ pub struct SbomVersionDiff {
pub(crate) type AgentExt = axum::extract::Extension<std::sync::Arc<crate::agent::ComplianceAgent>>;
pub(crate) type ApiResult<T> = Result<axum::Json<ApiResponse<T>>, axum::http::StatusCode>;
/// Resolve a tenant-scoped [`Database`] from the request's
/// [`TenantContext`] (inserted by the M7.1 JWT middleware, or by the
/// dev fallback in unsecured environments). The pool ensures the
/// tenant's indexes idempotently.
///
/// Returns 500 on the rare path where Mongo refuses the database
/// handle — the M7.1 auth/status middleware already rejects every
/// other failure mode with 4xx before we get here.
pub(crate) async fn tenant_db(
agent: &crate::agent::ComplianceAgent,
tenant: &compliance_core::tenant_ctx::TenantCtx,
) -> Result<crate::database::Database, axum::http::StatusCode> {
agent.db_pool.for_tenant(&tenant.0).await.map_err(|e| {
tracing::error!(
tenant_id = %tenant.0.tenant_id,
"Failed to acquire tenant database: {e}"
);
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})
}
pub(crate) async fn collect_cursor_async<T: serde::de::DeserializeOwned + Unpin + Send>(
mut cursor: mongodb::Cursor<T>,
) -> Vec<T> {
+16 -11
View File
@@ -5,13 +5,16 @@ use mongodb::bson::doc;
use super::dto::*;
use compliance_core::models::Finding;
use compliance_core::tenant_ctx::TenantCtx;
#[tracing::instrument(skip_all, fields(repo_id = ?filter.repo_id, severity = ?filter.severity, scan_type = ?filter.scan_type))]
pub async fn list_findings(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(filter): Query<FindingsFilter>,
) -> ApiResult<Vec<Finding>> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let mut query = doc! {};
if let Some(repo_id) = &filter.repo_id {
query.insert("repo_id", repo_id);
@@ -81,11 +84,12 @@ pub async fn list_findings(
#[tracing::instrument(skip_all, fields(finding_id = %id))]
pub async fn get_finding(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<Finding>>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let finding = agent
.db
let db = tenant_db(&agent, &tenant).await?;
let finding = db
.findings()
.find_one(doc! { "_id": oid })
.await
@@ -102,14 +106,14 @@ pub async fn get_finding(
#[tracing::instrument(skip_all, fields(finding_id = %id))]
pub async fn update_finding_status(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
Json(req): Json<UpdateStatusRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
agent
.db
.findings()
db.findings()
.update_one(
doc! { "_id": oid },
doc! { "$set": { "status": &req.status, "updated_at": mongodb::bson::DateTime::now() } },
@@ -123,6 +127,7 @@ pub async fn update_finding_status(
#[tracing::instrument(skip_all)]
pub async fn bulk_update_finding_status(
Extension(agent): AgentExt,
tenant: TenantCtx,
Json(req): Json<BulkUpdateStatusRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oids: Vec<mongodb::bson::oid::ObjectId> = req
@@ -135,8 +140,8 @@ pub async fn bulk_update_finding_status(
return Err(StatusCode::BAD_REQUEST);
}
let result = agent
.db
let db = tenant_db(&agent, &tenant).await?;
let result = db
.findings()
.update_many(
doc! { "_id": { "$in": oids } },
@@ -153,14 +158,14 @@ pub async fn bulk_update_finding_status(
#[tracing::instrument(skip_all)]
pub async fn update_finding_feedback(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
Json(req): Json<UpdateFeedbackRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
agent
.db
.findings()
db.findings()
.update_one(
doc! { "_id": oid },
doc! { "$set": { "developer_feedback": &req.feedback, "updated_at": mongodb::bson::DateTime::now() } },
+37 -15
View File
@@ -7,9 +7,11 @@ use mongodb::bson::doc;
use serde::{Deserialize, Serialize};
use compliance_core::models::graph::{CodeEdge, CodeNode, GraphBuildRun, ImpactAnalysis};
use compliance_core::tenant_ctx::TenantCtx;
use crate::agent::ComplianceAgent;
use super::dto::tenant_db;
use super::{collect_cursor_async, ApiResponse};
type AgentExt = Extension<Arc<ComplianceAgent>>;
@@ -36,9 +38,11 @@ fn default_search_limit() -> usize {
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub async fn get_graph(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
) -> Result<Json<ApiResponse<GraphData>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
// Get latest build
let build: Option<GraphBuildRun> = db
@@ -98,9 +102,11 @@ pub async fn get_graph(
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub async fn get_nodes(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
) -> Result<Json<ApiResponse<Vec<CodeNode>>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let filter = doc! { "repo_id": &repo_id };
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter).await {
@@ -123,9 +129,11 @@ pub async fn get_nodes(
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub async fn get_communities(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
) -> Result<Json<ApiResponse<Vec<CommunityInfo>>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let filter = doc! { "repo_id": &repo_id };
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter).await {
@@ -176,9 +184,11 @@ pub struct CommunityInfo {
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, finding_id = %finding_id))]
pub async fn get_impact(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path((repo_id, finding_id)): Path<(String, String)>,
) -> Result<Json<ApiResponse<Option<ImpactAnalysis>>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let filter = doc! { "repo_id": &repo_id, "finding_id": &finding_id };
let impact = db
@@ -198,10 +208,12 @@ pub async fn get_impact(
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, query = %params.q))]
pub async fn search_symbols(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
Query(params): Query<SearchParams>,
) -> Result<Json<ApiResponse<Vec<CodeNode>>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
// Simple text search on qualified_name and name fields
let filter = doc! {
@@ -234,14 +246,16 @@ pub async fn search_symbols(
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub async fn get_file_content(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
Query(params): Query<FileContentParams>,
) -> Result<Json<ApiResponse<FileContent>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
// Look up the repository to get repo name
let repo = db
.repositories()
.onboarded_targets()
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
@@ -296,13 +310,14 @@ pub struct FileContent {
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub async fn trigger_build(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(repo_id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let db = tenant_db(&agent, &tenant).await?;
let agent_clone = (*agent).clone();
tokio::spawn(async move {
let repo = match agent_clone
.db
.repositories()
let repo = match db
.onboarded_targets()
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
.await
{
@@ -313,14 +328,22 @@ pub async fn trigger_build(
}
};
let code = match repo.code_artifact() {
Some(c) => c,
None => {
tracing::error!("Target {repo_id} has no code artifact for graph build");
return;
}
};
let view = crate::pipeline::repo_view::RepoView::from_target(&repo, code);
let creds = crate::pipeline::git::RepoCredentials {
ssh_key_path: Some(agent_clone.config.ssh_key_path.clone()),
auth_token: repo.auth_token.clone(),
auth_username: repo.auth_username.clone(),
auth_token: view.auth_token.clone(),
auth_username: view.auth_username.clone(),
};
let git_ops =
crate::pipeline::git::GitOps::new(&agent_clone.config.git_clone_base_path, creds);
let repo_path = match git_ops.clone_or_fetch(&repo.git_url, &repo.name) {
let repo_path = match git_ops.clone_or_fetch(&view.git_url, &view.name) {
Ok(p) => p,
Err(e) => {
tracing::error!("Failed to clone repo for graph build: {e}");
@@ -333,8 +356,7 @@ pub async fn trigger_build(
match engine.build_graph(&repo_path, &repo_id, &graph_build_id) {
Ok((code_graph, build_run)) => {
let store =
compliance_graph::graph::persistence::GraphStore::new(agent_clone.db.inner());
let store = compliance_graph::graph::persistence::GraphStore::new(db.inner());
let _ = store.delete_repo_graph(&repo_id).await;
let _ = store
.store_graph(&build_run, &code_graph.nodes, &code_graph.edges)
+20 -3
View File
@@ -3,18 +3,35 @@ use mongodb::bson::doc;
use super::dto::*;
use compliance_core::models::ScanRun;
use compliance_core::tenant_ctx::TenantCtx;
#[tracing::instrument(skip_all)]
pub async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "status": "ok" }))
}
/// GET /api/v1/settings/ssh-public-key — the agent's SSH deploy public key,
/// for adding as a read-only deploy key on private git targets.
#[tracing::instrument(skip_all)]
pub async fn stats_overview(axum::extract::Extension(agent): AgentExt) -> ApiResult<OverviewStats> {
let db = &agent.db;
pub async fn get_ssh_public_key(
axum::extract::Extension(agent): AgentExt,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
let public_path = format!("{}.pub", agent.config.ssh_key_path);
let public_key =
std::fs::read_to_string(&public_path).map_err(|_| axum::http::StatusCode::NOT_FOUND)?;
Ok(Json(serde_json::json!({ "public_key": public_key.trim() })))
}
#[tracing::instrument(skip_all)]
pub async fn stats_overview(
axum::extract::Extension(agent): AgentExt,
tenant: TenantCtx,
) -> ApiResult<OverviewStats> {
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let total_repositories = db
.repositories()
.onboarded_targets()
.count_documents(doc! {})
.await
.unwrap_or(0);
+43 -13
View File
@@ -104,28 +104,58 @@ fn load_docs(root: &Path) -> String {
/// Returns a reference to the cached doc context string, initialised on
/// first call via `OnceLock`.
///
/// Discovery order:
/// 1. `HELP_DOCS_PATH` env var (explicit override)
/// 2. Walk up from the binary location
/// 3. Current working directory
/// 4. Common Docker paths (/app, /opt/compliance-scanner)
fn doc_context() -> &'static str {
DOC_CONTEXT.get_or_init(|| {
// 1. Explicit env var
if let Ok(path) = std::env::var("HELP_DOCS_PATH") {
let p = PathBuf::from(&path);
if p.join("README.md").is_file() || p.join("docs").is_dir() {
tracing::info!("help_chat: loading docs from HELP_DOCS_PATH={path}");
return load_docs(&p);
}
tracing::warn!("help_chat: HELP_DOCS_PATH={path} has no README.md or docs/");
}
// 2. Walk up from binary location
let start = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."));
match find_project_root(&start) {
Some(root) => load_docs(&root),
None => {
// Fallback: try current working directory
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if cwd.join("README.md").is_file() {
return load_docs(&cwd);
}
tracing::error!(
"help_chat: could not locate project root from {}; doc context will be empty",
start.display()
);
String::new()
if let Some(root) = find_project_root(&start) {
return load_docs(&root);
}
// 3. Current working directory
if let Ok(cwd) = std::env::current_dir() {
if let Some(root) = find_project_root(&cwd) {
return load_docs(&root);
}
if cwd.join("README.md").is_file() {
return load_docs(&cwd);
}
}
// 4. Common Docker/deployment paths
for candidate in ["/app", "/opt/compliance-scanner", "/srv/compliance-scanner"] {
let p = PathBuf::from(candidate);
if p.join("README.md").is_file() || p.join("docs").is_dir() {
tracing::info!("help_chat: found docs at {candidate}");
return load_docs(&p);
}
}
tracing::error!(
"help_chat: could not locate project root; doc context will be empty. \
Set HELP_DOCS_PATH to the directory containing README.md and docs/"
);
String::new()
})
}
+4 -1
View File
@@ -4,13 +4,16 @@ use mongodb::bson::doc;
use super::dto::*;
use compliance_core::models::TrackerIssue;
use compliance_core::tenant_ctx::TenantCtx;
#[tracing::instrument(skip_all)]
pub async fn list_issues(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<PaginationParams>,
) -> ApiResult<Vec<TrackerIssue>> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = db
.tracker_issues()
@@ -0,0 +1,186 @@
//! `/api/v1/mcp-tokens` — per-tenant API tokens for the MCP server.
//!
//! These are opaque static bearers issued via the dashboard (or a
//! direct curl with a KC JWT) and copied into LLM clients (Claude
//! Desktop / Cursor / ChatGPT). The MCP server hashes incoming bearers
//! and looks them up in the cross-tenant `<prefix>__admin.mcp_tokens`
//! collection to derive the tenant_id for routing.
//!
//! The raw token is shown to the caller exactly once at creation; the
//! database only ever stores the SHA-256 hash. Revocation is a soft
//! delete (sets `revoked: true`) so the audit log keeps the record.
use axum::extract::{Extension, Path};
use axum::http::StatusCode;
use axum::Json;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use compliance_core::models::{McpToken, McpTokenView};
use compliance_core::tenant_ctx::TenantCtx;
use mongodb::bson::doc;
use rand::RngCore;
use sha2::{Digest, Sha256};
use super::dto::{AgentExt, ApiResponse};
/// Mongo collection name inside the admin DB.
const COLLECTION: &str = "mcp_tokens";
/// Token prefix the MCP server expects on every bearer.
const TOKEN_PREFIX: &str = "mcpt_";
/// Bytes of randomness behind each token. 32 → ~256 bits.
/// Encoded as URL-safe base64 without padding → 43 chars.
/// Combined with `mcpt_` → 48-char tokens.
const TOKEN_RAND_BYTES: usize = 32;
#[derive(serde::Deserialize)]
pub struct CreateMcpTokenRequest {
pub name: String,
}
/// Returned exactly once at creation. The `token` field is gone from
/// the listing endpoint — the user must save it now.
#[derive(serde::Serialize)]
pub struct CreateMcpTokenResponse {
pub token: String,
pub view: McpTokenView,
}
/// `POST /api/v1/mcp-tokens` — mint a new token for the caller's tenant.
#[tracing::instrument(skip_all)]
pub async fn create_mcp_token(
Extension(agent): AgentExt,
tenant: TenantCtx,
Json(req): Json<CreateMcpTokenRequest>,
) -> Result<Json<CreateMcpTokenResponse>, StatusCode> {
if req.name.trim().is_empty() {
return Err(StatusCode::BAD_REQUEST);
}
let raw = generate_token();
let token_hash = sha256_hex(&raw);
let token_prefix: String = raw.chars().take(12).collect();
let mut token = McpToken {
id: None,
token_hash,
token_prefix,
tenant_id: tenant.0.tenant_id.clone(),
name: req.name.trim().to_string(),
created_by: tenant.0.user_id.clone(),
created_at: chrono::Utc::now(),
last_used_at: None,
revoked: false,
};
let col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
let res = col.insert_one(&token).await.map_err(|e| {
tracing::error!("Failed to insert MCP token: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
token.id = res.inserted_id.as_object_id();
Ok(Json(CreateMcpTokenResponse {
view: McpTokenView::from(&token),
token: raw,
}))
}
/// `GET /api/v1/mcp-tokens` — list tokens for the caller's tenant.
/// Hash is never returned; only metadata + the 12-char prefix so the
/// user can identify which row is which.
#[tracing::instrument(skip_all)]
pub async fn list_mcp_tokens(
Extension(agent): AgentExt,
tenant: TenantCtx,
) -> Result<Json<ApiResponse<Vec<McpTokenView>>>, StatusCode> {
let col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
let mut cursor = col
.find(doc! { "tenant_id": &tenant.0.tenant_id })
.sort(doc! { "created_at": -1 })
.await
.map_err(|e| {
tracing::error!("Failed to list MCP tokens: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut out = Vec::new();
while cursor.advance().await.map_err(|e| {
tracing::warn!("MCP tokens cursor advance failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})? {
match cursor.deserialize_current() {
Ok(t) => out.push(McpTokenView::from(&t)),
Err(e) => tracing::warn!("Failed to deserialize MCP token: {e}"),
}
}
Ok(Json(ApiResponse {
data: out,
total: None,
page: None,
}))
}
/// `DELETE /api/v1/mcp-tokens/{id}` — revoke (soft delete).
/// Scoped to the caller's tenant: a user can't revoke another tenant's
/// token even if they guess its id.
#[tracing::instrument(skip_all, fields(id = %id))]
pub async fn revoke_mcp_token(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
let result = col
.update_one(
doc! { "_id": oid, "tenant_id": &tenant.0.tenant_id },
doc! { "$set": { "revoked": true } },
)
.await
.map_err(|e| {
tracing::error!("Failed to revoke MCP token: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if result.matched_count == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(serde_json::json!({ "status": "revoked" })))
}
/// 32 bytes random → URL-safe base64 → 43 chars, no padding.
/// Prefixed with `mcpt_` so the MCP server can sniff the format
/// before bothering with the DB lookup.
fn generate_token() -> String {
let mut bytes = [0u8; TOKEN_RAND_BYTES];
rand::rng().fill_bytes(&mut bytes);
format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
}
fn sha256_hex(s: &str) -> String {
let mut h = Sha256::new();
h.update(s.as_bytes());
hex::encode(h.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_tokens_are_unique_and_prefixed() {
let a = generate_token();
let b = generate_token();
assert_ne!(a, b);
assert!(a.starts_with(TOKEN_PREFIX));
assert!(b.starts_with(TOKEN_PREFIX));
// 5 + 43 = 48 chars
assert_eq!(a.len(), 5 + 43);
}
#[test]
fn sha256_is_stable_and_64_hex() {
let h = sha256_hex("mcpt_abc");
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(sha256_hex("mcpt_abc"), h);
}
}
+6 -2
View File
@@ -1,3 +1,4 @@
pub mod admin;
pub mod chat;
pub mod dast;
pub mod dto;
@@ -6,17 +7,20 @@ pub mod graph;
pub mod health;
pub mod help_chat;
pub mod issues;
pub mod mcp_tokens;
pub mod notifications;
pub mod onboarding;
pub mod oscal;
pub mod pentest_handlers;
pub use pentest_handlers as pentest;
pub mod repos;
pub mod sbom;
pub mod scans;
pub mod werkbank_jobs;
// Re-export all handler functions so routes.rs can use `handlers::function_name`
pub use dto::*;
pub use findings::*;
pub use health::*;
pub use issues::*;
pub use repos::*;
pub use sbom::*;
pub use scans::*;
@@ -0,0 +1,183 @@
use axum::extract::Extension;
use axum::http::StatusCode;
use axum::Json;
use mongodb::bson::doc;
use serde::Deserialize;
use compliance_core::models::notification::CveNotification;
use compliance_core::tenant_ctx::TenantCtx;
use super::dto::{tenant_db, AgentExt, ApiResponse};
/// GET /api/v1/notifications — List CVE notifications (newest first)
#[tracing::instrument(skip_all)]
pub async fn list_notifications(
Extension(agent): AgentExt,
tenant: TenantCtx,
axum::extract::Query(params): axum::extract::Query<NotificationFilter>,
) -> Result<Json<ApiResponse<Vec<CveNotification>>>, StatusCode> {
let db = tenant_db(&agent, &tenant).await?;
let mut filter = doc! {};
// Filter by status (default: show new + read, exclude dismissed)
match params.status.as_deref() {
Some("all") => {}
Some(s) => {
filter.insert("status", s);
}
None => {
filter.insert("status", doc! { "$in": ["new", "read"] });
}
}
// Filter by severity
if let Some(ref sev) = params.severity {
filter.insert("severity", sev.as_str());
}
// Filter by repo
if let Some(ref repo_id) = params.repo_id {
filter.insert("repo_id", repo_id.as_str());
}
let page = params.page.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(50).min(200);
let skip = (page - 1) * limit as u64;
let total = db
.cve_notifications()
.count_documents(filter.clone())
.await
.unwrap_or(0);
let notifications: Vec<CveNotification> = match db
.cve_notifications()
.find(filter)
.sort(doc! { "created_at": -1 })
.skip(skip)
.limit(limit)
.await
{
Ok(cursor) => {
use futures_util::StreamExt;
let mut items = Vec::new();
let mut cursor = cursor;
while let Some(Ok(n)) = cursor.next().await {
items.push(n);
}
items
}
Err(e) => {
tracing::error!("Failed to list notifications: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
Ok(Json(ApiResponse {
data: notifications,
total: Some(total),
page: Some(page),
}))
}
/// GET /api/v1/notifications/count — Count of unread notifications
#[tracing::instrument(skip_all)]
pub async fn notification_count(
Extension(agent): AgentExt,
tenant: TenantCtx,
) -> Result<Json<serde_json::Value>, StatusCode> {
let db = tenant_db(&agent, &tenant).await?;
let count = db
.cve_notifications()
.count_documents(doc! { "status": "new" })
.await
.unwrap_or(0);
Ok(Json(serde_json::json!({ "count": count })))
}
/// PATCH /api/v1/notifications/:id/read — Mark a notification as read
#[tracing::instrument(skip_all, fields(id = %id))]
pub async fn mark_read(
Extension(agent): AgentExt,
tenant: TenantCtx,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
let result = db
.cve_notifications()
.update_one(
doc! { "_id": oid },
doc! { "$set": {
"status": "read",
"read_at": mongodb::bson::DateTime::now(),
}},
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if result.matched_count == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(serde_json::json!({ "status": "read" })))
}
/// PATCH /api/v1/notifications/:id/dismiss — Dismiss a notification
#[tracing::instrument(skip_all, fields(id = %id))]
pub async fn dismiss_notification(
Extension(agent): AgentExt,
tenant: TenantCtx,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
let result = db
.cve_notifications()
.update_one(
doc! { "_id": oid },
doc! { "$set": { "status": "dismissed" } },
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if result.matched_count == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(serde_json::json!({ "status": "dismissed" })))
}
/// POST /api/v1/notifications/read-all — Mark all new notifications as read
#[tracing::instrument(skip_all)]
pub async fn mark_all_read(
Extension(agent): AgentExt,
tenant: TenantCtx,
) -> Result<Json<serde_json::Value>, StatusCode> {
let db = tenant_db(&agent, &tenant).await?;
let result = db
.cve_notifications()
.update_many(
doc! { "status": "new" },
doc! { "$set": {
"status": "read",
"read_at": mongodb::bson::DateTime::now(),
}},
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(
serde_json::json!({ "updated": result.modified_count }),
))
}
#[derive(Debug, Deserialize)]
pub struct NotificationFilter {
pub status: Option<String>,
pub severity: Option<String>,
pub repo_id: Option<String>,
pub page: Option<u64>,
pub limit: Option<i64>,
}
@@ -0,0 +1,614 @@
//! Onboarding API — CRUD for unified targets, artifact add, classification, and
//! the scan-applicability matrix. The wizard (and future integrations) drive
//! onboarding through these endpoints.
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Extension, Multipart, Path, Query};
use axum::http::StatusCode;
use axum::Json;
use mongodb::bson::{doc, oid::ObjectId, to_bson};
use serde::{Deserialize, Serialize};
use compliance_core::models::{
Artifact, ArtifactKind, ComplianceProfile, OnboardedTarget, PlcFormat, TargetScanConfig,
TargetType,
};
use compliance_core::scan_matrix::{applicable_scans, supports_pentest};
use compliance_core::tenant_ctx::TenantCtx;
use crate::agent::ComplianceAgent;
use crate::classify::{classify_target, MockFirmwareDetector};
use super::dto::tenant_db;
use super::{collect_cursor_async, ApiResponse, PaginationParams};
type AgentExt = Extension<Arc<ComplianceAgent>>;
/// A client-supplied artifact spec. The server builds the [`Artifact`] (and its
/// id) from it, so clients never set internal fields.
#[derive(Deserialize)]
pub struct ArtifactInput {
pub kind: ArtifactKind,
pub source_ref: String,
#[serde(default)]
pub branch: Option<String>,
#[serde(default)]
pub plc_format: Option<PlcFormat>,
}
impl ArtifactInput {
fn build(&self) -> Artifact {
let s = self.source_ref.clone();
match self.kind {
ArtifactKind::GitRepo => {
Artifact::git_repo(s, self.branch.clone().unwrap_or_else(|| "main".to_string()))
}
ArtifactKind::LiveUrl => Artifact::live_url(s),
ArtifactKind::FirmwareImage => Artifact::firmware_image(s),
ArtifactKind::SourceArchive => Artifact::source_archive(s),
ArtifactKind::MobilePackage => Artifact::mobile_package(s),
ArtifactKind::ContainerImage => Artifact::container_image(s),
ArtifactKind::PlcProject => {
Artifact::plc_project(s, self.plc_format.unwrap_or(PlcFormat::PlcopenXml))
}
ArtifactKind::PlaintextDescription => Artifact::plaintext(s),
}
}
}
#[derive(Deserialize)]
pub struct CreateTargetRequest {
pub name: String,
pub target_type: TargetType,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub artifacts: Vec<ArtifactInput>,
}
#[derive(Deserialize)]
pub struct UpdateTargetRequest {
pub name: Option<String>,
pub target_type: Option<TargetType>,
pub scan_config: Option<TargetScanConfig>,
pub compliance_profile: Option<ComplianceProfile>,
pub scan_schedule: Option<String>,
/// Replace the target's artifacts wholesale (used by the dashboard editor).
#[serde(default)]
pub artifacts: Option<Vec<ArtifactInput>>,
}
/// One applicable-scan option, serialized for the wizard.
#[derive(Serialize)]
pub struct ScanOptionDto {
pub scan: String,
pub default_on: bool,
pub rationale: String,
pub required_artifact: Option<String>,
pub blocked_reason: Option<String>,
}
#[derive(Serialize)]
pub struct ApplicableScansResponse {
pub scans: Vec<ScanOptionDto>,
pub pentest_supported: bool,
}
fn parse_oid(id: &str) -> Result<ObjectId, StatusCode> {
ObjectId::parse_str(id).map_err(|_| StatusCode::BAD_REQUEST)
}
/// GET /api/v1/targets — list onboarded targets (paginated).
#[tracing::instrument(skip_all)]
pub async fn list_targets(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<Vec<OnboardedTarget>>>, StatusCode> {
let db = tenant_db(&agent, &tenant).await?;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = db
.onboarded_targets()
.count_documents(doc! {})
.await
.unwrap_or(0);
let targets = match db
.onboarded_targets()
.find(doc! {})
.skip(skip)
.limit(params.limit)
.await
{
Ok(cursor) => collect_cursor_async(cursor).await,
Err(e) => {
tracing::warn!("Failed to fetch onboarded targets: {e}");
Vec::new()
}
};
Ok(Json(ApiResponse {
data: targets,
total: Some(total),
page: Some(params.page),
}))
}
/// POST /api/v1/targets — create an onboarded target.
#[tracing::instrument(skip_all)]
pub async fn create_target(
Extension(agent): AgentExt,
tenant: TenantCtx,
Json(req): Json<CreateTargetRequest>,
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
let mut target = OnboardedTarget::new(req.name, req.target_type);
target.description = req.description;
target.artifacts = req.artifacts.iter().map(ArtifactInput::build).collect();
let db = tenant_db(&agent, &tenant).await?;
let res = db
.onboarded_targets()
.insert_one(&target)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
target.id = res.inserted_id.as_object_id();
Ok(Json(ApiResponse {
data: target,
total: None,
page: None,
}))
}
/// GET /api/v1/targets/{id} — fetch one target.
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn get_target(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
let target = db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(ApiResponse {
data: target,
total: None,
page: None,
}))
}
/// PATCH /api/v1/targets/{id} — update mutable fields.
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn update_target(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
Json(req): Json<UpdateTargetRequest>,
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
let mut set = doc! { "updated_at": mongodb::bson::DateTime::now() };
if let Some(name) = req.name {
set.insert("name", name);
}
if let Some(tt) = req.target_type {
set.insert(
"target_type",
to_bson(&tt).map_err(|_| StatusCode::BAD_REQUEST)?,
);
}
if let Some(sc) = req.scan_config {
set.insert(
"scan_config",
to_bson(&sc).map_err(|_| StatusCode::BAD_REQUEST)?,
);
}
if let Some(cp) = req.compliance_profile {
set.insert(
"compliance_profile",
to_bson(&cp).map_err(|_| StatusCode::BAD_REQUEST)?,
);
}
if let Some(ss) = req.scan_schedule {
set.insert("scan_schedule", ss);
}
if let Some(arts) = req.artifacts {
let built: Vec<Artifact> = arts.iter().map(ArtifactInput::build).collect();
set.insert(
"artifacts",
to_bson(&built).map_err(|_| StatusCode::BAD_REQUEST)?,
);
}
db.onboarded_targets()
.update_one(doc! { "_id": oid }, doc! { "$set": set })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
get_target(Extension(agent), tenant, Path(id)).await
}
/// DELETE /api/v1/targets/{id} — remove the target and its findings/scans.
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn delete_target(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
db.onboarded_targets()
.delete_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Cascade all data keyed by repo_id == target id (best-effort).
let db = &db;
let _ = db.findings().delete_many(doc! { "repo_id": &id }).await;
let _ = db.sbom_entries().delete_many(doc! { "repo_id": &id }).await;
let _ = db.scan_runs().delete_many(doc! { "repo_id": &id }).await;
let _ = db.cve_alerts().delete_many(doc! { "repo_id": &id }).await;
let _ = db
.tracker_issues()
.delete_many(doc! { "repo_id": &id })
.await;
let _ = db.graph_nodes().delete_many(doc! { "repo_id": &id }).await;
let _ = db.graph_edges().delete_many(doc! { "repo_id": &id }).await;
let _ = db.graph_builds().delete_many(doc! { "repo_id": &id }).await;
let _ = db
.impact_analyses()
.delete_many(doc! { "repo_id": &id })
.await;
let _ = db
.code_embeddings()
.delete_many(doc! { "repo_id": &id })
.await;
let _ = db
.embedding_builds()
.delete_many(doc! { "repo_id": &id })
.await;
// DAST targets linked to this target, and all their downstream data.
if let Ok(mut cursor) = db.dast_targets().find(doc! { "repo_id": &id }).await {
use futures_util::StreamExt;
while let Some(Ok(dt)) = cursor.next().await {
let dast_target_id = dt.id.map(|oid| oid.to_hex()).unwrap_or_default();
if !dast_target_id.is_empty() {
cascade_delete_dast_target(db, &dast_target_id).await;
}
}
}
// Pentest sessions linked directly to this target (not via a DAST target).
if let Ok(mut cursor) = db.pentest_sessions().find(doc! { "repo_id": &id }).await {
use futures_util::StreamExt;
while let Some(Ok(session)) = cursor.next().await {
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
if !session_id.is_empty() {
let _ = db
.attack_chain_nodes()
.delete_many(doc! { "session_id": &session_id })
.await;
let _ = db
.pentest_messages()
.delete_many(doc! { "session_id": &session_id })
.await;
let _ = db
.dast_findings()
.delete_many(doc! { "session_id": &session_id })
.await;
}
}
}
let _ = db
.pentest_sessions()
.delete_many(doc! { "repo_id": &id })
.await;
Ok(Json(serde_json::json!({ "status": "deleted" })))
}
/// Delete a DAST target and everything downstream of it (pentest sessions +
/// their attack chains / messages / findings, DAST scan runs + findings).
async fn cascade_delete_dast_target(db: &crate::database::Database, target_id: &str) {
use futures_util::StreamExt;
if let Ok(mut cursor) = db
.pentest_sessions()
.find(doc! { "target_id": target_id })
.await
{
while let Some(Ok(session)) = cursor.next().await {
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
if !session_id.is_empty() {
let _ = db
.attack_chain_nodes()
.delete_many(doc! { "session_id": &session_id })
.await;
let _ = db
.pentest_messages()
.delete_many(doc! { "session_id": &session_id })
.await;
let _ = db
.dast_findings()
.delete_many(doc! { "session_id": &session_id })
.await;
}
}
}
let _ = db
.pentest_sessions()
.delete_many(doc! { "target_id": target_id })
.await;
let _ = db
.dast_findings()
.delete_many(doc! { "target_id": target_id })
.await;
let _ = db
.dast_scan_runs()
.delete_many(doc! { "target_id": target_id })
.await;
if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(target_id) {
let _ = db.dast_targets().delete_one(doc! { "_id": oid }).await;
}
}
/// POST /api/v1/targets/{id}/artifacts — attach an artifact (by reference).
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn add_artifact(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
Json(input): Json<ArtifactInput>,
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
let artifact = to_bson(&input.build()).map_err(|_| StatusCode::BAD_REQUEST)?;
db.onboarded_targets()
.update_one(
doc! { "_id": oid },
doc! { "$push": { "artifacts": artifact }, "$set": { "updated_at": mongodb::bson::DateTime::now() } },
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
get_target(Extension(agent), tenant, Path(id)).await
}
/// POST /api/v1/targets/{id}/artifacts/upload — attach an artifact by uploading
/// its file (PLC project, firmware image, source archive, mobile package). The
/// bytes are written to the artifact blob store and referenced by `stored_path`,
/// so ingest resolves them locally (no URL fetch).
///
/// Multipart fields: `file` (required), `kind` (required, snake_case
/// `ArtifactKind`), `plc_format` (optional, for PLC projects).
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn upload_artifact(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
if db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.is_none()
{
return Err(StatusCode::NOT_FOUND);
}
let mut kind: Option<ArtifactKind> = None;
let mut plc_format: Option<PlcFormat> = None;
let mut filename = String::from("upload.bin");
let mut bytes: Option<axum::body::Bytes> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| StatusCode::BAD_REQUEST)?
{
match field.name().unwrap_or("") {
"kind" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
kind = parse_enum(&v);
}
"plc_format" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
plc_format = parse_enum(&v);
}
"file" => {
if let Some(fname) = field.file_name() {
filename = fname.to_string();
}
bytes = Some(field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?);
}
_ => {}
}
}
let (Some(kind), Some(bytes)) = (kind, bytes) else {
return Err(StatusCode::BAD_REQUEST);
};
// Store the uploaded bytes under the artifact blob store.
let safe_name: String = filename
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect();
let dir = std::path::Path::new(&agent.config.artifact_store_base_path)
.join("uploads")
.join(&id);
std::fs::create_dir_all(&dir).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let dest = dir.join(format!("{}_{safe_name}", uuid::Uuid::new_v4()));
std::fs::write(&dest, bytes.as_ref()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Build the artifact for this kind, referencing the stored file.
let mut artifact = match kind {
ArtifactKind::PlcProject => Artifact::plc_project(
filename.clone(),
plc_format.unwrap_or(PlcFormat::PlcopenXml),
),
ArtifactKind::FirmwareImage => Artifact::firmware_image(filename.clone()),
ArtifactKind::SourceArchive => Artifact::source_archive(filename.clone()),
ArtifactKind::MobilePackage => Artifact::mobile_package(filename.clone()),
// Non-file kinds (git repo, live URL, container ref, text) use the JSON
// add-artifact endpoint, not upload.
_ => return Err(StatusCode::BAD_REQUEST),
};
artifact.stored_path = Some(dest.to_string_lossy().to_string());
artifact.size_bytes = Some(bytes.len() as u64);
let artifact_bson = to_bson(&artifact).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
db.onboarded_targets()
.update_one(
doc! { "_id": oid },
doc! { "$push": { "artifacts": artifact_bson }, "$set": { "updated_at": mongodb::bson::DateTime::now() } },
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
get_target(Extension(agent), tenant, Path(id)).await
}
/// Deserialize a snake_case enum value from a plain string.
fn parse_enum<T: for<'de> Deserialize<'de>>(s: &str) -> Option<T> {
serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
}
/// GET /api/v1/targets/{id}/applicable-scans — the scan-applicability matrix.
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn applicable_scans_for_target(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<ApplicableScansResponse>>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
let target = db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let scans = applicable_scans(&target)
.into_iter()
.map(|o| ScanOptionDto {
scan: o.scan.to_string(),
default_on: o.default_on,
rationale: o.rationale,
required_artifact: o.required_artifact.map(|k| k.to_string()),
blocked_reason: o.blocked_reason,
})
.collect();
Ok(Json(ApiResponse {
data: ApplicableScansResponse {
scans,
pentest_supported: supports_pentest(target.target_type),
},
total: None,
page: None,
}))
}
/// POST /api/v1/targets/{id}/detect — classify the target from its artifacts.
///
/// This is the lightweight pass: it classifies from artifact kinds without
/// ingesting (cloning) sources, so it returns immediately. Deep detection (after
/// ingest, with tramiton firmware analysis) is a follow-up background step.
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn detect_target(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
let mut target = db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
// No ingested working paths here → kind-based classification only; the mock
// firmware detector is never invoked (no firmware working path present).
let empty = HashMap::new();
let detector = MockFirmwareDetector { detection: None };
let classification = classify_target(&target, &empty, &detector)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let classification_bson =
to_bson(&classification).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
db.onboarded_targets()
.update_one(
doc! { "_id": oid },
doc! { "$set": { "classification": classification_bson, "updated_at": mongodb::bson::DateTime::now() } },
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
target.classification = Some(classification);
Ok(Json(ApiResponse {
data: target,
total: None,
page: None,
}))
}
/// POST /api/v1/targets/{id}/scan — trigger a scan for the target.
///
/// Dispatches to the unified pipeline when `UNIFIED_PIPELINE` is set (else the
/// legacy path). Runs in the background and returns immediately.
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn trigger_target_scan(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
// 404 if the target doesn't exist for this tenant.
if db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.is_none()
{
return Err(StatusCode::NOT_FOUND);
}
let agent_clone = (*agent).clone();
let tenant_id = tenant.0.tenant_id.clone();
tokio::spawn(async move {
// Always the unified target pipeline — this endpoint is about an
// onboarded target by construction, independent of the global
// `unified_pipeline` transition flag used by the legacy paths.
if let Err(e) = agent_clone
.run_target_scan(
&tenant_id,
&id,
compliance_core::models::ScanTrigger::Manual,
)
.await
{
tracing::error!("Manual target scan failed for {id}: {e}");
}
});
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
}
@@ -0,0 +1,48 @@
//! 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()
}
@@ -13,10 +13,11 @@ use compliance_core::models::dast::DastFinding;
use compliance_core::models::finding::Finding;
use compliance_core::models::pentest::*;
use compliance_core::models::sbom::SbomEntry;
use compliance_core::tenant_ctx::TenantCtx;
use crate::agent::ComplianceAgent;
use super::super::dto::collect_cursor_async;
use super::super::dto::{collect_cursor_async, tenant_db};
type AgentExt = Extension<Arc<ComplianceAgent>>;
@@ -35,11 +36,15 @@ pub struct ExportBody {
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn export_session_report(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
Json(body): Json<ExportBody>,
) -> Result<axum::response::Response, (StatusCode, String)> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
let db = tenant_db(&agent, &tenant)
.await
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
if body.password.len() < 8 {
return Err((
@@ -49,8 +54,7 @@ pub async fn export_session_report(
}
// Fetch session
let session = agent
.db
let session = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
@@ -64,9 +68,7 @@ pub async fn export_session_report(
// Resolve target name
let target = if let Ok(tid) = mongodb::bson::oid::ObjectId::parse_str(&session.target_id) {
agent
.db
.dast_targets()
db.dast_targets()
.find_one(doc! { "_id": tid })
.await
.ok()
@@ -84,8 +86,7 @@ pub async fn export_session_report(
.unwrap_or_default();
// Fetch attack chain nodes
let nodes: Vec<AttackChainNode> = match agent
.db
let nodes: Vec<AttackChainNode> = match db
.attack_chain_nodes()
.find(doc! { "session_id": &id })
.sort(doc! { "started_at": 1 })
@@ -96,8 +97,7 @@ pub async fn export_session_report(
};
// Fetch DAST findings for this session, then deduplicate
let raw_findings: Vec<DastFinding> = match agent
.db
let raw_findings: Vec<DastFinding> = match db
.dast_findings()
.find(doc! { "session_id": &id })
.sort(doc! { "severity": -1, "created_at": -1 })
@@ -122,8 +122,7 @@ pub async fn export_session_report(
.or_else(|| target.as_ref().and_then(|t| t.repo_id.clone()));
let (sast_findings, sbom_entries, code_context) = if let Some(ref rid) = repo_id {
let sast: Vec<Finding> = match agent
.db
let sast: Vec<Finding> = match db
.findings()
.find(doc! {
"repo_id": rid,
@@ -143,8 +142,7 @@ pub async fn export_session_report(
Err(_) => Vec::new(),
};
let sbom: Vec<SbomEntry> = match agent
.db
let sbom: Vec<SbomEntry> = match db
.sbom_entries()
.find(doc! {
"repo_id": rid,
@@ -164,8 +162,7 @@ pub async fn export_session_report(
};
// Build code context from graph nodes
let code_ctx: Vec<CodeContextHint> = match agent
.db
let code_ctx: Vec<CodeContextHint> = match db
.graph_nodes()
.find(doc! { "repo_id": rid, "is_entry_point": true })
.limit(50)
@@ -7,11 +7,12 @@ use mongodb::bson::doc;
use serde::Deserialize;
use compliance_core::models::pentest::*;
use compliance_core::tenant_ctx::TenantCtx;
use crate::agent::ComplianceAgent;
use crate::pentest::PentestOrchestrator;
use super::super::dto::{collect_cursor_async, ApiResponse, PaginationParams};
use super::super::dto::{collect_cursor_async, tenant_db, ApiResponse, PaginationParams};
type AgentExt = Extension<Arc<ComplianceAgent>>;
@@ -43,6 +44,7 @@ pub struct LookupRepoQuery {
#[tracing::instrument(skip_all)]
pub async fn create_session(
Extension(agent): AgentExt,
tenant: TenantCtx,
Json(req): Json<CreateSessionRequest>,
) -> Result<Json<ApiResponse<PentestSession>>, (StatusCode, String)> {
// Try to acquire a concurrency permit
@@ -57,6 +59,10 @@ pub async fn create_session(
)
})?;
let db = tenant_db(&agent, &tenant)
.await
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
if let Some(ref config) = req.config {
// ── Wizard path ──────────────────────────────────────────────
if !config.disclaimer_accepted {
@@ -67,8 +73,7 @@ pub async fn create_session(
}
// Look up or auto-create DastTarget by app_url
let target = match agent
.db
let target = match db
.dast_targets()
.find_one(doc! { "base_url": &config.app_url })
.await
@@ -87,7 +92,7 @@ pub async fn create_session(
}
t.allow_destructive = config.allow_destructive;
t.excluded_paths = config.scope_exclusions.clone();
let res = agent.db.dast_targets().insert_one(&t).await.map_err(|e| {
let res = db.dast_targets().insert_one(&t).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create target: {e}"),
@@ -108,20 +113,18 @@ pub async fn create_session(
session.config = Some(config.clone());
session.repo_id = target.repo_id.clone();
// Resolve repo_id from git_repo_url if provided
// Resolve repo_id (target id) from git_repo_url if provided
if let Some(ref git_url) = config.git_repo_url {
if let Ok(Some(repo)) = agent
.db
.repositories()
.find_one(doc! { "git_url": git_url })
if let Ok(Some(target)) = db
.onboarded_targets()
.find_one(doc! { "artifacts.source_ref": git_url })
.await
{
session.repo_id = repo.id.map(|oid| oid.to_hex());
session.repo_id = target.id.map(|oid| oid.to_hex());
}
}
let insert_result = agent
.db
let insert_result = db
.pentest_sessions()
.insert_one(&session)
.await
@@ -212,8 +215,7 @@ pub async fn create_session(
// Persist encrypted credentials to DB
if session_for_task.config.is_some() {
if let Some(sid) = session.id {
let _ = agent
.db
let _ = db
.pentest_sessions()
.update_one(
doc! { "_id": sid },
@@ -245,12 +247,13 @@ pub async fn create_session(
});
let llm = agent.llm.clone();
let db = agent.db.clone();
let db_for_orchestrator = db.clone();
let session_clone = session.clone();
let target_clone = target.clone();
let agent_ref = agent.clone();
tokio::spawn(async move {
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, Some(pause_rx));
let orchestrator =
PentestOrchestrator::new(llm, db_for_orchestrator, event_tx, Some(pause_rx));
orchestrator
.run_session_guarded(&session_clone, &target_clone, &initial_message)
.await;
@@ -292,8 +295,7 @@ pub async fn create_session(
)
})?;
let target = agent
.db
let target = db
.dast_targets()
.find_one(doc! { "_id": oid })
.await
@@ -310,8 +312,7 @@ pub async fn create_session(
let mut session = PentestSession::new(target_id, strategy);
session.repo_id = target.repo_id.clone();
let insert_result = agent
.db
let insert_result = db
.pentest_sessions()
.insert_one(&session)
.await
@@ -338,12 +339,13 @@ pub async fn create_session(
});
let llm = agent.llm.clone();
let db = agent.db.clone();
let db_for_orchestrator = db.clone();
let session_clone = session.clone();
let target_clone = target.clone();
let agent_ref = agent.clone();
tokio::spawn(async move {
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, Some(pause_rx));
let orchestrator =
PentestOrchestrator::new(llm, db_for_orchestrator, event_tx, Some(pause_rx));
orchestrator
.run_session_guarded(&session_clone, &target_clone, &initial_message)
.await;
@@ -373,21 +375,25 @@ fn parse_strategy(s: &str) -> PentestStrategy {
#[tracing::instrument(skip_all)]
pub async fn lookup_repo(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<LookupRepoQuery>,
) -> Result<Json<ApiResponse<serde_json::Value>>, StatusCode> {
let repo = agent
.db
.repositories()
.find_one(doc! { "git_url": &params.url })
let db = tenant_db(&agent, &tenant).await?;
let repo = db
.onboarded_targets()
.find_one(doc! { "artifacts.source_ref": &params.url })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let data = match repo {
Some(r) => serde_json::json!({
"name": r.name,
"default_branch": r.default_branch,
"last_scanned_commit": r.last_scanned_commit,
}),
Some(r) => {
let git = r.code_artifact().and_then(|c| c.git.as_ref());
serde_json::json!({
"name": r.name,
"default_branch": git.map(|g| g.default_branch.clone()),
"last_scanned_commit": git.and_then(|g| g.last_scanned_commit.clone()),
})
}
None => serde_json::Value::Null,
};
@@ -402,9 +408,11 @@ pub async fn lookup_repo(
#[tracing::instrument(skip_all)]
pub async fn list_sessions(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<Vec<PentestSession>>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = db
.pentest_sessions()
@@ -438,12 +446,13 @@ pub async fn list_sessions(
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn get_session(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<PentestSession>>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
let mut session = agent
.db
let mut session = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
@@ -471,15 +480,18 @@ pub async fn get_session(
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn send_message(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
Json(req): Json<SendMessageRequest>,
) -> Result<Json<ApiResponse<PentestMessage>>, (StatusCode, String)> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
let db = tenant_db(&agent, &tenant)
.await
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
// Verify session exists and is running
let session = agent
.db
let session = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
@@ -506,8 +518,7 @@ pub async fn send_message(
)
})?;
let target = agent
.db
let target = db
.dast_targets()
.find_one(doc! { "_id": target_oid })
.await
@@ -527,13 +538,13 @@ pub async fn send_message(
// Store user message
let session_id = id.clone();
let user_msg = PentestMessage::user(session_id.clone(), req.message.clone());
let _ = agent.db.pentest_messages().insert_one(&user_msg).await;
let _ = db.pentest_messages().insert_one(&user_msg).await;
let response_msg = user_msg.clone();
// Spawn orchestrator to continue the session
let llm = agent.llm.clone();
let db = agent.db.clone();
let db_for_orchestrator = db.clone();
let message = req.message.clone();
// Use existing broadcast sender if available, otherwise create a new one
@@ -548,7 +559,7 @@ pub async fn send_message(
.unwrap_or_else(|| agent.register_session_stream(&session_id));
tokio::spawn(async move {
let orchestrator = PentestOrchestrator::new(llm, db, event_tx, None);
let orchestrator = PentestOrchestrator::new(llm, db_for_orchestrator, event_tx, None);
orchestrator
.run_session_guarded(&session, &target, &message)
.await;
@@ -565,13 +576,16 @@ pub async fn send_message(
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn stop_session(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<PentestSession>>, (StatusCode, String)> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
let db = tenant_db(&agent, &tenant)
.await
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
let session = agent
.db
let session = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
@@ -590,9 +604,7 @@ pub async fn stop_session(
));
}
agent
.db
.pentest_sessions()
db.pentest_sessions()
.update_one(
doc! { "_id": oid },
doc! { "$set": {
@@ -612,8 +624,7 @@ pub async fn stop_session(
// Clean up session resources
agent.cleanup_session(&id);
let updated = agent
.db
let updated = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
@@ -641,13 +652,16 @@ pub async fn stop_session(
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn pause_session(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, String)> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
let db = tenant_db(&agent, &tenant)
.await
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
let session = agent
.db
let session = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
@@ -684,13 +698,16 @@ pub async fn pause_session(
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn resume_session(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<serde_json::Value>>, (StatusCode, String)> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid session ID".to_string()))?;
let db = tenant_db(&agent, &tenant)
.await
.map_err(|s| (s, "failed to acquire tenant database".to_string()))?;
let session = agent
.db
let session = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
@@ -727,12 +744,13 @@ pub async fn resume_session(
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn get_attack_chain(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Json<ApiResponse<Vec<AttackChainNode>>>, StatusCode> {
let _oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
let nodes = match agent
.db
let nodes = match db
.attack_chain_nodes()
.find(doc! { "session_id": &id })
.sort(doc! { "started_at": 1 })
@@ -757,21 +775,21 @@ pub async fn get_attack_chain(
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn get_messages(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<Vec<PentestMessage>>>, StatusCode> {
let _oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = agent
.db
let total = db
.pentest_messages()
.count_documents(doc! { "session_id": &id })
.await
.unwrap_or(0);
let messages = match agent
.db
let messages = match db
.pentest_messages()
.find(doc! { "session_id": &id })
.sort(doc! { "created_at": 1 })
@@ -797,21 +815,21 @@ pub async fn get_messages(
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn get_session_findings(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
Query(params): Query<PaginationParams>,
) -> Result<Json<ApiResponse<Vec<compliance_core::models::dast::DastFinding>>>, StatusCode> {
let _oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = agent
.db
let total = db
.dast_findings()
.count_documents(doc! { "session_id": &id })
.await
.unwrap_or(0);
let findings = match agent
.db
let findings = match db
.dast_findings()
.find(doc! { "session_id": &id })
.sort(doc! { "created_at": -1 })
@@ -6,10 +6,11 @@ use axum::Json;
use mongodb::bson::doc;
use compliance_core::models::pentest::*;
use compliance_core::tenant_ctx::TenantCtx;
use crate::agent::ComplianceAgent;
use super::super::dto::{collect_cursor_async, ApiResponse};
use super::super::dto::{collect_cursor_async, tenant_db, ApiResponse};
type AgentExt = Extension<Arc<ComplianceAgent>>;
@@ -17,8 +18,10 @@ type AgentExt = Extension<Arc<ComplianceAgent>>;
#[tracing::instrument(skip_all)]
pub async fn pentest_stats(
Extension(agent): AgentExt,
tenant: TenantCtx,
) -> Result<Json<ApiResponse<PentestStats>>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let running_sessions = db
.pentest_sessions()
@@ -11,10 +11,11 @@ use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use compliance_core::models::pentest::*;
use compliance_core::tenant_ctx::TenantCtx;
use crate::agent::ComplianceAgent;
use super::super::dto::collect_cursor_async;
use super::super::dto::{collect_cursor_async, tenant_db};
type AgentExt = Extension<Arc<ComplianceAgent>>;
@@ -25,13 +26,14 @@ type AgentExt = Extension<Arc<ComplianceAgent>>;
#[tracing::instrument(skip_all, fields(session_id = %id))]
pub async fn session_stream(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
) -> Result<Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = tenant_db(&agent, &tenant).await?;
// Verify session exists
let _session = agent
.db
let _session = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
@@ -43,8 +45,7 @@ pub async fn session_stream(
let mut initial_events: Vec<Result<Event, Infallible>> = Vec::new();
// Fetch recent messages for this session
let messages: Vec<PentestMessage> = match agent
.db
let messages: Vec<PentestMessage> = match db
.pentest_messages()
.find(doc! { "session_id": &id })
.sort(doc! { "created_at": 1 })
@@ -56,8 +57,7 @@ pub async fn session_stream(
};
// Fetch recent attack chain nodes
let nodes: Vec<AttackChainNode> = match agent
.db
let nodes: Vec<AttackChainNode> = match db
.attack_chain_nodes()
.find(doc! { "session_id": &id })
.sort(doc! { "started_at": 1 })
@@ -94,8 +94,7 @@ pub async fn session_stream(
}
// Add current session status event
let session = agent
.db
let session = db
.pentest_sessions()
.find_one(doc! { "_id": oid })
.await
-328
View File
@@ -1,328 +0,0 @@
use axum::extract::{Extension, Path, Query};
use axum::http::StatusCode;
use axum::Json;
use mongodb::bson::doc;
use super::dto::*;
use compliance_core::models::*;
#[tracing::instrument(skip_all)]
pub async fn list_repositories(
Extension(agent): AgentExt,
Query(params): Query<PaginationParams>,
) -> ApiResult<Vec<TrackedRepository>> {
let db = &agent.db;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = db
.repositories()
.count_documents(doc! {})
.await
.unwrap_or(0);
let repos = match db
.repositories()
.find(doc! {})
.skip(skip)
.limit(params.limit)
.await
{
Ok(cursor) => collect_cursor_async(cursor).await,
Err(e) => {
tracing::warn!("Failed to fetch repositories: {e}");
Vec::new()
}
};
Ok(Json(ApiResponse {
data: repos,
total: Some(total),
page: Some(params.page),
}))
}
#[tracing::instrument(skip_all)]
pub async fn add_repository(
Extension(agent): AgentExt,
Json(req): Json<AddRepositoryRequest>,
) -> Result<Json<ApiResponse<TrackedRepository>>, (StatusCode, String)> {
// Validate repository access before saving
let creds = crate::pipeline::git::RepoCredentials {
ssh_key_path: Some(agent.config.ssh_key_path.clone()),
auth_token: req.auth_token.clone(),
auth_username: req.auth_username.clone(),
};
if let Err(e) = crate::pipeline::git::GitOps::test_access(&req.git_url, &creds) {
return Err((
StatusCode::BAD_REQUEST,
format!("Cannot access repository: {e}"),
));
}
let mut repo = TrackedRepository::new(req.name, req.git_url);
repo.default_branch = req.default_branch;
repo.auth_token = req.auth_token;
repo.auth_username = req.auth_username;
repo.tracker_type = req.tracker_type;
repo.tracker_owner = req.tracker_owner;
repo.tracker_repo = req.tracker_repo;
repo.tracker_token = req.tracker_token;
repo.scan_schedule = req.scan_schedule;
agent
.db
.repositories()
.insert_one(&repo)
.await
.map_err(|_| {
(
StatusCode::CONFLICT,
"Repository already exists".to_string(),
)
})?;
Ok(Json(ApiResponse {
data: repo,
total: None,
page: None,
}))
}
#[tracing::instrument(skip_all, fields(repo_id = %id))]
pub async fn update_repository(
Extension(agent): AgentExt,
Path(id): Path<String>,
Json(req): Json<UpdateRepositoryRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let mut set_doc = doc! { "updated_at": mongodb::bson::DateTime::now() };
if let Some(name) = &req.name {
set_doc.insert("name", name);
}
if let Some(branch) = &req.default_branch {
set_doc.insert("default_branch", branch);
}
if let Some(token) = &req.auth_token {
set_doc.insert("auth_token", token);
}
if let Some(username) = &req.auth_username {
set_doc.insert("auth_username", username);
}
if let Some(tracker_type) = &req.tracker_type {
set_doc.insert("tracker_type", tracker_type.to_string());
}
if let Some(owner) = &req.tracker_owner {
set_doc.insert("tracker_owner", owner);
}
if let Some(repo) = &req.tracker_repo {
set_doc.insert("tracker_repo", repo);
}
if let Some(token) = &req.tracker_token {
set_doc.insert("tracker_token", token);
}
if let Some(schedule) = &req.scan_schedule {
set_doc.insert("scan_schedule", schedule);
}
let result = agent
.db
.repositories()
.update_one(doc! { "_id": oid }, doc! { "$set": set_doc })
.await
.map_err(|e| {
tracing::warn!("Failed to update repository: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if result.matched_count == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(serde_json::json!({ "status": "updated" })))
}
#[tracing::instrument(skip_all)]
pub async fn get_ssh_public_key(
Extension(agent): AgentExt,
) -> Result<Json<serde_json::Value>, StatusCode> {
let public_path = format!("{}.pub", agent.config.ssh_key_path);
let public_key = std::fs::read_to_string(&public_path).map_err(|_| StatusCode::NOT_FOUND)?;
Ok(Json(serde_json::json!({ "public_key": public_key.trim() })))
}
#[tracing::instrument(skip_all, fields(repo_id = %id))]
pub async fn trigger_scan(
Extension(agent): AgentExt,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let agent_clone = (*agent).clone();
tokio::spawn(async move {
if let Err(e) = agent_clone.run_scan(&id, ScanTrigger::Manual).await {
tracing::error!("Manual scan failed for {id}: {e}");
}
});
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
}
/// Return the webhook secret for a repository (used by dashboard to display it)
pub async fn get_webhook_config(
Extension(agent): AgentExt,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let repo = agent
.db
.repositories()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
let tracker_type = repo
.tracker_type
.as_ref()
.map(|t| t.to_string())
.unwrap_or_else(|| "gitea".to_string());
Ok(Json(serde_json::json!({
"webhook_secret": repo.webhook_secret,
"tracker_type": tracker_type,
})))
}
#[tracing::instrument(skip_all, fields(repo_id = %id))]
pub async fn delete_repository(
Extension(agent): AgentExt,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
let db = &agent.db;
// Delete the repository
let result = db
.repositories()
.delete_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if result.deleted_count == 0 {
return Err(StatusCode::NOT_FOUND);
}
// Cascade delete all related data
let _ = db.findings().delete_many(doc! { "repo_id": &id }).await;
let _ = db.sbom_entries().delete_many(doc! { "repo_id": &id }).await;
let _ = db.scan_runs().delete_many(doc! { "repo_id": &id }).await;
let _ = db.cve_alerts().delete_many(doc! { "repo_id": &id }).await;
let _ = db
.tracker_issues()
.delete_many(doc! { "repo_id": &id })
.await;
let _ = db.graph_nodes().delete_many(doc! { "repo_id": &id }).await;
let _ = db.graph_edges().delete_many(doc! { "repo_id": &id }).await;
let _ = db.graph_builds().delete_many(doc! { "repo_id": &id }).await;
let _ = db
.impact_analyses()
.delete_many(doc! { "repo_id": &id })
.await;
let _ = db
.code_embeddings()
.delete_many(doc! { "repo_id": &id })
.await;
let _ = db
.embedding_builds()
.delete_many(doc! { "repo_id": &id })
.await;
// Cascade delete DAST targets linked to this repo, and all their downstream data
// (scan runs, findings, pentest sessions, attack chains, messages)
if let Ok(mut cursor) = db.dast_targets().find(doc! { "repo_id": &id }).await {
use futures_util::StreamExt;
while let Some(Ok(target)) = cursor.next().await {
let target_id = target.id.map(|oid| oid.to_hex()).unwrap_or_default();
if !target_id.is_empty() {
cascade_delete_dast_target(db, &target_id).await;
}
}
}
// Also delete pentest sessions linked directly to this repo (not via target)
if let Ok(mut cursor) = db.pentest_sessions().find(doc! { "repo_id": &id }).await {
use futures_util::StreamExt;
while let Some(Ok(session)) = cursor.next().await {
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
if !session_id.is_empty() {
let _ = db
.attack_chain_nodes()
.delete_many(doc! { "session_id": &session_id })
.await;
let _ = db
.pentest_messages()
.delete_many(doc! { "session_id": &session_id })
.await;
// Delete DAST findings produced by this session
let _ = db
.dast_findings()
.delete_many(doc! { "session_id": &session_id })
.await;
}
}
}
let _ = db
.pentest_sessions()
.delete_many(doc! { "repo_id": &id })
.await;
Ok(Json(serde_json::json!({ "status": "deleted" })))
}
/// Cascade-delete a DAST target and all its downstream data.
async fn cascade_delete_dast_target(db: &crate::database::Database, target_id: &str) {
// Delete pentest sessions for this target (and their attack chains + messages)
if let Ok(mut cursor) = db
.pentest_sessions()
.find(doc! { "target_id": target_id })
.await
{
use futures_util::StreamExt;
while let Some(Ok(session)) = cursor.next().await {
let session_id = session.id.map(|oid| oid.to_hex()).unwrap_or_default();
if !session_id.is_empty() {
let _ = db
.attack_chain_nodes()
.delete_many(doc! { "session_id": &session_id })
.await;
let _ = db
.pentest_messages()
.delete_many(doc! { "session_id": &session_id })
.await;
let _ = db
.dast_findings()
.delete_many(doc! { "session_id": &session_id })
.await;
}
}
}
let _ = db
.pentest_sessions()
.delete_many(doc! { "target_id": target_id })
.await;
// Delete DAST scan runs and their findings
let _ = db
.dast_findings()
.delete_many(doc! { "target_id": target_id })
.await;
let _ = db
.dast_scan_runs()
.delete_many(doc! { "target_id": target_id })
.await;
// Delete the target itself
if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(target_id) {
let _ = db.dast_targets().delete_one(doc! { "_id": oid }).await;
}
}
+17 -6
View File
@@ -6,6 +6,7 @@ use mongodb::bson::doc;
use super::dto::*;
use compliance_core::models::SbomEntry;
use compliance_core::tenant_ctx::TenantCtx;
const COPYLEFT_LICENSES: &[&str] = &[
"GPL-2.0",
@@ -29,8 +30,10 @@ const COPYLEFT_LICENSES: &[&str] = &[
#[tracing::instrument(skip_all)]
pub async fn sbom_filters(
Extension(agent): AgentExt,
tenant: TenantCtx,
) -> Result<Json<serde_json::Value>, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let managers: Vec<String> = db
.sbom_entries()
@@ -61,9 +64,11 @@ pub async fn sbom_filters(
#[tracing::instrument(skip_all, fields(repo_id = ?filter.repo_id, package_manager = ?filter.package_manager))]
pub async fn list_sbom(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(filter): Query<SbomFilter>,
) -> ApiResult<Vec<SbomEntry>> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let mut query = doc! {};
if let Some(repo_id) = &filter.repo_id {
@@ -120,9 +125,11 @@ pub async fn list_sbom(
#[tracing::instrument(skip_all)]
pub async fn export_sbom(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<SbomExportParams>,
) -> Result<impl IntoResponse, StatusCode> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let entries: Vec<SbomEntry> = match db
.sbom_entries()
.find(doc! { "repo_id": &params.repo_id })
@@ -236,9 +243,11 @@ pub async fn export_sbom(
#[tracing::instrument(skip_all)]
pub async fn license_summary(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<SbomFilter>,
) -> ApiResult<Vec<LicenseSummary>> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let mut query = doc! {};
if let Some(repo_id) = &params.repo_id {
query.insert("repo_id", repo_id);
@@ -273,7 +282,7 @@ pub async fn license_summary(
}
})
.collect();
summaries.sort_by(|a, b| b.count.cmp(&a.count));
summaries.sort_by_key(|s| std::cmp::Reverse(s.count));
Ok(Json(ApiResponse {
data: summaries,
@@ -285,9 +294,11 @@ pub async fn license_summary(
#[tracing::instrument(skip_all)]
pub async fn sbom_diff(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<SbomDiffParams>,
) -> ApiResult<SbomDiffResult> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let entries_a: Vec<SbomEntry> = match db
.sbom_entries()
+4 -1
View File
@@ -4,13 +4,16 @@ use mongodb::bson::doc;
use super::dto::*;
use compliance_core::models::ScanRun;
use compliance_core::tenant_ctx::TenantCtx;
#[tracing::instrument(skip_all)]
pub async fn list_scan_runs(
Extension(agent): AgentExt,
tenant: TenantCtx,
Query(params): Query<PaginationParams>,
) -> ApiResult<Vec<ScanRun>> {
let db = &agent.db;
let db = tenant_db(&agent, &tenant).await?;
let db = &db;
let skip = (params.page.saturating_sub(1)) * params.limit as u64;
let total = db.scan_runs().count_documents(doc! {}).await.unwrap_or(0);
@@ -0,0 +1,289 @@
//! Werkbank runner endpoints (`/api/v1/werkbank/jobs/*`).
//!
//! The pull API a Werkbank runner talks to: lease a job, heartbeat while it runs,
//! and post the result back. Machine auth is a **static bearer token**
//! (`WERKBANK_RUNNER_TOKEN`) — not a Keycloak JWT, because a runner acts across
//! tenants (each request names its `tenant`). Routes are only mounted when the
//! token is configured; with none set they don't exist (404).
//!
//! On completion the runner's findings are persisted against the job's target,
//! so a job run by a remote runner lands the same findings an in-process run
//! would (WB-05, the control-plane cut-over).
use axum::extract::{Extension, Path, Request};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Json;
use mongodb::bson::{doc, oid::ObjectId};
use secrecy::ExposeSecret;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use compliance_core::models::werkbank::{
CompleteRequest, CompleteResponse, HeartbeatRequest, InputRef, Job, JobResult, LeaseRequest,
};
use compliance_core::models::ArtifactKind;
use super::dto::AgentExt;
use crate::database::Database;
use crate::werkbank::JobQueue;
/// Gate the runner endpoints behind the static runner bearer token.
pub async fn require_runner_token(
Extension(agent): AgentExt,
request: Request,
next: Next,
) -> Response {
let Some(expected) = agent.config.werkbank_runner_token.as_ref() else {
return (StatusCode::NOT_FOUND, "werkbank runner API disabled").into_response();
};
let presented = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.map(str::trim)
.filter(|s| !s.is_empty());
let Some(presented) = presented else {
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
};
if !constant_time_eq(presented, expected.expose_secret()) {
return (StatusCode::UNAUTHORIZED, "Invalid runner token").into_response();
}
next.run(request).await
}
/// `POST /api/v1/werkbank/jobs/lease` — lease the oldest runnable job, or `204`.
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, runner = %req.runner_id))]
pub async fn lease(
Extension(agent): AgentExt,
Json(req): Json<LeaseRequest>,
) -> Result<Response, StatusCode> {
let queue = JobQueue::new(&tenant_db(&agent, &req.tenant).await?);
let leased = queue
.lease(
&req.runner_id,
req.executor,
&req.labels,
Duration::from_secs(req.lease_ttl_secs),
chrono::Utc::now(),
)
.await
.map_err(internal)?;
Ok(match leased {
Some(job) => Json(job).into_response(),
None => StatusCode::NO_CONTENT.into_response(),
})
}
/// `POST /api/v1/werkbank/jobs/heartbeat` — extend the lease; `409` if it's lost.
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, job = %req.job_id))]
pub async fn heartbeat(
Extension(agent): AgentExt,
Json(req): Json<HeartbeatRequest>,
) -> Result<Response, StatusCode> {
let queue = JobQueue::new(&tenant_db(&agent, &req.tenant).await?);
let ack = queue
.heartbeat(
&req.job_id,
&req.lease_token,
Duration::from_secs(req.lease_ttl_secs),
chrono::Utc::now(),
)
.await
.map_err(internal)?;
Ok(match ack {
Some(ack) => Json(ack).into_response(),
// Lease lost — the runner should abandon the job.
None => StatusCode::CONFLICT.into_response(),
})
}
/// `POST /api/v1/werkbank/jobs/complete` — record the result and persist findings.
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, job = %req.job_id))]
pub async fn complete(
Extension(agent): AgentExt,
Json(req): Json<CompleteRequest>,
) -> Result<Json<CompleteResponse>, StatusCode> {
let db = tenant_db(&agent, &req.tenant).await?;
let queue = JobQueue::new(&db);
let now = chrono::Utc::now();
let recorded = queue
.complete(&req.job_id, &req.lease_token, &req.result, now)
.await
.map_err(internal)?;
// Only persist findings for the run that actually recorded the result, so a
// duplicate/late completion can't double-insert.
if recorded {
if let Some(record) = queue.get(&req.job_id).await.map_err(internal)? {
persist_findings(&db, &record.job.target_id, &req.result).await;
}
}
Ok(Json(CompleteResponse { recorded }))
}
/// `GET /api/v1/werkbank/artifacts/{hash}` — serve a content-addressed blob (the
/// program a runner needs to load). The hash is validated against traversal by
/// [`crate::ingest::blob::read_blob`]; a runner fetches this for a job's `blob`
/// input.
#[tracing::instrument(skip_all, fields(hash = %hash))]
pub async fn serve_artifact(
Extension(agent): AgentExt,
Path(hash): Path<String>,
) -> Result<Response, StatusCode> {
let base = std::path::Path::new(&agent.config.artifact_store_base_path);
match crate::ingest::blob::read_blob(base, &hash) {
Ok(bytes) => {
Ok(([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response())
}
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
/// Enqueue a `plc-provision` job for a target: extract its control-logic program,
/// stash it as a content-addressed blob (which the runner fetches via
/// [`serve_artifact`]), and queue the job. This is the control-plane "enqueue"
/// half of the loop — a runner then leases it, provisions, and posts results.
#[derive(Debug, Deserialize)]
pub struct EnqueueRequest {
/// The tenant whose queue to enqueue into.
pub tenant: String,
/// The onboarded target to test.
pub target_id: String,
}
/// The enqueued job's id.
#[derive(Debug, Serialize)]
pub struct EnqueueResponse {
/// The new job id.
pub job_id: String,
/// Whether this call inserted it (false = already queued).
pub enqueued: bool,
}
#[tracing::instrument(skip_all, fields(tenant = %req.tenant, target = %req.target_id))]
pub async fn enqueue(
Extension(agent): AgentExt,
Json(req): Json<EnqueueRequest>,
) -> Result<Json<EnqueueResponse>, StatusCode> {
let db = tenant_db(&agent, &req.tenant).await?;
let oid = ObjectId::parse_str(&req.target_id).map_err(|_| StatusCode::BAD_REQUEST)?;
let target = db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(internal)?
.ok_or(StatusCode::NOT_FOUND)?;
// Extract the control-logic program from the target's PLC-source artifacts
// (same selection as the in-process PLC scan).
let ctx = crate::ingest::IngestContext::from_config(&agent.config, &req.target_id);
let ingest_set = crate::ingest::ingest_all(&target, &ctx).map_err(internal)?;
let program = target
.artifacts
.iter()
.filter(|a| {
matches!(
a.kind,
ArtifactKind::PlcProject | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
)
})
.find_map(|a| {
let path = ingest_set
.get(&a.id)
.and_then(|ia| ia.working_path.clone())?;
werkbank_exec::plc::extract_program(&path)
})
.ok_or(StatusCode::UNPROCESSABLE_ENTITY)?;
// Stash the program source so the runner can fetch it by hash.
let base = std::path::Path::new(&agent.config.artifact_store_base_path);
let hash =
crate::ingest::blob::store_bytes(base, program.source.as_bytes()).map_err(internal)?;
let job_id = format!("job_{}", uuid::Uuid::new_v4().simple());
let job = Job::plc_provision(
&job_id,
&req.tenant,
&req.target_id,
InputRef::blob(hash),
agent.config.plc_runtime.max_lifetime_secs,
);
let enqueued = JobQueue::new(&db)
.enqueue(job, chrono::Utc::now())
.await
.map_err(internal)?;
Ok(Json(EnqueueResponse { job_id, enqueued }))
}
/// Persist a job result's findings against its target: general findings
/// (dedup'd by fingerprint) and DAST findings. Best-effort — a persistence hiccup
/// is logged, not surfaced to the runner (its result is already recorded).
async fn persist_findings(db: &Database, target_id: &str, result: &JobResult) {
for finding in &result.findings {
let exists = db
.findings()
.find_one(doc! { "fingerprint": &finding.fingerprint })
.await
.ok()
.flatten()
.is_some();
if !exists {
if let Err(e) = db.findings().insert_one(finding).await {
tracing::warn!(target_id, error = %e, "werkbank: persist finding failed");
}
}
}
for finding in &result.dast_findings {
if let Err(e) = db.dast_findings().insert_one(finding).await {
tracing::warn!(target_id, error = %e, "werkbank: persist DAST finding failed");
}
}
tracing::info!(
target_id,
findings = result.findings.len(),
dast = result.dast_findings.len(),
"werkbank: persisted runner results"
);
}
/// Resolve the tenant-scoped database for a request.
async fn tenant_db(
agent: &crate::agent::ComplianceAgent,
tenant: &str,
) -> Result<Database, StatusCode> {
agent.db_pool.for_tenant_id(tenant).await.map_err(internal)
}
/// Map any internal error to a 500.
fn internal<E: std::fmt::Display>(e: E) -> StatusCode {
tracing::error!("werkbank endpoint error: {e}");
StatusCode::INTERNAL_SERVER_ERROR
}
/// Length-checked, constant-time-ish token comparison.
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::constant_time_eq;
#[test]
fn token_compare() {
assert!(constant_time_eq("secret", "secret"));
assert!(!constant_time_eq("secret", "secrex"));
assert!(!constant_time_eq("secret", "secretx"));
assert!(!constant_time_eq("", "x"));
}
}
-1
View File
@@ -1,4 +1,3 @@
pub mod auth_middleware;
pub mod handlers;
pub mod routes;
pub mod server;
+62 -22
View File
@@ -2,29 +2,46 @@ use axum::routing::{delete, get, patch, post};
use axum::Router;
use crate::api::handlers;
use crate::webhooks;
pub fn build_router() -> Router {
Router::new()
.route("/api/v1/health", get(handlers::health))
.route("/api/v1/oscal/assess", post(handlers::oscal::assess_target))
.route("/api/v1/stats/overview", get(handlers::stats_overview))
.route(
"/api/v1/settings/ssh-public-key",
get(handlers::get_ssh_public_key),
)
.route("/api/v1/repositories", get(handlers::list_repositories))
.route("/api/v1/repositories", post(handlers::add_repository))
// Unified onboarding targets (#131).
.route(
"/api/v1/repositories/{id}/scan",
post(handlers::trigger_scan),
"/api/v1/targets",
get(handlers::onboarding::list_targets).post(handlers::onboarding::create_target),
)
.route(
"/api/v1/repositories/{id}",
delete(handlers::delete_repository).patch(handlers::update_repository),
"/api/v1/targets/{id}",
get(handlers::onboarding::get_target)
.patch(handlers::onboarding::update_target)
.delete(handlers::onboarding::delete_target),
)
.route(
"/api/v1/repositories/{id}/webhook-config",
get(handlers::get_webhook_config),
"/api/v1/targets/{id}/artifacts",
post(handlers::onboarding::add_artifact),
)
.route(
"/api/v1/targets/{id}/artifacts/upload",
post(handlers::onboarding::upload_artifact),
)
.route(
"/api/v1/targets/{id}/applicable-scans",
get(handlers::onboarding::applicable_scans_for_target),
)
.route(
"/api/v1/targets/{id}/detect",
post(handlers::onboarding::detect_target),
)
.route(
"/api/v1/targets/{id}/scan",
post(handlers::onboarding::trigger_target_scan),
)
.route("/api/v1/findings", get(handlers::list_findings))
.route("/api/v1/findings/{id}", get(handlers::get_finding))
@@ -47,6 +64,15 @@ pub fn build_router() -> Router {
.route("/api/v1/sbom/diff", get(handlers::sbom_diff))
.route("/api/v1/issues", get(handlers::list_issues))
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
// MCP token management (per-tenant API tokens for the MCP server)
.route(
"/api/v1/mcp-tokens",
get(handlers::mcp_tokens::list_mcp_tokens).post(handlers::mcp_tokens::create_mcp_token),
)
.route(
"/api/v1/mcp-tokens/{id}",
delete(handlers::mcp_tokens::revoke_mcp_token),
)
// Graph API endpoints
.route("/api/v1/graph/{repo_id}", get(handlers::graph::get_graph))
.route(
@@ -101,6 +127,27 @@ pub fn build_router() -> Router {
)
// Help chat (documentation-grounded Q&A)
.route("/api/v1/help/chat", post(handlers::help_chat::help_chat))
// CVE notification endpoints
.route(
"/api/v1/notifications",
get(handlers::notifications::list_notifications),
)
.route(
"/api/v1/notifications/count",
get(handlers::notifications::notification_count),
)
.route(
"/api/v1/notifications/read-all",
post(handlers::notifications::mark_all_read),
)
.route(
"/api/v1/notifications/{id}/read",
patch(handlers::notifications::mark_read),
)
.route(
"/api/v1/notifications/{id}/dismiss",
patch(handlers::notifications::dismiss_notification),
)
// Pentest API endpoints
.route(
"/api/v1/pentest/lookup-repo",
@@ -154,17 +201,10 @@ pub fn build_router() -> Router {
"/api/v1/pentest/stats",
get(handlers::pentest::pentest_stats),
)
// Webhook endpoints (proxied through dashboard)
.route(
"/webhook/github/{repo_id}",
post(webhooks::github::handle_github_webhook),
)
.route(
"/webhook/gitlab/{repo_id}",
post(webhooks::gitlab::handle_gitlab_webhook),
)
.route(
"/webhook/gitea/{repo_id}",
post(webhooks::gitea::handle_gitea_webhook),
)
// Webhook routes live on the separate webhook server (port 3002,
// see crate::webhooks::server). The M7.2-C tenant-in-URL form is
// `/webhook/{tenant_id}/{platform}/{repo_id}` and the handlers
// expect a (tenant_id, repo_id) path tuple. Anything mounting
// them here on the API server would mismatch the handler
// signature, so the routes are not exported.
}
+134 -6
View File
@@ -1,20 +1,137 @@
use std::sync::Arc;
use axum::{middleware, Extension};
use axum::extract::{DefaultBodyLimit, Request};
use axum::http::HeaderValue;
use axum::middleware::Next;
use axum::response::Response;
use axum::routing::{delete, get, post};
use axum::{middleware, Extension, Router};
use tokio::sync::RwLock;
use tower_http::cors::CorsLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tower_http::trace::TraceLayer;
use compliance_core::auth::{require_jwt_auth, require_tenant_status, JwksState};
use compliance_core::{TenantContext, TenantStatus};
use crate::agent::ComplianceAgent;
use crate::api::auth_middleware::{require_jwt_auth, JwksState};
use crate::api::handlers;
use crate::api::routes;
use crate::error::AgentError;
/// Synthetic tenant id used when Keycloak isn't configured (local dev,
/// `cargo run` against a bare Mongo). Lets the handler stack stay
/// uniformly tenant-scoped without the operator having to spin up KC
/// just to poke at the API. Override via `DEV_TENANT_ID`.
const DEFAULT_DEV_TENANT_ID: &str = "dev";
/// Inject a synthetic [`TenantContext`] for any request that lacks one.
/// Only mounted when Keycloak is NOT configured; with KC, the real
/// `require_jwt_auth` middleware owns this and we never reach here
/// without a context.
///
/// Public so the integration-test harness can mount it without
/// duplicating the synthetic-context shape.
pub async fn inject_dev_tenant(mut request: Request, next: Next) -> Response {
if request.extensions().get::<TenantContext>().is_none() {
let tenant_id =
std::env::var("DEV_TENANT_ID").unwrap_or_else(|_| DEFAULT_DEV_TENANT_ID.to_string());
let ctx = TenantContext {
tenant_slug: tenant_id.clone(),
tenant_id,
org_roles: vec![],
products: vec![],
plan: "dev".to_string(),
status: TenantStatus::Active,
user_id: "dev-user".to_string(),
user_name: None,
};
request.extensions_mut().insert(ctx);
}
next.run(request).await
}
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
// Admin sub-router. Routes are only mounted when ADMIN_API_TOKEN is
// configured — without it, the paths don't exist at all (404 rather
// than 401), so an operator who hasn't opted in can't fingerprint
// the surface area.
let admin_router: Router = if agent.config.admin_api_token.is_some() {
tracing::info!("Admin API enabled — /api/v1/admin/* mounted behind ADMIN_API_TOKEN bearer");
Router::new()
.route(
"/api/v1/admin/tenants",
get(handlers::admin::list_tenant_dbs),
)
.route(
"/api/v1/admin/tenants/{tenant_id}",
delete(handlers::admin::drop_tenant_db),
)
.layer(middleware::from_fn(handlers::admin::require_admin_token))
} else {
Router::new()
};
// Werkbank runner API. Like admin, only mounted when its bearer token is
// configured; runners authenticate with WERKBANK_RUNNER_TOKEN (not a JWT).
let werkbank_router: Router = if agent.config.werkbank_runner_token.is_some() {
tracing::info!(
"Werkbank runner API enabled — /api/v1/werkbank/jobs/* behind WERKBANK_RUNNER_TOKEN"
);
Router::new()
.route(
"/api/v1/werkbank/jobs/lease",
post(handlers::werkbank_jobs::lease),
)
.route(
"/api/v1/werkbank/jobs/heartbeat",
post(handlers::werkbank_jobs::heartbeat),
)
.route(
"/api/v1/werkbank/jobs/complete",
post(handlers::werkbank_jobs::complete),
)
.route(
"/api/v1/werkbank/jobs/enqueue",
post(handlers::werkbank_jobs::enqueue),
)
.route(
"/api/v1/werkbank/artifacts/{hash}",
get(handlers::werkbank_jobs::serve_artifact),
)
.layer(middleware::from_fn(
handlers::werkbank_jobs::require_runner_token,
))
} else {
Router::new()
};
let mut app = routes::build_router()
.merge(admin_router)
.merge(werkbank_router)
// Allow large artifact uploads (PLC .projectarchive, firmware images,
// mobile packages) — axum's default request-body limit is only 2 MiB.
.layer(DefaultBodyLimit::max(512 * 1024 * 1024))
.layer(Extension(Arc::new(agent.clone())))
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http());
.layer(TraceLayer::new_for_http())
// Security headers (defense-in-depth, primary enforcement via Traefik)
.layer(SetResponseHeaderLayer::overriding(
axum::http::header::STRICT_TRANSPORT_SECURITY,
HeaderValue::from_static("max-age=31536000; includeSubDomains"),
))
.layer(SetResponseHeaderLayer::overriding(
axum::http::header::X_FRAME_OPTIONS,
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::overriding(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::overriding(
axum::http::header::REFERRER_POLICY,
HeaderValue::from_static("strict-origin-when-cross-origin"),
));
if let (Some(kc_url), Some(kc_realm)) =
(&agent.config.keycloak_url, &agent.config.keycloak_realm)
@@ -25,11 +142,22 @@ pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), A
jwks_url,
};
tracing::info!("Keycloak JWT auth enabled for realm '{kc_realm}'");
// Layers execute outermost-first. Extension(jwks_state) must run
// before require_jwt_auth so the middleware can read it; the
// status gate runs after JWT so TenantContext is in extensions.
app = app
.layer(Extension(jwks_state))
.layer(middleware::from_fn(require_jwt_auth));
.layer(middleware::from_fn(require_tenant_status))
.layer(middleware::from_fn(require_jwt_auth))
.layer(Extension(jwks_state));
} else {
tracing::warn!("Keycloak not configured - API endpoints are unprotected");
let tenant_id =
std::env::var("DEV_TENANT_ID").unwrap_or_else(|_| DEFAULT_DEV_TENANT_ID.to_string());
tracing::warn!(
tenant_id = %tenant_id,
"Keycloak not configured — running unauthenticated against the dev tenant. \
DO NOT use in any environment with real customer data."
);
app = app.layer(middleware::from_fn(inject_dev_tenant));
}
let addr = format!("0.0.0.0:{port}");
+217
View File
@@ -0,0 +1,217 @@
//! Firmware classification via tramiton.
//!
//! tramiton is the company's firmware build/repro engine; we do not re-implement
//! its detection. We depend on `tramiton-core` directly (same-company IP) and run
//! its provider analysis in-process behind a [`FirmwareDetector`] port, mapping
//! tramiton's `BuildPlan` onto a [`TargetType`]. A deterministic
//! [`MockFirmwareDetector`] backs the tests so CI unit tests need neither the
//! tramiton sources nor a real firmware tree.
use std::path::Path;
use compliance_core::error::CoreError;
use compliance_core::models::{DetectedFact, TargetType};
use compliance_core::traits::ClassifierVerdict;
/// A minimal firmware-detection summary, mapped from tramiton's `BuildPlan`.
/// Kept small and tramiton-independent so the classifier and the test mock don't
/// need to construct a full tramiton plan.
#[derive(Debug, Clone, Default)]
pub struct FirmwareDetection {
/// The detecting provider (e.g. `zephyr`, `cmake`, `source-archaeology`).
pub provider: String,
/// Detection confidence: `low` | `medium` | `high`.
pub confidence: String,
/// Build-system label (e.g. `Zephyr`, `ESP-IDF`, `CMake`).
pub build_system: String,
/// Framework, when known (`zephyr`, `esp-idf`, `bare-metal`, ...).
pub framework: Option<String>,
/// Target board / MCU / arch.
pub target: FirmwareTarget,
/// Unresolved gaps in the plan.
pub gaps: Vec<String>,
}
/// The detected firmware target (board / MCU / arch).
#[derive(Debug, Clone, Default)]
pub struct FirmwareTarget {
/// Board name.
pub board: Option<String>,
/// MCU part.
pub mcu: Option<String>,
/// Architecture.
pub arch: Option<String>,
}
/// A source of tramiton firmware detection.
#[allow(async_fn_in_trait)]
pub trait FirmwareDetector: Send + Sync {
/// Run detection over a path, returning a firmware detection if tramiton
/// could form a build plan.
async fn detect(&self, path: &Path) -> Result<Option<FirmwareDetection>, CoreError>;
}
/// Uses `tramiton-core` in-process. The analysis is blocking (filesystem walk),
/// so it runs on a blocking thread to avoid stalling the async runtime. A path
/// with no recognizable build system yields `Ok(None)`.
pub struct TramitonNative;
impl FirmwareDetector for TramitonNative {
async fn detect(&self, path: &Path) -> Result<Option<FirmwareDetection>, CoreError> {
let path = path.to_path_buf();
let plan = tokio::task::spawn_blocking(move || {
let repo = tramiton_core::Repo::new(&path);
tramiton_core::provider::analyze(&repo)
})
.await
.map_err(|e| CoreError::Other(format!("tramiton detect task join error: {e}")))?
.map_err(|e| CoreError::Other(format!("tramiton analyze error: {e}")))?;
Ok(plan.map(|bp| detection_from_build_plan(&bp)))
}
}
/// Map tramiton's `BuildPlan` onto our minimal detection summary.
fn detection_from_build_plan(bp: &tramiton_core::BuildPlan) -> FirmwareDetection {
FirmwareDetection {
provider: bp.provider.clone(),
confidence: bp.confidence.to_string(),
build_system: bp.build_system.label().to_string(),
framework: bp.framework.clone(),
target: FirmwareTarget {
board: bp.target.board.clone(),
mcu: bp.target.mcu.clone(),
arch: bp.target.arch.clone(),
},
gaps: bp.gaps.clone(),
}
}
/// Map a firmware detection to a target type. Framework/build-system signals
/// distinguish RTOS from bare-metal from Yocto.
pub fn detection_to_target_type(det: &FirmwareDetection) -> TargetType {
let framework = det.framework.as_deref().unwrap_or("").to_lowercase();
let build_system = det.build_system.to_lowercase();
let signal = format!("{framework} {build_system} {}", det.provider.to_lowercase());
const RTOS: [&str; 6] = ["zephyr", "esp-idf", "freertos", "nuttx", "riot", "chibios"];
if signal.contains("bitbake") || signal.contains("yocto") || signal.contains("openembedded") {
TargetType::EmbeddedLinuxYocto
} else if RTOS.iter().any(|k| signal.contains(k)) {
TargetType::FirmwareRtos
} else {
TargetType::FirmwareBareMetal
}
}
/// Map tramiton's confidence label to a `[0,1]` score.
fn confidence_score(label: &str) -> f32 {
match label.to_lowercase().as_str() {
"high" => 0.9,
"medium" => 0.6,
"low" => 0.3,
_ => 0.4,
}
}
/// Turn a firmware detection into a classifier verdict, carrying the MCU / board
/// / build-system as facts.
pub fn detection_to_verdict(det: &FirmwareDetection) -> ClassifierVerdict {
let target_type = detection_to_target_type(det);
let mut facts = vec![DetectedFact::new(
"build_system",
det.build_system.clone(),
"tramiton",
)];
if let Some(fw) = &det.framework {
facts.push(DetectedFact::new("framework", fw.clone(), "tramiton"));
}
if let Some(mcu) = &det.target.mcu {
facts.push(DetectedFact::new("mcu", mcu.clone(), "tramiton"));
}
if let Some(board) = &det.target.board {
facts.push(DetectedFact::new("board", board.clone(), "tramiton"));
}
if let Some(arch) = &det.target.arch {
facts.push(DetectedFact::new("arch", arch.clone(), "tramiton"));
}
ClassifierVerdict {
target_type,
confidence: confidence_score(&det.confidence),
facts,
rationale: format!(
"tramiton detected build system '{}'{}",
det.build_system,
det.framework
.as_ref()
.map(|f| format!(" (framework {f})"))
.unwrap_or_default()
),
}
}
/// A deterministic [`FirmwareDetector`] for tests — returns a preset detection.
pub struct MockFirmwareDetector {
/// The detection to return (or `None` for "no detection").
pub detection: Option<FirmwareDetection>,
}
impl FirmwareDetector for MockFirmwareDetector {
async fn detect(&self, _path: &Path) -> Result<Option<FirmwareDetection>, CoreError> {
Ok(self.detection.clone())
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
fn detection(build_system: &str, framework: Option<&str>) -> FirmwareDetection {
FirmwareDetection {
provider: build_system.to_string(),
confidence: "high".to_string(),
build_system: build_system.to_string(),
framework: framework.map(|s| s.to_string()),
target: FirmwareTarget {
mcu: Some("stm32f429".to_string()),
..Default::default()
},
gaps: Vec::new(),
}
}
#[test]
fn zephyr_maps_to_rtos() {
assert_eq!(
detection_to_target_type(&detection("zephyr", Some("zephyr"))),
TargetType::FirmwareRtos
);
}
#[test]
fn bare_cmake_maps_to_bare_metal() {
assert_eq!(
detection_to_target_type(&detection("cmake", Some("bare-metal"))),
TargetType::FirmwareBareMetal
);
}
#[test]
fn bitbake_maps_to_yocto() {
assert_eq!(
detection_to_target_type(&detection("bitbake", None)),
TargetType::EmbeddedLinuxYocto
);
}
#[test]
fn verdict_carries_mcu_fact_and_confidence() {
let v = detection_to_verdict(&detection("esp-idf", Some("esp-idf")));
assert_eq!(v.target_type, TargetType::FirmwareRtos);
assert!((v.confidence - 0.9).abs() < f32::EPSILON);
assert!(v
.facts
.iter()
.any(|f| f.key == "mcu" && f.value == "stm32f429"));
}
}
+357
View File
@@ -0,0 +1,357 @@
//! Heuristic target-type classification from artifact kinds and source markers.
//!
//! Complements the tramiton firmware detector: this handles web / backend /
//! mobile / desktop / PLC by sniffing manifest files and file extensions in the
//! ingested code trees, plus strong priors from the artifact kinds themselves
//! (a PLC-project artifact is a PLC target; an `.ipa` is an iOS app).
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use compliance_core::error::CoreError;
use compliance_core::models::{ArtifactKind, DetectedFact, TargetType};
use compliance_core::traits::{ClassificationInput, ClassifierVerdict, TargetClassifier};
/// Max directory depth scanned for marker files.
const SCAN_DEPTH: usize = 2;
/// Markers collected from a code tree.
#[derive(Default)]
struct Markers {
files: HashSet<String>,
dirs: HashSet<String>,
exts: HashSet<String>,
}
impl Markers {
fn has_file(&self, name: &str) -> bool {
self.files.contains(name)
}
fn has_ext(&self, ext: &str) -> bool {
self.exts.contains(ext)
}
fn any_dir_ends_with(&self, suffix: &str) -> bool {
self.dirs.iter().any(|d| d.ends_with(suffix))
}
}
/// Recursively collect marker file/dir/extension names up to [`SCAN_DEPTH`].
fn collect_markers(root: &Path) -> Markers {
let mut m = Markers::default();
scan_dir(root, 0, &mut m);
m
}
fn scan_dir(dir: &Path, depth: usize, m: &mut Markers) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_lowercase();
if path.is_dir() {
m.dirs.insert(name);
if depth < SCAN_DEPTH {
scan_dir(&path, depth + 1, m);
}
} else {
if let Some(ext) = path.extension() {
m.exts.insert(ext.to_string_lossy().to_lowercase());
}
m.files.insert(name);
}
}
}
/// Whether a `package.json` at `root` looks like a front-end app.
fn package_json_is_frontend(root: &Path) -> bool {
let Ok(content) = fs::read_to_string(root.join("package.json")) else {
return false;
};
let c = content.to_lowercase();
["react", "next", "vue", "@angular", "svelte", "vite"]
.iter()
.any(|f| c.contains(f))
}
/// The heuristic classifier: artifact-kind priors + source-tree markers.
pub struct HeuristicClassifier;
impl HeuristicClassifier {
/// Verdicts from the artifact kinds alone (no filesystem needed).
fn kind_priors(&self, input: &ClassificationInput<'_>) -> Vec<ClassifierVerdict> {
let mut out = Vec::new();
for a in input.artifacts {
let lower = a.source_ref.to_lowercase();
match a.kind {
ArtifactKind::PlcProject => out.push(verdict(
TargetType::PlcSps,
0.85,
"PLC project artifact",
vec![],
)),
ArtifactKind::MobilePackage => {
let (tt, why) = if lower.ends_with(".ipa") {
(TargetType::IosApp, "iOS package (.ipa)")
} else {
(TargetType::AndroidApp, "Android package (.apk/.aab)")
};
out.push(verdict(tt, 0.85, why, vec![]));
}
ArtifactKind::ContainerImage => out.push(verdict(
TargetType::BackendService,
0.4,
"container image",
vec![],
)),
ArtifactKind::FirmwareImage => out.push(verdict(
TargetType::FirmwareBareMetal,
0.35,
"firmware image (pending tramiton detection)",
vec![],
)),
ArtifactKind::LiveUrl if input.artifacts.len() == 1 => {
out.push(verdict(TargetType::WebApp, 0.3, "live URL only", vec![]))
}
_ => {}
}
}
out
}
/// Verdicts from scanning the ingested code trees for manifest markers.
fn source_verdicts(&self, input: &ClassificationInput<'_>) -> Vec<ClassifierVerdict> {
let mut out = Vec::new();
for a in input.artifacts {
if !matches!(a.kind, ArtifactKind::GitRepo | ArtifactKind::SourceArchive) {
continue;
}
let Some(path) = input.working_paths.get(&a.id) else {
continue;
};
let m = collect_markers(path);
// Mobile (checked first — strongest signal).
if m.has_file("androidmanifest.xml") || m.has_ext("apk") || m.has_ext("aab") {
out.push(verdict(
TargetType::AndroidApp,
0.8,
"Android manifest / gradle",
facts_lang("kotlin/java"),
));
}
if m.any_dir_ends_with(".xcodeproj")
|| m.has_file("info.plist")
|| m.has_file("podfile")
|| m.has_ext("ipa")
{
out.push(verdict(
TargetType::IosApp,
0.8,
"Xcode project / Info.plist",
facts_lang("swift/objc"),
));
}
// Desktop.
if m.has_ext("sln")
|| m.has_ext("csproj")
|| m.has_ext("vcxproj")
|| m.has_ext("desktop")
{
out.push(verdict(
TargetType::DesktopApp,
0.7,
"desktop project files",
facts_lang("dotnet/native"),
));
}
// PLC.
if m.has_ext("st") {
out.push(verdict(
TargetType::PlcSps,
0.8,
"Structured Text sources",
facts_lang("iec-61131-3"),
));
}
// Web vs backend from package.json.
if m.has_file("package.json") {
if package_json_is_frontend(path) {
out.push(verdict(
TargetType::WebApp,
0.65,
"package.json with a front-end framework",
facts_lang("javascript"),
));
} else {
out.push(verdict(
TargetType::BackendService,
0.55,
"package.json (no front-end framework)",
facts_lang("javascript"),
));
}
}
// Backend languages.
for (file, lang) in [
("cargo.toml", "rust"),
("go.mod", "go"),
("pom.xml", "java"),
("requirements.txt", "python"),
("pyproject.toml", "python"),
] {
if m.has_file(file) {
out.push(verdict(
TargetType::BackendService,
0.6,
"backend build manifest",
facts_lang(lang),
));
}
}
// Container-only.
if m.has_file("dockerfile") && out.is_empty() {
out.push(verdict(
TargetType::BackendService,
0.4,
"Dockerfile",
facts_lang("container"),
));
}
}
out
}
}
impl TargetClassifier for HeuristicClassifier {
fn name(&self) -> &str {
"heuristic"
}
async fn classify(
&self,
input: &ClassificationInput<'_>,
) -> Result<Vec<ClassifierVerdict>, CoreError> {
let mut out = self.kind_priors(input);
out.extend(self.source_verdicts(input));
Ok(out)
}
}
fn verdict(
target_type: TargetType,
confidence: f32,
rationale: &str,
facts: Vec<DetectedFact>,
) -> ClassifierVerdict {
ClassifierVerdict {
target_type,
confidence,
facts,
rationale: rationale.to_string(),
}
}
fn facts_lang(lang: &str) -> Vec<DetectedFact> {
vec![DetectedFact::new("language", lang, "heuristic")]
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use compliance_core::models::Artifact;
use std::collections::HashMap;
use std::path::PathBuf;
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("cs-classify-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&p).expect("mkdir");
Self(p)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
async fn classify_tree(setup: impl FnOnce(&Path)) -> Vec<ClassifierVerdict> {
let scratch = Scratch::new();
setup(&scratch.0);
let artifact = Artifact::git_repo("https://git/x", "main");
let mut wp = HashMap::new();
wp.insert(artifact.id.clone(), scratch.0.clone());
let artifacts = vec![artifact];
let input = ClassificationInput {
artifacts: &artifacts,
working_paths: &wp,
description: None,
};
HeuristicClassifier
.classify(&input)
.await
.expect("classify")
}
#[tokio::test]
async fn frontend_package_json_is_webapp() {
let v = classify_tree(|root| {
fs::write(
root.join("package.json"),
r#"{"dependencies":{"react":"18"}}"#,
)
.unwrap();
})
.await;
assert!(v.iter().any(|x| x.target_type == TargetType::WebApp));
}
#[tokio::test]
async fn cargo_toml_is_backend() {
let v = classify_tree(|root| {
fs::write(root.join("Cargo.toml"), "[package]\nname='x'").unwrap();
})
.await;
assert!(v
.iter()
.any(|x| x.target_type == TargetType::BackendService));
}
#[tokio::test]
async fn android_manifest_is_android() {
let v = classify_tree(|root| {
fs::write(root.join("AndroidManifest.xml"), "<manifest/>").unwrap();
})
.await;
assert!(v.iter().any(|x| x.target_type == TargetType::AndroidApp));
}
#[tokio::test]
async fn structured_text_is_plc() {
let v = classify_tree(|root| {
fs::write(root.join("main.st"), "PROGRAM main END_PROGRAM").unwrap();
})
.await;
assert!(v.iter().any(|x| x.target_type == TargetType::PlcSps));
}
#[tokio::test]
async fn ipa_artifact_prior_is_ios() {
let artifacts = vec![Artifact::mobile_package("app.ipa")];
let wp = HashMap::new();
let input = ClassificationInput {
artifacts: &artifacts,
working_paths: &wp,
description: None,
};
let v = HeuristicClassifier
.classify(&input)
.await
.expect("classify");
assert!(v.iter().any(|x| x.target_type == TargetType::IosApp));
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Target classification.
//!
//! Runs the classifier registry over a target's artifacts and their ingested
//! working paths, then merges and ranks the verdicts into a [`Classification`].
//! The registry is the heuristic classifier (artifact kinds + source markers)
//! plus the tramiton firmware detector (behind a [`FirmwareDetector`] port).
mod firmware;
mod language;
pub use firmware::{
FirmwareDetection, FirmwareDetector, FirmwareTarget, MockFirmwareDetector, TramitonNative,
};
pub use language::HeuristicClassifier;
use std::collections::HashMap;
use std::path::PathBuf;
use compliance_core::error::CoreError;
use compliance_core::models::{
ArtifactKind, Classification, DetectedFact, OnboardedTarget, TargetType, TargetTypeCandidate,
};
use compliance_core::traits::{ClassificationInput, ClassifierVerdict, TargetClassifier};
use firmware::detection_to_verdict;
/// Classify a target from its artifacts and their ingested working paths, using
/// the heuristic classifier plus the tramiton firmware detector. Verdicts are
/// merged (max confidence per target type) and ranked into a [`Classification`].
pub async fn classify_target<D: FirmwareDetector>(
target: &OnboardedTarget,
working_paths: &HashMap<String, PathBuf>,
firmware_detector: &D,
) -> Result<Classification, CoreError> {
let input = ClassificationInput {
artifacts: &target.artifacts,
working_paths,
description: target.description.as_deref(),
};
let mut verdicts = Vec::new();
let mut detected_by = Vec::new();
let heuristic = HeuristicClassifier.classify(&input).await?;
if !heuristic.is_empty() {
detected_by.push("heuristic".to_string());
}
verdicts.extend(heuristic);
// Tramiton firmware detection over firmware / code working paths.
let mut tramiton_used = false;
for artifact in &target.artifacts {
if !matches!(
artifact.kind,
ArtifactKind::FirmwareImage | ArtifactKind::GitRepo | ArtifactKind::SourceArchive
) {
continue;
}
let Some(path) = working_paths.get(&artifact.id) else {
continue;
};
if let Some(detection) = firmware_detector.detect(path).await? {
verdicts.push(detection_to_verdict(&detection));
tramiton_used = true;
}
}
if tramiton_used {
detected_by.push("tramiton".to_string());
}
Ok(rank(verdicts, detected_by, target.target_type))
}
/// Merge verdicts by target type (keeping the max confidence and its rationale),
/// dedupe facts, rank by descending confidence, and assemble a [`Classification`].
/// Falls back to the declared type when no verdict is produced.
fn rank(
verdicts: Vec<ClassifierVerdict>,
detected_by: Vec<String>,
fallback: TargetType,
) -> Classification {
let mut best: HashMap<TargetType, (f32, String)> = HashMap::new();
let mut facts: Vec<DetectedFact> = Vec::new();
for verdict in verdicts {
for fact in verdict.facts {
if !facts
.iter()
.any(|e| e.key == fact.key && e.value == fact.value)
{
facts.push(fact);
}
}
let entry = best
.entry(verdict.target_type)
.or_insert((0.0, String::new()));
if verdict.confidence > entry.0 {
*entry = (verdict.confidence, verdict.rationale);
}
}
let mut candidates: Vec<TargetTypeCandidate> = best
.into_iter()
.map(
|(target_type, (confidence, rationale))| TargetTypeCandidate {
target_type,
confidence,
rationale,
},
)
.collect();
// Descending confidence; ties broken by type name for deterministic ordering.
candidates.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.target_type.to_string().cmp(&b.target_type.to_string()))
});
let suggested = candidates
.first()
.map(|c| c.target_type)
.unwrap_or(fallback);
Classification {
suggested,
candidates,
facts,
detected_by,
detected_at: chrono::Utc::now(),
confirmed: false,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use compliance_core::models::Artifact;
use std::fs;
use std::path::Path;
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("cs-classify-mod-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&p).expect("mkdir");
Self(p)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn no_firmware() -> MockFirmwareDetector {
MockFirmwareDetector { detection: None }
}
#[tokio::test]
async fn backend_repo_classifies_as_backend() {
let scratch = Scratch::new();
fs::write(scratch.0.join("go.mod"), "module x").unwrap();
let artifact = Artifact::git_repo("https://git/x", "main");
let mut wp = HashMap::new();
wp.insert(artifact.id.clone(), scratch.0.clone());
let mut target = OnboardedTarget::new("x".to_string(), TargetType::WebApp);
target.artifacts.push(artifact);
let c = classify_target(&target, &wp, &no_firmware())
.await
.expect("classify");
assert_eq!(c.suggested, TargetType::BackendService);
assert!(c.detected_by.contains(&"heuristic".to_string()));
assert!(!c.confirmed);
}
#[tokio::test]
async fn firmware_detector_verdict_ranks_top() {
let scratch = Scratch::new();
fs::write(scratch.0.join("fw.bin"), b"x").unwrap();
let artifact =
Artifact::firmware_image(scratch.0.join("fw.bin").to_string_lossy().to_string());
let mut wp = HashMap::new();
wp.insert(artifact.id.clone(), scratch.0.clone());
let mut target = OnboardedTarget::new("fw".to_string(), TargetType::FirmwareBareMetal);
target.artifacts.push(artifact);
let detector = MockFirmwareDetector {
detection: Some(FirmwareDetection {
provider: "zephyr".to_string(),
confidence: "high".to_string(),
build_system: "zephyr".to_string(),
framework: Some("zephyr".to_string()),
target: FirmwareTarget {
mcu: Some("nrf52840".to_string()),
..Default::default()
},
gaps: vec![],
}),
};
let c = classify_target(&target, &wp, &detector)
.await
.expect("classify");
// tramiton's high-confidence RTOS verdict beats the weak firmware prior.
assert_eq!(c.suggested, TargetType::FirmwareRtos);
assert!(c.detected_by.contains(&"tramiton".to_string()));
assert!(c.facts.iter().any(|f| f.key == "mcu"));
}
#[tokio::test]
async fn no_signal_falls_back_to_declared_type() {
let scratch = Scratch::new();
let _ = Path::new(&scratch.0);
let target = OnboardedTarget::new("empty".to_string(), TargetType::DesktopApp);
let wp = HashMap::new();
let c = classify_target(&target, &wp, &no_firmware())
.await
.expect("classify");
assert_eq!(c.suggested, TargetType::DesktopApp);
assert!(c.candidates.is_empty());
}
}
+48 -1
View File
@@ -1,3 +1,4 @@
use compliance_core::config::{BreakpilotConfig, PlcRuntimeConfig};
use compliance_core::AgentConfig;
use secrecy::SecretString;
@@ -42,9 +43,11 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
.unwrap_or(3001),
scan_schedule: env_var_opt("SCAN_SCHEDULE").unwrap_or_else(|| "0 0 */6 * * *".to_string()),
cve_monitor_schedule: env_var_opt("CVE_MONITOR_SCHEDULE")
.unwrap_or_else(|| "0 0 0 * * *".to_string()),
.unwrap_or_else(|| "0 0 * * * *".to_string()),
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
.unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()),
artifact_store_base_path: env_var_opt("ARTIFACT_STORE_BASE_PATH")
.unwrap_or_else(|| "/data/compliance-scanner/artifacts".to_string()),
ssh_key_path: env_var_opt("SSH_KEY_PATH")
.unwrap_or_else(|| "/data/compliance-scanner/ssh/id_ed25519".to_string()),
keycloak_url: env_var_opt("KEYCLOAK_URL"),
@@ -59,5 +62,49 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
.unwrap_or(true),
pentest_imap_username: env_var_opt("PENTEST_IMAP_USERNAME"),
pentest_imap_password: env_secret_opt("PENTEST_IMAP_PASSWORD"),
admin_api_token: env_secret_opt("ADMIN_API_TOKEN"),
tenant_registry_url: env_var_opt("TENANT_REGISTRY_URL"),
plc_runtime: load_plc_runtime_config(),
werkbank_runner_token: env_secret_opt("WERKBANK_RUNNER_TOKEN"),
breakpilot: load_breakpilot_config(),
})
}
/// Build the ephemeral soft-PLC provisioning config from the environment,
/// falling back to [`PlcRuntimeConfig::default`] for any unset knob. Disabled
/// unless `PLC_RUNTIME_ENABLED` is truthy — it requires Docker access.
fn load_plc_runtime_config() -> PlcRuntimeConfig {
let d = PlcRuntimeConfig::default();
PlcRuntimeConfig {
enabled: env_var_opt("PLC_RUNTIME_ENABLED")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(d.enabled),
image: env_var_opt("PLC_RUNTIME_IMAGE").unwrap_or(d.image),
network: env_var_opt("PLC_RUNTIME_NETWORK").unwrap_or(d.network),
memory: env_var_opt("PLC_RUNTIME_MEMORY").unwrap_or(d.memory),
cpus: env_var_opt("PLC_RUNTIME_CPUS").unwrap_or(d.cpus),
max_lifetime_secs: env_var_opt("PLC_RUNTIME_MAX_LIFETIME_SECS")
.and_then(|v| v.parse().ok())
.unwrap_or(d.max_lifetime_secs),
openplc_user: env_var_opt("PLC_RUNTIME_OPENPLC_USER").unwrap_or(d.openplc_user),
openplc_password: env_secret_opt("PLC_RUNTIME_OPENPLC_PASSWORD")
.unwrap_or(d.openplc_password),
}
}
/// Assemble the breakpilot OSCAL-catalog source from env, defaulting the snapshot
/// directory. A missing `BREAKPILOT_BASE_URL` leaves the controls provider off.
fn load_breakpilot_config() -> BreakpilotConfig {
let d = BreakpilotConfig::default();
BreakpilotConfig {
base_url: env_var_opt("BREAKPILOT_BASE_URL"),
token: env_secret_opt("BREAKPILOT_TOKEN"),
snapshot_dir: env_var_opt("BREAKPILOT_SNAPSHOT_DIR").unwrap_or(d.snapshot_dir),
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),
}
}
+114
View File
@@ -0,0 +1,114 @@
//! 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(), &regions, "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());
}
}
+245
View File
@@ -0,0 +1,245 @@
//! 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);
}
}
+167
View File
@@ -0,0 +1,167 @@
//! 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(), &region);
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"));
}
}
+23
View File
@@ -0,0 +1,23 @@
//! 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};
@@ -0,0 +1,229 @@
//! 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);
}
}
@@ -0,0 +1,299 @@
//! 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, &region).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, &regions, 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,
&region,
&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());
}
}
+121
View File
@@ -0,0 +1,121 @@
//! 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, &region, &[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, &region, &[1.0, 0.0], 1, "repo").await;
assert!(findings.is_empty());
}
}
+258
View File
@@ -0,0 +1,258 @@
//! 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]);
}
}
+234
View File
@@ -0,0 +1,234 @@
//! 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"), &region()).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"), &region()).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, &region).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"), &region())
.await;
assert_eq!(out, TriageOutcome::Unmapped);
}
}
+331 -14
View File
@@ -1,11 +1,233 @@
use std::sync::Arc;
use dashmap::DashMap;
use mongodb::bson::doc;
use mongodb::options::IndexOptions;
use mongodb::{Client, Collection, IndexModel};
use sha2::{Digest, Sha256};
use compliance_core::models::*;
use compliance_core::TenantContext;
use crate::error::AgentError;
/// Mongo enforces a 63-byte cap on database names (older clusters: 64
/// on Linux, 63 on Windows; we target the conservative limit).
const MAX_DB_NAME_LEN: usize = 63;
/// Hex length of the SHA-256 truncation used for the hash fallback
/// tenant DB name (16 bytes → 32 hex chars). 16 bytes gives ~2^64
/// birthday-collision resistance — at our 10s-100s tenant scale this
/// is effectively impossible to hit.
const HASH_HEX_LEN: usize = 32;
/// Largest `db_prefix` that still guarantees the hash-fallback name
/// fits in the 63-byte cap: `prefix + "_" + 32 hex chars`.
const MAX_PREFIX_LEN: usize = MAX_DB_NAME_LEN - 1 - HASH_HEX_LEN;
/// Per-tenant Mongo connection broker (M7.2 isolation model).
///
/// Holds one [`Client`] and hands out [`Database`] handles physically
/// scoped to `<db_prefix>_<tenant_id>`. The driver is the isolation
/// boundary — a handle for tenant A cannot see tenant B's documents
/// because it is connected to a different database, not because of an
/// application-level filter.
///
/// Index creation runs idempotently the first time each tenant is seen
/// in the process's lifetime. Mongo's `createIndex` is itself idempotent
/// by index name; the in-memory `ensured` set just skips the round-trip.
#[derive(Clone, Debug)]
pub struct DatabasePool {
client: Client,
db_prefix: String,
ensured: Arc<DashMap<String, ()>>,
}
impl DatabasePool {
/// Connect to the cluster and prepare to hand out tenant databases
/// named `<db_prefix>_<tenant_id>`.
///
/// Validates `db_prefix.len() <= MAX_PREFIX_LEN` so the
/// hash-fallback path is provably within Mongo's 63-byte db-name
/// cap. Refuses to construct a pool that could ever produce an
/// over-long name.
pub async fn connect(uri: &str, db_prefix: &str) -> Result<Self, AgentError> {
if db_prefix.len() > MAX_PREFIX_LEN {
return Err(AgentError::Other(format!(
"db_prefix '{db_prefix}' is {} chars; max is {MAX_PREFIX_LEN} so the \
hash-fallback tenant DB name fits Mongo's {MAX_DB_NAME_LEN}-byte cap",
db_prefix.len()
)));
}
let client = Client::with_uri_str(uri).await?;
client
.database("admin")
.run_command(doc! { "ping": 1 })
.await?;
tracing::info!(
"MongoDB cluster reachable; per-tenant pool ready (db prefix '{db_prefix}')"
);
Ok(Self {
client,
db_prefix: db_prefix.to_string(),
ensured: Arc::new(DashMap::new()),
})
}
/// Return a [`Database`] scoped to this tenant. Ensures indexes on
/// first call per tenant (per process). Cheap on the hot path —
/// subsequent calls skip the round-trip.
pub async fn for_tenant(&self, ctx: &TenantContext) -> Result<Database, AgentError> {
self.for_tenant_id(&ctx.tenant_id).await
}
/// Like [`Self::for_tenant`] but accepts a bare tenant_id.
/// For background paths (scheduler, webhooks, pipeline orchestrators)
/// that don't have a full [`TenantContext`] but know which tenant
/// they're operating on (typically resolved from a URL path, a job
/// argument, or the registry).
pub async fn for_tenant_id(&self, tenant_id: &str) -> Result<Database, AgentError> {
let db_name = self.tenant_db_name(tenant_id);
let db = Database::from_database(self.client.database(&db_name));
// `DashMap::insert` returns the previous value; `None` means we
// were the first writer for this tenant_id and own the
// index-ensure work.
if self.ensured.insert(tenant_id.to_string(), ()).is_none() {
if let Err(e) = db.ensure_indexes().await {
// Roll the marker back so the next request retries.
self.ensured.remove(tenant_id);
return Err(e);
}
tracing::debug!(
tenant_id = %tenant_id,
db_name = %db_name,
"Indexes ensured for tenant database"
);
}
Ok(db)
}
/// Compute the Mongo database name for a tenant. Public for tests
/// and tenant offboarding (`pool.client().database(name).drop()`).
///
/// Format: `<prefix>_<sanitized_tenant_id>` if it fits the 63-byte
/// cap, else `<prefix>_<sha256-16-byte-hex-of-tenant_id>`. The
/// `db_prefix` length invariant established at [`Self::connect`]
/// guarantees the hash-fallback name always fits — no runtime
/// assertion needed.
///
/// Collision resistance: the hash fallback is a 16-byte SHA-256
/// truncation, which gives ~2^64 birthday-collision resistance. At
/// our 10s100s tenant scale the probability of two tenant_ids
/// colliding is effectively zero. (8-byte truncation would have
/// been ~2^32 — too close for comfort on a regulated product.)
pub fn tenant_db_name(&self, tenant_id: &str) -> String {
let sanitized = sanitize_tenant_id(tenant_id);
let natural = format!("{}_{}", self.db_prefix, sanitized);
if natural.len() <= MAX_DB_NAME_LEN {
natural
} else {
let mut hasher = Sha256::new();
hasher.update(tenant_id.as_bytes());
let digest = hasher.finalize();
let suffix = hex::encode(&digest[..HASH_HEX_LEN / 2]);
format!("{}_{}", self.db_prefix, suffix)
}
}
/// Raw client handle. Reserved for cross-tenant admin flows that
/// must opt in explicitly (tenant listing, drop-on-offboard).
pub fn client(&self) -> &Client {
&self.client
}
/// Cross-tenant admin database used by features that intentionally
/// span tenants (today: MCP bearer tokens — each token row carries
/// a `tenant_id` and the MCP server reads them to route requests).
///
/// The name `<db_prefix>__admin` (double underscore) is reserved —
/// the sanitizer never produces it for a normal tenant DB because
/// the natural format is `<db_prefix>_<sanitized_tenant_id>` (one
/// underscore) and tenant_ids would have to start with `_admin` to
/// collide. New tenant provisioning should reject such ids.
pub fn admin_db(&self) -> mongodb::Database {
self.client.database(&self.admin_db_name())
}
/// Name of the admin database — public so tests / operators can
/// drop it via the raw client.
pub fn admin_db_name(&self) -> String {
format!("{}__admin", self.db_prefix)
}
/// List every Mongo database currently belonging to this pool,
/// identified by the `<db_prefix>_` prefix. The result is the raw
/// database names — opening one for offboarding/cleanup goes
/// through [`Self::client`].
///
/// Note: hashed-fallback names (very long tenant_ids) lose the
/// original tenant_id at the cluster level — we know a database
/// exists for *some* tenant but not which one. In practice
/// tenant_ids are UUIDs (36 chars) and never hit the fallback,
/// so this is a theoretical concern, not an operational one.
pub async fn list_tenant_db_names(&self) -> Result<Vec<String>, AgentError> {
let prefix = format!("{}_", self.db_prefix);
let names = self.client.list_database_names().await?;
Ok(names
.into_iter()
.filter(|n| n.starts_with(&prefix))
.collect())
}
/// Tenant ids for every provisioned tenant database, derived by stripping
/// the `<prefix>_` from the database names. Skips the admin database
/// (`<prefix>__admin`). Hash-fallback names (very long tenant_ids) are lost
/// at the cluster level and cannot be recovered here — in practice tenant
/// ids are UUIDs and never hit that path. Used by the migration CLI's
/// `--all` mode.
pub async fn list_tenant_ids(&self) -> Result<Vec<String>, AgentError> {
let prefix = format!("{}_", self.db_prefix);
Ok(self
.list_tenant_db_names()
.await?
.into_iter()
.filter_map(|n| n.strip_prefix(&prefix).map(str::to_string))
.filter(|id| !id.starts_with('_'))
.collect())
}
/// Drop the database for a specific tenant. Used by GDPR delete
/// and tenant offboarding. Idempotent — dropping a non-existent
/// database is a no-op at the driver level.
///
/// Also evicts the tenant from the in-memory `ensured` set so a
/// later re-provision triggers fresh `ensure_indexes`.
pub async fn drop_tenant(&self, tenant_id: &str) -> Result<(), AgentError> {
let db_name = self.tenant_db_name(tenant_id);
self.client.database(&db_name).drop().await?;
self.ensured.remove(tenant_id);
tracing::info!(
tenant_id = %tenant_id,
db_name = %db_name,
"Dropped tenant database"
);
Ok(())
}
}
/// Mongo database names disallow `/`, `\`, `.`, `"`, `$`, ` `, and NUL.
/// breakpilot-dev tenant_ids are UUIDs so this is belt-and-braces, but
/// it lets the pool tolerate any future tenant_id shape without surprise.
fn sanitize_tenant_id(tenant_id: &str) -> String {
tenant_id
.chars()
.map(|c| match c {
'/' | '\\' | '.' | '"' | '$' | ' ' | '\0' => '_',
c => c,
})
.collect()
}
#[derive(Clone, Debug)]
pub struct Database {
inner: mongodb::Database,
@@ -20,17 +242,13 @@ impl Database {
Ok(Self { inner: db })
}
pub async fn ensure_indexes(&self) -> Result<(), AgentError> {
// repositories: unique git_url
self.repositories()
.create_index(
IndexModel::builder()
.keys(doc! { "git_url": 1 })
.options(IndexOptions::builder().unique(true).build())
.build(),
)
.await?;
/// Wrap an already-resolved Mongo database. Used by [`DatabasePool`]
/// to hand out tenant-scoped handles without a fresh client per tenant.
pub(crate) fn from_database(inner: mongodb::Database) -> Self {
Self { inner }
}
pub async fn ensure_indexes(&self) -> Result<(), AgentError> {
// findings: unique fingerprint
self.findings()
.create_index(
@@ -78,6 +296,25 @@ impl Database {
)
.await?;
// cve_notifications: unique cve_id + repo_id + package, status filter
self.cve_notifications()
.create_index(
IndexModel::builder()
.keys(
doc! { "cve_id": 1, "repo_id": 1, "package_name": 1, "package_version": 1 },
)
.options(IndexOptions::builder().unique(true).build())
.build(),
)
.await?;
self.cve_notifications()
.create_index(
IndexModel::builder()
.keys(doc! { "status": 1, "created_at": -1 })
.build(),
)
.await?;
// tracker_issues: unique finding_id
self.tracker_issues()
.create_index(
@@ -198,14 +435,68 @@ impl Database {
)
.await?;
// onboarded_targets: multikey on artifact source ref (webhook + dedupe
// lookup). Non-unique — "one git URL per tenant" is enforced in the
// create handler, since a unique multikey index on an array field has
// null-collision caveats.
self.onboarded_targets()
.create_index(
IndexModel::builder()
.keys(doc! { "artifacts.source_ref": 1 })
.build(),
)
.await?;
// onboarded_targets: multikey on artifact kind
self.onboarded_targets()
.create_index(
IndexModel::builder()
.keys(doc! { "artifacts.kind": 1 })
.build(),
)
.await?;
// onboarded_targets: target_type filter
self.onboarded_targets()
.create_index(
IndexModel::builder()
.keys(doc! { "target_type": 1 })
.build(),
)
.await?;
// werkbank_jobs: unique job id (idempotent enqueue by job id)
self.werkbank_jobs()
.create_index(
IndexModel::builder()
.keys(doc! { "job.id": 1 })
.options(IndexOptions::builder().unique(true).build())
.build(),
)
.await?;
// werkbank_jobs: lease query — oldest queued job for an executor
self.werkbank_jobs()
.create_index(
IndexModel::builder()
.keys(doc! { "status": 1, "job.executor": 1, "created_at": 1 })
.build(),
)
.await?;
// werkbank_jobs: visibility-timeout sweep of expired leases
self.werkbank_jobs()
.create_index(
IndexModel::builder()
.keys(doc! { "status": 1, "lease_expires_at": 1 })
.build(),
)
.await?;
tracing::info!("Database indexes ensured");
Ok(())
}
pub fn repositories(&self) -> Collection<TrackedRepository> {
self.inner.collection("repositories")
}
pub fn findings(&self) -> Collection<Finding> {
self.inner.collection("findings")
}
@@ -222,6 +513,12 @@ impl Database {
self.inner.collection("cve_alerts")
}
pub fn cve_notifications(
&self,
) -> Collection<compliance_core::models::notification::CveNotification> {
self.inner.collection("cve_notifications")
}
pub fn tracker_issues(&self) -> Collection<TrackerIssue> {
self.inner.collection("tracker_issues")
}
@@ -248,6 +545,20 @@ impl Database {
self.inner.collection("dast_targets")
}
/// The unified onboarding targets that replace `repositories` and
/// `dast_targets`. Ids are preserved from the legacy collections during
/// migration so downstream `repo_id` / `target_id` references keep resolving.
pub fn onboarded_targets(&self) -> Collection<OnboardedTarget> {
self.inner.collection("onboarded_targets")
}
/// A typed handle to an arbitrary collection by name. For bookkeeping
/// collections without a dedicated model (e.g. `schema_migrations`,
/// `onboarding_migration_log`).
pub fn collection_named<T: Send + Sync>(&self, name: &str) -> Collection<T> {
self.inner.collection(name)
}
pub fn dast_scan_runs(&self) -> Collection<DastScanRun> {
self.inner.collection("dast_scan_runs")
}
@@ -280,6 +591,12 @@ impl Database {
self.inner.collection("pentest_messages")
}
/// The Werkbank job queue (WB-02): declarative dynamic-execution jobs the
/// control plane enqueues and runners lease.
pub fn werkbank_jobs(&self) -> Collection<compliance_core::models::werkbank::JobRecord> {
self.inner.collection("werkbank_jobs")
}
#[allow(dead_code)]
pub fn raw_collection(&self, name: &str) -> Collection<mongodb::bson::Document> {
self.inner.collection(name)
+3
View File
@@ -27,6 +27,9 @@ pub enum AgentError {
#[error("Configuration error: {0}")]
Config(String),
#[error("Dynamic-execution error: {0}")]
Exec(#[from] werkbank_exec::ExecError),
#[error("{0}")]
Other(String),
}
+178
View File
@@ -0,0 +1,178 @@
//! Content-addressed blob storage and archive extraction for ingest.
//!
//! Blobs are stored at `<base>/blobs/<sha[0:2]>/<sha>` and deduplicated by
//! digest; per-run working directories live under `<base>/work/`.
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::error::AgentError;
/// Read buffer size for streaming hashes/copies (64 KiB).
const BUF_LEN: usize = 64 * 1024;
/// Stream-hash a file with SHA-256, returning the lowercase-hex digest and the
/// byte length. Streams so large firmware images never load fully into memory.
pub fn hash_file(path: &Path) -> Result<(String, u64), AgentError> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; BUF_LEN];
let mut total: u64 = 0;
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
total += n as u64;
}
Ok((hex::encode(hasher.finalize()), total))
}
/// Store raw bytes in the content-addressed blob store under `base`, returning
/// the SHA-256 digest. Used to stash a small derived artifact (e.g. the extracted
/// PLC program source) so a Werkbank runner can fetch it by hash. Idempotent.
pub fn store_bytes(base: &Path, bytes: &[u8]) -> Result<String, AgentError> {
let sha = hex::encode(Sha256::digest(bytes));
let dir = base.join("blobs").join(&sha[0..2]);
fs::create_dir_all(&dir)?;
let dest = dir.join(&sha);
if !dest.exists() {
fs::write(&dest, bytes)?;
}
Ok(sha)
}
/// Read a blob's bytes by its SHA-256 digest. Rejects a non-hex/wrong-length hash
/// so a request can't traverse outside the blob store.
pub fn read_blob(base: &Path, sha: &str) -> Result<Vec<u8>, AgentError> {
if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(AgentError::Other(format!("invalid content hash '{sha}'")));
}
let path = base.join("blobs").join(&sha[0..2]).join(sha);
Ok(fs::read(path)?)
}
/// Copy `src` into the content-addressed blob store under `base`, returning the
/// stored path. Idempotent: an already-present blob is not rewritten.
pub fn store_file(base: &Path, src: &Path, sha: &str) -> Result<PathBuf, AgentError> {
if sha.len() < 2 {
return Err(AgentError::Other(format!("invalid content hash '{sha}'")));
}
let dir = base.join("blobs").join(&sha[0..2]);
fs::create_dir_all(&dir)?;
let dest = dir.join(sha);
if !dest.exists() {
fs::copy(src, &dest)?;
}
Ok(dest)
}
/// Extract a zip archive into `dest` (created if needed). `enclosed_name`
/// sanitizes each entry path, so this is safe against zip-slip traversal.
pub fn extract_zip(archive: &Path, dest: &Path) -> Result<(), AgentError> {
let file = File::open(archive)?;
let mut zip =
zip::ZipArchive::new(file).map_err(|e| AgentError::Other(format!("open zip: {e}")))?;
fs::create_dir_all(dest)?;
for i in 0..zip.len() {
let mut entry = zip
.by_index(i)
.map_err(|e| AgentError::Other(format!("read zip entry: {e}")))?;
// `enclosed_name` returns `None` for traversal-unsafe paths — skip them.
let Some(rel) = entry.enclosed_name() else {
continue;
};
let out = dest.join(rel);
if entry.is_dir() {
fs::create_dir_all(&out)?;
} else {
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)?;
}
let mut outfile = File::create(&out)?;
io::copy(&mut entry, &mut outfile)?;
}
}
Ok(())
}
/// The working directory for one artifact of a target: `<base>/work/<target>/<artifact>`.
pub fn work_dir(base: &Path, target_id: &str, artifact_id: &str) -> PathBuf {
base.join("work").join(target_id).join(artifact_id)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
/// A unique scratch directory, removed on drop.
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("cs-ingest-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&p).expect("mkdir scratch");
Self(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn hash_is_stable_and_reports_size() {
let dir = Scratch::new();
let f = dir.path().join("a.bin");
fs::write(&f, b"hello world").expect("write");
let (sha, size) = hash_file(&f).expect("hash");
assert_eq!(size, 11);
// Known SHA-256 of "hello world".
assert_eq!(
sha,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn store_is_content_addressed_and_idempotent() {
let base = Scratch::new();
let src = base.path().join("src.bin");
fs::write(&src, b"payload").expect("write");
let (sha, _) = hash_file(&src).expect("hash");
let p1 = store_file(base.path(), &src, &sha).expect("store");
let p2 = store_file(base.path(), &src, &sha).expect("store again");
assert_eq!(p1, p2);
assert!(p1.ends_with(&sha));
assert!(p1.starts_with(base.path().join("blobs").join(&sha[0..2])));
assert_eq!(fs::read(&p1).expect("read"), b"payload");
}
#[test]
fn extract_zip_writes_entries() {
let base = Scratch::new();
let archive = base.path().join("a.zip");
{
let file = File::create(&archive).expect("create");
let mut w = zip::ZipWriter::new(file);
let opts: zip::write::SimpleFileOptions = Default::default();
w.start_file("dir/hello.txt", opts).expect("start");
io::Write::write_all(&mut w, b"hi").expect("write");
w.finish().expect("finish");
}
let dest = base.path().join("out");
extract_zip(&archive, &dest).expect("extract");
assert_eq!(
fs::read_to_string(dest.join("dir/hello.txt")).expect("read"),
"hi"
);
}
}
+416
View File
@@ -0,0 +1,416 @@
//! Artifact ingest.
//!
//! Normalizes each [`Artifact`] on an [`OnboardedTarget`] into a local working
//! path plus recorded metadata (content hash, size, discovered facts) that the
//! classifier and scanners consume. Every blob is SHA-256 hashed — that digest
//! is also the reconciliation key against sibling products (a firmware sha256
//! matches tramiton's `Artifact.sha256`).
pub(crate) mod blob;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use compliance_core::models::{Artifact, ArtifactKind, DetectedFact, OnboardedTarget};
use compliance_core::AgentConfig;
use crate::error::AgentError;
use crate::pipeline::git::{GitOps, RepoCredentials};
/// The paths and identifiers an ingest needs. Decoupled from the full
/// [`AgentConfig`] so ingest is testable without a complete config.
pub struct IngestContext<'a> {
/// Base directory for content-addressed blobs and working dirs.
pub artifact_store_base: &'a Path,
/// Base directory for git clones.
pub git_clone_base: &'a str,
/// Default SSH key path (used when an artifact provides none).
pub ssh_key_path: &'a str,
/// The id of the target these artifacts belong to (namespaces working dirs).
pub target_id: &'a str,
}
impl<'a> IngestContext<'a> {
/// Build an ingest context from the agent config for a given target.
pub fn from_config(config: &'a AgentConfig, target_id: &'a str) -> Self {
Self {
artifact_store_base: Path::new(&config.artifact_store_base_path),
git_clone_base: &config.git_clone_base_path,
ssh_key_path: &config.ssh_key_path,
target_id,
}
}
}
/// The result of ingesting one artifact.
pub struct IngestedArtifact {
/// The artifact this corresponds to ([`Artifact::id`]).
pub artifact_id: String,
/// The artifact kind.
pub kind: ArtifactKind,
/// Local working path (clone dir, extracted dir, or blob file). `None` for
/// artifacts with no on-disk form (live URL, plaintext, container ref).
pub working_path: Option<PathBuf>,
/// SHA-256 of the content (blobs) or git head SHA (git repos).
pub content_hash: Option<String>,
/// Stored blob size in bytes, when applicable.
pub size_bytes: Option<u64>,
/// Facts discovered during ingest.
pub facts: Vec<DetectedFact>,
}
/// All ingested artifacts for a target, keyed by artifact id.
pub struct IngestSet {
/// The ingested artifacts, keyed by [`Artifact::id`].
pub by_artifact: HashMap<String, IngestedArtifact>,
}
impl IngestSet {
/// The working paths of every ingested artifact that has one — the input the
/// classifier expects.
pub fn working_paths(&self) -> HashMap<String, PathBuf> {
self.by_artifact
.iter()
.filter_map(|(id, a)| a.working_path.clone().map(|p| (id.clone(), p)))
.collect()
}
/// The ingest result for a specific artifact.
pub fn get(&self, artifact_id: &str) -> Option<&IngestedArtifact> {
self.by_artifact.get(artifact_id)
}
}
/// Ingest every artifact on a target.
pub fn ingest_all(
target: &OnboardedTarget,
ctx: &IngestContext<'_>,
) -> Result<IngestSet, AgentError> {
let mut by_artifact = HashMap::new();
for artifact in &target.artifacts {
let ingested = ingest_artifact(artifact, ctx)?;
by_artifact.insert(artifact.id.clone(), ingested);
}
Ok(IngestSet { by_artifact })
}
/// Ingest a single artifact, dispatching on its kind.
pub fn ingest_artifact(
artifact: &Artifact,
ctx: &IngestContext<'_>,
) -> Result<IngestedArtifact, AgentError> {
match artifact.kind {
ArtifactKind::GitRepo => ingest_git(artifact, ctx),
ArtifactKind::SourceArchive | ArtifactKind::MobilePackage | ArtifactKind::PlcProject => {
ingest_blob(artifact, ctx, true)
}
ArtifactKind::FirmwareImage => ingest_blob(artifact, ctx, false),
ArtifactKind::ContainerImage => Ok(metadata_only(
artifact,
DetectedFact::new("container_ref", artifact.source_ref.as_str(), "ingest"),
)),
ArtifactKind::LiveUrl => Ok(metadata_only(
artifact,
DetectedFact::new("live_url", artifact.source_ref.as_str(), "ingest"),
)),
ArtifactKind::PlaintextDescription => Ok(metadata_only(
artifact,
DetectedFact::new(
"description_len",
artifact.source_ref.len().to_string(),
"ingest",
),
)),
}
}
/// Clone (or fetch) a git artifact, recording the head SHA as the content hash.
fn ingest_git(
artifact: &Artifact,
ctx: &IngestContext<'_>,
) -> Result<IngestedArtifact, AgentError> {
let creds = credentials_for(artifact, ctx.ssh_key_path);
let git_ops = GitOps::new(ctx.git_clone_base, creds);
let repo_path = git_ops.clone_or_fetch(&artifact.source_ref, &artifact.id)?;
let head = GitOps::get_head_sha(&repo_path).ok();
Ok(IngestedArtifact {
artifact_id: artifact.id.clone(),
kind: artifact.kind,
working_path: Some(repo_path),
content_hash: head,
size_bytes: None,
facts: Vec::new(),
})
}
/// Store a blob artifact content-addressed. When `extract` is set and the blob
/// is a zip container (source archive, APK/AAB/IPA), also unpack it into a
/// working directory; otherwise the working path is the stored blob.
fn ingest_blob(
artifact: &Artifact,
ctx: &IngestContext<'_>,
extract: bool,
) -> Result<IngestedArtifact, AgentError> {
let base = ctx.artifact_store_base;
let src = local_source(artifact)?;
let (sha, size) = blob::hash_file(&src)?;
let stored = blob::store_file(base, &src, &sha)?;
let mut facts = Vec::new();
let working_path = if extract {
let dest = blob::work_dir(base, ctx.target_id, &artifact.id);
match blob::extract_zip(&stored, &dest) {
Ok(()) => dest,
Err(e) => {
// Not a zip container — this is a single uploaded file (e.g. a
// `.st`/`.xml` PLC project or a `.tar.gz`). The content-addressed
// blob has no extension, so materialize it into a working dir
// under its original name; extension-based scanners (PLC) can then
// discover it and report a readable path.
facts.push(DetectedFact::new(
"archive_unextracted",
e.to_string(),
"ingest",
));
match materialize_single(&stored, &dest, &blob_file_name(artifact)) {
Ok(dir) => dir,
Err(copy_err) => {
facts.push(DetectedFact::new(
"materialize_failed",
copy_err.to_string(),
"ingest",
));
stored.clone()
}
}
}
}
} else {
stored.clone()
};
Ok(IngestedArtifact {
artifact_id: artifact.id.clone(),
kind: artifact.kind,
working_path: Some(working_path),
content_hash: Some(sha),
size_bytes: Some(size),
facts,
})
}
/// Copy a stored blob into `dest`/`name`, returning `dest`. Used when an
/// "extractable" artifact turns out to be a single file rather than an archive.
fn materialize_single(stored: &Path, dest: &Path, name: &str) -> Result<PathBuf, AgentError> {
std::fs::create_dir_all(dest)?;
std::fs::copy(stored, dest.join(name))?;
Ok(dest.to_path_buf())
}
/// A safe, single-segment file name for an artifact, preserving the original
/// extension so scanners can identify it. Derives from `source_ref` (the
/// uploaded/original file name); `file_name` strips any directory components,
/// so this is traversal-safe. Falls back to the artifact id.
fn blob_file_name(artifact: &Artifact) -> String {
Path::new(&artifact.source_ref)
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("artifact-{}", artifact.id))
}
/// An artifact with no on-disk form: record a single fact, no hash/path.
fn metadata_only(artifact: &Artifact, fact: DetectedFact) -> IngestedArtifact {
IngestedArtifact {
artifact_id: artifact.id.clone(),
kind: artifact.kind,
working_path: None,
content_hash: None,
size_bytes: None,
facts: vec![fact],
}
}
/// The local file backing a blob artifact: its `stored_path` if already
/// uploaded, else its `source_ref` interpreted as a filesystem path.
fn local_source(artifact: &Artifact) -> Result<PathBuf, AgentError> {
let path = artifact
.stored_path
.as_deref()
.unwrap_or(artifact.source_ref.as_str());
let path = PathBuf::from(path);
if !path.exists() {
return Err(AgentError::Other(format!(
"artifact {} source not found at {}",
artifact.id,
path.display()
)));
}
Ok(path)
}
/// Build git credentials from an artifact's auth plus a default SSH key path.
fn credentials_for(artifact: &Artifact, default_ssh_key_path: &str) -> RepoCredentials {
let auth = artifact.auth.as_ref();
RepoCredentials {
ssh_key_path: auth
.and_then(|a| a.ssh_key_path.clone())
.or_else(|| Some(default_ssh_key_path.to_string())),
auth_token: auth.and_then(|a| a.secret.clone()),
auth_username: auth.and_then(|a| a.username.clone()),
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use compliance_core::models::{ArtifactAuth, TargetType};
/// A unique scratch directory, removed on drop.
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let p = std::env::temp_dir().join(format!("cs-ingest-mod-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&p).expect("mkdir scratch");
Self(p)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn ctx_for<'a>(store: &'a Path, target_id: &'a str) -> IngestContext<'a> {
IngestContext {
artifact_store_base: store,
git_clone_base: "/tmp/cs-ingest-test-repos",
ssh_key_path: "/tmp/cs-ingest-test-ssh",
target_id,
}
}
#[test]
fn firmware_blob_is_hashed_and_stored() {
let scratch = Scratch::new();
let store = scratch.0.join("store");
let fw = scratch.0.join("fw.bin");
std::fs::write(&fw, b"firmware-bytes").expect("write");
let ctx = ctx_for(&store, "t1");
let artifact = Artifact::firmware_image(fw.to_string_lossy().to_string());
let out = ingest_artifact(&artifact, &ctx).expect("ingest");
assert_eq!(out.kind, ArtifactKind::FirmwareImage);
assert_eq!(out.size_bytes, Some(14));
let sha = out.content_hash.expect("hash");
assert_eq!(sha.len(), 64);
// working path is the content-addressed blob
let wp = out.working_path.expect("working path");
assert!(wp.starts_with(store.join("blobs")));
}
#[test]
fn live_url_has_no_blob() {
let scratch = Scratch::new();
let store = scratch.0.join("store");
let ctx = ctx_for(&store, "t1");
let artifact = Artifact::live_url("https://example.com");
let out = ingest_artifact(&artifact, &ctx).expect("ingest");
assert!(out.working_path.is_none());
assert!(out.content_hash.is_none());
assert!(out.facts.iter().any(|f| f.key == "live_url"));
}
#[test]
fn ingest_all_collects_working_paths() {
let scratch = Scratch::new();
let store = scratch.0.join("store");
let fw = scratch.0.join("fw.bin");
std::fs::write(&fw, b"abc").expect("write");
let ctx = ctx_for(&store, "t1");
let mut target = OnboardedTarget::new("t".to_string(), TargetType::FirmwareBareMetal);
target
.artifacts
.push(Artifact::firmware_image(fw.to_string_lossy().to_string()));
target.artifacts.push(Artifact::live_url("https://x"));
let set = ingest_all(&target, &ctx).expect("ingest all");
assert_eq!(set.by_artifact.len(), 2);
// Only the firmware artifact yields a working path.
assert_eq!(set.working_paths().len(), 1);
}
#[test]
fn credentials_prefer_artifact_auth() {
let mut artifact = Artifact::git_repo("https://git/x", "main");
artifact.auth = Some(ArtifactAuth {
method: "token".to_string(),
username: Some("bob".to_string()),
secret: Some("pat".to_string()),
..Default::default()
});
let creds = credentials_for(&artifact, "/default/ssh/key");
assert_eq!(creds.auth_token.as_deref(), Some("pat"));
assert_eq!(creds.auth_username.as_deref(), Some("bob"));
}
#[test]
fn credentials_fall_back_to_default_ssh_key() {
let artifact = Artifact::git_repo("git@host:x.git", "main");
let creds = credentials_for(&artifact, "/default/ssh/key");
assert_eq!(creds.ssh_key_path.as_deref(), Some("/default/ssh/key"));
assert!(creds.auth_token.is_none());
}
/// A single uploaded PLC file (not an archive) must land in a working dir
/// under its original name so the PLC scanner can discover it by extension
/// and report a readable path — the demo's upload → scan path.
#[test]
fn single_uploaded_plc_file_is_materialized_and_scannable() {
use compliance_core::models::PlcFormat;
let scratch = Scratch::new();
let store = scratch.0.join("store");
// Simulate the upload handler: bytes written to an `uploads/` path,
// `source_ref` carrying the original (clean) file name.
let uploads = scratch.0.join("uploads");
std::fs::create_dir_all(&uploads).expect("mkdir uploads");
let uploaded = uploads.join("a1b2c3_pump_station.st");
std::fs::write(
&uploaded,
"PROGRAM P\nVAR\n ApiKey : STRING := 'sk-live-1234';\nEND_VAR\nEND_PROGRAM\n",
)
.expect("write st");
let mut artifact = Artifact::plc_project("pump_station.st", PlcFormat::StructuredText);
artifact.stored_path = Some(uploaded.to_string_lossy().to_string());
let ctx = ctx_for(&store, "t-plc");
let out = ingest_artifact(&artifact, &ctx).expect("ingest");
// Working path is a directory (not the extensionless blob) holding the
// file under its original name.
let wp = out.working_path.expect("working path");
assert!(wp.is_dir(), "expected a working dir, got {wp:?}");
assert!(wp.join("pump_station.st").is_file());
// The PLC scanner finds the hardcoded credential and reports a clean path.
let findings = crate::pipeline::plc::analyze_tree(&wp, "t-plc");
assert!(
!findings.is_empty(),
"scanner should flag the uploaded file"
);
assert!(findings
.iter()
.any(|f| f.rule_id.as_deref() == Some("plc-hardcoded-credential")));
assert_eq!(
findings[0].file_path.as_deref(),
Some("pump_station.st"),
"finding should reference the original file name"
);
}
}
+20
View File
@@ -0,0 +1,20 @@
// Library entrypoint — re-exports for integration tests and the binary.
pub mod agent;
pub mod api;
pub mod classify;
pub mod config;
pub mod controls;
pub mod database;
pub mod error;
pub mod ingest;
pub mod llm;
pub mod pentest;
pub mod pipeline;
pub mod rag;
pub mod scheduler;
pub mod ssh;
#[allow(dead_code)]
pub mod trackers;
pub mod webhooks;
pub mod werkbank;
+6 -1
View File
@@ -19,12 +19,17 @@ impl LlmClient {
model: String,
embed_model: String,
) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default();
Self {
base_url,
api_key,
model,
embed_model,
http: reqwest::Client::new(),
http,
}
}
+49 -1
View File
@@ -22,6 +22,11 @@ struct EmbeddingData {
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 ───────────────────────────────────
impl LlmClient {
@@ -29,8 +34,21 @@ impl LlmClient {
&self.embed_model
}
/// Generate embeddings for a batch of texts
/// Generate embeddings for a batch of texts, chunking into backend-sized
/// requests and preserving input order across chunks.
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 request_body = EmbeddingRequest {
@@ -72,3 +90,33 @@ impl LlmClient {
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"
);
}
}
+8 -18
View File
@@ -1,23 +1,10 @@
mod agent;
mod api;
pub(crate) mod config;
mod database;
mod error;
mod llm;
mod pentest;
mod pipeline;
mod rag;
mod scheduler;
mod ssh;
#[allow(dead_code)]
mod trackers;
mod webhooks;
use compliance_agent::{agent, api, config, database, scheduler, ssh, webhooks};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
match dotenvy::dotenv() {
Ok(path) => eprintln!("[dotenv] Loaded from: {}", path.display()),
Err(e) => eprintln!("[dotenv] FAILED: {e}"),
Err(_) => eprintln!("[dotenv] No .env file found, using environment variables"),
}
let _telemetry_guard = compliance_core::telemetry::init_telemetry("compliance-agent");
@@ -38,10 +25,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
tracing::info!("Connecting to MongoDB...");
let db = database::Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
db.ensure_indexes().await?;
// Per-tenant pool only — the agent has no shared "default" database
// after M7.2-D. `mongodb_database` is now the db-name prefix used
// for tenant databases (`<prefix>_<tenant_id>`).
let db_pool =
database::DatabasePool::connect(&config.mongodb_uri, &config.mongodb_database).await?;
let agent = agent::ComplianceAgent::new(config.clone(), db.clone());
let agent = agent::ComplianceAgent::new(config.clone(), db_pool);
tracing::info!("Starting scheduler...");
let scheduler_agent = agent.clone();
+6
View File
@@ -328,6 +328,7 @@ mod tests {
scan_schedule: String::new(),
cve_monitor_schedule: String::new(),
git_clone_base_path: String::new(),
artifact_store_base_path: String::new(),
ssh_key_path: String::new(),
keycloak_url: None,
keycloak_realm: None,
@@ -339,6 +340,11 @@ mod tests {
pentest_imap_tls: true,
pentest_imap_username: None,
pentest_imap_password: None,
admin_api_token: None,
tenant_registry_url: None,
plc_runtime: compliance_core::PlcRuntimeConfig::default(),
werkbank_runner_token: None,
breakpilot: compliance_core::config::BreakpilotConfig::default(),
}
}
+283
View File
@@ -204,6 +204,202 @@ impl CveScanner {
Ok(results)
}
/// Match the CODESYS **runtime** component against NVD by CPE.
///
/// CODESYS advisories (the CoDe16 cluster and friends) are indexed in NVD by
/// CPE (`cpe:2.3:a:codesys:control*`) keyed off the *runtime* version — not by
/// the internal `Cmp*`/`Sys*` library names OSV-by-purl would look up. So we
/// find the runtime SBOM entry, pull every `cpe:2.3:a:codesys:*` CVE from NVD,
/// and keep the ones whose affected-version range covers our runtime version.
/// Best-effort: returns empty without an NVD key, on a network error, or when
/// no CODESYS runtime component is present.
pub async fn scan_codesys(&self, repo_id: &str, entries: &mut [SbomEntry]) -> Vec<CveAlert> {
let Some((name, version)) = codesys_runtime(entries) else {
return Vec::new();
};
let url = "https://services.nvd.nist.gov/rest/json/cves/2.0\
?virtualMatchString=cpe:2.3:a:codesys";
let mut req = self.http.get(url);
if let Some(key) = &self.nvd_api_key {
req = req.header("apiKey", key.as_str());
}
let body: serde_json::Value = match req.send().await {
Ok(r) if r.status().is_success() => match r.json().await {
Ok(b) => b,
Err(e) => {
tracing::warn!("CODESYS NVD parse failed: {e}");
return Vec::new();
}
},
Ok(r) => {
tracing::warn!("CODESYS NVD returned {}", r.status());
return Vec::new();
}
Err(e) => {
tracing::warn!("CODESYS NVD request failed: {e}");
return Vec::new();
}
};
let matched = parse_codesys_nvd(&body, &version);
let mut alerts = Vec::new();
for cve in matched {
if let Some(e) = entries
.iter_mut()
.find(|e| e.name == name && e.version == version)
{
e.known_vulnerabilities.push(VulnRef {
id: cve.id.clone(),
source: "nvd".to_string(),
severity: None,
url: Some(format!("https://nvd.nist.gov/vuln/detail/{}", cve.id)),
});
}
let mut alert = CveAlert::new(
cve.id,
repo_id.to_string(),
name.clone(),
version.clone(),
CveSource::Nvd,
);
alert.summary = cve.summary;
alert.cvss_score = cve.cvss;
alerts.push(alert);
}
tracing::info!(runtime = %name, version = %version, cves = alerts.len(), "CODESYS CVE match");
alerts
}
}
/// The CODESYS runtime component (name + version) from an SBOM, if present. The
/// runtime carries the version CODESYS advisories key off; the internal library
/// components do not.
fn codesys_runtime(entries: &[SbomEntry]) -> Option<(String, String)> {
entries
.iter()
.find(|e| e.package_manager == "codesys" && e.name.starts_with("CODESYS Control"))
.map(|e| (e.name.clone(), e.version.clone()))
}
/// A parsed NVD CVE that affects the CODESYS runtime.
struct CodesysCve {
id: String,
summary: Option<String>,
cvss: Option<f64>,
}
/// Version constraints from an NVD `cpeMatch` node.
#[derive(Default)]
struct CpeRange {
exact: Option<String>,
start_incl: Option<String>,
start_excl: Option<String>,
end_incl: Option<String>,
end_excl: Option<String>,
}
/// Parse an NVD CVE-list response and keep the CVEs whose CODESYS CPE match covers
/// `runtime_version`.
fn parse_codesys_nvd(body: &serde_json::Value, runtime_version: &str) -> Vec<CodesysCve> {
let mut out = Vec::new();
let Some(vulns) = body["vulnerabilities"].as_array() else {
return out;
};
for v in vulns {
let cve = &v["cve"];
let Some(id) = cve["id"].as_str() else {
continue;
};
let covered = cve["configurations"]
.as_array()
.into_iter()
.flatten()
.flat_map(|c| c["nodes"].as_array().into_iter().flatten())
.flat_map(|n| n["cpeMatch"].as_array().into_iter().flatten())
.any(|cm| {
cm["vulnerable"].as_bool() == Some(true)
&& cm["criteria"]
.as_str()
.is_some_and(|c| c.contains(":codesys:"))
&& version_matches(runtime_version, &cpe_range(cm))
});
if covered {
let summary = cve["descriptions"]
.as_array()
.and_then(|d| d.iter().find(|x| x["lang"].as_str() == Some("en")))
.and_then(|x| x["value"].as_str())
.map(String::from);
let cvss = cve["metrics"]["cvssMetricV31"]
.as_array()
.and_then(|m| m.first())
.and_then(|m| m["cvssData"]["baseScore"].as_f64());
out.push(CodesysCve {
id: id.to_string(),
summary,
cvss,
});
}
}
out
}
/// Build a [`CpeRange`] from an NVD `cpeMatch` object.
fn cpe_range(cm: &serde_json::Value) -> CpeRange {
let exact = cm["criteria"]
.as_str()
.and_then(cpe_version)
.filter(|v| v != "*" && v != "-" && !v.is_empty());
CpeRange {
exact,
start_incl: cm["versionStartIncluding"].as_str().map(String::from),
start_excl: cm["versionStartExcluding"].as_str().map(String::from),
end_incl: cm["versionEndIncluding"].as_str().map(String::from),
end_excl: cm["versionEndExcluding"].as_str().map(String::from),
}
}
/// The version field (6th component) of a CPE 2.3 string.
fn cpe_version(criteria: &str) -> Option<String> {
criteria.split(':').nth(5).map(String::from)
}
/// Whether `v` satisfies a CPE version range.
fn version_matches(v: &str, r: &CpeRange) -> bool {
use std::cmp::Ordering::{Equal, Greater, Less};
if let Some(exact) = &r.exact {
return cmp_dotted(v, exact) == Equal;
}
let mut ok = true;
if let Some(s) = &r.start_incl {
ok &= cmp_dotted(v, s) != Less;
}
if let Some(s) = &r.start_excl {
ok &= cmp_dotted(v, s) == Greater;
}
if let Some(e) = &r.end_incl {
ok &= cmp_dotted(v, e) != Greater;
}
if let Some(e) = &r.end_excl {
ok &= cmp_dotted(v, e) == Less;
}
ok
}
/// Compare two dotted numeric versions (`4.17.0.0` vs `4.9.0.0`); missing
/// components count as 0, non-numeric components as 0.
fn cmp_dotted(a: &str, b: &str) -> std::cmp::Ordering {
let pa: Vec<u64> = a.split('.').map(|x| x.parse().unwrap_or(0)).collect();
let pb: Vec<u64> = b.split('.').map(|x| x.parse().unwrap_or(0)).collect();
for i in 0..pa.len().max(pb.len()) {
let x = pa.get(i).copied().unwrap_or(0);
let y = pb.get(i).copied().unwrap_or(0);
match x.cmp(&y) {
std::cmp::Ordering::Equal => continue,
other => return other,
}
}
std::cmp::Ordering::Equal
}
#[derive(serde::Deserialize)]
@@ -228,3 +424,90 @@ struct OsvVuln {
summary: Option<String>,
severity: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::cmp::Ordering::{Equal, Greater, Less};
fn entry(name: &str, ver: &str, pm: &str) -> SbomEntry {
SbomEntry::new("t".into(), name.into(), ver.into(), pm.into())
}
#[test]
fn finds_the_codesys_runtime_component() {
let entries = vec![
entry("Standard", "3.5.18.0", "codesys"),
entry("CODESYS Control for Linux ARM SL", "4.17.0.0", "codesys"),
];
assert_eq!(
codesys_runtime(&entries),
Some(("CODESYS Control for Linux ARM SL".into(), "4.17.0.0".into()))
);
// Internal library components are not the runtime.
assert!(codesys_runtime(&[entry("Util", "3.5.21.0", "codesys")]).is_none());
}
#[test]
fn dotted_version_comparison() {
assert_eq!(cmp_dotted("4.17.0.0", "4.9.0.0"), Greater);
assert_eq!(cmp_dotted("4.9.0.0", "4.17.0.0"), Less);
assert_eq!(cmp_dotted("3.5.18.0", "3.5.18.0"), Equal);
assert_eq!(cmp_dotted("4.2", "4.2.0.0"), Equal); // missing components = 0
}
#[test]
fn version_range_matching() {
let end_excl = CpeRange {
end_excl: Some("4.9.0.0".into()),
..Default::default()
};
assert!(!version_matches("4.17.0.0", &end_excl)); // patched
assert!(version_matches("4.5.0.0", &end_excl)); // affected
let exact = CpeRange {
exact: Some("3.5.16.0".into()),
..Default::default()
};
assert!(version_matches("3.5.16.0", &exact));
assert!(!version_matches("3.5.17.0", &exact));
let span = CpeRange {
start_incl: Some("3.0.0.0".into()),
end_incl: Some("3.5.16.0".into()),
..Default::default()
};
assert!(version_matches("3.5.16.0", &span));
assert!(!version_matches("3.5.17.0", &span));
}
#[test]
fn parses_nvd_and_matches_by_runtime_version() {
// Two CODESYS CVEs: one affects < 4.9 (our 4.17 is patched), one affects
// <= 4.20 (our 4.17 is affected). Only the latter should match.
let body = serde_json::json!({
"vulnerabilities": [
{"cve": {"id":"CVE-2023-0001",
"descriptions":[{"lang":"en","value":"old CmpBlkDrvTcp bug"}],
"metrics":{"cvssMetricV31":[{"cvssData":{"baseScore":7.5}}]},
"configurations":[{"nodes":[{"cpeMatch":[
{"vulnerable":true,
"criteria":"cpe:2.3:a:codesys:control_for_linux_sl:*:*:*:*:*:*:*:*",
"versionEndExcluding":"4.9.0.0"}
]}]}]}},
{"cve": {"id":"CVE-2024-0002",
"descriptions":[{"lang":"en","value":"recent runtime bug"}],
"metrics":{"cvssMetricV31":[{"cvssData":{"baseScore":9.8}}]},
"configurations":[{"nodes":[{"cpeMatch":[
{"vulnerable":true,
"criteria":"cpe:2.3:a:codesys:control_for_linux_sl:*:*:*:*:*:*:*:*",
"versionEndIncluding":"4.20.0.0"}
]}]}]}}
]
});
let matched = parse_codesys_nvd(&body, "4.17.0.0");
let ids: Vec<&str> = matched.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, vec!["CVE-2024-0002"]);
assert_eq!(matched[0].cvss, Some(9.8));
}
}
@@ -0,0 +1,152 @@
//! Firmware SBOM via tramiton.
//!
//! Phase 2 (full, the default): drive a **reproducible build** with tramiton's
//! `NixBackend` — `analyze` → `seal_and_build` → a sealed lock whose libraries
//! are pinned and whose firmware artifact carries a content hash — then render
//! the SBOM from the lock plus deep binary SCA of pre-compiled inputs. This is
//! the complete bill of materials (toolchain + every fetched library + the
//! firmware image), the same one `tramiton sbom` produces.
//!
//! Phase 1 fallback (analysis-only): when no nix backend is available or the
//! build fails, fall back to the resolvable libraries + toolchain from the build
//! plan alone (no build). A scan therefore always yields *something*, and a nix
//! that can't run in the deployment never breaks a scan.
use std::path::Path;
use compliance_core::models::{SbomEntry, TargetType};
use tramiton_repro::ReproBackend;
use tramiton_sbom::ComponentKind;
/// Whether firmware SBOM applies to this target family.
pub fn is_firmware_target(target_type: TargetType) -> bool {
matches!(
target_type,
TargetType::FirmwareBareMetal | TargetType::FirmwareRtos | TargetType::EmbeddedLinuxYocto
)
}
/// Build SBOM entries for a firmware target from its source tree. Prefers a full
/// reproducible build (sealed lock); falls back to analysis-only. Returns an
/// empty vector when tramiton cannot even form a build plan.
pub async fn firmware_sbom_entries(path: &Path, repo_id: &str) -> Vec<SbomEntry> {
let p = path.to_path_buf();
let repo = repo_id.to_string();
// The whole analyze → seal → build → render sequence is blocking (it shells
// out to nix), so keep it off the async runtime. Bound it: a firmware build
// that hangs must not wedge the scan (the orphaned task is abandoned).
let handle = tokio::task::spawn_blocking(move || build_sbom_blocking(&p, &repo));
match tokio::time::timeout(std::time::Duration::from_secs(900), handle).await {
Ok(Ok(entries)) => entries,
Ok(Err(e)) => {
tracing::warn!(repo_id, error = %e, "Firmware SBOM: task join error");
Vec::new()
}
Err(_) => {
tracing::warn!(repo_id, "Firmware SBOM: build exceeded 15m; skipping");
Vec::new()
}
}
}
fn build_sbom_blocking(path: &Path, repo_id: &str) -> Vec<SbomEntry> {
let repo = tramiton_core::Repo::new(path);
let plan = match tramiton_core::provider::analyze(&repo) {
Ok(Some(bp)) => bp,
Ok(None) => return Vec::new(),
Err(e) => {
tracing::warn!(repo_id, error = %e, "Firmware SBOM: tramiton analyze failed");
return Vec::new();
}
};
// Phase 2: reproducible build → sealed lock → complete SBOM.
if let Some(backend) = tramiton_repro::NixBackend::detect() {
match tramiton_repro::seal_and_build(&backend, &plan, path) {
Ok(lock) => {
let mut sbom = tramiton_sbom::Sbom::from_lock(&lock, repo_id);
// Deep binary SCA of any pre-compiled inputs in the tree.
sbom.components.extend(tramiton_sbom::binary::scan(path));
let entries = sbom_to_entries(&sbom, repo_id);
tracing::info!(
repo_id,
backend = backend.name(),
count = entries.len(),
"Firmware SBOM: sealed reproducible build"
);
return entries;
}
Err(e) => {
tracing::warn!(repo_id, error = %e, "Firmware SBOM: reproducible build failed; falling back to analysis-only")
}
}
} else {
tracing::info!(
repo_id,
"Firmware SBOM: no nix backend available; analysis-only SBOM"
);
}
// Phase 1 fallback: analysis-only (toolchain + resolvable libraries).
analysis_entries(&plan, repo_id)
}
/// Map a rendered [`tramiton_sbom::Sbom`] (primary firmware + components) into
/// our [`SbomEntry`] rows. Source-file (`File`) components are dropped — they are
/// build inputs, not a dependency inventory.
fn sbom_to_entries(sbom: &tramiton_sbom::Sbom, repo_id: &str) -> Vec<SbomEntry> {
let mut entries = Vec::new();
if let Some(primary) = &sbom.primary {
entries.push(component_to_entry(primary, repo_id));
}
for c in &sbom.components {
if matches!(c.kind, ComponentKind::File) {
continue;
}
entries.push(component_to_entry(c, repo_id));
}
entries
}
fn component_to_entry(c: &tramiton_sbom::Component, repo_id: &str) -> SbomEntry {
let manager = match c.kind {
ComponentKind::Firmware => "firmware",
ComponentKind::Library => "library",
ComponentKind::Toolchain => "toolchain",
ComponentKind::File => "file",
};
let mut entry = SbomEntry::new(
repo_id.to_string(),
c.name.clone(),
c.version.clone().unwrap_or_default(),
manager.to_string(),
);
entry.purl = c.source.clone();
entry
}
/// Analysis-only components from the build plan: the cross-toolchain plus the
/// resolvable fetched libraries, without a build.
fn analysis_entries(bp: &tramiton_core::BuildPlan, repo_id: &str) -> Vec<SbomEntry> {
let mut entries = Vec::new();
if let Some(id) = bp.toolchain.id.clone() {
let version = bp.toolchain.version.clone().unwrap_or_default();
entries.push(SbomEntry::new(
repo_id.to_string(),
id,
version,
"toolchain".to_string(),
));
}
for lib in tramiton_repro::lock::libraries_from_inputs(&bp.inputs) {
let mut entry = SbomEntry::new(
repo_id.to_string(),
lib.name,
lib.revision,
"library".to_string(),
);
entry.purl = lib.source;
entries.push(entry);
}
entries
}
+48 -2
View File
@@ -80,7 +80,10 @@ impl GitOps {
#[tracing::instrument(skip_all, fields(repo_name = %repo_name))]
pub fn clone_or_fetch(&self, git_url: &str, repo_name: &str) -> Result<PathBuf, AgentError> {
let repo_path = self.base_path.join(repo_name);
// Names can contain slashes or other path-hostile characters (a target
// named after a repo path, say); collapse to one safe directory segment
// so the clone path never nests or breaks.
let repo_path = self.base_path.join(sanitize_repo_dir(repo_name));
if repo_path.exists() {
tracing::info!("fetching updates for existing repo");
@@ -135,7 +138,7 @@ impl GitOps {
/// Build credentials from agent config + per-repo overrides
pub fn make_repo_credentials(
config: &compliance_core::AgentConfig,
repo: &compliance_core::models::TrackedRepository,
repo: &crate::pipeline::repo_view::RepoView,
) -> RepoCredentials {
RepoCredentials {
ssh_key_path: Some(config.ssh_key_path.clone()),
@@ -253,3 +256,46 @@ pub struct DiffFile {
pub path: String,
pub hunks: String,
}
/// Collapse a repository name into a single filesystem-safe directory segment.
/// Names may carry slashes or other path-hostile characters (a target named
/// after a repo path, for instance); those would otherwise nest or break the
/// clone path, so map anything outside `[A-Za-z0-9._-]` to `_`.
fn sanitize_repo_dir(name: &str) -> String {
let mapped: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
let trimmed = mapped.trim_matches(|c| c == '.' || c == '_');
if trimmed.is_empty() {
"repo".to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::sanitize_repo_dir;
#[test]
fn sanitizes_path_hostile_names() {
assert_eq!(
sanitize_repo_dir("zephyr-example-app"),
"zephyr-example-app"
);
assert_eq!(
sanitize_repo_dir("ChristianRinn/bare_metal_stm32f411xe"),
"ChristianRinn_bare_metal_stm32f411xe"
);
assert_eq!(sanitize_repo_dir("../../etc/passwd"), "etc_passwd");
assert_eq!(sanitize_repo_dir("a b:c"), "a_b_c");
assert_eq!(sanitize_repo_dir("///"), "repo");
}
}
+27 -20
View File
@@ -19,26 +19,33 @@ impl Scanner for GitleaksScanner {
#[tracing::instrument(skip_all)]
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
let output = tokio::process::Command::new("gitleaks")
.args([
"detect",
"--source",
".",
"--report-format",
"json",
"--report-path",
"/dev/stdout",
"--no-banner",
"--exit-code",
"0",
])
.current_dir(repo_path)
.output()
.await
.map_err(|e| CoreError::Scanner {
scanner: "gitleaks".to_string(),
source: Box::new(e),
})?;
let output = tokio::time::timeout(
std::time::Duration::from_secs(300),
tokio::process::Command::new("gitleaks")
.args([
"detect",
"--source",
".",
"--report-format",
"json",
"--report-path",
"/dev/stdout",
"--no-banner",
"--exit-code",
"0",
])
.current_dir(repo_path)
.output(),
)
.await
.map_err(|_| CoreError::Scanner {
scanner: "gitleaks".to_string(),
source: "timed out after 5 minutes".into(),
})?
.map_err(|e| CoreError::Scanner {
scanner: "gitleaks".to_string(),
source: Box::new(e),
})?;
if output.stdout.is_empty() {
return Ok(ScanOutput::default());
@@ -1,5 +1,6 @@
use mongodb::bson::doc;
use crate::pipeline::repo_view::RepoView;
use compliance_core::models::*;
use super::orchestrator::{extract_base_url, PipelineOrchestrator};
@@ -10,7 +11,7 @@ use crate::trackers;
impl PipelineOrchestrator {
/// Build an issue tracker client from a repository's tracker configuration.
/// Returns `None` if the repo has no tracker configured.
pub(super) fn build_tracker(&self, repo: &TrackedRepository) -> Option<TrackerDispatch> {
pub(super) fn build_tracker(&self, repo: &RepoView) -> Option<TrackerDispatch> {
let tracker_type = repo.tracker_type.as_ref()?;
// Per-repo token takes precedence, fall back to global config
match tracker_type {
@@ -81,7 +82,7 @@ impl PipelineOrchestrator {
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub(super) async fn create_tracker_issues(
&self,
repo: &TrackedRepository,
repo: &RepoView,
repo_id: &str,
new_findings: &[Finding],
) -> Result<(), AgentError> {
+4
View File
@@ -1,6 +1,7 @@
pub mod code_review;
pub mod cve;
pub mod dedup;
pub mod firmware_sbom;
pub mod git;
pub mod gitleaks;
mod graph_build;
@@ -8,7 +9,10 @@ mod issue_creation;
pub mod lint;
pub mod orchestrator;
pub mod patterns;
pub mod plan;
pub mod plc;
mod pr_review;
pub mod repo_view;
pub mod sbom;
pub mod semgrep;
mod tracker_dispatch;
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -33,6 +33,7 @@ struct PatternRule {
file_extensions: Vec<String>,
}
#[allow(clippy::new_without_default)]
impl GdprPatternScanner {
pub fn new() -> Self {
let patterns = vec![
@@ -98,6 +99,7 @@ impl Scanner for GdprPatternScanner {
}
}
#[allow(clippy::new_without_default)]
impl OAuthPatternScanner {
pub fn new() -> Self {
let patterns = vec![
@@ -213,7 +215,7 @@ fn scan_with_patterns(
repo_id.to_string(),
fingerprint,
scanner_name.to_string(),
scan_type.clone(),
scan_type,
pattern.title.clone(),
pattern.description.clone(),
pattern.severity.clone(),
+214
View File
@@ -0,0 +1,214 @@
//! The scan plan.
//!
//! [`build_scan_plan`] turns an [`OnboardedTarget`] into the concrete ordered
//! list of scans to run, each bound to the artifact it consumes. It intersects
//! the scan-applicability matrix ([`applicable_scans`]) with the target's
//! `scan_config` overrides: a scan runs when its required artifact is present and
//! it is either on by default or explicitly enabled, and is not explicitly
//! disabled. This is the decision engine the unified pipeline (`run_target`)
//! executes.
use compliance_core::models::{Artifact, ArtifactKind, OnboardedTarget, ScanPhase, ScanType};
use compliance_core::scan_matrix::applicable_scans;
/// One scan to run, bound to the artifact it operates on.
#[derive(Debug, Clone, PartialEq)]
pub struct ScanStep {
/// The scan to run.
pub scan_type: ScanType,
/// The pipeline phase to report while it runs.
pub phase: ScanPhase,
/// The id of the artifact this scan consumes ([`Artifact::id`]).
pub artifact_id: String,
}
/// The ordered set of scans to run for a target.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ScanPlan {
/// The scans, in matrix order.
pub steps: Vec<ScanStep>,
}
impl ScanPlan {
/// Whether the plan contains a step for the given scan type.
pub fn has(&self, scan: ScanType) -> bool {
self.steps.iter().any(|s| s.scan_type == scan)
}
/// Whether the plan is empty (nothing to run).
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
}
}
/// Build the scan plan for a target: matrix defaults ∩ `scan_config`, each scan
/// bound to the artifact it consumes. Scans whose required artifact is absent, or
/// that are disabled, or off-by-default and not explicitly enabled, are dropped.
pub fn build_scan_plan(target: &OnboardedTarget) -> ScanPlan {
let enabled = &target.scan_config.enabled_scans;
let disabled = &target.scan_config.disabled_scans;
let mut steps = Vec::new();
for option in applicable_scans(target) {
// Required artifact missing → not runnable.
if option.blocked_reason.is_some() {
continue;
}
// Explicit opt-out wins.
if disabled.contains(&option.scan) {
continue;
}
// Run if on by default, or explicitly enabled.
if !option.default_on && !enabled.contains(&option.scan) {
continue;
}
let Some(artifact) = resolve_artifact(target, option.required_artifact) else {
continue;
};
steps.push(ScanStep {
scan_type: option.scan,
phase: phase_for(option.scan),
artifact_id: artifact.id.clone(),
});
}
ScanPlan { steps }
}
/// Resolve the artifact a scan consumes. A "code" requirement (represented by
/// `GitRepo`) is satisfied by a git repo *or* a source archive. The PLC
/// control-logic requirement (represented by `PlcProject`) prefers an uploaded
/// PLC project but also accepts a code artifact — a git repo / source archive
/// holding PLCopen XML / ST exports.
fn resolve_artifact(target: &OnboardedTarget, required: Option<ArtifactKind>) -> Option<&Artifact> {
match required {
Some(ArtifactKind::GitRepo) => target.code_artifact(),
Some(ArtifactKind::PlcProject) => target
.first_of(ArtifactKind::PlcProject)
.or_else(|| target.code_artifact()),
Some(kind) => target.first_of(kind),
None => target.code_artifact().or_else(|| target.artifacts.first()),
}
}
/// The pipeline phase reported while a given scan runs.
fn phase_for(scan: ScanType) -> ScanPhase {
match scan {
ScanType::Sast => ScanPhase::Sast,
ScanType::Sbom => ScanPhase::SbomGeneration,
ScanType::Cve => ScanPhase::CveScanning,
ScanType::Gdpr | ScanType::OAuth => ScanPhase::PatternScanning,
ScanType::SecretDetection => ScanPhase::SecretDetection,
ScanType::Lint => ScanPhase::LintScanning,
ScanType::CodeReview => ScanPhase::CodeReview,
ScanType::Graph => ScanPhase::GraphBuilding,
ScanType::Dast => ScanPhase::DastScanning,
ScanType::FirmwareStatic => ScanPhase::FirmwareStatic,
ScanType::PlcControlLogic => ScanPhase::PlcAnalysis,
ScanType::MobileStatic => ScanPhase::MobileStatic,
ScanType::ContainerScan => ScanPhase::ContainerScan,
ScanType::IcsProbe => ScanPhase::IcsProbe,
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use compliance_core::models::{PlcFormat, TargetType};
fn target(target_type: TargetType, artifacts: Vec<Artifact>) -> OnboardedTarget {
let mut t = OnboardedTarget::new("t".to_string(), target_type);
t.artifacts = artifacts;
t
}
fn step_for<'a>(plan: &'a ScanPlan, scan: ScanType) -> Option<&'a ScanStep> {
plan.steps.iter().find(|s| s.scan_type == scan)
}
#[test]
fn webapp_with_code_and_url_runs_sast_and_dast_bound_to_the_right_artifacts() {
let code = Artifact::git_repo("https://git/x", "main");
let url = Artifact::live_url("https://x");
let (code_id, url_id) = (code.id.clone(), url.id.clone());
let t = target(TargetType::WebApp, vec![code, url]);
let plan = build_scan_plan(&t);
let sast = step_for(&plan, ScanType::Sast).expect("sast planned");
assert_eq!(sast.artifact_id, code_id);
assert_eq!(sast.phase, ScanPhase::Sast);
let dast = step_for(&plan, ScanType::Dast).expect("dast planned");
assert_eq!(dast.artifact_id, url_id);
}
#[test]
fn webapp_without_url_omits_dast() {
let t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
let plan = build_scan_plan(&t);
assert!(plan.has(ScanType::Sast));
assert!(!plan.has(ScanType::Dast));
}
#[test]
fn code_scan_binds_to_source_archive_when_no_git_repo() {
let arc = Artifact::source_archive("src.zip");
let arc_id = arc.id.clone();
let t = target(TargetType::BackendService, vec![arc]);
let plan = build_scan_plan(&t);
let sast = step_for(&plan, ScanType::Sast).expect("sast planned");
assert_eq!(sast.artifact_id, arc_id);
}
#[test]
fn firmware_sbom_and_cve_bind_to_the_firmware_image() {
let fw = Artifact::firmware_image("fw.bin");
let fw_id = fw.id.clone();
let t = target(TargetType::FirmwareBareMetal, vec![fw]);
let plan = build_scan_plan(&t);
let sbom = step_for(&plan, ScanType::Sbom).expect("sbom planned");
assert_eq!(sbom.artifact_id, fw_id);
assert!(step_for(&plan, ScanType::FirmwareStatic).is_some());
assert!(!plan.has(ScanType::Dast));
}
#[test]
fn plc_plans_only_control_logic() {
let plc = Artifact::plc_project("p.xml", PlcFormat::PlcopenXml);
let t = target(TargetType::PlcSps, vec![plc]);
let plan = build_scan_plan(&t);
assert_eq!(plan.steps.len(), 1);
assert_eq!(plan.steps[0].scan_type, ScanType::PlcControlLogic);
assert_eq!(plan.steps[0].phase, ScanPhase::PlcAnalysis);
}
#[test]
fn plc_control_logic_binds_to_a_git_repo() {
// A CODESYS project in git (PLCopen XML / ST exports) with no uploaded
// PlcProject: control-logic still plans, bound to the git artifact.
let git = Artifact::git_repo("https://git/plc", "main");
let git_id = git.id.clone();
let t = target(TargetType::PlcSps, vec![git]);
let plan = build_scan_plan(&t);
let step = step_for(&plan, ScanType::PlcControlLogic).expect("control-logic planned");
assert_eq!(step.artifact_id, git_id, "PLC scan binds to the git repo");
}
#[test]
fn disabled_scan_is_dropped_and_off_by_default_can_be_enabled() {
let mut t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
t.scan_config.disabled_scans = vec![ScanType::Lint];
// CodeReview is off by default for web; enable it explicitly.
t.scan_config.enabled_scans = vec![ScanType::CodeReview];
let plan = build_scan_plan(&t);
assert!(!plan.has(ScanType::Lint));
assert!(plan.has(ScanType::CodeReview));
assert!(plan.has(ScanType::Sast));
}
#[test]
fn no_code_artifact_yields_empty_plan_for_web() {
let t = target(TargetType::WebApp, vec![]);
let plan = build_scan_plan(&t);
assert!(plan.is_empty());
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Abstract syntax tree for IEC 61131-3 Structured Text (ST).
//!
//! This is the security-relevant subset: POUs with their variable declarations
//! and statement bodies, enough to run semantic control-logic rules over. It is
//! deliberately not a full language model — declarations we don't reason about
//! (e.g. exotic type definitions) are parsed loosely and kept as raw text.
/// A Program Organization Unit: a PROGRAM, FUNCTION, or FUNCTION_BLOCK.
#[derive(Debug, Clone)]
pub struct Pou {
pub name: String,
pub kind: PouKind,
/// The declared variables, across all VAR_* sections.
pub vars: Vec<VarDecl>,
/// The statement body.
pub body: Vec<Stmt>,
/// 1-based line where the POU header appears (in the source that was parsed).
pub line: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PouKind {
Program,
Function,
FunctionBlock,
}
impl PouKind {
pub fn label(self) -> &'static str {
match self {
PouKind::Program => "PROGRAM",
PouKind::Function => "FUNCTION",
PouKind::FunctionBlock => "FUNCTION_BLOCK",
}
}
}
/// A single declared variable.
#[derive(Debug, Clone)]
pub struct VarDecl {
pub name: String,
pub section: VarSection,
/// The declared type as written (e.g. `BOOL`, `INT`, `ARRAY[0..9] OF INT`).
pub type_name: String,
/// Whether the type is an ARRAY, and its declared bounds `(lo, hi)` when
/// they are literal integers — used by the array-bounds rule.
pub array_bounds: Option<(i64, i64)>,
/// The initializer expression, if any (`:= <expr>`).
pub init: Option<Expr>,
pub line: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VarSection {
Var,
Input,
Output,
InOut,
Global,
Temp,
External,
}
/// A statement.
#[derive(Debug, Clone)]
pub enum Stmt {
Assign {
target: Expr,
value: Expr,
line: u32,
},
If {
/// (condition, body) for IF and each ELSIF, in order.
branches: Vec<(Expr, Vec<Stmt>)>,
else_body: Option<Vec<Stmt>>,
line: u32,
},
Case {
selector: Expr,
/// (label expressions, body) per CASE arm.
arms: Vec<(Vec<Expr>, Vec<Stmt>)>,
else_body: Option<Vec<Stmt>>,
line: u32,
},
For {
var: String,
from: Expr,
to: Expr,
by: Option<Expr>,
body: Vec<Stmt>,
line: u32,
},
While {
cond: Expr,
body: Vec<Stmt>,
line: u32,
},
Repeat {
body: Vec<Stmt>,
until: Expr,
line: u32,
},
/// A bare call statement, e.g. `TON1(IN := x, PT := T#5s);`.
Call {
callee: String,
args: Vec<CallArg>,
line: u32,
},
Return {
line: u32,
},
Exit {
line: u32,
},
/// `JMP label;` — an unstructured jump.
Jump {
label: String,
line: u32,
},
/// `label:` — a jump target.
Label {
name: String,
line: u32,
},
}
/// One argument in a call: positional (`name: None`) or named (`X := expr`).
#[derive(Debug, Clone)]
pub struct CallArg {
pub name: Option<String>,
pub value: Expr,
}
/// An expression.
#[derive(Debug, Clone)]
pub enum Expr {
Int(i64, u32),
Real(f64, u32),
Bool(bool, u32),
/// A string literal, with the unquoted contents.
Str(String, u32),
/// A duration / date / time literal, kept as raw text (`T#5s`, `DT#...`).
Time(String, u32),
Ident(String, u32),
/// `base[index]`.
Index {
base: Box<Expr>,
index: Box<Expr>,
line: u32,
},
/// `base.field`.
Member {
base: Box<Expr>,
field: String,
line: u32,
},
Unary {
op: UnOp,
expr: Box<Expr>,
line: u32,
},
Binary {
op: BinOp,
lhs: Box<Expr>,
rhs: Box<Expr>,
line: u32,
},
/// A function call used as an expression, e.g. `LIMIT(a, b, c)`.
Call {
callee: String,
args: Vec<CallArg>,
line: u32,
},
}
impl Expr {
/// The 1-based source line this expression starts on.
pub fn line(&self) -> u32 {
match self {
Expr::Int(_, l)
| Expr::Real(_, l)
| Expr::Bool(_, l)
| Expr::Str(_, l)
| Expr::Time(_, l)
| Expr::Ident(_, l)
| Expr::Index { line: l, .. }
| Expr::Member { line: l, .. }
| Expr::Unary { line: l, .. }
| Expr::Binary { line: l, .. }
| Expr::Call { line: l, .. } => *l,
}
}
/// If this expression is a plain identifier, its name.
pub fn as_ident(&self) -> Option<&str> {
match self {
Expr::Ident(name, _) => Some(name.as_str()),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnOp {
Not,
Neg,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinOp {
Add,
Sub,
Mul,
Div,
Mod,
Pow,
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
And,
Or,
Xor,
}
+372
View File
@@ -0,0 +1,372 @@
//! Lexer for IEC 61131-3 Structured Text.
//!
//! Tokenizes ST source into a flat token stream with 1-based line numbers.
//! Keywords are case-insensitive. Handles `(* *)` and `//` comments, `'..'` and
//! `".."` strings (with `''`/`""` escapes), based integers (`16#FF`, `2#1010`),
//! and duration/date literals (`T#5s`, `DT#...`) kept as raw text.
/// A lexed token with its source line.
#[derive(Debug, Clone)]
pub struct Token {
pub kind: Tok,
pub line: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Tok {
Int(i64),
Real(f64),
Str(String),
Time(String),
Bool(bool),
Ident(String),
Kw(Keyword),
Assign, // :=
Plus, // +
Minus, // -
Star, // *
Slash, // /
Power, // **
LParen, // (
RParen, // )
LBrack, // [
RBrack, // ]
Dot, // .
DotDot, // ..
Comma, // ,
Semi, // ;
Colon, // :
Lt, // <
Le, // <=
Gt, // >
Ge, // >=
Eq, // =
Ne, // <>
Amp, // &
Eof,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Keyword {
Program,
EndProgram,
Function,
EndFunction,
FunctionBlock,
EndFunctionBlock,
Var,
VarInput,
VarOutput,
VarInOut,
VarGlobal,
VarTemp,
VarExternal,
Constant,
EndVar,
Array,
Of,
If,
Then,
Elsif,
Else,
EndIf,
Case,
EndCase,
For,
To,
By,
Do,
EndFor,
While,
EndWhile,
Repeat,
Until,
EndRepeat,
Return,
Exit,
Jmp,
Not,
And,
Or,
Xor,
Mod,
Type,
EndType,
Struct,
EndStruct,
}
fn keyword_from(word: &str) -> Option<Keyword> {
use Keyword::*;
Some(match word.to_ascii_uppercase().as_str() {
"PROGRAM" => Program,
"END_PROGRAM" => EndProgram,
"FUNCTION" => Function,
"END_FUNCTION" => EndFunction,
"FUNCTION_BLOCK" => FunctionBlock,
"END_FUNCTION_BLOCK" => EndFunctionBlock,
"VAR" => Var,
"VAR_INPUT" => VarInput,
"VAR_OUTPUT" => VarOutput,
"VAR_IN_OUT" => VarInOut,
"VAR_GLOBAL" => VarGlobal,
"VAR_TEMP" => VarTemp,
"VAR_EXTERNAL" => VarExternal,
"CONSTANT" => Constant,
"END_VAR" => EndVar,
"ARRAY" => Array,
"OF" => Of,
"IF" => If,
"THEN" => Then,
"ELSIF" => Elsif,
"ELSE" => Else,
"END_IF" => EndIf,
"CASE" => Case,
"END_CASE" => EndCase,
"FOR" => For,
"TO" => To,
"BY" => By,
"DO" => Do,
"END_FOR" => EndFor,
"WHILE" => While,
"END_WHILE" => EndWhile,
"REPEAT" => Repeat,
"UNTIL" => Until,
"END_REPEAT" => EndRepeat,
"RETURN" => Return,
"EXIT" => Exit,
"JMP" => Jmp,
"NOT" => Not,
"AND" => And,
"OR" => Or,
"XOR" => Xor,
"MOD" => Mod,
"TYPE" => Type,
"END_TYPE" => EndType,
"STRUCT" => Struct,
"END_STRUCT" => EndStruct,
_ => return None,
})
}
/// Tokenize `src`. Unknown characters are skipped (best-effort — a scanner must
/// not die on odd input).
pub fn lex(src: &str) -> Vec<Token> {
let chars: Vec<char> = src.chars().collect();
let mut i = 0usize;
let mut line = 1u32;
let mut out = Vec::new();
let bump_line = |c: char, line: &mut u32| {
if c == '\n' {
*line += 1;
}
};
while i < chars.len() {
let c = chars[i];
// Whitespace.
if c.is_whitespace() {
bump_line(c, &mut line);
i += 1;
continue;
}
// Line comment: //
if c == '/' && i + 1 < chars.len() && chars[i + 1] == '/' {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
continue;
}
// Block comment: (* ... *)
if c == '(' && i + 1 < chars.len() && chars[i + 1] == '*' {
i += 2;
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == ')') {
bump_line(chars[i], &mut line);
i += 1;
}
i = (i + 2).min(chars.len());
continue;
}
let tok_line = line;
// String literal: '...' or "..."
if c == '\'' || c == '"' {
let quote = c;
i += 1;
let mut s = String::new();
while i < chars.len() {
let ch = chars[i];
if ch == quote {
// Doubled quote is an escaped quote.
if i + 1 < chars.len() && chars[i + 1] == quote {
s.push(quote);
i += 2;
continue;
}
i += 1;
break;
}
bump_line(ch, &mut line);
s.push(ch);
i += 1;
}
out.push(Token {
kind: Tok::Str(s),
line: tok_line,
});
continue;
}
// Identifier / keyword / time literal / boolean.
if c.is_ascii_alphabetic() || c == '_' {
let start = i;
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
i += 1;
}
let word: String = chars[start..i].iter().collect();
// Duration/date/time literal prefix: T#, TIME#, DT#, D#, TOD#, LT# ...
if i < chars.len() && chars[i] == '#' {
let up = word.to_ascii_uppercase();
if matches!(
up.as_str(),
"T" | "TIME" | "DT" | "D" | "TOD" | "LT" | "DATE"
) {
let lit_start = start;
i += 1; // consume '#'
while i < chars.len()
&& (chars[i].is_ascii_alphanumeric()
|| chars[i] == '.'
|| chars[i] == '_'
|| chars[i] == ':')
{
i += 1;
}
let lit: String = chars[lit_start..i].iter().collect();
out.push(Token {
kind: Tok::Time(lit),
line: tok_line,
});
continue;
}
}
let kind = match word.to_ascii_uppercase().as_str() {
"TRUE" => Tok::Bool(true),
"FALSE" => Tok::Bool(false),
_ => match keyword_from(&word) {
Some(kw) => Tok::Kw(kw),
None => Tok::Ident(word),
},
};
out.push(Token {
kind,
line: tok_line,
});
continue;
}
// Number: decimal, real, or based (16#..., 2#...).
if c.is_ascii_digit() {
let start = i;
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '_') {
i += 1;
}
// Based literal: <base>#<digits>
if i < chars.len() && chars[i] == '#' {
let base_str: String = chars[start..i].iter().filter(|c| **c != '_').collect();
i += 1;
let dstart = i;
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
i += 1;
}
let digits: String = chars[dstart..i].iter().filter(|c| **c != '_').collect();
let radix = base_str.parse::<u32>().unwrap_or(10);
let val = i64::from_str_radix(&digits, radix.clamp(2, 36)).unwrap_or(0);
out.push(Token {
kind: Tok::Int(val),
line: tok_line,
});
continue;
}
// Real: has a '.' (not '..') or exponent.
let is_real =
i < chars.len() && chars[i] == '.' && !(i + 1 < chars.len() && chars[i + 1] == '.');
if is_real {
i += 1;
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '_') {
i += 1;
}
let raw: String = chars[start..i].iter().filter(|c| **c != '_').collect();
out.push(Token {
kind: Tok::Real(raw.parse().unwrap_or(0.0)),
line: tok_line,
});
continue;
}
let raw: String = chars[start..i].iter().filter(|c| **c != '_').collect();
out.push(Token {
kind: Tok::Int(raw.parse().unwrap_or(0)),
line: tok_line,
});
continue;
}
// Operators / punctuation (longest match first).
let two: String = chars[i..(i + 2).min(chars.len())].iter().collect();
let kind = match two.as_str() {
":=" => Some(Tok::Assign),
"<=" => Some(Tok::Le),
">=" => Some(Tok::Ge),
"<>" => Some(Tok::Ne),
".." => Some(Tok::DotDot),
"**" => Some(Tok::Power),
_ => None,
};
if let Some(k) = kind {
out.push(Token {
kind: k,
line: tok_line,
});
i += 2;
continue;
}
let one = match c {
'+' => Some(Tok::Plus),
'-' => Some(Tok::Minus),
'*' => Some(Tok::Star),
'/' => Some(Tok::Slash),
'(' => Some(Tok::LParen),
')' => Some(Tok::RParen),
'[' => Some(Tok::LBrack),
']' => Some(Tok::RBrack),
'.' => Some(Tok::Dot),
',' => Some(Tok::Comma),
';' => Some(Tok::Semi),
':' => Some(Tok::Colon),
'<' => Some(Tok::Lt),
'>' => Some(Tok::Gt),
'=' => Some(Tok::Eq),
'&' => Some(Tok::Amp),
_ => None,
};
if let Some(k) = one {
out.push(Token {
kind: k,
line: tok_line,
});
}
i += 1;
}
out.push(Token {
kind: Tok::Eof,
line,
});
out
}
+234
View File
@@ -0,0 +1,234 @@
//! PLC control-logic security scanner for IEC 61131-3 targets.
//!
//! Parses Structured Text (raw `.st`/`.scl`/`.exp` files and PLCopen-XML
//! projects) into an AST and runs semantic control-logic security rules over it.
//! Implements [`ScanType::PlcControlLogic`].
pub mod ast;
pub mod lexer;
pub mod parser;
pub mod plcopen;
pub mod rules;
pub mod sbom;
use std::path::Path;
use compliance_core::error::CoreError;
use compliance_core::models::{Finding, ScanType};
use compliance_core::traits::{ScanOutput, Scanner};
use crate::pipeline::dedup;
/// Scanner for `ScanType::PlcControlLogic`.
pub struct PlcControlLogicScanner;
impl Scanner for PlcControlLogicScanner {
fn name(&self) -> &str {
"plc-control-logic"
}
fn scan_type(&self) -> ScanType {
ScanType::PlcControlLogic
}
#[tracing::instrument(skip_all)]
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
let findings = analyze_tree(repo_path, repo_id);
Ok(ScanOutput {
findings,
sbom_entries: Vec::new(),
})
}
}
/// Walk a PLC project tree and produce findings.
pub(crate) fn analyze_tree(root: &Path, repo_id: &str) -> Vec<Finding> {
let mut findings = Vec::new();
for entry in walkdir::WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let is_st = matches!(ext.as_str(), "st" | "iecst" | "scl" | "exp" | "il");
let is_xml = matches!(ext.as_str(), "xml" | "plcopen" | "project");
if !is_st && !is_xml {
continue;
}
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
let pous = if is_xml {
plcopen::parse_plcopen(&content)
} else {
parser::parse(&content)
};
if pous.is_empty() {
continue;
}
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
for pou in &pous {
for hit in rules::analyze(pou) {
let line_s = hit.line.to_string();
let fingerprint =
dedup::compute_fingerprint(&[repo_id, &rel, hit.rule_id, &pou.name, &line_s]);
let mut f = Finding::new(
repo_id.to_string(),
fingerprint,
"plc-control-logic".to_string(),
ScanType::PlcControlLogic,
hit.title,
hit.description,
hit.severity,
);
f.file_path = Some(rel.clone());
f.line_number = Some(hit.line);
f.rule_id = Some(hit.rule_id.to_string());
f.cwe = hit.cwe.map(String::from);
f.remediation = Some(hit.remediation.to_string());
findings.push(f);
}
}
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::path::PathBuf;
fn demo_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("workspace root")
.join("examples/plc-demo")
}
#[test]
fn scans_demo_project_end_to_end() {
let findings = analyze_tree(&demo_dir(), "demo-target");
assert!(!findings.is_empty(), "demo project should produce findings");
let rules: HashSet<&str> = findings
.iter()
.filter_map(|f| f.rule_id.as_deref())
.collect();
for r in [
"plc-hardcoded-credential",
"plc-default-password",
"plc-safety-bypass",
"plc-array-unchecked-index",
"plc-insecure-comm",
"plc-insecure-protocol-port",
"plc-unstructured-jump",
"plc-division-by-zero",
] {
assert!(rules.contains(r), "expected rule {r}; got {rules:?}");
}
// Every finding is well-formed for storage.
for f in &findings {
assert_eq!(f.repo_id, "demo-target");
assert!(f.file_path.is_some(), "finding needs a file");
assert!(f.line_number.is_some(), "finding needs a line");
}
// The guarded division (IF ScaleFactor <> 0.0) must not be double-counted:
// exactly one division-by-zero (the unguarded MeasuredFlow divide).
let div0 = findings
.iter()
.filter(|f| f.rule_id.as_deref() == Some("plc-division-by-zero"))
.count();
assert_eq!(div0, 1, "only the unguarded division should be flagged");
}
/// The realistic OpenPLC-style traffic-light sample is mostly sound control
/// logic: the scanner must surface its few genuine defects and stay quiet on
/// the timed state machine and the guarded duty-cycle division.
#[test]
fn realistic_sample_flags_only_real_issues() {
let all = analyze_tree(&demo_dir(), "demo-target");
let tl: Vec<_> = all
.iter()
.filter(|f| {
f.file_path
.as_deref()
.is_some_and(|p| p.ends_with("traffic_light.st"))
})
.collect();
assert!(!tl.is_empty(), "traffic_light.st should produce findings");
let rules: HashSet<&str> = tl.iter().filter_map(|f| f.rule_id.as_deref()).collect();
// The three planted defects: hardcoded SCADA password, cleartext Modbus
// master (no auth), and a maintenance mode that drops the PedPermit.
for r in [
"plc-hardcoded-credential",
"plc-insecure-comm",
"plc-safety-bypass",
] {
assert!(rules.contains(r), "expected rule {r}; got {rules:?}");
}
// Modbus/TCP on 502 is also an insecure-protocol port.
assert!(rules.contains("plc-insecure-protocol-port"));
// Low false positives: the guarded `IF LampCount <> 0` division and the
// JMP-free state machine must not trip anything.
assert_eq!(
tl.iter()
.filter(|f| f.rule_id.as_deref() == Some("plc-division-by-zero"))
.count(),
0,
"the guarded duty-cycle division must not be flagged"
);
assert!(
!rules.contains("plc-unstructured-jump"),
"the CASE state machine uses no JMP"
);
}
/// Graphical logic must be analysed too: an FBD POU (blocks + in/out
/// variables) is translated to synthetic ST, so the same rules fire on the
/// cleartext Modbus block, the hardcoded HMI password and the safety write.
#[test]
fn fbd_graphical_body_is_analysed() {
let all = analyze_tree(&demo_dir(), "demo-target");
let fbd: Vec<_> = all
.iter()
.filter(|f| {
f.file_path
.as_deref()
.is_some_and(|p| p.ends_with("pump_fbd.xml"))
})
.collect();
assert!(
!fbd.is_empty(),
"pump_fbd.xml (FBD) should produce findings"
);
let rules: HashSet<&str> = fbd.iter().filter_map(|f| f.rule_id.as_deref()).collect();
for r in [
"plc-insecure-comm", // Modbus_TCP_Master(AUTH := FALSE)
"plc-insecure-protocol-port", // PORT := 502
"plc-hardcoded-credential", // HmiPassword := 'admin123'
"plc-safety-bypass", // Safety_Enable := FALSE
] {
assert!(
rules.contains(r),
"expected rule {r} from FBD; got {rules:?}"
);
}
}
}
+766
View File
@@ -0,0 +1,766 @@
//! Recursive-descent parser for the security-relevant subset of Structured Text.
//!
//! Tolerant by design: it parses the POUs, variable sections, and statement
//! bodies it understands, and skips (with statement/POU-level recovery) anything
//! it does not, so a single odd construct never sinks the whole file.
use super::ast::*;
use super::lexer::{Keyword as K, Tok, Token};
pub struct Parser {
toks: Vec<Token>,
pos: usize,
}
impl Parser {
pub fn new(toks: Vec<Token>) -> Self {
Self { toks, pos: 0 }
}
// ── token helpers ──────────────────────────────────────────────
fn peek(&self) -> &Tok {
&self.toks[self.pos.min(self.toks.len() - 1)].kind
}
fn line(&self) -> u32 {
self.toks[self.pos.min(self.toks.len() - 1)].line
}
fn at_end(&self) -> bool {
matches!(self.peek(), Tok::Eof)
}
fn advance(&mut self) -> Tok {
let t = self.toks[self.pos.min(self.toks.len() - 1)].kind.clone();
if self.pos < self.toks.len() - 1 {
self.pos += 1;
}
t
}
fn eat(&mut self, t: &Tok) -> bool {
if self.peek() == t {
self.advance();
true
} else {
false
}
}
fn eat_kw(&mut self, k: K) -> bool {
if matches!(self.peek(), Tok::Kw(x) if *x == k) {
self.advance();
true
} else {
false
}
}
fn at_kw(&self, k: K) -> bool {
matches!(self.peek(), Tok::Kw(x) if *x == k)
}
fn ident(&mut self) -> Option<String> {
if let Tok::Ident(s) = self.peek() {
let s = s.clone();
self.advance();
Some(s)
} else {
None
}
}
// ── top level ──────────────────────────────────────────────────
/// Parse every POU in the token stream.
pub fn parse_units(&mut self) -> Vec<Pou> {
let mut pous = Vec::new();
while !self.at_end() {
match self.peek() {
Tok::Kw(K::Program) => {
self.advance();
if let Some(p) = self.parse_pou(PouKind::Program, K::EndProgram) {
pous.push(p);
}
}
Tok::Kw(K::Function) => {
self.advance();
if let Some(p) = self.parse_pou(PouKind::Function, K::EndFunction) {
pous.push(p);
}
}
Tok::Kw(K::FunctionBlock) => {
self.advance();
if let Some(p) = self.parse_pou(PouKind::FunctionBlock, K::EndFunctionBlock) {
pous.push(p);
}
}
// Skip TYPE...END_TYPE and anything else at top level.
_ => {
self.advance();
}
}
}
pous
}
fn parse_pou(&mut self, kind: PouKind, end: K) -> Option<Pou> {
let line = self.line();
let name = self.ident().unwrap_or_else(|| "<anonymous>".to_string());
// Optional `: return_type` for functions.
if self.eat(&Tok::Colon) {
let _ = self.advance(); // return type token
}
let mut vars = Vec::new();
// Variable sections precede the body.
while let Some(section) = self.var_section_kw() {
self.advance();
let _ = self.eat_kw(K::Constant); // CONSTANT is informational for our rules
self.parse_var_decls(section, &mut vars);
}
// Body statements until END_<kind>.
let mut body = Vec::new();
while !self.at_end() && !self.at_kw(end) {
if let Some(s) = self.parse_stmt() {
body.push(s);
}
}
self.eat_kw(end);
Some(Pou {
name,
kind,
vars,
body,
line,
})
}
fn var_section_kw(&self) -> Option<VarSection> {
match self.peek() {
Tok::Kw(K::Var) => Some(VarSection::Var),
Tok::Kw(K::VarInput) => Some(VarSection::Input),
Tok::Kw(K::VarOutput) => Some(VarSection::Output),
Tok::Kw(K::VarInOut) => Some(VarSection::InOut),
Tok::Kw(K::VarGlobal) => Some(VarSection::Global),
Tok::Kw(K::VarTemp) => Some(VarSection::Temp),
Tok::Kw(K::VarExternal) => Some(VarSection::External),
_ => None,
}
}
fn parse_var_decls(&mut self, section: VarSection, out: &mut Vec<VarDecl>) {
while !self.at_end() && !self.at_kw(K::EndVar) {
let line = self.line();
// names: a, b, c
let mut names = Vec::new();
match self.ident() {
Some(n) => names.push(n),
None => {
// Not a declaration we understand — skip to next ; or END_VAR.
self.sync_decl();
continue;
}
}
while self.eat(&Tok::Comma) {
if let Some(n) = self.ident() {
names.push(n);
}
}
if !self.eat(&Tok::Colon) {
self.sync_decl();
continue;
}
let (type_name, array_bounds) = self.parse_type();
let init = if self.eat(&Tok::Assign) {
Some(self.parse_expr())
} else {
None
};
self.eat(&Tok::Semi);
for n in names {
out.push(VarDecl {
name: n,
section,
type_name: type_name.clone(),
array_bounds,
init: init.clone(),
line,
});
}
}
self.eat_kw(K::EndVar);
}
/// Parse a (possibly ARRAY) type, returning its rendered name and literal
/// bounds when present.
fn parse_type(&mut self) -> (String, Option<(i64, i64)>) {
if self.eat_kw(K::Array) {
let mut bounds = None;
if self.eat(&Tok::LBrack) {
let lo = self.int_lit();
self.eat(&Tok::DotDot);
let hi = self.int_lit();
if let (Some(lo), Some(hi)) = (lo, hi) {
bounds = Some((lo, hi));
}
// Skip any further dimensions / tokens to the closing bracket.
while !self.at_end() && !self.eat(&Tok::RBrack) {
self.advance();
}
}
self.eat_kw(K::Of);
let elem = self.type_ident();
(format!("ARRAY OF {elem}"), bounds)
} else {
(self.type_ident(), None)
}
}
fn type_ident(&mut self) -> String {
// Types can be qualified idents; keep it simple: one token, plus any
// string-length suffix like STRING[80].
let base = match self.advance() {
Tok::Ident(s) => s,
Tok::Kw(_) => "TYPE".to_string(),
other => format!("{other:?}"),
};
if self.eat(&Tok::LBrack) {
while !self.at_end() && !self.eat(&Tok::RBrack) {
self.advance();
}
}
base
}
fn int_lit(&mut self) -> Option<i64> {
match self.peek() {
Tok::Int(n) => {
let n = *n;
self.advance();
Some(n)
}
Tok::Minus => {
self.advance();
if let Tok::Int(n) = self.peek() {
let n = -*n;
self.advance();
Some(n)
} else {
None
}
}
_ => None,
}
}
fn sync_decl(&mut self) {
while !self.at_end() && !self.eat(&Tok::Semi) && !self.at_kw(K::EndVar) {
self.advance();
}
}
fn sync_stmt(&mut self) {
while !self.at_end() && !self.eat(&Tok::Semi) {
// Stop at block terminators so recovery doesn't swallow structure.
if matches!(
self.peek(),
Tok::Kw(
K::EndIf
| K::EndFor
| K::EndWhile
| K::EndCase
| K::EndRepeat
| K::EndProgram
| K::EndFunction
| K::EndFunctionBlock
| K::Else
| K::Elsif
)
) {
return;
}
self.advance();
}
}
// ── statements ─────────────────────────────────────────────────
fn parse_stmt(&mut self) -> Option<Stmt> {
let line = self.line();
match self.peek().clone() {
Tok::Semi => {
self.advance();
None
}
Tok::Kw(K::If) => self.parse_if(),
Tok::Kw(K::Case) => self.parse_case(),
Tok::Kw(K::For) => self.parse_for(),
Tok::Kw(K::While) => self.parse_while(),
Tok::Kw(K::Repeat) => self.parse_repeat(),
Tok::Kw(K::Return) => {
self.advance();
self.eat(&Tok::Semi);
Some(Stmt::Return { line })
}
Tok::Kw(K::Exit) => {
self.advance();
self.eat(&Tok::Semi);
Some(Stmt::Exit { line })
}
Tok::Kw(K::Jmp) => {
self.advance();
let label = self.ident().unwrap_or_default();
self.eat(&Tok::Semi);
Some(Stmt::Jump { label, line })
}
Tok::Ident(name) => {
// Could be `label:`, `call(...)`, or an assignment.
// Lookahead: ident ':' (not ':=') → label.
if matches!(
self.toks.get(self.pos + 1).map(|t| &t.kind),
Some(Tok::Colon)
) && !matches!(self.toks.get(self.pos + 2).map(|t| &t.kind), Some(Tok::Eq))
{
self.advance(); // ident
self.advance(); // ':'
return Some(Stmt::Label { name, line });
}
let lhs = self.parse_expr();
if self.eat(&Tok::Assign) {
let value = self.parse_expr();
self.eat(&Tok::Semi);
Some(Stmt::Assign {
target: lhs,
value,
line,
})
} else if let Expr::Call { callee, args, .. } = lhs {
self.eat(&Tok::Semi);
Some(Stmt::Call { callee, args, line })
} else {
// Bare expression / FB invocation without args recognized —
// skip to the terminator.
self.sync_stmt();
None
}
}
_ => {
self.sync_stmt();
None
}
}
}
fn parse_block_until(&mut self, terms: &[K]) -> Vec<Stmt> {
let mut body = Vec::new();
while !self.at_end() && !terms.iter().any(|k| self.at_kw(*k)) {
if let Some(s) = self.parse_stmt() {
body.push(s);
}
}
body
}
fn parse_if(&mut self) -> Option<Stmt> {
let line = self.line();
self.eat_kw(K::If);
let mut branches = Vec::new();
let cond = self.parse_expr();
self.eat_kw(K::Then);
let body = self.parse_block_until(&[K::Elsif, K::Else, K::EndIf]);
branches.push((cond, body));
while self.eat_kw(K::Elsif) {
let c = self.parse_expr();
self.eat_kw(K::Then);
let b = self.parse_block_until(&[K::Elsif, K::Else, K::EndIf]);
branches.push((c, b));
}
let else_body = if self.eat_kw(K::Else) {
Some(self.parse_block_until(&[K::EndIf]))
} else {
None
};
self.eat_kw(K::EndIf);
self.eat(&Tok::Semi);
Some(Stmt::If {
branches,
else_body,
line,
})
}
fn parse_case(&mut self) -> Option<Stmt> {
let line = self.line();
self.eat_kw(K::Case);
let selector = self.parse_expr();
self.eat_kw(K::Of);
let mut arms = Vec::new();
let mut else_body = None;
while !self.at_end() && !self.at_kw(K::EndCase) {
if self.eat_kw(K::Else) {
else_body = Some(self.parse_block_until(&[K::EndCase]));
break;
}
// labels: expr {, expr} :
let mut labels = vec![self.parse_expr()];
while self.eat(&Tok::Comma) {
labels.push(self.parse_expr());
}
self.eat(&Tok::Colon);
let body = self.parse_block_until(&[K::EndCase, K::Else]);
arms.push((labels, body));
}
self.eat_kw(K::EndCase);
self.eat(&Tok::Semi);
Some(Stmt::Case {
selector,
arms,
else_body,
line,
})
}
fn parse_for(&mut self) -> Option<Stmt> {
let line = self.line();
self.eat_kw(K::For);
let var = self.ident().unwrap_or_default();
self.eat(&Tok::Assign);
let from = self.parse_expr();
self.eat_kw(K::To);
let to = self.parse_expr();
let by = if self.eat_kw(K::By) {
Some(self.parse_expr())
} else {
None
};
self.eat_kw(K::Do);
let body = self.parse_block_until(&[K::EndFor]);
self.eat_kw(K::EndFor);
self.eat(&Tok::Semi);
Some(Stmt::For {
var,
from,
to,
by,
body,
line,
})
}
fn parse_while(&mut self) -> Option<Stmt> {
let line = self.line();
self.eat_kw(K::While);
let cond = self.parse_expr();
self.eat_kw(K::Do);
let body = self.parse_block_until(&[K::EndWhile]);
self.eat_kw(K::EndWhile);
self.eat(&Tok::Semi);
Some(Stmt::While { cond, body, line })
}
fn parse_repeat(&mut self) -> Option<Stmt> {
let line = self.line();
self.eat_kw(K::Repeat);
let body = self.parse_block_until(&[K::Until, K::EndRepeat]);
self.eat_kw(K::Until);
let until = self.parse_expr();
self.eat_kw(K::EndRepeat);
self.eat(&Tok::Semi);
Some(Stmt::Repeat { body, until, line })
}
// ── expressions (precedence climbing) ──────────────────────────
pub fn parse_expr(&mut self) -> Expr {
self.parse_or()
}
fn parse_or(&mut self) -> Expr {
let mut lhs = self.parse_and();
loop {
let op = match self.peek() {
Tok::Kw(K::Or) => BinOp::Or,
Tok::Kw(K::Xor) => BinOp::Xor,
_ => break,
};
let line = self.line();
self.advance();
let rhs = self.parse_and();
lhs = Expr::Binary {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
line,
};
}
lhs
}
fn parse_and(&mut self) -> Expr {
let mut lhs = self.parse_cmp();
while matches!(self.peek(), Tok::Kw(K::And) | Tok::Amp) {
let op = BinOp::And;
let line = self.line();
self.advance();
let rhs = self.parse_cmp();
lhs = Expr::Binary {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
line,
};
}
lhs
}
fn parse_cmp(&mut self) -> Expr {
let mut lhs = self.parse_add();
loop {
let op = match self.peek() {
Tok::Eq => BinOp::Eq,
Tok::Ne => BinOp::Ne,
Tok::Lt => BinOp::Lt,
Tok::Le => BinOp::Le,
Tok::Gt => BinOp::Gt,
Tok::Ge => BinOp::Ge,
_ => break,
};
let line = self.line();
self.advance();
let rhs = self.parse_add();
lhs = Expr::Binary {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
line,
};
}
lhs
}
fn parse_add(&mut self) -> Expr {
let mut lhs = self.parse_mul();
loop {
let op = match self.peek() {
Tok::Plus => BinOp::Add,
Tok::Minus => BinOp::Sub,
_ => break,
};
let line = self.line();
self.advance();
let rhs = self.parse_mul();
lhs = Expr::Binary {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
line,
};
}
lhs
}
fn parse_mul(&mut self) -> Expr {
let mut lhs = self.parse_unary();
loop {
let op = match self.peek() {
Tok::Star => BinOp::Mul,
Tok::Slash => BinOp::Div,
Tok::Kw(K::Mod) => BinOp::Mod,
Tok::Power => BinOp::Pow,
_ => break,
};
let line = self.line();
self.advance();
let rhs = self.parse_unary();
lhs = Expr::Binary {
op,
lhs: Box::new(lhs),
rhs: Box::new(rhs),
line,
};
}
lhs
}
fn parse_unary(&mut self) -> Expr {
let line = self.line();
match self.peek() {
Tok::Kw(K::Not) => {
self.advance();
Expr::Unary {
op: UnOp::Not,
expr: Box::new(self.parse_unary()),
line,
}
}
Tok::Minus => {
self.advance();
Expr::Unary {
op: UnOp::Neg,
expr: Box::new(self.parse_unary()),
line,
}
}
_ => self.parse_postfix(),
}
}
fn parse_postfix(&mut self) -> Expr {
let mut e = self.parse_primary();
loop {
let line = self.line();
match self.peek() {
Tok::LBrack => {
self.advance();
let index = self.parse_expr();
self.eat(&Tok::RBrack);
e = Expr::Index {
base: Box::new(e),
index: Box::new(index),
line,
};
}
Tok::Dot => {
self.advance();
let field = self.ident().unwrap_or_default();
e = Expr::Member {
base: Box::new(e),
field,
line,
};
}
_ => break,
}
}
e
}
fn parse_primary(&mut self) -> Expr {
let line = self.line();
match self.advance() {
Tok::Int(n) => Expr::Int(n, line),
Tok::Real(r) => Expr::Real(r, line),
Tok::Bool(b) => Expr::Bool(b, line),
Tok::Str(s) => Expr::Str(s, line),
Tok::Time(t) => Expr::Time(t, line),
Tok::LParen => {
let e = self.parse_expr();
self.eat(&Tok::RParen);
e
}
Tok::Ident(name) => {
if self.eat(&Tok::LParen) {
let args = self.parse_call_args();
Expr::Call {
callee: name,
args,
line,
}
} else {
Expr::Ident(name, line)
}
}
// Unrecognized start of expression — yield a placeholder identifier.
_ => Expr::Ident(String::new(), line),
}
}
fn parse_call_args(&mut self) -> Vec<CallArg> {
let mut args = Vec::new();
if self.eat(&Tok::RParen) {
return args;
}
loop {
// Named arg: ident := expr (peek two tokens).
if let Tok::Ident(name) = self.peek().clone() {
if matches!(
self.toks.get(self.pos + 1).map(|t| &t.kind),
Some(Tok::Assign)
) {
self.advance(); // ident
self.advance(); // :=
let value = self.parse_expr();
args.push(CallArg {
name: Some(name),
value,
});
if self.eat(&Tok::Comma) {
continue;
}
break;
}
}
let value = self.parse_expr();
args.push(CallArg { name: None, value });
if self.eat(&Tok::Comma) {
continue;
}
break;
}
self.eat(&Tok::RParen);
args
}
}
/// Parse ST source into its POUs.
pub fn parse(src: &str) -> Vec<Pou> {
let toks = super::lexer::lex(src);
Parser::new(toks).parse_units()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
PROGRAM Main
VAR
idx : INT;
pw : STRING := 'admin123';
buf : ARRAY[0..9] OF INT;
ok : BOOL := FALSE;
END_VAR
// a comment
IF idx > 0 THEN
buf[idx] := idx * 2;
ELSE
JMP done;
END_IF;
Comm(IP := '10.0.0.1', PORT := 502);
done:
ok := TRUE;
END_PROGRAM
"#;
#[test]
fn parses_program_vars_and_body() {
let pous = parse(SAMPLE);
assert_eq!(pous.len(), 1, "one POU");
let p = &pous[0];
assert_eq!(p.name, "Main");
assert_eq!(p.kind, PouKind::Program);
// vars: idx, pw, buf, ok
assert_eq!(p.vars.len(), 4);
let pw = p.vars.iter().find(|v| v.name == "pw").expect("pw");
assert!(matches!(&pw.init, Some(Expr::Str(s, _)) if s == "admin123"));
let buf = p.vars.iter().find(|v| v.name == "buf").expect("buf");
assert_eq!(buf.array_bounds, Some((0, 9)));
// body has an IF, a Call, a Label, and an Assign
assert!(p.body.iter().any(|s| matches!(s, Stmt::If { .. })));
assert!(p
.body
.iter()
.any(|s| matches!(s, Stmt::Call { callee, .. } if callee == "Comm")));
assert!(p
.body
.iter()
.any(|s| matches!(s, Stmt::Label { name, .. } if name == "done")));
}
#[test]
fn jmp_inside_if_is_captured() {
let pous = parse(SAMPLE);
let p = &pous[0];
// find the IF, check its else branch has a JMP
let has_jmp = p.body.iter().any(|s| match s {
Stmt::If { else_body, .. } => else_body
.as_ref()
.map(|b| b.iter().any(|s| matches!(s, Stmt::Jump { .. })))
.unwrap_or(false),
_ => false,
});
assert!(has_jmp, "JMP should be parsed inside the ELSE branch");
}
}
@@ -0,0 +1,418 @@
//! PLCopen XML → Structured Text POUs.
//!
//! A PLCopen project stores each POU as `<pou name=".." pouType="..">` with an
//! `<interface>` (typed variable sections) and a `<body>` in one of the IEC
//! 61131-3 languages. We reconstruct an equivalent Structured-Text source for
//! each POU (a `VAR` block from the interface + statements from the body) and run
//! it through the ST parser, so raw `.st` files and PLCopen projects — textual or
//! graphical — flow through one analysis path.
//!
//! Body languages:
//! - **ST** — taken verbatim.
//! - **FBD / LD** — the graphical network is translated to synthetic ST: blocks
//! become calls (`TypeName(pin := arg, …)`), out-variables / coils become
//! assignments, with input pins resolved by tracing connections. This lets the
//! semantic rules see comm calls, hardcoded arguments and safety writes that
//! live in graphical logic, not just in text.
//! - **SFC** — the step/transition graph itself is skipped; the ST/FBD/LD bodies
//! embedded in its actions and transitions are still translated.
use std::collections::HashMap;
use roxmltree::Node;
use super::ast::Pou;
use super::parser;
/// Parse every POU out of a PLCopen XML document (ST, FBD or LD bodies).
pub fn parse_plcopen(xml: &str) -> Vec<Pou> {
let doc = match roxmltree::Document::parse(xml) {
Ok(d) => d,
Err(_) => return Vec::new(),
};
let mut pous = Vec::new();
for pou in doc.descendants().filter(|n| n.has_tag_name("pou")) {
let name = pou.attribute("name").unwrap_or("pou").to_string();
let pou_type = pou.attribute("pouType").unwrap_or("program");
let Some(body) = reconstruct_body(pou) else {
continue;
};
if body.trim().is_empty() {
continue;
}
let var_block = build_var_block(pou);
let kw = match pou_type.to_ascii_lowercase().as_str() {
"function" => "FUNCTION",
"functionblock" | "functionblocktype" => "FUNCTION_BLOCK",
_ => "PROGRAM",
};
let synthetic = format!("{kw} {name}\n{var_block}{body}\nEND_{kw}\n");
pous.extend(parser::parse(&synthetic));
}
pous
}
/// Case-insensitive tag match (PLCopen uses `FBD`/`LD`/`ST`, CODESYS may vary).
fn tag_is(n: &Node, name: &str) -> bool {
n.tag_name().name().eq_ignore_ascii_case(name)
}
/// Reconstruct a POU's body as Structured Text, whatever language it is written
/// in. Concatenates every language body found under `<body>` (SFC actions and
/// transitions carry their own ST/FBD/LD sub-bodies).
fn reconstruct_body(pou: Node) -> Option<String> {
let mut out = String::new();
for body in pou.descendants().filter(|n| tag_is(n, "body")) {
for lang in body.children().filter(|n| n.is_element()) {
let piece = match lang.tag_name().name().to_ascii_uppercase().as_str() {
"ST" | "IL" => collect_text(lang),
"FBD" | "LD" => translate_network(lang),
_ => continue,
};
if !piece.trim().is_empty() {
out.push_str(&piece);
if !piece.ends_with('\n') {
out.push('\n');
}
}
}
}
if out.trim().is_empty() {
None
} else {
Some(out)
}
}
// ── graphical (FBD / LD) → synthetic ST ────────────────────────────────
/// Translate one FBD/LD network into ST statements: blocks → calls,
/// out-variables and coils → assignments.
fn translate_network(net: Node) -> String {
let by_id = index_local_ids(net);
let mut out = String::new();
for el in net.children().filter(|n| n.is_element()) {
let stmt = match el.tag_name().name().to_ascii_lowercase().as_str() {
"block" => block_call(el, &by_id).map(|c| format!("{c};")),
"outvariable" => out_assignment(el, &by_id),
"coil" => coil_assignment(el, &by_id),
_ => None,
};
if let Some(s) = stmt {
out.push_str(&s);
out.push('\n');
}
}
out
}
/// Index every element in a network by its `localId` so connections resolve.
fn index_local_ids<'a, 'input>(net: Node<'a, 'input>) -> HashMap<String, Node<'a, 'input>> {
net.descendants()
.filter(|n| n.is_element())
.filter_map(|n| n.attribute("localId").map(|id| (id.to_string(), n)))
.collect()
}
/// Build a call expression for a block: `TypeName(pin := arg, …)`.
fn block_call(block: Node, by_id: &HashMap<String, Node>) -> Option<String> {
let ty = block.attribute("typeName")?;
let mut args = Vec::new();
if let Some(inputs) = block.children().find(|n| tag_is(n, "inputVariables")) {
for v in inputs.children().filter(|n| tag_is(n, "variable")) {
let Some(expr) = input_expr(v, by_id, 0) else {
continue;
};
match v.attribute("formalParameter") {
Some(pin) if !pin.is_empty() => args.push(format!("{pin} := {expr}")),
_ => args.push(expr),
}
}
}
Some(format!("{ty}({})", args.join(", ")))
}
/// `target := <traced expression>;` for an FBD out-variable.
fn out_assignment(outvar: Node, by_id: &HashMap<String, Node>) -> Option<String> {
let target = expression_text(outvar)?;
let value = input_expr(outvar, by_id, 0).unwrap_or_else(|| "0".to_string());
Some(format!("{target} := {value};"))
}
/// `coil := <traced rung expression>;` for an LD coil (negated → `NOT (…)`).
fn coil_assignment(coil: Node, by_id: &HashMap<String, Node>) -> Option<String> {
let target = child_text(coil, "variable")?;
let rung = input_expr(coil, by_id, 0).unwrap_or_else(|| "TRUE".to_string());
let negated = matches!(coil.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true"));
let rhs = if negated {
format!("NOT ({rung})")
} else {
rung
};
Some(format!("{target} := {rhs};"))
}
/// Resolve the expression feeding `node`'s single input connection.
fn input_expr(node: Node, by_id: &HashMap<String, Node>, depth: u8) -> Option<String> {
let refid = ref_local_id(node)?;
Some(expr_for(&refid, by_id, depth))
}
/// Build the ST expression produced by the element with this `localId`.
fn expr_for(local_id: &str, by_id: &HashMap<String, Node>, depth: u8) -> String {
if depth > 24 {
return "0".to_string();
}
let Some(node) = by_id.get(local_id) else {
return format!("__net{local_id}");
};
match node.tag_name().name().to_ascii_lowercase().as_str() {
"invariable" | "inoutvariable" => {
expression_text(*node).unwrap_or_else(|| format!("__net{local_id}"))
}
// A block feeding another element: reference it by a synthetic result
// name; the block is emitted as its own call statement, so we neither
// duplicate the call nor lose it.
"block" => format!("__blk{local_id}"),
"contact" => {
let var = child_text(*node, "variable").unwrap_or_else(|| "TRUE".to_string());
let negated =
matches!(node.attribute("negated"), Some(v) if v.eq_ignore_ascii_case("true"));
let term = if negated { format!("NOT {var}") } else { var };
match ref_local_id(*node) {
Some(up) => {
let upstream = expr_for(&up, by_id, depth + 1);
if upstream == "TRUE" {
term
} else {
format!("({upstream} AND {term})")
}
}
None => term,
}
}
"leftpowerrail" => "TRUE".to_string(),
_ => format!("__net{local_id}"),
}
}
/// The `refLocalId` of `node`'s first input connection, if any.
fn ref_local_id(node: Node) -> Option<String> {
node.descendants()
.find(|n| tag_is(n, "connectionPointIn"))
.and_then(|cpi| cpi.descendants().find(|n| tag_is(n, "connection")))
.and_then(|c| c.attribute("refLocalId"))
.map(|s| s.to_string())
}
/// Text of a node's `<expression>` child (variable name or literal).
fn expression_text(node: Node) -> Option<String> {
let e = node.children().find(|n| tag_is(n, "expression"))?;
let t = collect_text(e).trim().to_string();
if t.is_empty() {
None
} else {
Some(t)
}
}
/// Text of a named child element (e.g. `<variable>` of a contact/coil).
fn child_text(node: Node, name: &str) -> Option<String> {
let c = node.children().find(|n| tag_is(n, name))?;
let t = collect_text(c).trim().to_string();
if t.is_empty() {
None
} else {
Some(t)
}
}
/// Concatenate the text of a node's descendant text nodes (bodies are often
/// wrapped in `<xhtml>` and may contain multiple text runs). Only text nodes are
/// gathered: an element's `.text()` would re-yield its first child's text, which
/// (with the text node itself) would duplicate every value.
fn collect_text(node: Node) -> String {
node.descendants()
.filter(|n| n.is_text())
.filter_map(|n| n.text())
.collect::<String>()
}
/// Build an ST `VAR … END_VAR` block from a POU's `<interface>` variable
/// sections, so declarations (types, initial values) reach the rules.
fn build_var_block(pou: Node) -> String {
let Some(interface) = pou.children().find(|n| n.has_tag_name("interface")) else {
return String::new();
};
let mut out = String::from("VAR\n");
let mut any = false;
for container in interface.children().filter(|n| n.is_element()) {
// localVars / inputVars / outputVars / inOutVars / tempVars / globalVars / externalVars
if !container.tag_name().name().ends_with("Vars") {
continue;
}
for var in container.children().filter(|n| n.has_tag_name("variable")) {
let Some(vname) = var.attribute("name") else {
continue;
};
let ty = var
.children()
.find(|n| n.has_tag_name("type"))
.map(type_name)
.unwrap_or_else(|| "BOOL".to_string());
let init = var
.children()
.find(|n| n.has_tag_name("initialValue"))
.and_then(initial_value);
match init {
Some(v) => out.push_str(&format!(" {vname} : {ty} := {v};\n")),
None => out.push_str(&format!(" {vname} : {ty};\n")),
}
any = true;
}
}
out.push_str("END_VAR\n");
if any {
out
} else {
String::new()
}
}
/// Render a PLCopen `<type>` element as an ST type string.
fn type_name(type_node: Node) -> String {
let Some(inner) = type_node.children().find(|n| n.is_element()) else {
return "BOOL".to_string();
};
let tag = inner.tag_name().name();
match tag {
"derived" => inner.attribute("name").unwrap_or("DERIVED").to_string(),
"array" => {
let dim = inner.children().find(|n| n.has_tag_name("dimension"));
let (lo, hi) = dim
.map(|d| {
(
d.attribute("lower").unwrap_or("0").to_string(),
d.attribute("upper").unwrap_or("0").to_string(),
)
})
.unwrap_or_else(|| ("0".to_string(), "0".to_string()));
let base = inner
.children()
.find(|n| n.has_tag_name("baseType"))
.map(type_name)
.unwrap_or_else(|| "INT".to_string());
format!("ARRAY[{lo}..{hi}] OF {base}")
}
"string" | "wstring" => "STRING".to_string(),
// BOOL, INT, DINT, REAL, TIME, ... — the tag name is the ST type.
other => other.to_ascii_uppercase(),
}
}
/// Extract an initial value as an ST literal (quoting strings).
fn initial_value(iv: Node) -> Option<String> {
let simple = iv.descendants().find(|n| n.has_tag_name("simpleValue"))?;
let raw = simple.attribute("value")?.trim().to_string();
if raw.is_empty() {
return None;
}
// Numbers / booleans / time literals pass through; everything else is a
// string literal.
let is_scalar = raw.eq_ignore_ascii_case("true")
|| raw.eq_ignore_ascii_case("false")
|| raw.starts_with(['T', 't', 'D', 'd']) && raw.contains('#')
|| raw
.chars()
.all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+');
if is_scalar || raw.starts_with('\'') || raw.starts_with('"') {
Some(raw)
} else {
Some(format!("'{}'", raw.replace('\'', "''")))
}
}
#[cfg(test)]
mod tests {
use super::parse_plcopen;
use crate::pipeline::plc::rules;
use std::collections::HashSet;
fn rule_ids(xml: &str) -> HashSet<&'static str> {
parse_plcopen(xml)
.iter()
.flat_map(rules::analyze)
.map(|h| h.rule_id)
.collect()
}
/// A Ladder Diagram network: a rung (power rail → contact → coil) plus an
/// insecure comm block. Coils/contacts translate to assignments; the block
/// translates to a call so the port rule fires.
#[test]
fn ld_coil_and_block_translate_and_are_analysed() {
let xml = r#"<?xml version="1.0"?>
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
<types><pous>
<pou name="Rung" pouType="program">
<interface><localVars>
<variable name="Motor"><type><BOOL/></type></variable>
</localVars></interface>
<body><LD>
<leftPowerRail localId="0"/>
<contact localId="1"><variable>Start</variable>
<connectionPointIn><connection refLocalId="0"/></connectionPointIn></contact>
<coil localId="2"><variable>Motor</variable>
<connectionPointIn><connection refLocalId="1"/></connectionPointIn></coil>
<inVariable localId="3"><expression>21</expression></inVariable>
<inVariable localId="4"><expression>FALSE</expression></inVariable>
<block localId="10" typeName="Ftp_Send">
<inputVariables>
<variable formalParameter="PORT">
<connectionPointIn><connection refLocalId="3"/></connectionPointIn></variable>
<variable formalParameter="ENCRYPT">
<connectionPointIn><connection refLocalId="4"/></connectionPointIn></variable>
</inputVariables>
</block>
</LD></body>
</pou>
</pous></types>
</project>"#;
let ids = rule_ids(xml);
// Ftp_Send(PORT := 21, ENCRYPT := FALSE) — port 21 is an insecure protocol.
assert!(
ids.contains("plc-insecure-protocol-port"),
"LD block should flag port 21; got {ids:?}"
);
}
/// Doubled-text regression: a graphical expression must be extracted once,
/// so literals like `502` and `FALSE` stay intact (not `502502`/`FALSEFALSE`).
#[test]
fn graphical_expression_text_is_not_duplicated() {
let xml = r#"<?xml version="1.0"?>
<project xmlns="http://www.plcopen.org/xml/tc6_0201">
<types><pous>
<pou name="Comm" pouType="program">
<body><FBD>
<inVariable localId="1"><expression>502</expression></inVariable>
<inVariable localId="2"><expression>FALSE</expression></inVariable>
<block localId="10" typeName="Modbus_TCP_Master">
<inputVariables>
<variable formalParameter="PORT">
<connectionPointIn><connection refLocalId="1"/></connectionPointIn></variable>
<variable formalParameter="AUTH">
<connectionPointIn><connection refLocalId="2"/></connectionPointIn></variable>
</inputVariables>
</block>
</FBD></body>
</pou>
</pous></types>
</project>"#;
let ids = rule_ids(xml);
assert!(ids.contains("plc-insecure-protocol-port")); // PORT := 502 (not 502502)
assert!(ids.contains("plc-insecure-comm")); // AUTH := FALSE (not FALSEFALSE)
}
}
+632
View File
@@ -0,0 +1,632 @@
//! Semantic control-logic security rules over the Structured Text AST.
//!
//! Each rule walks the parsed [`Pou`] and yields [`RuleHit`]s the scanner turns
//! into findings. Rules reason over structure (declarations, assignments, calls,
//! array accesses, division, jumps) rather than raw text, so they see through
//! formatting and comments.
use std::collections::{HashMap, HashSet};
use compliance_core::models::Severity;
use super::ast::*;
/// One rule match within a POU.
pub struct RuleHit {
pub line: u32,
pub severity: Severity,
pub rule_id: &'static str,
pub title: String,
pub description: String,
pub cwe: Option<&'static str>,
pub remediation: &'static str,
}
/// Run every rule over a POU.
pub fn analyze(pou: &Pou) -> Vec<RuleHit> {
let mut hits = Vec::new();
let ctx = Ctx::build(pou);
// Declaration-level rules.
for v in &pou.vars {
if let Some(init) = &v.init {
check_credential_binding(&v.name, init, &pou.name, &mut hits);
check_default_password(init, &v.name, &pou.name, &mut hits);
}
}
// Body walk.
walk(&pou.body, pou, &ctx, &GuardSet::default(), &mut hits);
hits
}
/// Per-POU context precomputed once.
struct Ctx {
/// Names declared in VAR_INPUT (untrusted / externally driven).
input_vars: HashSet<String>,
/// Array variable name → declared (lo, hi) bounds.
arrays: HashMap<String, (i64, i64)>,
}
impl Ctx {
fn build(pou: &Pou) -> Self {
let mut input_vars = HashSet::new();
let mut arrays = HashMap::new();
for v in &pou.vars {
if v.section == VarSection::Input {
input_vars.insert(v.name.to_ascii_lowercase());
}
if let Some(b) = v.array_bounds {
arrays.insert(v.name.to_ascii_lowercase(), b);
}
}
Self { input_vars, arrays }
}
}
/// Variables proven non-zero on the current control-flow path (from enclosing
/// `IF`/`WHILE` conditions), so guarded divisions aren't false-flagged.
#[derive(Default, Clone)]
struct GuardSet {
nonzero: HashSet<String>,
}
impl GuardSet {
fn with(&self, names: Vec<String>) -> Self {
let mut g = self.clone();
g.nonzero.extend(names);
g
}
fn is_nonzero(&self, name: &str) -> bool {
self.nonzero.contains(name)
}
}
/// Variable names a condition proves non-zero (`v <> 0`, `v > 0`, `v >= 1`,
/// `v < 0`, and conjunctions thereof).
fn guards_from_cond(cond: &Expr) -> Vec<String> {
let mut out = Vec::new();
collect_nonzero(cond, &mut out);
out
}
fn collect_nonzero(e: &Expr, out: &mut Vec<String>) {
let Expr::Binary { op, lhs, rhs, .. } = e else {
return;
};
let is_zero = |x: &Expr| {
matches!(x, Expr::Int(0, _)) || matches!(x, Expr::Real(r, _) if r.abs() < f64::EPSILON)
};
let int_of = |x: &Expr| match x {
Expr::Int(n, _) => Some(*n),
_ => None,
};
match op {
BinOp::And => {
collect_nonzero(lhs, out);
collect_nonzero(rhs, out);
}
BinOp::Ne => {
if let (Some(v), true) = (lhs.as_ident(), is_zero(rhs)) {
out.push(v.to_ascii_lowercase());
}
if let (true, Some(v)) = (is_zero(lhs), rhs.as_ident()) {
out.push(v.to_ascii_lowercase());
}
}
BinOp::Gt | BinOp::Lt => {
// v > 0 or v < 0
if let (Some(v), true) = (lhs.as_ident(), is_zero(rhs)) {
out.push(v.to_ascii_lowercase());
}
}
BinOp::Ge => {
// v >= n, n >= 1
if let (Some(v), Some(n)) = (lhs.as_ident(), int_of(rhs)) {
if n >= 1 {
out.push(v.to_ascii_lowercase());
}
}
}
_ => {}
}
}
// ── the walker ─────────────────────────────────────────────────────
fn walk(stmts: &[Stmt], pou: &Pou, ctx: &Ctx, guards: &GuardSet, hits: &mut Vec<RuleHit>) {
for s in stmts {
match s {
Stmt::Assign {
target,
value,
line,
} => {
check_safety_bypass(target, value, *line, &pou.name, hits);
// A string bound to a secret-looking target is a credential.
if let Some(name) = flatten_ident(target) {
check_credential_binding(&name, value, &pou.name, hits);
check_default_password(value, &name, &pou.name, hits);
}
walk_expr(target, pou, ctx, guards, hits);
walk_expr(value, pou, ctx, guards, hits);
}
Stmt::Call { callee, args, line } => {
check_insecure_comm(callee, args, *line, &pou.name, hits);
check_credentials_in_call(callee, args, *line, &pou.name, hits);
for a in args {
walk_expr(&a.value, pou, ctx, guards, hits);
}
}
Stmt::Jump { label, line } => hits.push(RuleHit {
line: *line,
severity: Severity::Medium,
rule_id: "plc-unstructured-jump",
title: "Unstructured jump (JMP) in control logic".to_string(),
description: format!(
"POU `{}` uses `JMP {label}`. Unstructured jumps make control flow hard to \
verify and can bypass safety interlocks or leave outputs in an undefined \
state on unexpected paths.",
pou.name
),
cwe: Some("CWE-691"),
remediation: "Replace JMP with structured constructs (IF/CASE/loops); reserve \
jumps for well-reviewed state machines only.",
}),
Stmt::If {
branches,
else_body,
..
} => {
for (cond, body) in branches {
walk_expr(cond, pou, ctx, guards, hits);
let child = guards.with(guards_from_cond(cond));
walk(body, pou, ctx, &child, hits);
}
if let Some(b) = else_body {
walk(b, pou, ctx, guards, hits);
}
}
Stmt::Case {
selector,
arms,
else_body,
..
} => {
walk_expr(selector, pou, ctx, guards, hits);
for (labels, body) in arms {
for l in labels {
walk_expr(l, pou, ctx, guards, hits);
}
walk(body, pou, ctx, guards, hits);
}
if let Some(b) = else_body {
walk(b, pou, ctx, guards, hits);
}
}
Stmt::For {
from, to, by, body, ..
} => {
walk_expr(from, pou, ctx, guards, hits);
walk_expr(to, pou, ctx, guards, hits);
if let Some(b) = by {
walk_expr(b, pou, ctx, guards, hits);
}
walk(body, pou, ctx, guards, hits);
}
Stmt::While { cond, body, .. } => {
walk_expr(cond, pou, ctx, guards, hits);
let child = guards.with(guards_from_cond(cond));
walk(body, pou, ctx, &child, hits);
}
Stmt::Repeat { body, until, .. } => {
walk(body, pou, ctx, guards, hits);
walk_expr(until, pou, ctx, guards, hits);
}
Stmt::Return { .. } | Stmt::Exit { .. } | Stmt::Label { .. } => {}
}
}
}
fn walk_expr(e: &Expr, pou: &Pou, ctx: &Ctx, guards: &GuardSet, hits: &mut Vec<RuleHit>) {
match e {
Expr::Index { base, index, line } => {
check_array_bounds(base, index, *line, ctx, &pou.name, hits);
walk_expr(base, pou, ctx, guards, hits);
walk_expr(index, pou, ctx, guards, hits);
}
Expr::Binary { op, lhs, rhs, line } => {
if matches!(op, BinOp::Div | BinOp::Mod) {
check_division(rhs, *line, &pou.name, guards, hits);
}
walk_expr(lhs, pou, ctx, guards, hits);
walk_expr(rhs, pou, ctx, guards, hits);
}
Expr::Unary { expr, .. } => walk_expr(expr, pou, ctx, guards, hits),
Expr::Member { base, .. } => walk_expr(base, pou, ctx, guards, hits),
Expr::Call { args, .. } => {
for a in args {
walk_expr(&a.value, pou, ctx, guards, hits);
}
}
_ => {}
}
}
// ── individual rules ───────────────────────────────────────────────
const SECRET_HINTS: &[&str] = &[
"password",
"passwd",
"pwd",
"secret",
"apikey",
"api_key",
"token",
"credential",
"privkey",
"private_key",
"passphrase",
];
const DEFAULT_PASSWORDS: &[&str] = &[
"admin",
"administrator",
"password",
"passwd",
"1234",
"12345",
"123456",
"0000",
"1111",
"root",
"default",
"admin123",
"changeme",
"letmein",
"guest",
"user",
"system",
"plc",
"codesys",
];
const COMM_FB_HINTS: &[&str] = &[
"modbus", "tcp", "udp", "socket", "mqtt", "opcua", "opc_ua", "ethernet", "ethip", "enip",
"dnp3", "ftp", "telnet", "http", "send", "connect", "sock", "comm", "profinet", "s7",
];
/// Insecure cleartext service ports.
const INSECURE_PORTS: &[i64] = &[21, 23, 80, 502, 20000, 44818, 102];
fn check_credential_binding(var_name: &str, value: &Expr, pou: &str, hits: &mut Vec<RuleHit>) {
let name = var_name.to_ascii_lowercase();
let looks_secret = SECRET_HINTS.iter().any(|h| name.contains(h));
if looks_secret {
if let Expr::Str(s, line) = value {
if !s.is_empty() {
hits.push(RuleHit {
line: *line,
severity: Severity::High,
rule_id: "plc-hardcoded-credential",
title: "Hardcoded credential in PLC program".to_string(),
description: format!(
"POU `{pou}` binds a hardcoded secret to `{var_name}`. Credentials \
embedded in control logic are extracted trivially from a project export \
or a firmware dump and cannot be rotated without a redeploy."
),
cwe: Some("CWE-798"),
remediation: "Store secrets outside the program (secure parameter store / \
operator-entered, retained-but-protected memory); never commit \
them to the POU.",
});
}
}
}
}
fn check_default_password(value: &Expr, var_name: &str, pou: &str, hits: &mut Vec<RuleHit>) {
if let Expr::Str(s, line) = value {
let lower = s.to_ascii_lowercase();
if DEFAULT_PASSWORDS.contains(&lower.as_str()) {
hits.push(RuleHit {
line: *line,
severity: Severity::Critical,
rule_id: "plc-default-password",
title: "Default/weak password in PLC program".to_string(),
description: format!(
"POU `{pou}` uses the well-known default/weak password `{s}` (bound to \
`{var_name}`). Default PLC credentials are the first thing an attacker tries."
),
cwe: Some("CWE-1393"),
remediation:
"Require a strong, unique, operator-set password; block commissioning \
until the default is changed.",
});
}
}
}
fn check_credentials_in_call(
callee: &str,
args: &[CallArg],
line: u32,
pou: &str,
hits: &mut Vec<RuleHit>,
) {
for a in args {
if let Some(name) = &a.name {
let n = name.to_ascii_lowercase();
if SECRET_HINTS.iter().any(|h| n.contains(h)) {
if let Expr::Str(s, l) = &a.value {
if !s.is_empty() {
hits.push(RuleHit {
line: *l,
severity: Severity::High,
rule_id: "plc-hardcoded-credential",
title: "Hardcoded credential passed to a function block".to_string(),
description: format!(
"POU `{pou}` passes a hardcoded secret as `{name}` to `{callee}`."
),
cwe: Some("CWE-798"),
remediation: "Supply credentials from protected configuration at \
runtime, not as a literal argument.",
});
}
}
}
}
}
let _ = line;
}
fn check_safety_bypass(target: &Expr, value: &Expr, line: u32, pou: &str, hits: &mut Vec<RuleHit>) {
let Some(name) = flatten_ident(target) else {
return;
};
let n = name.to_ascii_lowercase();
let safety = [
"safety",
"estop",
"e_stop",
"emergency",
"interlock",
"guard",
"permit",
]
.iter()
.any(|h| n.contains(h));
let watchdog = n.contains("watchdog") || n.contains("wdt");
// A safety enable / interlock / watchdog signal driven to FALSE or 0 in
// application logic is a bypass (e.g. `Safety_Enable := FALSE`, `Watchdog_Kick := 0`).
let disabling = matches!(value, Expr::Bool(false, _)) || matches!(value, Expr::Int(0, _));
if (safety || watchdog) && disabling {
hits.push(RuleHit {
line,
severity: Severity::Critical,
rule_id: "plc-safety-bypass",
title: "Safety interlock / watchdog disabled in logic".to_string(),
description: format!(
"POU `{pou}` disables a safety-related signal (`{name}`) in program logic. \
Bypassing interlocks or watchdogs in code defeats the plant's protective \
functions and is a direct hazard."
),
cwe: Some("CWE-1384"),
remediation: "Never disable safety functions from application logic; safety must be \
handled by a certified safety controller / hard-wired circuit.",
});
}
}
fn check_array_bounds(
base: &Expr,
index: &Expr,
line: u32,
ctx: &Ctx,
pou: &str,
hits: &mut Vec<RuleHit>,
) {
// Only reason about arrays we know the bounds of.
let Some(arr_name) = base.as_ident() else {
return;
};
if !ctx.arrays.contains_key(&arr_name.to_ascii_lowercase()) {
return;
}
// Index by an untrusted input variable → potential out-of-bounds access.
if let Some(idx_name) = index.as_ident() {
if ctx.input_vars.contains(&idx_name.to_ascii_lowercase()) {
hits.push(RuleHit {
line,
severity: Severity::High,
rule_id: "plc-array-unchecked-index",
title: "Array indexed by unvalidated input".to_string(),
description: format!(
"POU `{pou}` indexes array `{arr_name}` with the input variable `{idx_name}` \
without a validated bounds check. An out-of-range index corrupts adjacent \
memory or faults the PLC (loss of control)."
),
cwe: Some("CWE-129"),
remediation: "Clamp or validate the index against the array bounds (e.g. \
`LIMIT`/explicit `IF idx >= lo AND idx <= hi`) before the access.",
});
}
}
}
fn check_division(
divisor: &Expr,
line: u32,
pou: &str,
guards: &GuardSet,
hits: &mut Vec<RuleHit>,
) {
// A divisor proven non-zero by an enclosing guard is safe.
if let Expr::Ident(name, _) = divisor {
if guards.is_nonzero(&name.to_ascii_lowercase()) {
return;
}
}
// Flag division by a variable (could be zero); nonzero literals are fine.
let risky = matches!(
divisor,
Expr::Ident(_, _) | Expr::Member { .. } | Expr::Index { .. } | Expr::Int(0, _)
);
if risky {
hits.push(RuleHit {
line,
severity: Severity::Medium,
rule_id: "plc-division-by-zero",
title: "Division by a variable without a zero-guard".to_string(),
description: format!(
"POU `{pou}` divides by a variable that is not proven non-zero. A zero divisor \
raises a PLC exception and can halt the scan cycle (denial of control)."
),
cwe: Some("CWE-369"),
remediation: "Guard the divisor (`IF d <> 0 THEN …`) or use a safe-divide helper that \
returns a defined value for a zero denominator.",
});
}
}
fn check_insecure_comm(
callee: &str,
args: &[CallArg],
line: u32,
pou: &str,
hits: &mut Vec<RuleHit>,
) {
let c = callee.to_ascii_lowercase();
let is_comm = COMM_FB_HINTS.iter().any(|h| c.contains(h));
if !is_comm {
return;
}
// Auth/encryption explicitly disabled.
for a in args {
if let Some(name) = &a.name {
let n = name.to_ascii_lowercase();
let security_flag = ["auth", "secure", "encrypt", "tls", "ssl", "authentication"]
.iter()
.any(|h| n.contains(h));
if security_flag && matches!(a.value, Expr::Bool(false, _)) {
hits.push(RuleHit {
line,
severity: Severity::High,
rule_id: "plc-insecure-comm",
title: "Network communication with security disabled".to_string(),
description: format!(
"POU `{pou}` calls `{callee}` with `{name} := FALSE`, disabling \
authentication/encryption on an industrial network link."
),
cwe: Some("CWE-319"),
remediation: "Enable authentication + transport encryption; segment OT \
networks and restrict the endpoint to trusted peers.",
});
}
}
// Well-known cleartext port literal.
if let Expr::Int(p, _) = &a.value {
if INSECURE_PORTS.contains(p) {
hits.push(RuleHit {
line,
severity: Severity::Medium,
rule_id: "plc-insecure-protocol-port",
title: "Cleartext industrial protocol port".to_string(),
description: format!(
"POU `{pou}` opens `{callee}` on port {p}, a well-known cleartext OT \
protocol port with no built-in authentication or encryption."
),
cwe: Some("CWE-319"),
remediation: "Front the protocol with a secure gateway/VPN, or use the \
authenticated/encrypted variant; never expose it to untrusted \
networks.",
});
}
}
}
let _ = line;
}
/// The dotted/base identifier of an lvalue expression (`a`, `a.b` → `a.b`,
/// `a[i]` → `a`), for name-based rules.
fn flatten_ident(e: &Expr) -> Option<String> {
match e {
Expr::Ident(n, _) => Some(n.clone()),
Expr::Member { base, field, .. } => flatten_ident(base).map(|b| format!("{b}.{field}")),
Expr::Index { base, .. } => flatten_ident(base),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::plc::parser;
const VULN: &str = r#"
FUNCTION_BLOCK CommCtrl
VAR_INPUT
cmdIndex : INT;
END_VAR
VAR
Password : STRING := 'admin123';
buffer : ARRAY[0..15] OF INT;
Safety_Enable : BOOL := TRUE;
divisor : INT;
result : INT;
END_VAR
Safety_Enable := FALSE;
result := 100 / divisor;
buffer[cmdIndex] := 1;
Modbus_Connect(IP := '192.168.0.10', PORT := 502, AUTH := FALSE);
IF cmdIndex > 100 THEN
JMP fault;
END_IF;
fault:
result := 0;
END_FUNCTION_BLOCK
"#;
fn rule_ids(src: &str) -> Vec<&'static str> {
parser::parse(src)
.iter()
.flat_map(analyze)
.map(|h| h.rule_id)
.collect()
}
#[test]
fn vulnerable_program_triggers_every_rule() {
let ids = rule_ids(VULN);
for expected in [
"plc-hardcoded-credential",
"plc-default-password",
"plc-safety-bypass",
"plc-division-by-zero",
"plc-array-unchecked-index",
"plc-insecure-comm",
"plc-insecure-protocol-port",
"plc-unstructured-jump",
] {
assert!(
ids.contains(&expected),
"expected rule {expected}, got {ids:?}"
);
}
}
#[test]
fn clean_program_has_no_findings() {
let clean = r#"
PROGRAM Clean
VAR
a : INT := 5;
b : INT := 3;
total : INT;
END_VAR
IF b <> 0 THEN
total := a / b;
END_IF;
END_PROGRAM
"#;
assert!(rule_ids(clean).is_empty(), "clean program should be quiet");
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Control-application dependency SBOM from a CODESYS `.projectarchive`.
//!
//! A `.projectarchive` is a ZIP that bundles the project plus its referenced
//! libraries and the target runtime. Each referenced library is an entry whose
//! path segment follows the CODESYS convention
//! `Name, Major.Minor.Patch.Build (Company)` (e.g. `Standard, 3.5.18.0 (System)`,
//! `CSV Utility SL, 1.9.0.0 (CODESYS)`); the runtime appears as a device-descriptor
//! entry `CODESYS Control … <version> …`. We enumerate those entries — no binary
//! parsing — and emit SBOM components tagged `pkg:codesys/…`, so the CVE pipeline
//! can match them (the runtime `Cmp*` / `3SLicense` components carry real CODESYS
//! CVEs).
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use compliance_core::models::SbomEntry;
/// Collect the control-application SBOM from every `.projectarchive` reachable for
/// a target: the ingested artifact file itself (an uploaded archive), plus any
/// `*.projectarchive` committed inside the working tree — e.g. a git repo or an
/// extracted source archive that ships the archive alongside its PLCopen XML / ST
/// exports. Deduplicated by (name, version).
pub fn collect_sbom(artifact_file: &Path, working_path: &Path, repo_id: &str) -> Vec<SbomEntry> {
let mut archives: Vec<PathBuf> = Vec::new();
if artifact_file.is_file() {
archives.push(artifact_file.to_path_buf());
}
for entry in walkdir::WalkDir::new(working_path)
.max_depth(8)
.into_iter()
.filter_map(|e| e.ok())
{
let p = entry.path();
if entry.file_type().is_file()
&& p.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x.eq_ignore_ascii_case("projectarchive"))
{
archives.push(p.to_path_buf());
}
}
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
let mut out = Vec::new();
for a in archives {
for e in projectarchive_sbom(&a, repo_id) {
if seen.insert((e.name.clone(), e.version.clone())) {
out.push(e);
}
}
}
out
}
/// Extract CODESYS library + runtime components from a `.projectarchive` (a zip).
/// Best-effort: returns empty if the file is not a readable zip (e.g. a bare
/// `.st`/`.xml` project, which carries no library manifest).
pub fn projectarchive_sbom(archive: &Path, repo_id: &str) -> Vec<SbomEntry> {
let Ok(file) = std::fs::File::open(archive) else {
return Vec::new();
};
let Ok(mut zip) = zip::ZipArchive::new(file) else {
return Vec::new();
};
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
let mut entries = Vec::new();
for i in 0..zip.len() {
let Ok(entry) = zip.by_index(i) else {
continue;
};
// Entry paths use `\` (Windows-authored) and/or `/` separators; the
// component id is one path segment.
for seg in entry.name().split(['/', '\\']) {
if let Some((name, version)) = parse_library(seg).or_else(|| parse_runtime(seg)) {
if seen.insert((name.clone(), version.clone())) {
let mut e = SbomEntry::new(
repo_id.to_string(),
name.clone(),
version.clone(),
"codesys".to_string(),
);
e.purl = Some(format!(
"pkg:codesys/{}@{version}",
name.replace(' ', "%20")
));
entries.push(e);
}
}
}
}
entries
}
/// `Name, X.Y.Z.W (Company)` → (name, version).
fn parse_library(seg: &str) -> Option<(String, String)> {
let seg = seg.trim();
// Company is the trailing "(…)".
let open = seg.rfind(" (")?;
let rest = &seg[open + 2..];
let close = rest.find(')')?;
if rest[..close].trim().is_empty() {
return None;
}
let head = seg[..open].trim(); // "Name, X.Y.Z.W"
let comma = head.rfind(", ")?;
let name = head[..comma].trim().to_string();
let version = head[comma + 2..].trim().to_string();
if name.is_empty() || !is_dotted_version(&version) {
return None;
}
Some((name, version))
}
/// Device-descriptor entry `CODESYS Control … X.Y.Z.W …` → (runtime name, version).
fn parse_runtime(seg: &str) -> Option<(String, String)> {
let seg = seg.trim();
if !seg.starts_with("CODESYS Control") {
return None;
}
let version = seg
.split_whitespace()
.find(|t| is_dotted_version(t))?
.to_string();
// The runtime name is the first field, before the run of padding spaces that
// precede the descriptor's numeric columns.
let name = seg.split(" ").next().unwrap_or(seg).trim().to_string();
if name.is_empty() {
return None;
}
Some((name, version))
}
/// A dotted numeric version with at least 3 components (`3.5.18.0`, `4.17.0.0`).
fn is_dotted_version(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
parts.len() >= 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::io::Write;
/// Build a synthetic `.projectarchive` (zip) mirroring the real CODESYS entry
/// naming (verified against Proemion/codesys-examples): a native `.project`,
/// referenced libraries as `Name, Version (Company)` segments, and a runtime
/// device descriptor.
fn synthetic_archive(dir: &Path) -> std::path::PathBuf {
let path = dir.join("App.projectarchive");
write_synthetic_archive(&path);
path
}
fn write_synthetic_archive(path: &Path) {
let file = std::fs::File::create(path).expect("create");
let mut zip = zip::ZipWriter::new(file);
let opts: zip::write::SimpleFileOptions = Default::default();
let names = [
"App.project",
r"{b0b5}\App.Device.Plc.compileinfo",
r"{e179}\Standard, 3.5.18.0 (System) standard.compiled-library-v3",
r"{e179}\Util, 3.5.21.0 (System) util.compiled-library-v3",
r"{e179}\CSV Utility SL, 1.9.0.0 (CODESYS) csv utility sl.compiled-library-v3",
r"{e179}\3SLicense, 3.5.20.0 (CODESYS) 3slicense.compiled-library-v3",
r"{0c63}\CODESYS Control for Linux ARM SL 0000 0006 4.17.0.0 4096 .zip",
];
for n in names {
zip.start_file(n, opts).expect("start");
zip.write_all(b"x").expect("write");
}
zip.finish().expect("finish");
}
#[test]
fn extracts_libraries_and_runtime_from_projectarchive() {
let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).expect("mkdir");
let archive = synthetic_archive(&tmp);
let entries = projectarchive_sbom(&archive, "plc-target");
let by_name: HashMap<&str, &SbomEntry> =
entries.iter().map(|e| (e.name.as_str(), e)).collect();
// Libraries with their versions.
assert_eq!(
by_name.get("Standard").map(|e| e.version.as_str()),
Some("3.5.18.0")
);
assert_eq!(
by_name.get("Util").map(|e| e.version.as_str()),
Some("3.5.21.0")
);
assert_eq!(
by_name.get("CSV Utility SL").map(|e| e.version.as_str()),
Some("1.9.0.0"),
"multi-word library names must parse"
);
assert!(by_name.contains_key("3SLicense"));
// The runtime, from the device descriptor.
assert_eq!(
by_name
.get("CODESYS Control for Linux ARM SL")
.map(|e| e.version.as_str()),
Some("4.17.0.0")
);
// Every component is CODESYS-tagged with a purl the CVE pipeline can match,
// and the native `.project` / compileinfo are not mistaken for components.
for e in &entries {
assert_eq!(e.package_manager, "codesys");
assert!(e.purl.as_deref().unwrap_or("").starts_with("pkg:codesys/"));
}
assert!(!by_name.contains_key("App"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn collect_sbom_finds_a_projectarchive_committed_in_a_git_tree() {
let tmp = std::env::temp_dir().join(format!("cs-plc-collect-{}", uuid::Uuid::new_v4()));
let src = tmp.join("clone/src");
std::fs::create_dir_all(&src).expect("mkdir");
// Simulate a git clone that commits the archive alongside its exports.
write_synthetic_archive(&src.join("PumpStation.projectarchive"));
// The artifact "file" is a git URL (not a real file), so the SBOM must
// come from walking the cloned tree.
let entries = collect_sbom(Path::new("https://git.example/plc.git"), &tmp, "t");
let names: std::collections::HashSet<&str> =
entries.iter().map(|e| e.name.as_str()).collect();
assert!(
names.contains("Standard"),
"found libs in the committed archive"
);
assert!(names.contains("CODESYS Control for Linux ARM SL"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn non_zip_file_yields_no_sbom() {
let tmp = std::env::temp_dir().join(format!("cs-plc-sbom-st-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&tmp).expect("mkdir");
let st = tmp.join("prog.st");
std::fs::write(&st, "PROGRAM P\nVAR x : INT; END_VAR\nEND_PROGRAM\n").expect("write");
assert!(projectarchive_sbom(&st, "t").is_empty());
let _ = std::fs::remove_dir_all(&tmp);
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
use crate::pipeline::repo_view::RepoView;
use compliance_core::models::*;
use super::dedup::compute_fingerprint;
@@ -14,7 +15,7 @@ impl PipelineOrchestrator {
#[tracing::instrument(skip_all, fields(repo_id = %repo_id, pr_number))]
pub async fn run_pr_review(
&self,
repo: &TrackedRepository,
repo: &RepoView,
repo_id: &str,
pr_number: u64,
base_sha: &str,
@@ -0,0 +1,74 @@
//! `RepoView` — an internal, non-persisted view of a code target for the scan
//! pipeline.
//!
//! It replaces the old persisted `TrackedRepository` model. The pipeline
//! (SAST → SBOM → CVE → triage → issues → DAST, and PR review) only ever needs a
//! flat bundle of git + issue-tracker + auth fields; those are projected from an
//! [`OnboardedTarget`] and its code [`Artifact`] by [`RepoView::from_target`].
//! Nothing here is written to Mongo — onboarded targets are the sole persisted
//! entity.
use compliance_core::models::{Artifact, OnboardedTarget, TrackerType};
/// A flat, pipeline-facing view of a code target. Built from an onboarded
/// target; never persisted.
#[derive(Debug, Clone)]
pub struct RepoView {
/// The onboarded target's id (used as `repo_id` across findings/sbom/etc.).
pub id: Option<mongodb::bson::oid::ObjectId>,
pub name: String,
pub git_url: String,
pub default_branch: String,
pub local_path: Option<String>,
pub scan_schedule: Option<String>,
pub webhook_enabled: bool,
pub webhook_secret: Option<String>,
pub tracker_type: Option<TrackerType>,
pub tracker_owner: Option<String>,
pub tracker_repo: Option<String>,
pub tracker_token: Option<String>,
pub auth_token: Option<String>,
pub auth_username: Option<String>,
pub last_scanned_commit: Option<String>,
pub findings_count: u32,
}
impl RepoView {
/// Project an onboarded target + its code artifact into a pipeline view.
pub fn from_target(target: &OnboardedTarget, code: &Artifact) -> Self {
let mut view = Self {
id: target.id,
name: target.name.clone(),
git_url: code.source_ref.clone(),
default_branch: "main".to_string(),
local_path: None,
scan_schedule: target.scan_schedule.clone(),
webhook_enabled: target.webhook_enabled,
webhook_secret: target.webhook_secret.clone(),
tracker_type: None,
tracker_owner: None,
tracker_repo: None,
tracker_token: None,
auth_token: None,
auth_username: None,
last_scanned_commit: None,
findings_count: target.findings_count,
};
if let Some(git) = &code.git {
view.default_branch = git.default_branch.clone();
view.last_scanned_commit = git.last_scanned_commit.clone();
view.local_path = git.local_path.clone();
}
if let Some(auth) = &code.auth {
view.auth_token = auth.secret.clone();
view.auth_username = auth.username.clone();
}
if let Some(it) = &target.scan_config.issue_tracker {
view.tracker_type = it.tracker_type.clone();
view.tracker_owner = it.owner.clone();
view.tracker_repo = it.repo.clone();
view.tracker_token = it.token.clone();
}
view
}
}
+20 -14
View File
@@ -5,20 +5,26 @@ use compliance_core::CoreError;
#[tracing::instrument(skip_all, fields(repo_id = %repo_id))]
pub(super) async fn run_syft(repo_path: &Path, repo_id: &str) -> Result<Vec<SbomEntry>, CoreError> {
let output = tokio::process::Command::new("syft")
.arg(repo_path)
.args(["-o", "cyclonedx-json"])
// Enable remote license lookups for all ecosystems
.env("SYFT_GOLANG_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_JAVASCRIPT_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_PYTHON_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_JAVA_USE_NETWORK", "true")
.output()
.await
.map_err(|e| CoreError::Scanner {
scanner: "syft".to_string(),
source: Box::new(e),
})?;
let output = tokio::time::timeout(
std::time::Duration::from_secs(300),
tokio::process::Command::new("syft")
.arg(repo_path)
.args(["-o", "cyclonedx-json"])
.env("SYFT_GOLANG_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_JAVASCRIPT_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_PYTHON_SEARCH_REMOTE_LICENSES", "true")
.env("SYFT_JAVA_USE_NETWORK", "true")
.output(),
)
.await
.map_err(|_| CoreError::Scanner {
scanner: "syft".to_string(),
source: "timed out after 5 minutes".into(),
})?
.map_err(|e| CoreError::Scanner {
scanner: "syft".to_string(),
source: Box::new(e),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
+65 -9
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use compliance_core::models::{Finding, ScanType, Severity};
use compliance_core::traits::{ScanOutput, Scanner};
@@ -6,6 +6,30 @@ use compliance_core::CoreError;
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;
impl Scanner for SemgrepScanner {
@@ -19,11 +43,22 @@ impl Scanner for SemgrepScanner {
#[tracing::instrument(skip_all)]
async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
let output = tokio::process::Command::new("semgrep")
.args(["--config=auto", "--json", "--quiet"])
.arg(repo_path)
.output()
let cra_rules = stage_cra_rules().await;
let mut command = tokio::process::Command::new("semgrep");
command.arg("--config=auto");
if let Some(path) = &cra_rules {
command.arg(format!("--config={}", path.display()));
}
command
.args(["--json", "--quiet", "--max-memory", "500", "--jobs", "1"])
.arg(repo_path);
let output = tokio::time::timeout(std::time::Duration::from_secs(600), command.output())
.await
.map_err(|_| CoreError::Scanner {
scanner: "semgrep".to_string(),
source: "timed out after 10 minutes".into(),
})?
.map_err(|e| CoreError::Scanner {
scanner: "semgrep".to_string(),
source: Box::new(e),
@@ -67,10 +102,7 @@ impl Scanner for SemgrepScanner {
finding.file_path = Some(r.path);
finding.line_number = Some(r.start.line);
finding.code_snippet = Some(r.extra.lines);
finding.cwe = r
.extra
.metadata
.and_then(|m| m.get("cwe").and_then(|v| v.as_str()).map(|s| s.to_string()));
finding.cwe = r.extra.metadata.as_ref().and_then(extract_cwe);
finding
})
.collect();
@@ -109,10 +141,34 @@ struct SemgrepExtra {
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)]
mod tests {
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]
fn deserialize_semgrep_output() {
let json = r#"{
+87 -19
View File
@@ -6,11 +6,16 @@ use compliance_core::models::embedding::{CodeEmbedding, EmbeddingBuildRun, Embed
use compliance_core::models::graph::CodeNode;
use compliance_graph::graph::chunking::extract_chunks;
use compliance_graph::graph::embedding_store::EmbeddingStore;
use futures_util::stream::{FuturesUnordered, StreamExt};
use tracing::{error, info};
use crate::error::AgentError;
use crate::llm::LlmClient;
const EMBED_BATCH_SIZE: usize = 20;
const EMBED_CONCURRENCY: usize = 4;
const EMBED_FLUSH_EVERY: usize = 200;
/// RAG pipeline for building embeddings and performing retrieval
pub struct RagPipeline {
llm: Arc<LlmClient>,
@@ -77,25 +82,33 @@ impl RagPipeline {
.await
.map_err(|e| AgentError::Other(format!("Failed to delete old embeddings: {e}")))?;
// Step 3: Batch embed (small batches to stay within model limits)
let batch_size = 20;
let mut all_embeddings = Vec::new();
// Step 3: Batch embed with bounded concurrency. Flush to Mongo and
// update progress periodically so the dashboard can show live status.
let mut pending = Vec::with_capacity(EMBED_FLUSH_EVERY);
let mut embedded_count = 0u32;
for batch_start in (0..chunks.len()).step_by(batch_size) {
let batch_end = (batch_start + batch_size).min(chunks.len());
let batch_chunks = &chunks[batch_start..batch_end];
// Build the list of batch indices to process.
let batches: Vec<(usize, usize)> = (0..chunks.len())
.step_by(EMBED_BATCH_SIZE)
.map(|start| (start, (start + EMBED_BATCH_SIZE).min(chunks.len())))
.collect();
// Prepare texts: context_header + content
let texts: Vec<String> = batch_chunks
.iter()
.map(|c| format!("{}\n{}", c.context_header, c.content))
.collect();
let mut batch_iter = batches.into_iter();
let mut in_flight = FuturesUnordered::new();
match self.llm.embed(texts).await {
Ok(vectors) => {
// Prime up to EMBED_CONCURRENCY batches.
for _ in 0..EMBED_CONCURRENCY {
if let Some((start, end)) = batch_iter.next() {
in_flight.push(self.embed_batch(&chunks[start..end], start, end));
}
}
while let Some(result) = in_flight.next().await {
match result {
Ok((start, end, vectors)) => {
let batch_chunks = &chunks[start..end];
for (chunk, embedding) in batch_chunks.iter().zip(vectors) {
all_embeddings.push(CodeEmbedding {
pending.push(CodeEmbedding {
id: None,
repo_id: repo_id.to_string(),
graph_build_id: graph_build_id.to_string(),
@@ -113,9 +126,45 @@ impl RagPipeline {
});
}
embedded_count += batch_chunks.len() as u32;
// Flush pending embeddings to Mongo periodically and update progress.
if pending.len() >= EMBED_FLUSH_EVERY {
self.embedding_store
.store_embeddings(&pending)
.await
.map_err(|e| {
AgentError::Other(format!("Failed to store embeddings: {e}"))
})?;
pending.clear();
}
// Always update the progress counter on the build doc — even if
// we haven't flushed embeddings yet — so the UI shows movement.
if let Err(e) = self
.embedding_store
.update_build(
repo_id,
graph_build_id,
EmbeddingBuildStatus::Running,
embedded_count,
None,
)
.await
{
error!("[{repo_id}] Failed to update build progress: {e}");
}
// Queue the next batch to keep concurrency saturated.
if let Some((s, e)) = batch_iter.next() {
in_flight.push(self.embed_batch(&chunks[s..e], s, e));
}
}
Err(e) => {
error!("[{repo_id}] Embedding batch failed: {e}");
// Flush whatever we have so partial progress isn't lost.
if !pending.is_empty() {
let _ = self.embedding_store.store_embeddings(&pending).await;
}
build.status = EmbeddingBuildStatus::Failed;
build.error_message = Some(e.to_string());
build.completed_at = Some(Utc::now());
@@ -134,11 +183,13 @@ impl RagPipeline {
}
}
// Step 4: Store all embeddings
self.embedding_store
.store_embeddings(&all_embeddings)
.await
.map_err(|e| AgentError::Other(format!("Failed to store embeddings: {e}")))?;
// Step 4: Flush any remaining embeddings
if !pending.is_empty() {
self.embedding_store
.store_embeddings(&pending)
.await
.map_err(|e| AgentError::Other(format!("Failed to store embeddings: {e}")))?;
}
// Step 5: Update build status
build.status = EmbeddingBuildStatus::Completed;
@@ -161,4 +212,21 @@ impl RagPipeline {
);
Ok(build)
}
/// Embed one batch of chunks. Returns the (start, end, vectors) tuple so
/// out-of-order completion from `FuturesUnordered` can still be reconciled
/// against the original chunk slice.
async fn embed_batch(
&self,
batch_chunks: &[compliance_graph::graph::chunking::CodeChunk],
start: usize,
end: usize,
) -> Result<(usize, usize, Vec<Vec<f64>>), AgentError> {
let texts: Vec<String> = batch_chunks
.iter()
.map(|c| format!("{}\n{}", c.context_header, c.content))
.collect();
let vectors = self.llm.embed(texts).await?;
Ok((start, end, vectors))
}
}
+394 -25
View File
@@ -4,8 +4,21 @@ use tokio_cron_scheduler::{Job, JobScheduler};
use compliance_core::models::ScanTrigger;
use crate::agent::ComplianceAgent;
use crate::database::Database;
use crate::error::AgentError;
/// Default tenant the scheduler runs against when neither the tenant
/// registry nor `SCHEDULER_TENANT_IDS` are configured. Matches the
/// dev-injector default so a bare `cargo run` has the scheduler
/// scanning whatever lives in `<prefix>_dev`.
const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev";
/// Request timeout when fetching the live tenant list from the
/// registry. Kept short — if the registry is slow we'd rather fall
/// back to env-configured ids and finish the tick than block the
/// scheduler loop.
const REGISTRY_FETCH_TIMEOUT_SECS: u64 = 5;
pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> {
let sched = JobScheduler::new()
.await
@@ -18,7 +31,14 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
let agent = scan_agent.clone();
Box::pin(async move {
tracing::info!("Scheduled scan triggered");
scan_all_repos(&agent).await;
let tenants = scheduler_tenants(&agent).await;
tracing::debug!(
tenant_count = tenants.len(),
"Scheduled scan: tenants resolved"
);
for tenant_id in tenants {
scan_all_repos(&agent, &tenant_id).await;
}
})
})
.map_err(|e| AgentError::Scheduler(format!("Failed to create scan job: {e}")))?;
@@ -34,7 +54,14 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
let agent = cve_agent.clone();
Box::pin(async move {
tracing::info!("CVE monitor triggered");
monitor_cves(&agent).await;
let tenants = scheduler_tenants(&agent).await;
tracing::debug!(
tenant_count = tenants.len(),
"CVE monitor: tenants resolved"
);
for tenant_id in tenants {
monitor_cves(&agent, &tenant_id).await;
}
})
})
.map_err(|e| AgentError::Scheduler(format!("Failed to create CVE monitor job: {e}")))?;
@@ -48,8 +75,14 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
.await
.map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?;
let tenants = scheduler_tenants(agent).await;
let source = if agent.config.tenant_registry_url.is_some() {
"tenant-registry (env fallback)"
} else {
"env (SCHEDULER_TENANT_IDS)"
};
tracing::info!(
"Scheduler started: scans='{}', CVE monitor='{}'",
"Scheduler started: scans='{}', CVE monitor='{}', tenant source={source}, tenants={tenants:?}",
agent.config.scan_schedule,
agent.config.cve_monitor_schedule,
);
@@ -60,46 +93,382 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
}
}
async fn scan_all_repos(agent: &ComplianceAgent) {
use futures_util::StreamExt;
let cursor = match agent.db.repositories().find(doc! {}).await {
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to list repos for scheduled scan: {e}");
return;
/// Tenants the scheduler iterates each tick.
///
/// Resolution order:
/// 1. **Tenant registry** at `agent.config.tenant_registry_url`
/// (`GET /v1/tenants`). Fresh on every tick — picks up newly
/// provisioned tenants without an agent restart.
/// 2. **`SCHEDULER_TENANT_IDS`** env (comma-separated) — fallback when
/// the registry is unreachable, the response is malformed, or no
/// registry URL is configured.
/// 3. **`DEFAULT_SCHEDULER_TENANT_ID`** (`"dev"`) — last-ditch fallback
/// so the scheduler keeps doing something useful in dev.
///
/// We never panic out of this function — the scheduler must keep
/// firing even if the registry is offline.
async fn scheduler_tenants(agent: &ComplianceAgent) -> Vec<String> {
if let Some(url) = agent.config.tenant_registry_url.as_deref() {
match fetch_tenants_from_registry(&agent.http, url).await {
Ok(v) if !v.is_empty() => return v,
Ok(_) => {
tracing::warn!("tenant-registry returned empty list; falling back to env");
}
Err(e) => {
tracing::warn!(
url = %url,
error = %e,
"tenant-registry fetch failed; falling back to env"
);
}
}
};
}
tenants_from_env()
}
let repos: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await;
fn tenants_from_env() -> Vec<String> {
std::env::var("SCHEDULER_TENANT_IDS")
.ok()
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
.unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()])
}
for repo in repos {
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
if let Err(e) = agent.run_scan(&repo_id, ScanTrigger::Scheduled).await {
tracing::error!("Scheduled scan failed for {}: {e}", repo.name);
/// Shape we accept from the registry. Liberal in what we accept:
/// the registry can return any field shape as long as either `id` or
/// `tenant_id` is present. Other fields are ignored.
#[derive(serde::Deserialize)]
struct RegistryTenant {
#[serde(alias = "tenant_id")]
id: String,
/// Filter out non-running tenants if status is present. Missing
/// status defaults to "active" so older registry deployments keep
/// working.
#[serde(default = "default_status")]
status: String,
}
fn default_status() -> String {
"active".to_string()
}
#[derive(serde::Deserialize)]
struct RegistryListResponse {
data: Vec<RegistryTenant>,
}
async fn fetch_tenants_from_registry(
http: &reqwest::Client,
base_url: &str,
) -> Result<Vec<String>, String> {
let url = format!("{}/v1/tenants", base_url.trim_end_matches('/'));
let resp = http
.get(&url)
.timeout(std::time::Duration::from_secs(REGISTRY_FETCH_TIMEOUT_SECS))
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("registry returned {}", resp.status()));
}
let body: RegistryListResponse = resp
.json()
.await
.map_err(|e| format!("invalid JSON: {e}"))?;
Ok(filter_active(body.data))
}
/// Frozen/Archived tenants don't need scheduled scans; the M7.1
/// status gate would 402/410 anyway. Skip them so we don't waste
/// cycles. Active / trial / demo / anything-else-unknown all run.
fn filter_active(rows: Vec<RegistryTenant>) -> Vec<String> {
rows.into_iter()
.filter(|t| !matches!(t.status.as_str(), "frozen" | "archived"))
.map(|t| t.id)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn tenant(id: &str, status: &str) -> RegistryTenant {
RegistryTenant {
id: id.to_string(),
status: status.to_string(),
}
}
#[test]
fn filter_active_keeps_running_skips_frozen_archived() {
let rows = vec![
tenant("a", "active"),
tenant("b", "trial"),
tenant("c", "demo"),
tenant("d", "frozen"),
tenant("e", "archived"),
tenant("f", "weird-but-not-known-dead"),
];
let out = filter_active(rows);
assert_eq!(out, vec!["a", "b", "c", "f"]);
}
#[test]
fn deserialize_registry_response_accepts_id_or_tenant_id() {
let body = r#"{"data":[
{"id":"a","status":"active"},
{"tenant_id":"b","status":"trial"},
{"id":"c"}
]}"#;
let parsed: RegistryListResponse = serde_json::from_str(body).unwrap();
assert_eq!(parsed.data.len(), 3);
assert_eq!(parsed.data[0].id, "a");
assert_eq!(parsed.data[1].id, "b");
assert_eq!(parsed.data[2].id, "c");
// Default status for the third entry should be "active"
assert_eq!(parsed.data[2].status, "active");
}
/// Combined into a single test: cargo runs tests in parallel and
/// env vars are process-global, so two separate tests touching
/// `SCHEDULER_TENANT_IDS` race each other. Doing both checks in
/// one test keeps them in a deterministic order.
#[test]
fn tenants_from_env_resolution() {
std::env::remove_var("SCHEDULER_TENANT_IDS");
assert_eq!(
tenants_from_env(),
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
"unset → default"
);
std::env::set_var("SCHEDULER_TENANT_IDS", "acme, globex ,,hello");
let out = tenants_from_env();
std::env::remove_var("SCHEDULER_TENANT_IDS");
assert_eq!(
out,
vec!["acme", "globex", "hello"],
"splits + trims + drops empty"
);
std::env::set_var("SCHEDULER_TENANT_IDS", "");
let out = tenants_from_env();
std::env::remove_var("SCHEDULER_TENANT_IDS");
assert_eq!(
out,
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
"empty → default"
);
}
}
/// Resolve the per-tenant database. Logs and returns `None` on failure
/// so the loop in the caller can continue with other tenants.
async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option<Database> {
match agent.db_pool.for_tenant_id(tenant_id).await {
Ok(db) => Some(db),
Err(e) => {
tracing::error!("Scheduler: cannot open tenant database '{tenant_id}': {e}");
None
}
}
}
async fn monitor_cves(agent: &ComplianceAgent) {
async fn scan_all_repos(agent: &ComplianceAgent, tenant_id: &str) {
use futures_util::StreamExt;
// Re-scan all SBOM entries for new CVEs
let cursor = match agent.db.sbom_entries().find(doc! {}).await {
let db = match tenant_db(agent, tenant_id).await {
Some(db) => db,
None => return,
};
let cursor = match db.onboarded_targets().find(doc! {}).await {
Ok(c) => c,
Err(e) => {
tracing::error!("Failed to list SBOM entries for CVE monitoring: {e}");
tracing::error!("Failed to list targets for tenant '{tenant_id}': {e}");
return;
}
};
let entries: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await;
let targets: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await;
for target in targets {
let target_id = target.id.map(|id| id.to_hex()).unwrap_or_default();
if let Err(e) = agent
.run_target_scan(tenant_id, &target_id, ScanTrigger::Scheduled)
.await
{
tracing::error!(
"Scheduled scan failed for {} (tenant '{tenant_id}'): {e}",
target.name
);
}
}
}
async fn monitor_cves(agent: &ComplianceAgent, tenant_id: &str) {
use compliance_core::models::notification::{parse_severity, CveNotification};
use compliance_core::models::SbomEntry;
use futures_util::StreamExt;
let db = match tenant_db(agent, tenant_id).await {
Some(db) => db,
None => return,
};
// Fetch all SBOM entries grouped by repo
let cursor = match db.sbom_entries().find(doc! {}).await {
Ok(c) => c,
Err(e) => {
tracing::error!("CVE monitor: failed to list SBOM entries for '{tenant_id}': {e}");
return;
}
};
let entries: Vec<SbomEntry> = cursor.filter_map(|r| async { r.ok() }).collect().await;
if entries.is_empty() {
tracing::debug!("CVE monitor: no SBOM entries for tenant '{tenant_id}', skipping");
return;
}
tracing::info!("CVE monitor: checking {} dependencies", entries.len());
// The actual CVE checking is handled by the CveScanner in the pipeline
// This is a simplified version that just logs the activity
tracing::info!(
"CVE monitor: checking {} dependencies for new CVEs (tenant '{tenant_id}')",
entries.len()
);
// Build a repo_id → repo_name lookup
let repo_ids: std::collections::HashSet<String> =
entries.iter().map(|e| e.repo_id.clone()).collect();
let mut repo_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for rid in &repo_ids {
if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(rid) {
if let Ok(Some(repo)) = db.onboarded_targets().find_one(doc! { "_id": oid }).await {
repo_names.insert(rid.clone(), repo.name.clone());
}
}
}
// Use the existing CveScanner to query OSV.dev
let nvd_key = agent.config.nvd_api_key.as_ref().map(|k| {
use secrecy::ExposeSecret;
k.expose_secret().to_string()
});
let scanner = crate::pipeline::cve::CveScanner::new(
agent.http.clone(),
agent.config.searxng_url.clone(),
nvd_key,
);
// Group entries by repo for scanning
let mut entries_by_repo: std::collections::HashMap<String, Vec<SbomEntry>> =
std::collections::HashMap::new();
for entry in entries {
entries_by_repo
.entry(entry.repo_id.clone())
.or_default()
.push(entry);
}
let mut new_notifications = 0u32;
for (repo_id, mut repo_entries) in entries_by_repo {
let repo_name = repo_names
.get(&repo_id)
.cloned()
.unwrap_or_else(|| repo_id.clone());
// Scan dependencies for CVEs
let alerts = match scanner.scan_dependencies(&repo_id, &mut repo_entries).await {
Ok(a) => a,
Err(e) => {
tracing::warn!("CVE monitor: scan failed for {repo_name}: {e}");
continue;
}
};
// Upsert CVE alerts (existing logic)
for alert in &alerts {
let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id };
let update = doc! { "$setOnInsert": mongodb::bson::to_bson(alert).unwrap_or_default() };
let _ = db
.cve_alerts()
.update_one(filter, update)
.upsert(true)
.await;
}
// Update SBOM entries with discovered vulnerabilities
for entry in &repo_entries {
if entry.known_vulnerabilities.is_empty() {
continue;
}
if let Some(entry_id) = &entry.id {
let _ = db
.sbom_entries()
.update_one(
doc! { "_id": entry_id },
doc! { "$set": {
"known_vulnerabilities": mongodb::bson::to_bson(&entry.known_vulnerabilities).unwrap_or_default(),
"updated_at": mongodb::bson::DateTime::now(),
}},
)
.await;
}
}
// Create notifications for NEW CVEs (dedup against existing notifications)
for alert in &alerts {
let filter = doc! {
"cve_id": &alert.cve_id,
"repo_id": &alert.repo_id,
"package_name": &alert.affected_package,
"package_version": &alert.affected_version,
};
// Only insert if not already exists (upsert with $setOnInsert)
let severity = parse_severity(alert.severity.as_deref(), alert.cvss_score);
let mut notification = CveNotification::new(
alert.cve_id.clone(),
repo_id.clone(),
repo_name.clone(),
alert.affected_package.clone(),
alert.affected_version.clone(),
severity,
);
notification.cvss_score = alert.cvss_score;
notification.summary = alert.summary.clone();
notification.url = Some(format!("https://osv.dev/vulnerability/{}", alert.cve_id));
let update = doc! {
"$setOnInsert": mongodb::bson::to_bson(&notification).unwrap_or_default()
};
match db
.cve_notifications()
.update_one(filter, update)
.upsert(true)
.await
{
Ok(result) if result.upserted_id.is_some() => {
new_notifications += 1;
}
Err(e) => {
tracing::warn!("CVE monitor: failed to create notification: {e}");
}
_ => {} // Already exists
}
}
}
if new_notifications > 0 {
tracing::info!(
"CVE monitor: created {new_notifications} new notification(s) for tenant '{tenant_id}'"
);
} else {
tracing::info!("CVE monitor: no new CVEs found for tenant '{tenant_id}'");
}
}
+24 -10
View File
@@ -14,24 +14,30 @@ type HmacSha256 = Hmac<Sha256>;
pub async fn handle_gitea_webhook(
Extension(agent): Extension<Arc<ComplianceAgent>>,
Path(repo_id): Path<String>,
Path((tenant_id, repo_id)): Path<(String, String)>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
// Look up the repo to get its webhook secret
// Look up the repo in the tenant's database to get its webhook secret
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
Ok(oid) => oid,
Err(_) => return StatusCode::NOT_FOUND,
};
let repo = match agent
.db
.repositories()
let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
Ok(db) => db,
Err(e) => {
tracing::warn!("Gitea webhook: cannot open tenant database '{tenant_id}': {e}");
return StatusCode::NOT_FOUND;
}
};
let repo = match db
.onboarded_targets()
.find_one(mongodb::bson::doc! { "_id": oid })
.await
{
Ok(Some(repo)) => repo,
_ => {
tracing::warn!("Gitea webhook: repo {repo_id} not found");
tracing::warn!("Gitea webhook: repo {repo_id} not found in tenant '{tenant_id}'");
return StatusCode::NOT_FOUND;
}
};
@@ -66,15 +72,21 @@ pub async fn handle_gitea_webhook(
"push" => {
let agent_clone = (*agent).clone();
let repo_id = repo_id.clone();
let tenant_id = tenant_id.clone();
tokio::spawn(async move {
tracing::info!("Gitea push webhook: triggering scan for {repo_id}");
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
tracing::info!(
"Gitea push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
);
if let Err(e) = agent_clone
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
.await
{
tracing::error!("Webhook-triggered scan failed: {e}");
}
});
StatusCode::OK
}
"pull_request" => handle_pull_request(agent, &repo_id, &payload).await,
"pull_request" => handle_pull_request(agent, &tenant_id, &repo_id, &payload).await,
_ => {
tracing::debug!("Gitea webhook: ignoring event '{event}'");
StatusCode::OK
@@ -84,6 +96,7 @@ pub async fn handle_gitea_webhook(
async fn handle_pull_request(
agent: Arc<ComplianceAgent>,
tenant_id: &str,
repo_id: &str,
payload: &serde_json::Value,
) -> StatusCode {
@@ -106,13 +119,14 @@ async fn handle_pull_request(
}
let repo_id = repo_id.to_string();
let tenant_id = tenant_id.to_string();
let head_sha = head_sha.to_string();
let base_sha = base_sha.to_string();
let agent_clone = (*agent).clone();
tokio::spawn(async move {
tracing::info!("Gitea PR webhook: reviewing PR #{pr_number} on {repo_id}");
if let Err(e) = agent_clone
.run_pr_review(&repo_id, pr_number, &base_sha, &head_sha)
.run_pr_review(&tenant_id, &repo_id, pr_number, &base_sha, &head_sha)
.await
{
tracing::error!("PR review failed for #{pr_number}: {e}");
+24 -10
View File
@@ -14,24 +14,30 @@ type HmacSha256 = Hmac<Sha256>;
pub async fn handle_github_webhook(
Extension(agent): Extension<Arc<ComplianceAgent>>,
Path(repo_id): Path<String>,
Path((tenant_id, repo_id)): Path<(String, String)>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
// Look up the repo to get its webhook secret
// Look up the repo in the tenant's database to get its webhook secret
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
Ok(oid) => oid,
Err(_) => return StatusCode::NOT_FOUND,
};
let repo = match agent
.db
.repositories()
let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
Ok(db) => db,
Err(e) => {
tracing::warn!("GitHub webhook: cannot open tenant database '{tenant_id}': {e}");
return StatusCode::NOT_FOUND;
}
};
let repo = match db
.onboarded_targets()
.find_one(mongodb::bson::doc! { "_id": oid })
.await
{
Ok(Some(repo)) => repo,
_ => {
tracing::warn!("GitHub webhook: repo {repo_id} not found");
tracing::warn!("GitHub webhook: repo {repo_id} not found in tenant '{tenant_id}'");
return StatusCode::NOT_FOUND;
}
};
@@ -66,15 +72,21 @@ pub async fn handle_github_webhook(
"push" => {
let agent_clone = (*agent).clone();
let repo_id = repo_id.clone();
let tenant_id = tenant_id.clone();
tokio::spawn(async move {
tracing::info!("GitHub push webhook: triggering scan for {repo_id}");
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
tracing::info!(
"GitHub push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
);
if let Err(e) = agent_clone
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
.await
{
tracing::error!("Webhook-triggered scan failed: {e}");
}
});
StatusCode::OK
}
"pull_request" => handle_pull_request(agent, &repo_id, &payload).await,
"pull_request" => handle_pull_request(agent, &tenant_id, &repo_id, &payload).await,
_ => {
tracing::debug!("GitHub webhook: ignoring event '{event}'");
StatusCode::OK
@@ -84,6 +96,7 @@ pub async fn handle_github_webhook(
async fn handle_pull_request(
agent: Arc<ComplianceAgent>,
tenant_id: &str,
repo_id: &str,
payload: &serde_json::Value,
) -> StatusCode {
@@ -105,13 +118,14 @@ async fn handle_pull_request(
}
let repo_id = repo_id.to_string();
let tenant_id = tenant_id.to_string();
let head_sha = head_sha.to_string();
let base_sha = base_sha.to_string();
let agent_clone = (*agent).clone();
tokio::spawn(async move {
tracing::info!("GitHub PR webhook: reviewing PR #{pr_number} on {repo_id}");
if let Err(e) = agent_clone
.run_pr_review(&repo_id, pr_number, &base_sha, &head_sha)
.run_pr_review(&tenant_id, &repo_id, pr_number, &base_sha, &head_sha)
.await
{
tracing::error!("PR review failed for #{pr_number}: {e}");
+24 -10
View File
@@ -10,24 +10,30 @@ use crate::agent::ComplianceAgent;
pub async fn handle_gitlab_webhook(
Extension(agent): Extension<Arc<ComplianceAgent>>,
Path(repo_id): Path<String>,
Path((tenant_id, repo_id)): Path<(String, String)>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
// Look up the repo to get its webhook secret
// Look up the repo in the tenant's database to get its webhook secret
let oid = match mongodb::bson::oid::ObjectId::parse_str(&repo_id) {
Ok(oid) => oid,
Err(_) => return StatusCode::NOT_FOUND,
};
let repo = match agent
.db
.repositories()
let db = match agent.db_pool.for_tenant_id(&tenant_id).await {
Ok(db) => db,
Err(e) => {
tracing::warn!("GitLab webhook: cannot open tenant database '{tenant_id}': {e}");
return StatusCode::NOT_FOUND;
}
};
let repo = match db
.onboarded_targets()
.find_one(mongodb::bson::doc! { "_id": oid })
.await
{
Ok(Some(repo)) => repo,
_ => {
tracing::warn!("GitLab webhook: repo {repo_id} not found");
tracing::warn!("GitLab webhook: repo {repo_id} not found in tenant '{tenant_id}'");
return StatusCode::NOT_FOUND;
}
};
@@ -59,15 +65,21 @@ pub async fn handle_gitlab_webhook(
"push" => {
let agent_clone = (*agent).clone();
let repo_id = repo_id.clone();
let tenant_id = tenant_id.clone();
tokio::spawn(async move {
tracing::info!("GitLab push webhook: triggering scan for {repo_id}");
if let Err(e) = agent_clone.run_scan(&repo_id, ScanTrigger::Webhook).await {
tracing::info!(
"GitLab push webhook: triggering scan for {repo_id} in tenant {tenant_id}"
);
if let Err(e) = agent_clone
.run_scan(&tenant_id, &repo_id, ScanTrigger::Webhook)
.await
{
tracing::error!("Webhook-triggered scan failed: {e}");
}
});
StatusCode::OK
}
"merge_request" => handle_merge_request(agent, &repo_id, &payload).await,
"merge_request" => handle_merge_request(agent, &tenant_id, &repo_id, &payload).await,
_ => {
tracing::debug!("GitLab webhook: ignoring event '{event_type}'");
StatusCode::OK
@@ -77,6 +89,7 @@ pub async fn handle_gitlab_webhook(
async fn handle_merge_request(
agent: Arc<ComplianceAgent>,
tenant_id: &str,
repo_id: &str,
payload: &serde_json::Value,
) -> StatusCode {
@@ -101,13 +114,14 @@ async fn handle_merge_request(
}
let repo_id = repo_id.to_string();
let tenant_id = tenant_id.to_string();
let head_sha = head_sha.to_string();
let base_sha = base_sha.to_string();
let agent_clone = (*agent).clone();
tokio::spawn(async move {
tracing::info!("GitLab MR webhook: reviewing MR !{mr_iid} on {repo_id}");
if let Err(e) = agent_clone
.run_pr_review(&repo_id, mr_iid, &base_sha, &head_sha)
.run_pr_review(&tenant_id, &repo_id, mr_iid, &base_sha, &head_sha)
.await
{
tracing::error!("MR review failed for !{mr_iid}: {e}");
+8 -4
View File
@@ -9,17 +9,21 @@ use crate::webhooks::{gitea, github, gitlab};
pub async fn start_webhook_server(agent: &ComplianceAgent) -> Result<(), AgentError> {
let app = Router::new()
// Per-repo webhook URLs: /webhook/{platform}/{repo_id}
// Per-tenant per-repo webhook URLs: /webhook/{tenant_id}/{platform}/{repo_id}
// The tenant_id is resolved from the URL path because webhooks
// arrive without a JWT — they're authenticated via per-repo HMAC,
// not via the tenant gate. The dashboard surfaces the full URL
// including the tenant_id when the repo is registered.
.route(
"/webhook/github/{repo_id}",
"/webhook/{tenant_id}/github/{repo_id}",
post(github::handle_github_webhook),
)
.route(
"/webhook/gitlab/{repo_id}",
"/webhook/{tenant_id}/gitlab/{repo_id}",
post(gitlab::handle_gitlab_webhook),
)
.route(
"/webhook/gitea/{repo_id}",
"/webhook/{tenant_id}/gitea/{repo_id}",
post(gitea::handle_gitea_webhook),
)
.layer(Extension(Arc::new(agent.clone())));
+10
View File
@@ -0,0 +1,10 @@
//! Werkbank control-plane: the dynamic-execution job queue.
//!
//! The control plane enqueues declarative [`Job`](compliance_core::models::werkbank::Job)s
//! and Werkbank runners lease, run, and complete them. [`queue::JobQueue`] is the
//! Mongo-backed queue behind that flow (WB-02); the runner-facing HTTP transport
//! and the runner itself land in later stories.
pub mod queue;
pub use queue::{JobQueue, SweepOutcome};
+309
View File
@@ -0,0 +1,309 @@
//! The Mongo-backed Werkbank job queue (WB-02).
//!
//! A pull queue: the control plane [`enqueue`](JobQueue::enqueue)s jobs; a runner
//! [`lease`](JobQueue::lease)s the oldest queued job it can run (matched by
//! executor + labels), [`heartbeat`](JobQueue::heartbeat)s while it works, and
//! [`complete`](JobQueue::complete)s it. Leases carry a visibility timeout: if a
//! runner dies mid-job its heartbeats stop, the lease expires, and
//! [`sweep_expired`](JobQueue::sweep_expired) returns the job to `queued` (or
//! `expired` once it has been retried too many times).
//!
//! All state transitions are single atomic Mongo updates guarded by the lease
//! token, so two runners can never both own a job. Every operation takes an
//! explicit `now` so the queue's time-dependent behaviour is deterministically
//! testable.
use std::time::Duration;
use chrono::{DateTime, Utc};
use mongodb::bson::{doc, Bson, DateTime as BsonDateTime};
use mongodb::error::{ErrorKind, WriteFailure};
use mongodb::options::ReturnDocument;
use mongodb::Collection;
use compliance_core::models::werkbank::{
Executor, HeartbeatAck, Job, JobRecord, JobResult, JobStatus, LeasedJob,
};
use crate::database::Database;
use crate::error::AgentError;
/// The non-terminal states a job can be swept or cancelled from.
const ACTIVE_STATES: [&str; 2] = ["leased", "running"];
/// Every terminal state (no further transitions).
const TERMINAL_STATES: [&str; 4] = ["succeeded", "failed", "expired", "cancelled"];
/// What a visibility-timeout sweep did.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SweepOutcome {
/// Expired-lease jobs returned to `queued` for another runner.
pub requeued: u64,
/// Jobs that had exhausted their attempts and were marked `expired`.
pub expired: u64,
}
/// The Mongo-backed job queue.
pub struct JobQueue {
coll: Collection<JobRecord>,
}
impl JobQueue {
/// Build a queue over a tenant database's `werkbank_jobs` collection.
pub fn new(db: &Database) -> Self {
Self {
coll: db.werkbank_jobs(),
}
}
/// Enqueue a job. Idempotent by job id: a job that is already present is a
/// no-op. Returns `true` if this call inserted it, `false` if it existed.
pub async fn enqueue(&self, job: Job, now: DateTime<Utc>) -> Result<bool, AgentError> {
let record = JobRecord::queued(job, now);
match self.coll.insert_one(&record).await {
Ok(_) => Ok(true),
Err(e) if is_duplicate_key(&e) => Ok(false),
Err(e) => Err(e.into()),
}
}
/// Atomically lease the oldest `queued` job this runner can run — matched by
/// executor and by labels (every label the job requires must be one the
/// runner advertises). Returns the job plus a lease token, or `None` if
/// nothing is runnable.
pub async fn lease(
&self,
runner_id: &str,
executor: Executor,
runner_labels: &[String],
lease_ttl: Duration,
now: DateTime<Utc>,
) -> Result<Option<LeasedJob>, AgentError> {
let token = uuid::Uuid::new_v4().to_string();
let expires = bson_dt(now + ttl(lease_ttl));
let executor_bson = mongodb::bson::to_bson(&executor).unwrap_or(Bson::Null);
let filter = doc! {
"status": "queued",
"cancel_requested": { "$ne": true },
"job.executor": executor_bson,
// Every label the job requires must be in the runner's set — i.e. the
// job has no label that is not offered by the runner. Absent/empty
// job labels match any runner.
"job.labels": { "$not": { "$elemMatch": { "$nin": runner_labels.to_vec() } } },
};
let update = doc! {
"$set": {
"status": "leased",
"lease_token": &token,
"leased_by": runner_id,
"lease_expires_at": expires,
"heartbeat_at": bson_dt(now),
"updated_at": bson_dt(now),
},
"$inc": { "attempts": 1 },
};
let record = self
.coll
.find_one_and_update(filter, update)
.sort(doc! { "created_at": 1 }) // FIFO
.return_document(ReturnDocument::After)
.await?;
Ok(record.map(|r| LeasedJob {
job: r.job,
lease_token: token,
}))
}
/// Extend a lease and report whether the job has been asked to cancel.
/// Transitions the job to `running` on the first heartbeat. Returns `None`
/// when the lease is no longer valid (token mismatch, or the job is already
/// terminal) — the runner should then abandon the work.
pub async fn heartbeat(
&self,
job_id: &str,
lease_token: &str,
lease_ttl: Duration,
now: DateTime<Utc>,
) -> Result<Option<HeartbeatAck>, AgentError> {
let filter = doc! {
"job.id": job_id,
"lease_token": lease_token,
"status": { "$in": ACTIVE_STATES.to_vec() },
};
let update = doc! {
"$set": {
"status": "running",
"lease_expires_at": bson_dt(now + ttl(lease_ttl)),
"heartbeat_at": bson_dt(now),
"updated_at": bson_dt(now),
},
};
let record = self
.coll
.find_one_and_update(filter, update)
.return_document(ReturnDocument::After)
.await?;
Ok(record.map(|r| HeartbeatAck {
cancelled: r.cancel_requested,
}))
}
/// Record a job's terminal result. Guarded by the lease token and only from
/// an active (`leased`/`running`) state, so it is idempotent — a duplicate or
/// late submission after the job already finished matches nothing. Returns
/// `true` if this call recorded the result.
pub async fn complete(
&self,
job_id: &str,
lease_token: &str,
result: &JobResult,
now: DateTime<Utc>,
) -> Result<bool, AgentError> {
let status = result.status.unwrap_or(JobStatus::Failed);
let status_bson = mongodb::bson::to_bson(&status).unwrap_or(Bson::String("failed".into()));
let result_bson =
mongodb::bson::to_bson(result).map_err(|e| AgentError::Other(e.to_string()))?;
let filter = doc! {
"job.id": job_id,
"lease_token": lease_token,
"status": { "$in": ACTIVE_STATES.to_vec() },
};
let update = doc! {
"$set": {
"status": status_bson,
"result": result_bson,
"lease_token": Bson::Null,
"lease_expires_at": Bson::Null,
"updated_at": bson_dt(now),
},
};
let res = self.coll.update_one(filter, update).await?;
Ok(res.modified_count == 1)
}
/// Request cancellation of a job. A still-`queued` job is cancelled outright;
/// an in-flight one is flagged so the runner sees it on its next heartbeat and
/// tears down. Returns `true` if a non-terminal job matched.
pub async fn cancel(&self, job_id: &str, now: DateTime<Utc>) -> Result<bool, AgentError> {
let filter = doc! {
"job.id": job_id,
"status": { "$nin": TERMINAL_STATES.to_vec() },
};
// Pipeline update: flag cancellation, and if still queued flip straight to
// cancelled (nothing is running it).
let pipeline = vec![doc! {
"$set": {
"cancel_requested": true,
"status": {
"$cond": [ { "$eq": ["$status", "queued"] }, "cancelled", "$status" ]
},
"updated_at": bson_dt(now),
}
}];
let res = self.coll.update_one(filter, pipeline).await?;
Ok(res.matched_count == 1)
}
/// Sweep leases whose visibility timeout has elapsed: return them to `queued`
/// for another runner, or mark them `expired` once they have been leased
/// `max_attempts` times. This is what makes a crashed runner's job recover.
pub async fn sweep_expired(
&self,
now: DateTime<Utc>,
max_attempts: u32,
// (kept explicit rather than a const so callers can tune retry policy)
) -> Result<SweepOutcome, AgentError> {
let now_bson = bson_dt(now);
let max = i64::from(max_attempts);
let requeue = self
.coll
.update_many(
doc! {
"status": { "$in": ACTIVE_STATES.to_vec() },
"lease_expires_at": { "$lt": &now_bson },
"attempts": { "$lt": max },
},
doc! { "$set": {
"status": "queued",
"lease_token": Bson::Null,
"leased_by": Bson::Null,
"lease_expires_at": Bson::Null,
"updated_at": &now_bson,
} },
)
.await?;
let expire = self
.coll
.update_many(
doc! {
"status": { "$in": ACTIVE_STATES.to_vec() },
"lease_expires_at": { "$lt": &now_bson },
"attempts": { "$gte": max },
},
doc! { "$set": {
"status": "expired",
"lease_token": Bson::Null,
"lease_expires_at": Bson::Null,
"updated_at": &now_bson,
} },
)
.await?;
Ok(SweepOutcome {
requeued: requeue.modified_count,
expired: expire.modified_count,
})
}
/// Fetch a job record by job id (inspection / control-plane reads).
pub async fn get(&self, job_id: &str) -> Result<Option<JobRecord>, AgentError> {
Ok(self.coll.find_one(doc! { "job.id": job_id }).await?)
}
}
/// A `chrono::Duration` for a lease TTL, saturating rather than panicking on an
/// absurd input (`chrono::Duration::seconds` panics past its internal bound).
fn ttl(d: Duration) -> chrono::Duration {
let secs = i64::try_from(d.as_secs()).unwrap_or(i64::MAX);
chrono::Duration::try_seconds(secs).unwrap_or(chrono::Duration::MAX)
}
/// A chrono instant as a BSON date (so Mongo stores/compares it as a real date).
fn bson_dt(dt: DateTime<Utc>) -> BsonDateTime {
BsonDateTime::from_chrono(dt)
}
/// Whether a Mongo error is a duplicate-key (E11000) violation — a job with this
/// id is already enqueued.
fn is_duplicate_key(e: &mongodb::error::Error) -> bool {
match &*e.kind {
ErrorKind::Write(WriteFailure::WriteError(we)) => we.code == 11000,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ttl_saturates_and_converts() {
assert_eq!(ttl(Duration::from_secs(30)), chrono::Duration::seconds(30));
// An absurd TTL saturates instead of panicking.
assert_eq!(ttl(Duration::from_secs(u64::MAX)), chrono::Duration::MAX);
}
#[test]
fn state_constants_are_disjoint() {
for s in ACTIVE_STATES {
assert!(
!TERMINAL_STATES.contains(&s),
"{s} cannot be both active and terminal"
);
}
}
}
+125
View File
@@ -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);
}
+145
View File
@@ -0,0 +1,145 @@
//! 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"
);
}
+196 -2
View File
@@ -1,3 +1,197 @@
// Shared test helpers for compliance-agent integration tests.
// Shared test harness for E2E / integration tests.
//
// Add database mocks, fixtures, and test utilities here.
// Spins up the agent API server on a random port with an isolated test
// database. Each test gets a fresh database that is dropped on cleanup.
//
// Included via `mod common;` in several test binaries; not every binary uses
// every helper, so allow dead code here.
#![allow(dead_code)]
use std::sync::Arc;
use compliance_agent::agent::ComplianceAgent;
use compliance_agent::api;
use compliance_agent::database::DatabasePool;
use compliance_core::AgentConfig;
use secrecy::SecretString;
/// The runner bearer token wired into the test config.
pub const TEST_RUNNER_TOKEN: &str = "test-runner-token";
/// A minimal dev [`AgentConfig`] for tests: unauthenticated (no Keycloak), the
/// Werkbank runner API enabled with [`TEST_RUNNER_TOKEN`].
pub fn dev_config(mongodb_uri: String, db_name: String) -> AgentConfig {
AgentConfig {
mongodb_uri,
mongodb_database: db_name,
litellm_url: std::env::var("TEST_LITELLM_URL")
.unwrap_or_else(|_| "http://localhost:4000".into()),
litellm_api_key: SecretString::from(String::new()),
litellm_model: "gpt-4o".into(),
litellm_embed_model: "text-embedding-3-small".into(),
agent_port: 0, // not used — we bind ourselves
scan_schedule: String::new(),
cve_monitor_schedule: String::new(),
git_clone_base_path: "/tmp/compliance-scanner-tests/repos".into(),
artifact_store_base_path: "/tmp/compliance-scanner-tests/artifacts".into(),
ssh_key_path: "/tmp/compliance-scanner-tests/ssh/id_ed25519".into(),
github_token: None,
github_webhook_secret: None,
gitlab_url: None,
gitlab_token: None,
gitlab_webhook_secret: None,
jira_url: None,
jira_email: None,
jira_api_token: None,
jira_project_key: None,
searxng_url: None,
nvd_api_key: None,
keycloak_url: None,
keycloak_realm: None,
keycloak_admin_username: None,
keycloak_admin_password: None,
pentest_verification_email: None,
pentest_imap_host: None,
pentest_imap_port: None,
pentest_imap_tls: false,
pentest_imap_username: None,
pentest_imap_password: None,
admin_api_token: None,
tenant_registry_url: None,
plc_runtime: compliance_core::PlcRuntimeConfig::default(),
werkbank_runner_token: Some(SecretString::from(TEST_RUNNER_TOKEN.to_string())),
breakpilot: compliance_core::config::BreakpilotConfig::default(),
}
}
/// A running test server with a unique database.
pub struct TestServer {
pub base_url: String,
pub client: reqwest::Client,
db_name: String,
mongodb_uri: String,
}
impl TestServer {
/// Start an agent API server on a random port with an isolated database.
pub async fn start() -> Self {
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
// Unique db-name prefix per run. Must fit the pool's 30-char cap
// (`<prefix>_<32 hex>` <= 63), so use a 16-hex-char suffix.
let db_name = format!("t_{}", &uuid::Uuid::new_v4().simple().to_string()[..16]);
let db_pool = DatabasePool::connect(&mongodb_uri, &db_name)
.await
.expect("Failed to build DatabasePool");
let config = dev_config(mongodb_uri.clone(), db_name.clone());
let agent = ComplianceAgent::new(config, db_pool);
// Build the router with the agent extension. After M7.2-B every
// handler takes a TenantCtx extractor; without KC in the test
// harness, the dev-tenant injector mounts a synthetic context so
// tests run end-to-end against `<db_name>_dev`.
let app = api::routes::build_router()
.layer(axum::extract::Extension(Arc::new(agent)))
.layer(axum::middleware::from_fn(api::server::inject_dev_tenant))
.layer(tower_http::cors::CorsLayer::permissive());
// Bind to port 0 to get a random available port
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("Failed to bind test server");
let port = listener.local_addr().expect("no local addr").port();
tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
let base_url = format!("http://127.0.0.1:{port}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client");
// Wait for server to be ready
for _ in 0..50 {
if client
.get(format!("{base_url}/api/v1/health"))
.send()
.await
.is_ok()
{
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
Self {
base_url,
client,
db_name,
mongodb_uri,
}
}
/// GET helper
pub async fn get(&self, path: &str) -> reqwest::Response {
self.client
.get(format!("{}{path}", self.base_url))
.send()
.await
.expect("GET request failed")
}
/// POST helper with JSON body
pub async fn post(&self, path: &str, body: &serde_json::Value) -> reqwest::Response {
self.client
.post(format!("{}{path}", self.base_url))
.json(body)
.send()
.await
.expect("POST request failed")
}
/// PATCH helper with JSON body
pub async fn patch(&self, path: &str, body: &serde_json::Value) -> reqwest::Response {
self.client
.patch(format!("{}{path}", self.base_url))
.json(body)
.send()
.await
.expect("PATCH request failed")
}
/// DELETE helper
pub async fn delete(&self, path: &str) -> reqwest::Response {
self.client
.delete(format!("{}{path}", self.base_url))
.send()
.await
.expect("DELETE request failed")
}
/// Get the unique database name for direct MongoDB access in tests.
pub fn db_name(&self) -> &str {
&self.db_name
}
/// Drop every per-tenant database belonging to this test run.
/// Post-M7.2-D the agent never opens a `db_name` directly —
/// data lives only in `<db_name>_<tenant>` per-tenant databases.
pub async fn cleanup(&self) {
if let Ok(client) = mongodb::Client::with_uri_str(&self.mongodb_uri).await {
if let Ok(names) = client.list_database_names().await {
let prefix = format!("{}_", self.db_name);
for name in names {
if name.starts_with(&prefix) {
client.database(&name).drop().await.ok();
}
}
}
}
}
}
+7
View File
@@ -0,0 +1,7 @@
// E2E test entry point.
//
// Run with: cargo test -p compliance-agent --test e2e
// Requires: MongoDB running (set TEST_MONGODB_URI if not default)
mod common;
mod integration;
@@ -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"
);
}
@@ -0,0 +1,219 @@
use crate::common::TestServer;
use serde_json::json;
/// Insert a DAST target directly into MongoDB linked to a repo.
async fn insert_dast_target(server: &TestServer, repo_id: &str, name: &str) -> String {
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
let client = mongodb::Client::with_uri_str(&mongodb_uri).await.unwrap();
let db = client.database(&server.db_name());
let result = db
.collection::<mongodb::bson::Document>("dast_targets")
.insert_one(mongodb::bson::doc! {
"name": name,
"base_url": format!("https://{name}.example.com"),
"target_type": "webapp",
"repo_id": repo_id,
"rate_limit": 10,
"allow_destructive": false,
"created_at": mongodb::bson::DateTime::now(),
})
.await
.unwrap();
result.inserted_id.as_object_id().unwrap().to_hex()
}
/// Insert a pentest session linked to a target.
async fn insert_pentest_session(server: &TestServer, target_id: &str, repo_id: &str) -> String {
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
let client = mongodb::Client::with_uri_str(&mongodb_uri).await.unwrap();
let db = client.database(&server.db_name());
let result = db
.collection::<mongodb::bson::Document>("pentest_sessions")
.insert_one(mongodb::bson::doc! {
"target_id": target_id,
"repo_id": repo_id,
"strategy": "comprehensive",
"status": "completed",
"findings_count": 1_i32,
"exploitable_count": 0_i32,
"created_at": mongodb::bson::DateTime::now(),
})
.await
.unwrap();
result.inserted_id.as_object_id().unwrap().to_hex()
}
/// Insert an attack chain node linked to a session.
async fn insert_attack_node(server: &TestServer, session_id: &str) {
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
let client = mongodb::Client::with_uri_str(&mongodb_uri).await.unwrap();
let db = client.database(&server.db_name());
db.collection::<mongodb::bson::Document>("attack_chain_nodes")
.insert_one(mongodb::bson::doc! {
"session_id": session_id,
"node_id": "node-1",
"tool_name": "recon",
"status": "completed",
"created_at": mongodb::bson::DateTime::now(),
})
.await
.unwrap();
}
/// Insert a DAST finding linked to a target.
async fn insert_dast_finding(server: &TestServer, target_id: &str, session_id: &str) {
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
let client = mongodb::Client::with_uri_str(&mongodb_uri).await.unwrap();
let db = client.database(&server.db_name());
db.collection::<mongodb::bson::Document>("dast_findings")
.insert_one(mongodb::bson::doc! {
"scan_run_id": "run-1",
"target_id": target_id,
"vuln_type": "xss",
"title": "Reflected XSS",
"description": "XSS in search param",
"severity": "high",
"endpoint": "https://example.com/search",
"method": "GET",
"exploitable": true,
"evidence": [],
"session_id": session_id,
"created_at": mongodb::bson::DateTime::now(),
})
.await
.unwrap();
}
/// Helper to count documents in a collection
async fn count_docs(server: &TestServer, collection: &str) -> u64 {
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
let client = mongodb::Client::with_uri_str(&mongodb_uri).await.unwrap();
let db = client.database(&server.db_name());
db.collection::<mongodb::bson::Document>(collection)
.count_documents(mongodb::bson::doc! {})
.await
.unwrap()
}
#[tokio::test]
async fn delete_repo_cascades_to_dast_and_pentest_data() {
let server = TestServer::start().await;
// Create a repo
let resp = server
.post(
"/api/v1/targets",
&json!({
"name": "cascade-test",
"target_type": "web_app",
"artifacts": [{ "kind": "git_repo", "source_ref": "https://github.com/example/cascade-test.git", "branch": "main" }],
}),
)
.await;
let body: serde_json::Value = resp.json().await.unwrap();
let repo_id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
// Insert DAST target linked to repo
let target_id = insert_dast_target(&server, &repo_id, "cascade-target").await;
// Insert pentest session linked to target
let session_id = insert_pentest_session(&server, &target_id, &repo_id).await;
// Insert downstream data
insert_attack_node(&server, &session_id).await;
insert_dast_finding(&server, &target_id, &session_id).await;
// Verify data exists
assert_eq!(count_docs(&server, "dast_targets").await, 1);
assert_eq!(count_docs(&server, "pentest_sessions").await, 1);
assert_eq!(count_docs(&server, "attack_chain_nodes").await, 1);
assert_eq!(count_docs(&server, "dast_findings").await, 1);
// Delete the repo
let resp = server.delete(&format!("/api/v1/targets/{repo_id}")).await;
assert_eq!(resp.status(), 200);
// All downstream data should be gone
assert_eq!(count_docs(&server, "dast_targets").await, 0);
assert_eq!(count_docs(&server, "pentest_sessions").await, 0);
assert_eq!(count_docs(&server, "attack_chain_nodes").await, 0);
assert_eq!(count_docs(&server, "dast_findings").await, 0);
server.cleanup().await;
}
#[tokio::test]
async fn delete_repo_cascades_sast_findings_and_sbom() {
let server = TestServer::start().await;
// Create a repo
let resp = server
.post(
"/api/v1/targets",
&json!({
"name": "sast-cascade",
"target_type": "web_app",
"artifacts": [{ "kind": "git_repo", "source_ref": "https://github.com/example/sast-cascade.git", "branch": "main" }],
}),
)
.await;
let body: serde_json::Value = resp.json().await.unwrap();
let repo_id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
// Insert SAST finding and SBOM entry
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
let client = mongodb::Client::with_uri_str(&mongodb_uri).await.unwrap();
let db = client.database(&server.db_name());
let now = mongodb::bson::DateTime::now();
db.collection::<mongodb::bson::Document>("findings")
.insert_one(mongodb::bson::doc! {
"repo_id": &repo_id,
"fingerprint": "fp-test-1",
"scanner": "semgrep",
"scan_type": "sast",
"title": "SQL Injection",
"description": "desc",
"severity": "critical",
"status": "open",
"created_at": now,
"updated_at": now,
})
.await
.unwrap();
db.collection::<mongodb::bson::Document>("sbom_entries")
.insert_one(mongodb::bson::doc! {
"repo_id": &repo_id,
"name": "lodash",
"version": "4.17.20",
"package_manager": "npm",
"known_vulnerabilities": [],
})
.await
.unwrap();
assert_eq!(count_docs(&server, "findings").await, 1);
assert_eq!(count_docs(&server, "sbom_entries").await, 1);
// Delete repo
server.delete(&format!("/api/v1/targets/{repo_id}")).await;
// Both should be gone
assert_eq!(count_docs(&server, "findings").await, 0);
assert_eq!(count_docs(&server, "sbom_entries").await, 0);
server.cleanup().await;
}
@@ -0,0 +1,48 @@
use crate::common::TestServer;
use serde_json::json;
#[tokio::test]
async fn add_and_list_dast_targets() {
let server = TestServer::start().await;
// Initially empty
let resp = server.get("/api/v1/dast/targets").await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"].as_array().unwrap().len(), 0);
// Add a target
let resp = server
.post(
"/api/v1/dast/targets",
&json!({
"name": "test-app",
"base_url": "https://test-app.example.com",
"target_type": "webapp",
}),
)
.await;
assert_eq!(resp.status(), 200);
// List should return 1
let resp = server.get("/api/v1/dast/targets").await;
let body: serde_json::Value = resp.json().await.unwrap();
let targets = body["data"].as_array().unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0]["name"], "test-app");
assert_eq!(targets[0]["base_url"], "https://test-app.example.com");
server.cleanup().await;
}
#[tokio::test]
async fn list_dast_findings_empty() {
let server = TestServer::start().await;
let resp = server.get("/api/v1/dast/findings").await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"].as_array().unwrap().len(), 0);
server.cleanup().await;
}
@@ -0,0 +1,144 @@
use crate::common::TestServer;
use serde_json::json;
/// Helper: insert a finding directly via MongoDB for testing query endpoints.
async fn insert_finding(server: &TestServer, repo_id: &str, title: &str, severity: &str) {
// We insert via the agent's DB by posting to the internal test path.
// Since there's no direct "create finding" API, we use MongoDB directly.
let mongodb_uri = std::env::var("TEST_MONGODB_URI")
.unwrap_or_else(|_| "mongodb://root:example@localhost:27017/?authSource=admin".into());
// Extract the database name from the server's unique DB
// We'll use the agent's internal DB through the stats endpoint to verify
let client = mongodb::Client::with_uri_str(&mongodb_uri).await.unwrap();
// Get the DB name from the test server by parsing the health response
// For now, we use a direct insert approach
let db = client.database(&server.db_name());
let now = mongodb::bson::DateTime::now();
db.collection::<mongodb::bson::Document>("findings")
.insert_one(mongodb::bson::doc! {
"repo_id": repo_id,
"fingerprint": format!("fp-{title}-{severity}"),
"scanner": "test-scanner",
"scan_type": "sast",
"title": title,
"description": format!("Test finding: {title}"),
"severity": severity,
"status": "open",
"created_at": now,
"updated_at": now,
})
.await
.unwrap();
}
#[tokio::test]
async fn list_findings_empty() {
let server = TestServer::start().await;
let resp = server.get("/api/v1/findings").await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"].as_array().unwrap().len(), 0);
assert_eq!(body["total"], 0);
server.cleanup().await;
}
#[tokio::test]
async fn list_findings_with_data() {
let server = TestServer::start().await;
insert_finding(&server, "repo1", "SQL Injection", "critical").await;
insert_finding(&server, "repo1", "XSS", "high").await;
insert_finding(&server, "repo2", "Info Leak", "low").await;
let resp = server.get("/api/v1/findings").await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["total"], 3);
// Filter by severity
let resp = server.get("/api/v1/findings?severity=critical").await;
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["total"], 1);
assert_eq!(body["data"][0]["title"], "SQL Injection");
// Filter by repo
let resp = server.get("/api/v1/findings?repo_id=repo1").await;
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["total"], 2);
server.cleanup().await;
}
#[tokio::test]
async fn update_finding_status() {
let server = TestServer::start().await;
insert_finding(&server, "repo1", "Test Bug", "medium").await;
// Get the finding ID
let resp = server.get("/api/v1/findings").await;
let body: serde_json::Value = resp.json().await.unwrap();
let finding_id = body["data"][0]["_id"]["$oid"].as_str().unwrap();
// Update status to resolved
let resp = server
.patch(
&format!("/api/v1/findings/{finding_id}/status"),
&json!({ "status": "resolved" }),
)
.await;
assert_eq!(resp.status(), 200);
// Verify it's updated
let resp = server.get(&format!("/api/v1/findings/{finding_id}")).await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"]["status"], "resolved");
server.cleanup().await;
}
#[tokio::test]
async fn bulk_update_finding_status() {
let server = TestServer::start().await;
insert_finding(&server, "repo1", "Bug A", "high").await;
insert_finding(&server, "repo1", "Bug B", "high").await;
// Get both finding IDs
let resp = server.get("/api/v1/findings").await;
let body: serde_json::Value = resp.json().await.unwrap();
let ids: Vec<String> = body["data"]
.as_array()
.unwrap()
.iter()
.map(|f| f["_id"]["$oid"].as_str().unwrap().to_string())
.collect();
// Bulk update
let resp = server
.patch(
"/api/v1/findings/bulk-status",
&json!({
"ids": ids,
"status": "false_positive"
}),
)
.await;
assert_eq!(resp.status(), 200);
// Verify both are updated
for id in &ids {
let resp = server.get(&format!("/api/v1/findings/{id}")).await;
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"]["status"], "false_positive");
}
server.cleanup().await;
}
@@ -0,0 +1,29 @@
use crate::common::TestServer;
#[tokio::test]
async fn health_endpoint_returns_ok() {
let server = TestServer::start().await;
let resp = server.get("/api/v1/health").await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
server.cleanup().await;
}
#[tokio::test]
async fn stats_overview_returns_zeroes_on_empty_db() {
let server = TestServer::start().await;
let resp = server.get("/api/v1/stats/overview").await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let data = &body["data"];
assert_eq!(data["repositories"], 0);
assert_eq!(data["total_findings"], 0);
server.cleanup().await;
}
@@ -0,0 +1,6 @@
mod cascade_delete;
mod dast;
mod findings;
mod health;
mod onboarding;
mod stats;
@@ -0,0 +1,115 @@
use crate::common::TestServer;
use serde_json::json;
#[tokio::test]
async fn create_list_and_applicable_scans() {
let server = TestServer::start().await;
// Initially empty.
let resp = server.get("/api/v1/targets").await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"].as_array().unwrap().len(), 0);
// Create a web-app target with a git repo + a live URL.
let resp = server
.post(
"/api/v1/targets",
&json!({
"name": "acme-web",
"target_type": "web_app",
"artifacts": [
{ "kind": "git_repo", "source_ref": "https://git/acme.git", "branch": "main" },
{ "kind": "live_url", "source_ref": "https://acme.example.com" }
]
}),
)
.await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
assert!(!id.is_empty());
assert_eq!(body["data"]["artifacts"].as_array().unwrap().len(), 2);
// List returns it.
let resp = server.get("/api/v1/targets").await;
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"].as_array().unwrap().len(), 1);
// Applicable scans: SAST present + DAST offered (live URL present), pentest supported.
let resp = server
.get(&format!("/api/v1/targets/{id}/applicable-scans"))
.await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
let scans = body["data"]["scans"].as_array().unwrap();
let names: Vec<&str> = scans.iter().filter_map(|s| s["scan"].as_str()).collect();
assert!(names.contains(&"sast"));
assert!(names.contains(&"dast"));
assert_eq!(body["data"]["pentest_supported"], true);
server.cleanup().await;
}
#[tokio::test]
async fn detect_classifies_a_plc_target() {
let server = TestServer::start().await;
// A PLC project artifact is a strong kind-based signal.
let resp = server
.post(
"/api/v1/targets",
&json!({
"name": "line-controller",
"target_type": "backend_service", // deliberately wrong; detect should suggest PLC
"artifacts": [
{ "kind": "plc_project", "source_ref": "line.xml", "plc_format": "plcopen_xml" }
]
}),
)
.await;
let body: serde_json::Value = resp.json().await.unwrap();
let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
let resp = server
.post(&format!("/api/v1/targets/{id}/detect"), &json!({}))
.await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"]["classification"]["suggested"], "plc_sps");
server.cleanup().await;
}
#[tokio::test]
async fn add_artifact_and_delete_target() {
let server = TestServer::start().await;
let resp = server
.post(
"/api/v1/targets",
&json!({ "name": "svc", "target_type": "backend_service" }),
)
.await;
let body: serde_json::Value = resp.json().await.unwrap();
let id = body["data"]["_id"]["$oid"].as_str().unwrap().to_string();
// Attach a git repo.
let resp = server
.post(
&format!("/api/v1/targets/{id}/artifacts"),
&json!({ "kind": "git_repo", "source_ref": "https://git/svc.git" }),
)
.await;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["data"]["artifacts"].as_array().unwrap().len(), 1);
// Delete it.
let resp = server.delete(&format!("/api/v1/targets/{id}")).await;
assert_eq!(resp.status(), 200);
let resp = server.get(&format!("/api/v1/targets/{id}")).await;
assert_eq!(resp.status(), 404);
server.cleanup().await;
}

Some files were not shown because too many files have changed in this diff Show More