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¶
- HttpOnly cookie sessions: JWTs are delivered via HttpOnly, Secure, SameSite=Strict cookies (never exposed to JavaScript). Password changes rotate the session cookie so the embedded
pwd_sigstays current. - CSRF protection: double-submit cookie pattern: a non-HttpOnly CSRF cookie is set alongside the session cookie; JavaScript reads it and sends it as the
X-CSRF-Tokenheader on mutating requests. The middleware validates header-vs-cookie match using constant-time comparison. - Account lockout: after exceeding a configurable threshold of failed login attempts within a sliding window, the account is temporarily locked (HTTP 429 with
Retry-Afterheader). Lockout state is restored from SQLite on restart. - Refresh token rotation: optional single-use refresh tokens with replay detection; reuse of a consumed token logs the event and the affected session's refresh tokens are cascade-revoked.
- Concurrent session limits: configurable maximum active sessions per user; oldest sessions are revoked when the limit is exceeded.
- 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. -
Tiered rate limiting: three budgets surround the auth middleware. IP floor (outermost, un-gated) limits every request to 10,000 req/min by client IP, including ones the auth middleware rejects with 401; this protects against floods of forged-token traffic on protected endpoints that would otherwise bypass both user-gated tiers. Unauthenticated requests (only when
scope["user"]isNone) are limited to 20 req/min by client IP (aggressive cap on brute-force against login/setup/logout). Authenticated requests (only whenscope["user"]is set) are limited to 6,000 req/min by user ID (generous budget for normal dashboard usage, keyed by user ID so multi-user deployments behind a shared gateway or NAT do not collectively exhaust a single per-IP bucket). The floor default is sized above the authenticated per-user cap (10,000 ≥ 6,000) so a single user on a shared-NAT deployment cannot accidentally clip themselves on the floor before their per-user tier kicks in; config validation rejects a floor below the authenticated cap. The floor is still the aggregate-IP cap, so many active users behind one NAT can collectively exceed it. Raisefloor_max_requeststoN × auth_max_requestsfor large shared deployments. Auth runs before both user-gated tiers soscope["user"]is authoritatively populated for thecheck_throttle_handlerbranch; the IP floor runs before auth so invalid-auth floods still hit a rate cap. All three limits are configurable viaapi.rate_limit.floor_max_requests/unauth_max_requests/auth_max_requestsin the YAML config (orSYNTHORG_API_RATE_LIMIT_FLOOR_MAX_REQUESTS/SYNTHORG_API_RATE_LIMIT_UNAUTH_MAX_REQUESTS/SYNTHORG_API_RATE_LIMIT_AUTH_MAX_REQUESTSenvironment variables for Docker deployments). These are bootstrap-only settings; changes require a restart. The health endpoints (/api/v1/healthzand/api/v1/readyz) are excluded from rate limiting by default viarate_limit.exclude_paths. The WebSocket path is excluded from all three tiers; HTTP-style per-request rate limiting is inappropriate for persistent WebSocket connections. In-memory rate-limit storage is single-replica; multi-replica deployments with shared rate limiting require an external store (not yet supported).Migration note
The legacy
max_requestsfield onRateLimitConfighas been removed. Configurations usingapi.rate_limit.max_requestsare rejected at startup with a validation error directing operators to useunauth_max_requestsandauth_max_requestsinstead. -
Per-operation rate limiting: layered on top of the global three-tier limiter, individual expensive or abuse-prone operations carry a
per_op_rate_limitguard that buckets requests per(operation, subject)via a sliding window. Operations default to bucketing by authenticated user ID; external-facing endpoints (webhooks.receive) bucket by IP. Denials raisePerOperationRateLimitError(HTTP 429,error_code=5001,error_category=rate_limit,retryable=True) with aRetry-Afterheader. Pluggable behind aSlidingWindowStoreprotocol (default: in-memory; Redis reserved for cross-worker fairness). Operators tune individual operations viaapi.per_op_rate_limit.overrideswithout restart; the guard reads the live config on every request. Covers 85+ endpoints across memory, providers, agents, tasks, approvals, workflows, requests, meetings, users, webhooks, custom_rules, ontology, departments, scaling, connections, personalities, reviews, artifacts, backup, oauth, settings, setup, simulations, training, escalations, a2a, and auth ws-ticket. - Per-operation inflight concurrency: a companion middleware
(
PerOpConcurrencyMiddleware) caps simultaneous long-running requests per(operation, subject)for six HIGH-tier endpoints (memory.fine_tuneshared withfine_tune_resume,memory.checkpoint_deploy,memory.checkpoint_rollback,providers.pull_model,providers.discover_models). The sliding-window guard caps burst rate across time; the inflight cap separately enforces "one fine-tune per user at a time" even when the window would let a burst through. Denials raiseConcurrencyLimitExceededError(HTTP 429,error_code=5002, same envelope shape asPerOperationRateLimitError). Pluggable behind anInflightStoreprotocol with the same default/Redis roadmap. Tuned viaapi.per_op_concurrency.overrides.
Notification Security¶
Notification adapter configuration may contain credentials (SMTP passwords,
ntfy tokens, Slack webhook URLs). These values are stored in the params
dict of each NotificationSinkConfig entry in the YAML config.
- Credentials in params: Treat
password,token, andwebhook_urlparams as sensitive. Use environment variable substitution in YAML (${SMTP_PASSWORD}) rather than embedding plain-text secrets. - Log redaction: The observability pipeline's
sanitize_sensitive_fieldsprocessor automatically redacts keys matchingpassword,token, andsecretat all nesting depths, so adapter params are not leaked in logs. - Transport security: The email adapter enforces STARTTLS when
use_tls=true(default). The ntfy and Slack adapters validate that their target URLs use HTTPS before sending (SSRF-safe: private/loopback IPs are rejected).
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 | Per-request nonce generated by Caddy ({http.request.uuid}), substituted into <meta name="csp-nonce"> in index.html via the templates directive, read at runtime by lib/csp.ts, and propagated to CSPProvider (Base UI) and MotionConfig (Motion) so every inline <style> tag the app injects carries the nonce. style-src-elem is locked to 'self' 'nonce-...'; see CSP Nonce Infrastructure below. |
| Session cookies | JWTs are stored in HttpOnly, Secure, SameSite=Strict cookies. JavaScript never accesses the token. CSRF is mitigated via the double-submit cookie pattern (non-HttpOnly CSRF cookie + X-CSRF-Token header). The 401 interceptor clears auth state on session expiry. |
CSP Nonce Infrastructure¶
The dashboard's Content-Security-Policy uses CSP Level 3 directive splitting so that dynamically
injected <style> elements are locked to a per-request nonce while inline style attributes
retain the narrowly-scoped 'unsafe-inline' permission they need for layout positioning.
How the nonce flows end-to-end¶
- Generation.
web/Caddyfileuses Caddy's{http.request.uuid}placeholder, which produces a UUID (128-bit) per request. The value is stable within a single request, so the CSP header and response body both receive the same nonce. Caddy generates the UUID from Go'scrypto/rand. It is cryptographically random. - Injection. The
templatesdirective in the Caddyfile processesweb/index.htmlat response time, substituting{{placeholder "http.request.uuid"}}with the per-request UUID. The meta tag uses single-quoted attribute syntax (content='{{placeholder "http.request.uuid"}}') so the embedded double-quoted Go template placeholder parses cleanly under HTML parsers (parse5, browsers, Vite); the Caddytemplatesengine substitutes the{{...}}token regardless of outer-quote style. Every HTML response ships with a unique nonce in<meta name="csp-nonce" content='...'>. - Header. The
(spa_csp)snippet inweb/Caddyfileemits the CSP with the matching nonce:style-src-elem 'self' 'nonce-{http.request.uuid}'; style-src-attr 'unsafe-inline'. - Runtime read. On page load,
web/src/lib/csp.ts(getCspNonce()) reads the meta tag, rejects the un-substituted Go template placeholder (so local dev where Caddy isn't in the path still works), and caches the value. - Propagation.
web/src/App.tsxpasses the nonce to Base UI's<CSPProvider nonce>and Motion's<MotionConfig nonce>. Every inline<style>element injected by these libraries (keyframes, pop-up animations, motion values, etc.) carries the nonce.
Why the split directives¶
Under CSP Level 2, style-src 'unsafe-inline' allows all inline styles (both <style>
elements and style attributes). CSP Level 3 separates these into two directives:
style-src-elem 'self' 'nonce-...': every<style>element must either come from the same origin or carry the matching nonce. This locks down the higher-risk CSS-injection vector (where an attacker-controlled stylesheet can exfiltrate data via attribute-selector tricks).style-src-attr 'unsafe-inline': inlinestyleattributes on DOM elements are still permitted. Floating UI (used internally by Base UI for Popover/Menu/Select positioning) sets transient inline styles such asstyle="position: fixed; top: ...; left: ..."during layout. Per the CSP specification,styleattributes cannot carry nonces, so this is the only directive value that works for them.
Why style-src-attr 'unsafe-inline' is not a practical XSS vector¶
- Unlike
<script>or<style>elements, astyleattribute cannot execute JavaScript. - Data exfiltration via a
styleattribute is limited to single-element visual manipulation: no CSS selectors, no@import.url(...)loads are still gated byimg-src/font-src. - Same-page UI redress via a malicious
position: fixed; z-index: 99999is a limited-surface attack: an attacker who already has a way to inject a style attribute into the dashboard has broader problems, and every interactive surface the dashboard exposes is either gated by React event handlers (not redressable by a sibling element) or a server-side confirmation step. Note thatX-Frame-Options: DENYdoes NOT mitigate this case; it prevents the dashboard from being framed by a foreign origin, which is a different threat. - The higher-risk CSS-injection vector (attribute-selector exfiltration via a
malicious
<style>element) is eliminated bystyle-src-elem 'self' 'nonce-...'. script-srcremains locked to'self'with no'unsafe-inline'.
Browser support for 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 a browser does not recognise style-src-attr, it falls back to style-src; older browsers without style-src-attr support still receive the same restriction.
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; dashboard uses CSP Level 3 directive splitting: style-src-elem 'self' 'nonce-...' locks <style> elements to the per-request nonce, style-src-attr 'unsafe-inline' covers the transient inline positioning styles set by Floating UI. script-src 'self' with no 'unsafe-inline'. See CSP Nonce Infrastructure. Docs UI location has its own relaxed CSP (inline syntax-highlighting requirement of the Material theme). |
Container Hardening¶
Distroless Runtime¶
The backend runs on a Wolfi-based, apko-composed distroless Python image: no shell, no package manager, minimal attack surface. The build uses a 2-stage Dockerfile:
- Builder: compiles dependencies via uv, fixes venv symlink for Wolfi's Python path
- Runtime: apko-composed Wolfi base (no shell, UID 65532)
Base images are declared in docker/*/apko.yaml with relaxed package specs
(e.g. python-3.14). Exact patch versions are resolved and pinned by
docker/*/apko.lock.json, refreshed weekly by .github/workflows/apko-lock.yml.
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)
- apko lockfiles (
docker/*/apko.lock.json) reconciled weekly by.github/workflows/apko-lock.yml - Renovate auto-updates digests daily for the thin backend and sandbox 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 | Renovate | Daily updates, == pinned versions, grouped by domain |
| Node.js | npm audit |
Per-PR, blocks on critical/high |
| Node.js | Renovate | Daily updates via lockfile (/web, /site, /.github) |
| GitHub Actions | Renovate | Daily updates, pinned by commit SHA |
| Pre-commit hooks | Renovate | Daily updates, version-pinned rev: tags |
| CI binary tools | Renovate | Daily updates via regex managers (Trivy, Gitleaks, D2, apko) |
| 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 before push:
- Trivy: CRITICAL = hard fail, HIGH = warn-only (
.trivyignore.yamlfor vetted CVEs) - CIS Docker Benchmark:
trivy image --compliance docker-cis-1.6.0run against all images (enforced; any FAIL blocks the build)
Images are only pushed to GHCR after vulnerability scans and CIS benchmark 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 | Container vulnerability scanning + CIS compliance | 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 | CSRF is mitigated via the double-submit cookie pattern; Sec-Fetch-* headers are defence-in-depth but not required, and enforcing them would break non-browser API 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 |
| Application Error Disclosure | 90022 | Ignore | SynthOrg's RFC 9457 / ProblemDetail envelopes set ErrorCategory.INTERNAL with the title "Internal Server Error" (see category_title in src/synthorg/core/error_taxonomy.py), so the rule's substring match will trigger for our legitimate structured 5xx responses. Regression coverage for actual debug-page leaks lives in the exception-handler unit tests. |
| Debug Error Messages | 10023 | Ignore | Same trigger and rationale as 90022. |
| Cookie No HttpOnly Flag | 10010 | Ignore | The csrf_token cookie is intentionally configured non-HttpOnly (httponly=False in src/synthorg/api/auth/cookies.py) as part of the double-submit CSRF pattern: the frontend reads the cookie and echoes its value back in the X-CSRF-Token header. Suppressing the rule prevents recurring noise on this intentional configuration. The auth/session cookie itself is HttpOnly. |
| Authentication Request Identified | 10111 | Ignore | Under the current ZAP ruleset, this rule labels endpoints carrying a password field for the scanner's own auth-flow inference; not a security finding. |
| Sensitive Information in URL | 10024 | Ignore | Under the current ZAP ruleset matching behavior, this rule fires when a URL contains substrings such as session. In SynthOrg, session_id query parameters reference domain runtime/agent session resources (a workflow ID), not HTTP/auth session tokens; auth state is carried in HttpOnly cookies. Behavior can vary across ZAP versions, so revisit on upgrade. |
The rules file is reviewed when ZAP or the API surface changes.
When upgrading the ZAP action, the bundled ZAP version, or the
ruleset it uses, revisit each Ignore row above to confirm the
underlying rule's matcher behavior, severity, and rule ID have not
changed. Action wrapper bumps and ZAP-engine bumps both alter what
each rule fires on; do not skip the revisit just because the action
version changed by a single minor bump.
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.