Skip to content

Security hardening

This doc covers the security posture of the v1.1.1 FCC deployment artifacts — Docker images, Helm chart defaults, and recommended production hardening.

Threat model

The FCC stack is designed for trusted-internal deployments: - Team-scale knowledge work (docs, workflows, notebooks) - Research labs running reproducible experiments - Enterprise adopters deploying behind a corporate firewall

It is not designed to be a multi-tenant SaaS — there is no user-level authentication in the framework itself. Authentication must be enforced at the ingress layer (reverse proxy, API gateway, or OAuth2 proxy sidecar).

Container image security

All 4 images share these baseline hardening choices:

Non-root user

Every image runs as UID 1001 (fcc:fcc), created with --no-create-home (except jupyter, which sets HOME=/app). This enforces the baseline Pod Security Standard restricted profile without requiring SecurityContextConstraints.

Minimal base images

Image Base Why
backend python:3.12-slim No build-essential, no git, no shell beyond /bin/sh
frontend nginx:1.27-alpine Multi-stage: node builder discarded, only static + nginx remain
streamlit python:3.12-slim Adds curl for healthcheck only
jupyter python:3.12-slim Adds curl for healthcheck only

Pinned dependencies

  • Base image version pinned via ARG PYTHON_VERSION=3.12 (not :latest)
  • Node version pinned via ARG NODE_VERSION=20
  • nginx version pinned via ARG NGINX_VERSION=1.27
  • Python deps installed from the fcc wheel with transitive pins in pyproject.toml

HEALTHCHECK

Every image declares a HEALTHCHECK directive that probes the documented health endpoint every 30 seconds. This lets container orchestrators (Docker, Kubernetes, Nomad, etc.) detect wedged containers without external probe config.

Image scanning

The docker-publish.yml workflow builds multi-arch images but does not yet run an explicit vulnerability scanner. For production adopters:

# Trivy (recommended)
trivy image ghcr.io/rollingthunderfourtytwo-afk/fcc-backend:1.1.1

# Grype
grype ghcr.io/rollingthunderfourtytwo-afk/fcc-backend:1.1.1

# Docker Scout
docker scout cves ghcr.io/rollingthunderfourtytwo-afk/fcc-backend:1.1.1

Future versions (>= v1.2.0) will add Trivy to the CI pipeline with SARIF upload to GitHub Security tab.

Kubernetes security defaults

The Helm chart enforces these defaults for every pod:

podSecurityContext:
  runAsNonRoot: true
  runAsUser: 1001
  runAsGroup: 1001
  fsGroup: 1001
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: false   # fcc writes to .ai_cache/ — override per-service if not needed
  capabilities:
    drop:
      - ALL
  runAsNonRoot: true

These defaults map to the Pod Security Admission restricted profile with one exception: readOnlyRootFilesystem is false because FCC writes to .ai_cache/ at runtime. If your deployment doesn't use AI call caching, override via:

backend:
  securityContext:
    readOnlyRootFilesystem: true

ServiceAccount

The chart creates a dedicated ServiceAccount with automountServiceAccountToken: falseFCC doesn't call the Kubernetes API from any pod, so the token should never be present inside the container filesystem.

RBAC

The chart creates a namespace-scoped Role (not ClusterRole) with the minimum permissions required:

rules:
  - apiGroups: [""]
    resources: ["configmaps", "secrets"]
    resourceNames:
      - <release>-fcc-config
      - <release>-fcc-secrets
    verbs: ["get", "watch"]

Resource names are scoped to FCC's own ConfigMap and Secret — the service account cannot read any other configmaps/secrets in the namespace.

NetworkPolicies (opt-in)

The default chart does not ship NetworkPolicies because they depend on a cluster-installed CNI (Calico, Cilium, etc.) that supports them. For production, recommended policies:

# deny-all default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: fcc-default-deny
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/part-of: fcc-agent-team
  policyTypes: [Ingress, Egress]

# allow ingress from frontend → backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: fcc-frontend-to-backend
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/component: backend
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app.kubernetes.io/component: frontend
      ports:
        - port: 8765
          protocol: TCP

Secret management

The Helm chart ships an optional Secret template that only renders when at least one credential is supplied via values. For production, do NOT put secrets in values.yaml — use one of:

Option A: Pre-created Secret (simplest)

kubectl create secret generic fcc-secrets \
  --namespace fcc \
  --from-literal=ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
  --from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
  --from-literal=JUPYTER_TOKEN="$(openssl rand -hex 32)"

Then install without secret-related values — the chart references the secret by name (<release>-fcc-secrets).

Option B: External Secrets Operator

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: fcc-secrets
  namespace: fcc
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: fcc-fcc-secrets  # matches the chart's include "fcc.secretName"
    creationPolicy: Owner
  data:
    - secretKey: ANTHROPIC_API_KEY
      remoteRef:
        key: prod/fcc/anthropic-api-key
    - secretKey: JUPYTER_TOKEN
      remoteRef:
        key: prod/fcc/jupyter-token

Option C: Sealed Secrets

echo -n "$ANTHROPIC_API_KEY" | kubectl create secret generic fcc-secrets \
  --dry-run=client --from-file=ANTHROPIC_API_KEY=/dev/stdin -o yaml | \
  kubeseal --format yaml > sealed-fcc-secrets.yaml
kubectl apply -f sealed-fcc-secrets.yaml

TLS and ingress

The chart does NOT terminate TLS itself — it relies on the cluster's ingress controller (nginx-ingress, Traefik, cert-manager) to handle certificates.

Production pattern with cert-manager:

frontend:
  ingress:
    enabled: true
    className: nginx
    annotations:
      cert-manager.io/cluster-issuer: letsencrypt-prod
      nginx.ingress.kubernetes.io/websocket-services: "{{ .Release.Name }}-backend"
      nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
      nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    hosts:
      - host: fcc.example.com
        paths:
          - path: /
            pathType: Prefix
    tls:
      - secretName: fcc-tls
        hosts:
          - fcc.example.com

The long proxy timeouts are required for WebSocket connections to stay open through the ingress.

JupyterLab security

JupyterLab with no token is unsafe. The Dockerfile default is an empty token for local-dev convenience only. In production:

  1. Set jupyter.token to a strong random value
  2. Only expose JupyterLab to authenticated users (behind an OAuth2 proxy or via VPN)
  3. Never enable jupyter.token="" on internet-accessible deployments

The production overlay (values-prod.yaml) uses ${JUPYTER_TOKEN:?required} to force the caller to supply a token, otherwise Helm refuses to install.

Supply chain

  • Source repo: https://github.com/rollingthunderfourtytwo-afk/l2_fcc_agent_team_ext
  • All commits on main are signed by GitHub's web UI or the maintainer's gpg key
  • Images are built via a reproducible GitHub Actions workflow
  • PyPI publishing via trusted publisher (OIDC) — no long-lived API tokens

See also