Security Policy

Security Philosophy

Capacitarr is developed using AI-assisted coding ("vibe coding"). We believe this makes rigorous, transparent security practices more important, not less. Every line of code — whether human-written or AI-generated — passes through the same gauntlet of static analysis, dependency scanning, container scanning, and dynamic application security testing before it reaches a release.

This document is our commitment to transparency. Every security exception, suppression, and allowlist entry is individually documented with rationale — not hidden in config files. We want you to be able to audit our security posture yourself.

We actively welcome independent security assessments. If you run a scan, penetration test, or code review and find something, we want to hear about it. We will prioritize investigating and remediating any findings brought to our attention. See Reporting a Vulnerability below for how to reach us.

Supported Versions

Only the latest stable release receives security fixes. Pre-release versions (alpha, beta, RC) are not covered.

VersionSupported
Latest stable (3.x)
Pre-release (RC, beta)

Reporting a Vulnerability

If you discover a security vulnerability, please report it privately:

  1. GitHub: Open a security advisory (private by default)
  2. Email: Send details to the project maintainer listed in CONTRIBUTORS.md

Do not open a public issue for security vulnerabilities.

What to Include

  • Description of the vulnerability
  • Steps to reproduce
  • Affected version(s)
  • Potential impact assessment
  • Suggested fix (if you have one)

Response Timeline

  • Acknowledgment: Within 72 hours
  • Initial assessment: Within 1 week
  • Fix release: Dependent on severity; critical issues target a patch release within 2 weeks

Security Model

Capacitarr is designed as a self-hosted, single-instance application for home lab environments. The security model reflects this:

Authentication

  • Password authentication: bcrypt-hashed passwords (cost factor 12)
  • JWT sessions: HMAC-SHA256 signed tokens with 24-hour expiry. Set JWT_SECRET for persistent sessions across restarts
  • API keys: SHA-256 hashed before storage; plaintext shown once on generation and never stored
  • Reverse proxy auth: Optional trusted header authentication (AUTH_HEADER) for SSO integration (Authelia, Authentik, Organizr)

Data Protection

  • Integration API keys: Stored in plaintext in the SQLite database. This is an accepted trade-off: full encryption-at-rest would require a master key, adding complexity with minimal practical benefit when the database file is on a user-owned machine. Ensure the /config volume has restrictive file permissions (chmod 600)
  • API key masking: Integration API keys are masked in all API responses (only last 4 characters visible)
  • Cookie security: Set SECURE_COOKIES=true when serving over HTTPS

Network Security

  • SSRF protection: All user-provided URLs are validated to use HTTP or HTTPS schemes only
  • CORS: Same-origin by default; explicit CORS_ORIGINS configuration required for cross-origin access
  • Rate limiting: Three endpoints are rate-limited per IP address to prevent abuse:
    • Login: 10 attempts per 15-minute window
    • Integration connection test: 30 attempts per 5-minute window
    • Engine manual run: 5 attempts per 5-minute window
  • Response body limits: Upstream API responses are capped at 64 MiB via io.LimitReader to prevent denial-of-service from oversized responses
  • Security headers: All responses include:
    • Content-Security-Policy — restricts resource loading to same-origin with per-request cryptographic nonces for inline scripts (script-src uses nonce-based allowlisting; connect-src, font-src, frame-ancestors, base-uri, form-action are same-origin)
    • Strict-Transport-Security — HSTS with 2-year max-age (only when SECURE_COOKIES=true)
    • X-Content-Type-Options: nosniff
    • X-Frame-Options: DENY
    • Referrer-Policy: strict-origin-when-cross-origin
    • Permissions-Policy: camera=(), microphone=(), geolocation=()
    • Cross-Origin-Opener-Policy: same-origin
    • Cross-Origin-Resource-Policy: same-origin
    • X-Permitted-Cross-Domain-Policies: none

CI Security Scanning (SAST + SCA)

Every push and pull request is scanned by 7 static security tools. All are blocking — failures prevent merge. Run all scans locally with: make security:ci

Tool Inventory

ToolTypeWhat It CatchesCI JobBlocking?
gosecSAST (Go)SQL injection, hardcoded credentials, weak crypto, insecure TLS, SSRFlint:go (via golangci-lint)✅ Yes
govulncheckSCA (Go)Known vulnerabilities in Go dependencies (call-graph analysis)security:govulncheck✅ Yes
pnpm auditSCA (Node.js)Known vulnerabilities in npm/pnpm dependenciessecurity:pnpm-audit✅ Yes (see Temporary Workarounds)
Trivy (FS)SCA (multi-lang)Filesystem scan for Go module + Node.js dependency CVEs (HIGH/CRITICAL)security:trivy✅ Yes
Trivy (image)Container scanAlpine OS packages + binary CVEs in the Docker imagesecurity:trivy-image✅ Yes
GitleaksSecret scanningAccidentally committed API keys, passwords, tokens in git historysecurity:gitleaks✅ Yes
SemgrepSAST (multi-lang)The auto config rule set across all Go and Vue/TypeScript source files, plus YAML, Dockerfile, and Bashsecurity:semgrep✅ Yes

Gitleaks Configuration (.gitleaks.toml)

Gitleaks scans the entire git history for accidentally committed secrets. The following paths are allowlisted because they contain intentional example/test credentials, not real secrets:

Allowlisted Path PatternReason
*_test.goGo test files contain fake API keys (valid-api-key-12345, secret12345678) and test JWT tokens used as fixtures in middleware, integration, and auth tests
docs/reference/api/API documentation contains example JWT tokens (eyJhbGciOiJIUzI1NiIs...) and example API keys (ck_a1b2c3d4e5f6...) in curl command examples
docs/plans/Plan documents may reference example credentials in design discussions

All allowlisted patterns are documented in .gitleaks.toml with rationale. Gitleaks remains active for all production source code, configuration files, and scripts.

Semgrep Configuration (.semgrepignore and nosemgrep)

Semgrep scans all Go and Vue/TypeScript source files (every file tracked by git except the marketing site). Test files, utility files, and all production code are scanned.

.semgrepignore exclusion — 1 directory:

Excluded PathFilesReason
site/37 filesCompletely separate Nuxt Content marketing website with its own package.json, dependencies, and deployment. Has no authentication, no database, no API. The ProseCode.vue component uses v-html to render Mermaid SVG diagrams generated by the Mermaid library from trusted markdown — not user input. Would require its own security review.

Files skipped by Semgrep's built-in 1 MB size limit — 7 files:

Skipped FileSizeTypeSecurity Relevance
screenshots/*.png (7 files)1-5 MB eachPNG imagesNone — documentation screenshots, not parseable code

Inline nosemgrep annotations — every suppressed finding with rationale:

FileLineSemgrep RuleRationale
backend/internal/testutil/testutil.go247go.jwt-go.security.jwt.hardcoded-jwt-keyTestJWTSecret is a test-only constant used to sign JWT tokens in unit tests. It is never used in production code.
backend/routes/auth.go92go.lang.security.audit.net.cookie-missing-secureThe Secure flag is set to cfg.SecureCookies which evaluates to true when SECURE_COOKIES=true. Semgrep cannot evaluate runtime configuration.
backend/routes/auth.go106cookie-missing-httponly, cookie-missing-secureThe authenticated cookie is intentionally non-HttpOnly so the Vue SPA can detect auth state via JavaScript. It contains no secrets (just the string "true"). The JWT cookie (which holds the actual token) IS HttpOnly. Secure is conditional as above.
backend/routes/middleware_test.go130go.jwt-go.security.jwt.hardcoded-jwt-keyTest intentionally signs a JWT with the wrong secret ("wrong-secret") to verify the middleware rejects tokens signed with incorrect keys.
backend/routes/middleware_test.go233cookie-missing-httponly, cookie-missing-secureTest request attaches a JWT cookie to simulate browser behavior. HttpOnly/Secure are server-side attributes set when the cookie is issued by the login handler, not when the browser sends the cookie back.
frontend/app/composables/useEventStream.ts214unsafe-formatstringTemplate literal in console.warn uses eventType which is an internal SSE event type name from the server's event bus, not user-supplied input.
frontend/app/pages/help.vue75avoid-v-htmlHTML is pre-rendered at build time by the Vite announcements plugin from developer-authored markdown files in frontend/announcements/. Content is never user-supplied; it ships with each release and is version-controlled.

Inline nolint annotations — every suppressed golangci-lint finding with rationale:

FileLineLinter RuleRationale
backend/internal/cache/cache_test.go185errcheckDeliberate: testing concurrent cache access, return value intentionally ignored
backend/internal/config/config.go85gosec G706Logging trusted env var header name (AUTH_HEADER), not user input
backend/internal/config/config.go92gosec G706Security warning logs trusted env var header name, not user input
backend/internal/db/db.go182gosec G201fmt.Sprintf("PRAGMA table_info(%s)", table) — table name is a hardcoded string from within the function, never user input
backend/internal/engine/score_test.go29unparamvalue is always 10 in tests but the parameter documents intent for the helper function
backend/internal/events/sse_broadcaster.go262errcheckjson.Marshal of a string value cannot fail
backend/internal/integrations/arr_helpers.go246gosec G704URL is from admin-configured integration settings, not user-tainted
backend/internal/integrations/httpclient.go81gosec G704URL is from admin-configured integration settings, not user-tainted
backend/internal/integrations/httpclient.go89gosec G706Sanitized URL, HTTP status code, and duration are safe to log
backend/internal/integrations/httpclient.go137gosec G704URL is from admin-configured integration settings, not user-tainted
backend/internal/integrations/httpclient.go188gosec G704URL is from admin-configured integration settings, not user-tainted
backend/internal/integrations/httpclient.go233gosec G704URL is from admin-configured integration settings, not user-tainted
backend/internal/integrations/jellystat_test.go11gosec G101testJellystatAPIKey is a test fixture constant, not a real credential
backend/internal/integrations/plex.go243exhaustivePlex API only returns movie, show, season, and episode media types
backend/internal/integrations/sonarr.go164exhaustiveSonarr integration only handles show and season types
backend/internal/integrations/sonarr.go212gosec G704URL is from admin-configured integration settings, not user-tainted
backend/internal/notifications/httpclient.go52gosecURL is from admin-configured webhook notification settings
backend/internal/services/auth.go213gosec G706Username is from a trusted reverse proxy header, not user-supplied
backend/internal/services/notification_dispatch_test.go327duplTest structure intentionally similar to related dispatch tests
backend/internal/services/notification_dispatch_test.go348duplTest structure intentionally similar to related dispatch tests
backend/internal/services/poster_overlay.go419gosec G107URL is from *arr metadata (PosterURL → TMDb CDN), not user input
backend/internal/services/schema.go218gosec G201fmt.Sprintf("PRAGMA table_info(%s)", tableName) — table name is a hardcoded string from within the service, never user input
backend/internal/services/version.go153gosecURL is set at construction time (DefaultGitHubReleasesURL), not user-tainted
backend/routes/auth.go92gosecSecure flag conditionally set via cfg.SecureCookies — not all self-hosted environments use HTTPS. Also suppresses Semgrep (see nosemgrep table above)
backend/routes/auth.go106gosecHttpOnly intentionally false: cookie contains no secrets (just "true"), allows SPA auth state detection. Secure conditional as above. Also suppresses Semgrep (see nosemgrep table above)
backend/routes/security_test.go194gosec G101Test fixture API key in integration creation request body, not a real credential

Semgrep partial-parse warnings (files that are scanned but where Semgrep's parser can't fully parse certain syntax):

FileParsedUnparsed LinesReason
Dockerfile~45%Lines 29-62 (multi-stage build args + RUN commands)Semgrep's Dockerfile parser has limited support for complex ARG/RUN syntax. The important security-relevant parts (base images, package installs) are parsed.
frontend/nuxt.config.ts~99.5%<1% of linesComplex TypeScript config structure; nearly fully parsed.
scripts/docker-build.sh~97%~3%Shell script parsing limitation.
scripts/docker-mirror.sh~94%~6%Shell script parsing limitation.

Dynamic Application Security Testing (DAST)

In addition to static analysis, Capacitarr is tested with OWASP ZAP — the industry-standard open-source DAST tool. ZAP makes real HTTP requests with attack payloads against a running instance, testing for the OWASP Top 10 and 50+ additional vulnerability categories.

Run locally: make build && make security:zap

Latest baseline (2026-04-06, pre-release scan for v3.1.0): 119 rules tested (53 active + 66 passive), 118 PASS, 0 FAIL, 1 WARN

CategoryTestsResult
SQL Injection (6 database engines)6✅ All PASS
Cross-Site Scripting (Reflected, Persistent, DOM)5✅ All PASS
Remote Code/Command Execution5✅ All PASS
Server-Side Attacks (XXE, SSTI, SSRF, SOAP)6✅ All PASS
Path Traversal & File Disclosure5✅ All PASS
Known CVEs (Log4Shell, Spring4Shell)4✅ All PASS
Infrastructure (Buffer Overflow, CRLF, Cloud Metadata)16✅ All PASS
Authentication & Session3✅ All PASS
Security Headers & Configuration17✅ All PASS
Information Disclosure12✅ All PASS
Transport Security5✅ All PASS
Passive Authentication & Session5✅ All PASS
Known Vulnerabilities & Miscellaneous18✅ All PASS
Cross-Site & Redirect Attacks (Passive)8✅ All PASS
Unexpected Content-Type (SPA fallback)1⚠️ WARN (expected)

The full test-by-test breakdown with rule IDs is in docs/security/zap-baseline-20260406.md. Previous baselines: 2026-03-24, 2026-03-23, 2026-03-16, 2026-03-10.

Testing cadence: Run DAST scanning (make security:zap) before each release, after significant code changes affecting HTTP handlers or authentication, and periodically as part of routine security hygiene. The baseline should be updated in this document after each scan.

Temporary Workarounds — pnpm audit Registry 410

As of 2026-04-15, the npm registry retired both legacy audit endpoints (/-/npm/v1/security/audits and /-/npm/v1/security/audits/quick), returning HTTP 410 Gone. This breaks pnpm audit on all pnpm 10.x versions and 11.0.0-rc.0. The upstream fix is tracked in pnpm/pnpm#11265.

Workaround applied: The --ignore-registry-errors flag has been added to pnpm audit in both the Makefile (security:ci target) and .github/workflows/ci.yml (security-pnpm-audit job). This prevents the 410 response from failing the CI pipeline.

Impact on security posture: Minimal. The --ignore-registry-errors flag means pnpm audit will pass even if the registry is unreachable, but Trivy filesystem scanning (security:trivy) independently scans all Node.js dependencies for HIGH/CRITICAL CVEs using the same vulnerability databases. The pnpm.overrides policy (see below) continues to enforce patched dependency versions at install time.

When to remove: Once pnpm ships a release that uses the new bulk advisory endpoint (/-/npm/v1/security/advisories/bulk), remove the --ignore-registry-errors flag from the Makefile and CI workflow, and delete this section.

Dependency Override Policy (pnpm.overrides)

When transitive npm dependencies have known vulnerabilities but the upstream parent package (e.g., Nuxt, ESLint) has not yet released an update with a patched version, we use pnpm.overrides in frontend/package.json to force the patched version. This ensures:

  • The security:pnpm-audit CI job continues to enforce zero known vulnerabilities as a blocking gate
  • Shipped Docker images contain patched dependency versions, not just silenced findings
  • The security posture is not weakened by allow_failure or audit --ignore flags

Current overrides (as of 2026-04-05):

PackageOverrideAdvisorySeverityUpstream Dep
minimatch>=5.1.8 / >=9.0.7 / >=10.2.3 (per-major)GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74Highnuxt > nitropack > @vercel/nft > glob, @nuxt/eslint
picomatch2.3.2 (for <2.3.2) / 4.0.4 (for >=4.0.0 <4.0.4)GHSA-c2c7-rcm5-vvqj, GHSA-3v7f-55p6-f55pHigh / Moderate@vite-pwa/nuxt > workbox-build > @rollup/pluginutils, nuxt > unstorage > anymatch
rollup>=4.59.0GHSA-mw96-cpmx-2vgcHighnuxt > vite
serialize-javascript>=7.0.5GHSA-5c6j-r48x-rmvq, GHSA-qj8w-gfj5-8c6vModerate@vite-pwa/nuxt > vite-plugin-pwa > workbox-build > @rollup/plugin-terser
svgo>=4.0.1GHSA-xpqw-6gx7-v673Highnuxt > @nuxt/vite-builder > cssnano > postcss-svgo
simple-git>=3.32.3GHSA-r275-fr43-pm7qCriticalnuxt > @nuxt/devtools
tar>=7.5.11GHSA-9ppj-qmqm-q256Highnuxt > nitropack > @vercel/nft > @mapbox/node-pre-gyp
flatted>=3.4.2GHSA-25h7-pfq9-p65fHigheslint > file-entry-cache > flat-cache
devalue>=5.6.4GHSA-cfw5-2vxh-hr84, GHSA-mwv9-gp5h-frr4Moderate / Lownuxt
unhead>=2.1.11GHSA-g5xx-pwrp-g3fv, GHSA-5339-hvwr-7582Moderate / Lownuxt > @unhead/vue
h3>=1.15.9GHSA-22cc-p3c6-wpvm, GHSA-wr4h-v87w-p3r7, GHSA-72gr-qfp7-vwhw, GHSA-4hxc-9384-m385High / Moderatenuxt > nitropack > h3
yaml>=2.8.3GHSA-48c2-rrv3-qjmpModerate@nuxt/eslint > @nuxt/devtools-kit > vite > yaml
srvx>=0.11.13GHSA-p36q-q72m-gchrModeratenuxt > nitropack > srvx
brace-expansion>=5.0.5 (for <5.0.5) / >=2.0.3 (for >=2.0.0 <2.0.3)GHSA-f886-m6hf-6m8vModeratenuxt > nitropack > @vercel/nft > glob > brace-expansion
node-forge>=1.4.0CVE-2026-33891, CVE-2026-33894, CVE-2026-33895, CVE-2026-33896Highnuxt > @nuxt/cli > listhen > node-forge
lodash>=4.18.0GHSA-r5fr-rjxr-66jc, GHSA-f23m-r3pf-42rhHigh / Moderate@vite-pwa/nuxt > workbox-build > archiver-utils
lodash-es>=4.18.0GHSA-r5fr-rjxr-66jc, GHSA-f23m-r3pf-42rhHigh / Moderate@vite-pwa/nuxt > workbox-build (ESM variant of lodash)
defu>=6.1.5GHSA-737v-mqg7-c878Highnuxt > nitropack, nuxt > c12, nuxt > @nuxt/kit (UnJS config defaults utility)
socket.io-parser>=4.2.6GHSA-677m-j7p3-52f9Highnuxt > @nuxt/devtools > socket.io

When to remove overrides: After upstream packages release versions that natively depend on the patched versions, pnpm audit will pass without overrides. At that point, remove the override entries and verify. Overrides that remain after upstream updates are harmless (they match or are lower than the naturally resolved version) but should be cleaned up for hygiene.

When adding new overrides: Add the override to frontend/package.json, update this table, and include the advisory URL in the commit message. Run pnpm install to regenerate the lockfile and pnpm audit to verify.

Gosec G117 — JSON Secret Field Policy

Gosec rule G117 flags exported struct fields whose JSON key names match secret patterns (password, apiKey, token, secret). The rule aims to prevent accidental serialization of sensitive data — for example, a secret leaking into logs when a struct is formatted with %+v or marshaled to JSON in an error response.

How Capacitarr handles this:

  1. All internal structs use json:"-" tags on secret fields. This includes:
    • config.Config.JWTSecret — application configuration
    • db.AuthConfig.Password and db.AuthConfig.APIKey — user credentials
    • All integration client structs (EmbyClient.APIKey, PlexClient.Token, etc.)

    These fields are never serialized to JSON. The json:"-" tag is the correct structural fix for G117 and prevents accidental exposure regardless of how the struct is used.
  2. Three files are excluded from G117 via per-file linter exclusion. These files define structs where secret-pattern JSON keys are part of the REST API contract:
    • internal/db/models.goIntegrationConfig.APIKey (json:"apiKey") is the user-configured integration credential. It is masked before inclusion in any API response; only the last 4 characters are visible.
    • routes/auth.goLoginRequest.Password (json:"password") accepts the user's password for authentication. This struct is decode-only and is never JSON-encoded.
    • routes/integrations.go — Connection test request accepts an API key. Decode-only, never JSON-encoded.

    These exclusions are defined in backend/.golangci.yml using path+text matching. G117 remains active for all other files — any new struct with a secret-pattern JSON key will be flagged and must be addressed with either a json:"-" tag or an explicit addition to the exclusion list after security review.

Why not a global G117 exclusion? A global exclusion would silently pass any future struct that accidentally exposes a secret field in JSON. The per-file approach ensures that each exemption is explicitly documented and the rest of the codebase remains protected.

Container Hardening

The official Docker image uses a hardened Alpine runtime:

  • Alpine digest pinned: The runtime base image is pinned to a specific SHA-256 digest for reproducible, auditable builds. The digest is updated periodically (or via Renovate Bot) to pick up security patches
  • Package manager removed: apk is deleted after installing runtime dependencies (ca-certificates, tzdata, su-exec). An attacker with code execution cannot install additional tools
  • No curl/wget packages: Healthchecks use busybox wget (built into Alpine's busybox), eliminating the curl package from the attack surface
  • Capabilities dropped: cap_drop: ALL removes all Linux capabilities, then cap_add restores only the 4 needed by the PUID/PGID entrypoint: CHOWN (chown /config), DAC_OVERRIDE (create user in /etc/passwd), SETUID/SETGID (su-exec drops to PUID:PGID). The Go binary itself needs zero capabilities
  • No privilege escalation: no-new-privileges: true prevents any child process from gaining privileges via setuid/setgid binaries
  • Non-root execution: The entrypoint.sh creates a user with the configured PUID/PGID and uses su-exec to drop from root to that user before starting the application

Optional additional hardening for advanced users:

# Add to your docker-compose.yml for maximum lockdown:
services:
  capacitarr:
    read_only: true        # Immutable root filesystem
    tmpfs:
      - /tmp:size=10M      # Writable temp directory in RAM
    user: "1001:1001"      # Fixed UID/GID (replaces PUID/PGID env vars)

Note: read_only: true requires using user: instead of PUID/PGID because the PUID/PGID entrypoint writes to /etc/passwd at startup. The /config volume is always writable regardless of read_only.

Supply Chain Security — Docker Image Pinning

All Docker images used in CI pipelines and local Makefile targets are pinned to specific version tags rather than :latest. This prevents silent supply chain attacks where a compromised upstream image could propagate into our build and security scanning pipeline.

Pinning Policy

  • No :latest tags: Every Docker image reference in CI workflows and Makefile must use a specific version tag (e.g., :0.69.3, :v2.11.4, :3.21)
  • No curl-pipe-to-shell: CI jobs must not download and execute scripts from external URLs at runtime. All tools must be consumed via their official Docker images
  • Makefile ↔ CI parity: Every tool version in CI workflows must match the corresponding version in the Makefile. Both files are updated together. Note: CI uses GitHub Actions (which install tool binaries directly), while the Makefile uses Docker images. Tool versions are kept in sync even though the delivery mechanism differs
  • Digest pinning for runtime image: The production Dockerfile runtime base image (alpine) is pinned to a specific SHA-256 digest for reproducible, auditable builds

Regular Re-evaluation

Pinned Docker image versions are re-evaluated on a regular basis to pick up security patches and new features:

  1. Check each pinned image for newer stable releases
  2. Pull and test updated versions locally with make ci
  3. Update version tags in both Makefile and CI workflows
  4. Update the Dockerfile runtime base image digest if a new Alpine patch is available
  5. Commit with chore(deps): bump <tool> to v<version>

Currently Pinned Images

ImagePinned VersionPurpose
ghcr.io/aquasecurity/trivy0.69.3Filesystem and container image vulnerability scanning
golangci/golangci-lintv2.11.4Go static analysis and linting
zricethezav/gitleaksv8.30.1Secret scanning in git history
semgrep/semgrep1.155.0Multi-language SAST scanning
orhunp/git-cliff2.12.0Changelog generation from commits
goreleaser/goreleaser-action@v6version: v2.14.1Cross-compiled release binary builds (consumed via GitHub Action, not Docker image)
google/go-containerregistry (crane)v0.21.3Docker image mirroring (GHCR → Docker Hub)
ghcr.io/zaproxy/zaproxystableOWASP ZAP DAST scanning (see note below)
alpine3.21Production runtime base image (digest-pinned in Dockerfile)
node24-alpineFrontend build and test
golang1.26.2-alpineBackend build and test
pnpm (CLI)10.32.1Node.js package manager (pinned in Makefile and Dockerfile)

Note on ZAP :stable tag: The OWASP ZAP proxy image (ghcr.io/zaproxy/zaproxy) uses the :stable tag because ZAP does not publish individually versioned image tags. The :stable tag tracks the latest stable release. This is an accepted exception to the pinning policy — ZAP is used only for local DAST scanning (make security:zap), not in CI pipelines, so a compromised image cannot affect builds or releases.

Note on crane: The crane CLI tool (from google/go-containerregistry) is used in the release pipeline to mirror multi-arch Docker images from GHCR to Docker Hub. It is currently installed via curl-pipe-to-shell in the GitHub Actions workflow (release.yml), which is a known deviation from the "no curl-pipe-to-shell" policy. This is being tracked for remediation (e.g., switching to a pre-built Docker image or a pinned GitHub Action).

Next reassessment date: 2026-04-27

Important Caveats

  • AUTH_HEADER trust model: When enabled, Capacitarr unconditionally trusts the configured header. The server must be behind a reverse proxy that sets this header. Direct internet exposure with AUTH_HEADER enabled allows authentication bypass
  • Single-user design: Capacitarr does not implement role-based access control. All authenticated users have full access
  • Local network assumption: The security model assumes the application runs on a trusted local network or behind a reverse proxy