Security¶
SynthOrg is designed to run autonomous AI agents with real tools and real consequences. Security is not an afterthought -- it is a core architectural concern woven through every layer of the framework, from the application runtime to the CI/CD pipeline and container infrastructure.
Application Security¶
SecOps Agent & Rule Engine¶
Every tool invocation passes through a centralized security evaluation pipeline before execution. The SecOps service coordinates a fail-closed rule engine with five built-in detectors:
| Detector | What It Catches |
|---|---|
| Policy Validator | Action type policies (soft-allow / hard-deny / escalate) |
| Credential Detector | API keys, passwords, tokens, private keys in arguments or output |
| Path Traversal Detector | ../, absolute paths, symlink escape attempts |
| Destructive Operation Detector | rm -rf, git reset --hard, destructive shell commands |
| Data Leak Detector | PII patterns -- emails, SSNs, credit card numbers, phone numbers |
Rules are evaluated sequentially by priority. The first DENY or ESCALATE
verdict wins. If a rule raises an exception, the engine defaults to DENY
(fail-closed). Every decision is recorded in a persistent audit log.
Output Scanning¶
After tool execution, the output scanner inspects results for sensitive data using the same credential and PII patterns. Configurable response policies:
- Redact -- replace matches with
[REDACTED]before returning to the agent - Withhold -- suppress the entire output
- Log-only -- record the finding, return unmodified output
- Autonomy-tiered -- different policies per autonomy level
Progressive Trust¶
Agents start with restricted permissions and earn autonomy over time.
Four pluggable strategies behind the TrustStrategy protocol:
- Disabled -- all actions require approval regardless of history
- Weighted -- accumulate a trust score from successful actions
- Per-category -- independent trust tracks per action type (read, write, delete)
- Milestone gates -- unlock action categories after specific success thresholds
Approval Workflow¶
Actions that trigger ESCALATE verdicts create approval items with configurable
timeout policies:
- Wait forever -- block until a human responds
- Auto-deny -- reject after timeout
- Tiered -- different timeouts by risk level
- Escalation chain -- escalate to supervisor on timeout
Tasks are parked (suspended) while awaiting approval and resumed automatically on resolution.
Authentication & Authorization¶
- JWT bearer tokens with password-change detection (
pwd_sigclaim, skipped for system user) - System user (CLI) -- internal identity bootstrapped at startup with a random Argon2id password hash. CLI tokens use
sub: "system"withiss: "synthorg-cli"and skippwd_sigvalidation (JWT HMAC signature is the sole authentication gate). The system user cannot log in, change its password, or be modified through the API. - API key authentication via HMAC-SHA256 deterministic hashing
- Argon2id password hashing (time_cost=3, memory_cost=64 MB, parallelism=4)
- Timing-attack prevention -- dummy hash computation for non-existent users
- Forced password change --
must_change_passwordflag blocks API access - One-time WebSocket tickets -- short-lived (30 s), single-use, cryptographically random tokens exchanged via
POST /api/v1/auth/ws-ticket(requires valid JWT). Prevents long-lived JWT leakage by replacing it with an ephemeral ticket in the WebSocket query parameter. In-memory store, monotonic clock expiry, per-process scope. JWT/API key auth middleware is scoped to HTTP requests only (ScopeType.HTTP) -- WebSocket connections bypass the middleware entirely and rely on handler-level ticket validation. - Rate limiting -- configurable per-deployment (default: 100 req/min). The WebSocket path is excluded from rate limiting -- HTTP-style per-request rate limiting is inappropriate for persistent WebSocket connections. Auth-sensitive endpoints (
/auth/login,/auth/setup,/auth/change-password) have a stricter route-level rate limit of 10 req/min to mitigate credential brute-forcing.
Frontend Security¶
The React dashboard enforces several measures to reduce the client-side attack surface:
| Measure | Mechanism |
|---|---|
| XSS prevention | ESLint no-restricted-syntax rule bans dangerouslySetInnerHTML at write time. Override requires // eslint-disable-next-line with justification. |
| CSP nonce readiness | <MotionConfig nonce> wrapper in App.tsx + lib/csp.ts reader. Framer Motion's dynamically injected <style> tags are nonce-ready. react-style-singleton (used by Radix Dialog/AlertDialog/Popover via react-remove-scroll) also supports nonces via the get-nonce package -- wiring setNonce() in lib/csp.ts is the remaining step. Currently staged -- see the activation checklist in web/index.html and the accepted risk section below. |
| JWT storage | localStorage with short-lived tokens, automatic expiry cleanup, and 401 interceptor. Cookie-based auth (httpOnly) is a future enhancement tracked separately. |
Accepted Risk: Inline Style Attributes¶
The dashboard CSP includes style-src 'unsafe-inline' because Radix UI primitives inject
inline styles at runtime. This section documents the risk, the upstream blocker, and the
recommended mitigation path.
What is permitted¶
Under CSP Level 2, style-src 'unsafe-inline' allows all inline styles -- both style
attributes on DOM elements and <style> elements without nonces. With CSP Level 3 directive
splitting, style-src-attr 'unsafe-inline' permits only inline style attributes while
style-src-elem controls <style> elements separately (e.g. via nonces or hashes).
Why this is required¶
Radix UI primitives violate CSP in two distinct ways:
-
Inline
styleattributes on DOM elements. Radix Popper (used by Popover, Tooltip, Select, DropdownMenu) sets CSS custom properties viastyle="--radix-*: ...". Dialog/AlertDialog setstyle="pointer-events: auto"on overlays. Tabs setanimationDuration: "0s"on mount. CSP nonces cannot fix this -- the CSP specification only supports nonces on<style>and<script>elements, not onstyleattributes. -
<style>tag injection.react-remove-scroll/react-style-singleton(used by Dialog, AlertDialog, Popover) inject<style>tags at runtime. This is fixable --react-style-singletonalready callsgetNonce()from theget-noncepackage and applies nonces to its<style>tags whensetNonce()has been called.
Affected dashboard components: Dialog (4 pages), AlertDialog/ConfirmDialog (1),
Popover/ThemeToggle (1), Tabs/OrgEditPage (1), FocusScope/CommandPalette (1). Additionally,
cmdk (CommandPalette) internally depends on @radix-ui/react-dialog.
Risk assessment¶
Inline style attribute injection is not a practical XSS vector:
- Unlike
<script>or<style>elements, astyleattribute cannot execute JavaScript - Data exfiltration via
styleattributes is limited to single-element visual manipulation (no CSS selectors and no@import;url(...)loading is possible but constrained by relevant CSP fetch directives such asimg-srcandfont-src) - The primary theoretical risk is UI redress/clickjacking via
position: fixed; z-index: 99999, which is already mitigated byX-Frame-Options: DENY - The higher-risk vector (
<style>element injection for CSS-based data exfiltration via attribute selectors) is separately addressable via nonces
script-src -- the critical XSS vector -- is locked down to 'self' with no 'unsafe-inline'.
Upstream status (stagnant)¶
| Date | Event |
|---|---|
| 2024-02 | Nonce prop merged for ScrollArea/Select only (PR #2728) |
| 2024-09 | CSS export approach PR closed by maintainer (backlog triage, PR #3131) |
| 2024-10 | Maintainer said "near the top of my todo list" |
| 2025-04 | Community asked for update -- no response |
| 2025-07 | Community followed up -- no response |
| 2026-01 | Community re-opened inquiry -- no response |
| 2026-02 | Discussion #3130 closed -- author pointed to Base UI as the successor with CSP support |
Open issues with no maintainer engagement: #3063, #3117.
Base UI migration assessment¶
Base UI 1.0 (released December 2025, currently v1.3.0) offers first-class
CSP support via CSPProvider with context-based
nonce propagation. However, migrating is prohibitively large:
- The dashboard has 9 direct Radix imports across the codebase
- shadcn/ui is architecturally built on Radix primitives -- migrating away from Radix means
replacing the entire component system in
web/src/components/ui/ cmdk(CommandPalette) depends on@radix-ui/react-dialoginternally -- no Base UI equivalent exists- Base UI's
CSPProvidersolves<style>element injection but would still likely requirestyle-src-attr 'unsafe-inline'for positioned components that use inlinestyleattributes (Floating UI, which Base UI also uses, sets inline styles for positioning)
Verdict: the migration scope is disproportionate to the marginal security gain beyond what nonce wiring + directive splitting already achieves.
Recommended mitigation path¶
Phase 1 -- Wire get-nonce bridge (low effort, high impact):
Add a setNonce() call from the get-nonce package in web/src/lib/csp.ts so that
react-style-singleton's <style> tags receive the CSP nonce. Combined with the existing
MotionConfig nonce wrapper, this makes all <style> element injection nonce-capable.
Phase 2 -- Activate nonce infrastructure + split CSP directives (coordinated deployment):
Uncomment the <meta name="csp-nonce"> tag in web/index.html, add sub_filter to
web/nginx.conf, and replace style-src 'self' 'unsafe-inline' with CSP Level 3 split
directives in web/security-headers.conf:
style-src-elem 'self' 'nonce-...'-- locks down<style>elements (higher-risk vector)style-src-attr 'unsafe-inline'-- permits inlinestyleattributes (low-risk, required by Radix)
Browser support for CSP Level 3 directive splitting:
style-src-elem: Chrome 75+, Firefox 108+, Safari 15.4+ (partial, full at 26.2+), Edge 79+style-src-attr: Chrome 75+, Edge 79+. Not supported in Firefox (bug 1529338) or Safari
When style-src-attr is unsupported, browsers fall back to style-src, so the directive
splitting is backwards-compatible -- unsupported browsers continue using style-src rules.
Phase 3 -- Re-evaluate quarterly:
Check upstream Radix issues (#3063,
#3117) for movement. If Radix ships
nonce support for inline styles, or if Base UI reaches feature parity with the dashboard's
Radix usage, remove style-src-attr 'unsafe-inline'.
Security Headers¶
All API responses include:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Strict-Transport-Security |
max-age=63072000; includeSubDomains |
Permissions-Policy |
geolocation=(), camera=(), microphone=() |
Cross-Origin-Resource-Policy |
same-origin |
Cross-Origin-Opener-Policy |
same-origin (API); same-origin-allow-popups (docs) |
Cache-Control |
no-store (API); no-cache (dashboard HTML); public, max-age=31536000, immutable (dashboard hashed assets); public, max-age=300 (docs) |
Content-Security-Policy |
Strict default; relaxed only for docs UI. Dashboard requires style-src 'unsafe-inline' due to Radix UI inline style injection (accepted risk). Nonce infrastructure is staged for <style> elements (Framer Motion + react-style-singleton); full activation blocked by Radix inline style attributes. See recommended mitigation path for CSP Level 3 directive splitting. |
Container Hardening¶
Distroless Runtime¶
The backend runs on a Chainguard distroless Python image -- no shell, no package manager, minimal attack surface. The build uses a 3-stage Dockerfile:
- Builder -- compiles dependencies and project wheel
- Setup -- fixes paths and creates directories (dev image with shell)
- Runtime -- distroless production image (no shell, UID 65532)
CIS Docker Benchmark¶
Both backend and web containers enforce CIS v1.6.0 controls in compose.yml:
| Control | Setting |
|---|---|
| CIS 5.3 | security_opt: no-new-privileges:true |
| CIS 5.12 | cap_drop: ALL |
| CIS 5.25 | read_only: true with tmpfs mounts (noexec, nosuid, nodev) |
| CIS 5.28 | deploy.resources.limits.pids per container (256 backend, 64 web) |
Resource limits (deploy.resources.limits) cap memory, CPU, and PIDs per container (4G/2CPU/256pids backend, 256M/0.5CPU/64pids web). Log rotation (json-file driver, max-size: 10m, max-file: 3) prevents disk exhaustion.
Artifact Provenance¶
- All base images pinned by SHA-256 digest (no mutable tags)
- Dependabot auto-updates digests daily for all three Dockerfiles
- cosign keyless signing on every pushed image (Sigstore OIDC-bound)
- Buildx SPDX SBOMs (SLSA L1) auto-generated and pushed to GHCR as registry attestations (inspect via
docker buildx imagetools inspect). Standalone CycloneDX JSON SBOMs are generated separately by Syft -- see Software Bill of Materials below. - Build-level provenance (SLSA L1) auto-generated by Docker Buildx
- SLSA Level 3 provenance for CLI binary releases and container images (generated by
actions/attest-build-provenance, Sigstore-signed, independently verifiable) - Client-side verification: The CLI (
synthorg start,synthorg update) automatically verifies cosign signatures and SLSA provenance for container images before pulling. Verified digests are pinned in the compose file to prevent tag mutation attacks. Bypass with--skip-verifyorSYNTHORG_SKIP_VERIFY=1for air-gapped environments (not recommended).
Supply Chain Security¶
Dependency Management¶
| Layer | Tool | Policy |
|---|---|---|
| Python | pip-audit |
Per-PR + weekly scan for known CVEs |
| Python | Dependabot | Daily updates, == pinned versions, grouped minor/patch |
| Node.js | npm audit |
Per-PR, blocks on critical/high |
| Node.js | Dependabot | Daily updates via lockfile (/web, /site, /.github) |
| GitHub Actions | Dependabot | Daily updates, pinned by SHA |
| Pre-commit hooks | Dependabot | Daily updates, version-pinned rev: tags |
| License | dependency-review-action |
Permissive-only allowlist (MIT, Apache-2.0, BSD, ISC, etc.) |
| Supply chain | Socket.dev | GitHub App -- detects typosquatting, malware, suspicious ownership changes |
Container Scanning¶
Every container image is scanned by two independent tools before push:
- Trivy -- CRITICAL = hard fail, HIGH = warn-only (
.trivyignore.yamlfor vetted CVEs) - Grype -- critical severity cutoff (
.grype.yamlfor overrides) - CIS Docker Benchmark --
trivy image --compliance docker-cis-1.6.0run against all three images (informational; will become enforced once baseline is clean)
Images are only pushed to GHCR after both vulnerability scanners pass.
Signed Artifacts¶
- Container images: cosign keyless signatures (verify via
cosign verify) + SLSA Level 3 provenance attestations (verify viagh attestation verify) - CLI binaries:
- cosign keyless signature on checksums file (verify via
cosign verify-blob) - SLSA Level 3 provenance attestations (verify via
gh attestation verify) - Sigstore provenance bundle (
.sigstore.json, verify viacosign verify-blob-attestation)
- cosign keyless signature on checksums file (verify via
- Git commits: GPG/SSH signed (enforced by branch protection ruleset)
- GitHub Actions: All actions pinned by full SHA commit hash
- GitHub Releases: Immutable releases enabled -- once published, assets and body cannot be modified (prevents supply chain tampering). Releases are created as drafts by Release Please, finalized after all assets are attached.
Software Bill of Materials (SBOM)¶
Every release includes CycloneDX JSON SBOMs for all released artifacts:
- Container images: per-image SBOMs (
sbom-backend.cdx.json,sbom-web.cdx.json,sbom-sandbox.cdx.json) generated by Syft, attached to GitHub Releases as downloadable assets - CLI binaries: per-archive SBOMs (e.g.
synthorg_linux_amd64.tar.gz.cdx.json) generated by GoReleaser + Syft, attached to GitHub Releases - Registry attestations: Buildx-generated SPDX SBOMs pushed to GHCR alongside
each image (inspect via
docker buildx imagetools inspect)
CI/CD Security¶
Pre-Commit Hooks¶
Every commit is checked locally before it reaches the remote:
- gitleaks -- secret detection on every commit
- hadolint -- Dockerfile linting
- ruff -- Python linting and formatting
- commitizen -- conventional commit message enforcement
- Large file prevention -- blocks files over 1 MB
Pre-push hooks run mypy type checking and unit tests as a fast gate.
Continuous Integration¶
| Check | Gate |
|---|---|
| Ruff lint + format | Required |
| mypy strict type-check | Required |
| pytest + 80% coverage | Required |
| pip-audit (Python CVEs) | Required |
| npm audit (Node.js CVEs) | Required |
| hadolint (Dockerfile lint) | Required |
| All checks must pass | ci-pass required status check |
Security Scanning¶
| Scanner | Scope | Schedule |
|---|---|---|
| gitleaks | Secret detection (push/PR + weekly) | Continuous |
| CodeQL | Static analysis (GitHub Advanced Security) | On push/PR |
| zizmor | GitHub Actions workflow security | On push/PR |
| ZAP DAST | Dynamic API scan against OpenAPI spec | On push to main + weekly |
| OSSF Scorecard | Supply chain maturity scoring | Weekly + on push |
| Trivy + Grype | Container vulnerability scanning | On image build |
| Socket.dev | Supply chain attack detection | On PR |
| dependency-review | License + vulnerability review | On PR |
DAST Tuning¶
The ZAP API scan runs with a rules file (.github/zap-rules.tsv) that
suppresses validated false positives and informational findings:
| Rule | ID | Action | Rationale |
|---|---|---|---|
| Unexpected Content-Type | 100001 | Ignore | /docs intentionally serves Scalar UI HTML |
| Client Error Responses | 100000 | Ignore | ZAP sends literal path params, expected 4xx |
| Base64 Disclosure | 10094 | Ignore | OpenAPI schema contains UUID/JWT-format refs, not secrets |
| Sec-Fetch-* Missing | 90005 | Ignore | JWT auth (not cookies) -- no CSRF risk, would break non-browser clients |
| Non-Storable Content | 10049 | Warn | API endpoints correctly use no-store; dashboard HTML uses no-cache; hashed assets use immutable; docs use public, max-age=300 |
The rules file is reviewed when ZAP or the API surface changes.
Cache-Control is path-aware: API data endpoints use no-store to prevent
sensitive data caching, the web dashboard entry point (index.html) uses
no-cache to force revalidation on every request (ensuring fresh deployments),
content-hashed dashboard assets (/assets/*) use public, max-age=31536000,
immutable for long-lived caching, and documentation endpoints (/docs/*)
allow brief client and proxy caching since they serve public, non-user-specific
content.
Branch Protection¶
The protect-main ruleset enforces:
- Signed commits required
- No direct pushes -- all changes via pull request
- 1 approving review required (stale reviews dismissed on push)
ci-passstatus check required before merge- No branch deletion or non-fast-forward pushes
Reporting Vulnerabilities¶
If you discover a security vulnerability, please report it responsibly via GitHub Security Advisories. Do not open a public issue for security vulnerabilities.