Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .env.prod.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
# Copy to .env.prod and fill in real secrets, then:
# echo -n "$METRICS_TOKEN" > ops/secrets/metrics_token
# Copy to .env.prod and fill in real secrets. EVERY secret is delivered to its
# container as a MOUNTED FILE (not an env var, so nothing shows up in
# `docker inspect` / /proc/<pid>/environ). The values below seed those files.
# Materialise them all from .env.prod, then bring the stack up:
#
# set -a; . ./.env.prod; set +a
# mkdir -p ops/secrets
# printf %s "$JWT_SECRET" > ops/secrets/jwt_secret
# printf %s "$STATS_TOKEN" > ops/secrets/stats_token
# printf %s "$METRICS_TOKEN" > ops/secrets/metrics_token
# printf %s "$POSTGRES_PASSWORD" > ops/secrets/pg_password
# printf %s "$REDIS_PASSWORD" > ops/secrets/redis_password
# printf %s "$GRAFANA_ADMIN_PASSWORD" > ops/secrets/grafana_admin_password
# printf 'redis://:%s@redis:6379/0' "$REDIS_PASSWORD" > ops/secrets/redis_url
# printf 'postgresql://tensies:%s@postgres:5432/tensies' "$POSTGRES_PASSWORD" > ops/secrets/pg_dsn
# docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build
#
# .env.prod and ops/secrets/ are gitignored — never commit real secrets.

# Strong, unique values (e.g. `openssl rand -hex 32`):
Expand Down
112 changes: 112 additions & 0 deletions .github/workflows/image-security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Image supply-chain gate (phase-3 security layer).
# scan — build the web image, fail the PR on fixable HIGH/CRITICAL CVEs
# (Trivy), and publish an SBOM artifact (Syft / SPDX). Runs on every
# PR and on main.
# publish — main only: build + push the web and nginx images to GHCR with SLSA
# provenance + SBOM attestations, then keyless-sign them with cosign
# (Sigstore OIDC — no long-lived keys). Signing needs a pushed digest,
# which is why publishing lives here rather than in the build-only
# `docker` job in ci.yml.
#
# Action versions are major-tag pinned to match ci.yml; SHA-pinning is the same
# deferred follow-up noted there.
name: image-security

on:
pull_request:
push:
branches: [main]

concurrency:
group: imgsec-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
scan:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- name: Build app image (web stage), load into the local daemon
uses: docker/build-push-action@v7
with:
context: .
target: web
load: true
tags: tensies:scan
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Trivy scan — fail on fixable HIGH/CRITICAL (OS + Python deps)
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: tensies:scan
format: table
exit-code: "1"
severity: HIGH,CRITICAL
ignore-unfixed: true
vuln-type: os,library
- name: Generate SBOM (SPDX JSON) and upload as a build artifact
uses: anchore/sbom-action@v0
with:
image: tensies:scan
format: spdx-json
output-file: tensies-web.sbom.spdx.json
upload-artifact: true

publish:
# Push + sign only the immutable main builds; PRs just scan above.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: scan
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write # push images to GHCR
id-token: write # cosign keyless signing (Sigstore OIDC)
env:
IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: sigstore/cosign-installer@v3
- name: Build & push web (with provenance + SBOM attestations)
id: web
uses: docker/build-push-action@v7
with:
context: .
target: web
push: true
tags: |
${{ env.IMAGE }}/web:latest
${{ env.IMAGE }}/web:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Build & push nginx (with provenance + SBOM attestations)
id: nginx
uses: docker/build-push-action@v7
with:
context: .
target: nginx
push: true
tags: |
${{ env.IMAGE }}/nginx:latest
${{ env.IMAGE }}/nginx:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
- name: Cosign keyless-sign both images by digest
run: |
cosign sign --yes "${IMAGE}/web@${{ steps.web.outputs.digest }}"
cosign sign --yes "${IMAGE}/nginx@${{ steps.nginx.outputs.digest }}"
91 changes: 69 additions & 22 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,55 +25,102 @@ RUN node scripts/build_assets.mjs
# ── Stage 2: nginx serving the prebuilt dist straight from disk ───────────────
# Serves everything under /static (sendfile + gzip_static + immutable) and
# proxies the rest to the app. Config + dist are baked in (no runtime volumes).
FROM nginx:1.31.2-alpine AS nginx
# The *unprivileged* image runs the master process as a non-root user (uid 101)
# and keeps its pid/temp paths under /tmp, so the container can run with a
# read-only root filesystem and no capabilities (it binds 8080, not 80, so it
# needs no NET_BIND_SERVICE). The companion config (ops/nginx.conf) listens on
# 8080 and routes nginx's pid/temp/log writes to /tmp + stdout/stderr.
FROM nginxinc/nginx-unprivileged:1.31.2-alpine AS nginx
COPY ops/nginx.conf /etc/nginx/nginx.conf
COPY --from=assets /build/dist/static /srv/dist/static

# ── Stage 3: the Python app (default build target) ────────────────────────────
# Pinned to a specific patch tag (intentionally NOT a digest) so local dev
# builds still pick up base-image patch updates. The prod *service* images are
# digest-pinned in docker-compose.prod.yml instead.
FROM python:3.12.8-slim-bookworm AS web
# ── Stage 3a: builder — has the C/C++ toolchain, produces a populated venv ─────
# asyncpg and blspy ship C/C++ extensions that the slim base can't compile
# without gcc + cmake. We build them HERE, into a self-contained virtualenv, and
# copy only that venv into the runtime stage below — so the compiler, headers,
# and apt metadata never reach the shipped image (the largest piece of runtime
# attack surface). Pinned to a patch tag (not a digest) so local builds pick up
# base-image patches; prod *service* images are digest-pinned in compose.
FROM python:3.12.8-slim-bookworm AS pybuild

# Don't write .pyc, unbuffered logs, no pip version chatter.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1

WORKDIR /app

# Install build dependencies for packages with C/C++ extensions (asyncpg, blspy).
# The slim base image lacks gcc and cmake, which are required to compile these
# packages from source. We use --no-install-recommends and clean the apt cache
# to keep the final image size minimal.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
cmake \
&& rm -rf /var/lib/apt/lists/*

# Install deps first for layer caching. Prefer the fully-pinned lock for
# reproducible/prod builds; fall back to requirements.txt if the lock is absent.
# Self-contained venv we can lift wholesale into the runtime image.
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Install deps first for layer caching. Prefer the fully-pinned, hashed lock for
# reproducible/prod builds (installed with --require-hashes so a tampered or
# MITM'd index can't substitute a different artifact); fall back to the unhashed
# requirements.txt only if the lock is absent.
COPY requirements.txt requirements.lock* ./
# Optional build-time CA bundle (BuildKit secret `proxy_ca`): lets pip reach the
# index behind a TLS-intercepting egress proxy. Absent in normal builds -> plain
# pip with the default trust store.
RUN --mount=type=secret,id=proxy_ca \
pip install --no-cache-dir \
$(test -s /run/secrets/proxy_ca && echo --cert=/run/secrets/proxy_ca) \
-r $( [ -f requirements.lock ] && echo requirements.lock || echo requirements.txt )
$( [ -f requirements.lock ] \
&& echo "--require-hashes -r requirements.lock" \
|| echo "-r requirements.txt" )

COPY . .
# ── Stage 3b: the Python app (default build target) ───────────────────────────
# Clean slim base with NO build toolchain — only the prebuilt venv and the app
# source. Runs as an unprivileged user with a read-only-friendly layout (writes
# nothing at runtime; PYTHONDONTWRITEBYTECODE keeps it from emitting .pyc).
FROM python:3.12.8-slim-bookworm AS web

# Don't write .pyc, unbuffered logs, no pip version chatter. PATH points at the
# copied venv so `uvicorn`/`python` resolve to the installed dependency set.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PATH="/opt/venv/bin:$PATH"

WORKDIR /app

# Apply the latest Debian security updates to the base image's OS packages
# (openssl, perl-base, …). The pinned python base tag lags Debian's security
# repo, so a fresh build still ships fixable CVEs that the image-security scan
# gates on; upgrading in place clears them. apt lists are removed so nothing
# extra ships, and the setuid strip in the final RUN below covers any new bits.
RUN apt-get update \
&& apt-get -y upgrade \
&& rm -rf /var/lib/apt/lists/*

# Lift the compiled dependency set from the builder. No gcc/cmake/apt-lists ship.
COPY --from=pybuild /opt/venv /opt/venv

# Copy ONLY what the server needs at runtime: the entrypoint, the app package,
# the DB migrations applied on startup (server/db.py), and the frontend source
# (served directly only in dev; in prod nginx serves /static and the app serves
# the single baked dist/index.html below). Everything else in the repo —
# scripts/, ops/, tools/, tests/, loadtest.py, build/compose files — stays out
# of the image to shrink the surface and avoid shipping non-runtime files.
COPY main.py ./
COPY server/ ./server/
COPY migrations/ ./migrations/
COPY static/ ./static/

# Bake ONLY the prebuilt index.html. In prod (FRONTEND_DIST=/app/dist, set in
# docker-compose.prod.yml) the app serves this single document — so the CSP
# stays single-sourced in the security middleware — while nginx serves every
# /static asset. The app builds no in-process JS cache and mounts no StaticFiles.
COPY --from=assets /build/dist/index.html /app/dist/index.html

# Run as an unprivileged user, not root.
# Create an unprivileged user, hand it the app tree, and strip every setuid/
# setgid bit in the image so a compromised process can't use a leftover
# privileged helper (su, mount, etc.) to escalate. Done as the last root step.
RUN useradd --create-home --uid 10001 appuser \
&& chown -R appuser:appuser /app
&& chown -R appuser:appuser /app \
&& find / -xdev -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
USER appuser

EXPOSE 8000
Expand Down
Loading
Loading