diff --git a/.dockerignore b/.dockerignore index e5d1b0e520..56f4fc506e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,10 +23,8 @@ docs/ # Documentation build output (Zensical) _site/ -# Astro / Node.js (landing page) -site/node_modules/ -site/dist/ -site/.astro/ +# Astro landing page (not needed by any Docker build) +site/ # Virtual environments .venv/ @@ -39,6 +37,9 @@ __pycache__/ .ruff_cache/ .pytest_cache/ +# Hypothesis test database +.hypothesis/ + # Coverage htmlcov/ coverage.xml @@ -57,6 +58,9 @@ coverage.xml .idea/ .vscode/ +# CI/build utility scripts (not needed in Docker builds) +scripts/ + # CLI (Go binary, not needed in Docker builds) cli/ @@ -71,10 +75,12 @@ logs/ Thumbs.db .DS_Store -# Web dashboard build artifacts +# Web dashboard build artifacts and test infrastructure web/node_modules/ web/dist/ web/.env +web/__tests__/ +web/vitest.config.* # uv .python-version diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 37519e5a85..8e76ab61c3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -110,24 +110,59 @@ jobs: id: scan-ref run: echo "ref=ghcr.io/aureliolo/synthorg-backend:sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - - name: Trivy scan (critical — hard fail) + # Single Trivy run for CRITICAL + HIGH (saves ~30s vs two separate runs). + # JSON output lets us fail on CRITICAL only while still reporting HIGH. + - name: Trivy scan uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: image-ref: ${{ steps.scan-ref.outputs.ref }} - format: table - exit-code: "1" - severity: CRITICAL + format: json + output: trivy-backend.json + exit-code: "0" + severity: CRITICAL,HIGH trivyignores: .github/.trivyignore.yaml - - name: Trivy scan (high — warn only) - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 - continue-on-error: true + - name: Evaluate Trivy results + run: | + if [ ! -f trivy-backend.json ]; then + echo "::error::trivy-backend.json not found — Trivy scan may have failed to produce output" + exit 1 + fi + echo "## Trivy Scan — Backend" + TOTAL=$(jq '[.Results[]?.Vulnerabilities[]?] | length' trivy-backend.json) + if [ "$TOTAL" -eq 0 ]; then + echo "No CRITICAL or HIGH vulnerabilities found." + echo "## Trivy Scan — Backend: No CRITICAL or HIGH vulnerabilities found." >> "$GITHUB_STEP_SUMMARY" + else + TABLE=$(jq -r ' + ["SEVERITY","CVE","PACKAGE","VERSION","TITLE"], + (.Results[]?.Vulnerabilities[]? | + [.Severity, .VulnerabilityID, .PkgName, .InstalledVersion, (.Title // "")[0:60]]) | + @tsv + ' trivy-backend.json | column -t -s$'\t') + echo "$TABLE" + printf '## Trivy Scan — Backend\n```\n%s\n```\n' "$TABLE" >> "$GITHUB_STEP_SUMMARY" + fi + + CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-backend.json) + HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-backend.json) + echo "Critical: $CRITICAL, High: $HIGH" + echo "**Critical: $CRITICAL, High: $HIGH**" >> "$GITHUB_STEP_SUMMARY" + if [ "$HIGH" -gt 0 ]; then + echo "::warning::Found $HIGH HIGH severity vulnerabilities" + fi + if [ "$CRITICAL" -gt 0 ]; then + echo "::error::Found $CRITICAL CRITICAL vulnerabilities — failing build" + exit 1 + fi + + - name: Upload Trivy report (backend) + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - image-ref: ${{ steps.scan-ref.outputs.ref }} - format: table - exit-code: "0" - severity: HIGH - trivyignores: .github/.trivyignore.yaml + name: trivy-backend-report + path: trivy-backend.json + retention-days: 30 - name: Grype scan uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c # v7.3.2 @@ -137,6 +172,16 @@ jobs: severity-cutoff: critical config: .github/.grype.yaml + # CIS Docker Benchmark v1.6.0 compliance check (uses trivy installed above). + # Informational for now — remove continue-on-error once baseline is clean. + - name: CIS Docker Benchmark (backend) + continue-on-error: true + env: + IMAGE_REF: ${{ steps.scan-ref.outputs.ref }} + run: | + command -v trivy >/dev/null 2>&1 || { echo "::warning::trivy not on PATH, skipping CIS scan"; exit 0; } + trivy image --compliance docker-cis-1.6.0 --format table "$IMAGE_REF" + # Push only after scans pass — prevents publishing vulnerable images. # NOTE: This is a separate build invocation from the scan step. GHA cache # ensures deterministic layer content, but the manifest digest differs due @@ -236,24 +281,58 @@ jobs: id: scan-ref run: echo "ref=ghcr.io/aureliolo/synthorg-web:sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - - name: Trivy scan (critical — hard fail) + # Single Trivy run for CRITICAL + HIGH (saves ~12s vs two separate runs). + - name: Trivy scan uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: image-ref: ${{ steps.scan-ref.outputs.ref }} - format: table - exit-code: "1" - severity: CRITICAL + format: json + output: trivy-web.json + exit-code: "0" + severity: CRITICAL,HIGH trivyignores: .github/.trivyignore.yaml - - name: Trivy scan (high — warn only) - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 - continue-on-error: true + - name: Evaluate Trivy results + run: | + if [ ! -f trivy-web.json ]; then + echo "::error::trivy-web.json not found — Trivy scan may have failed to produce output" + exit 1 + fi + echo "## Trivy Scan — Web" + TOTAL=$(jq '[.Results[]?.Vulnerabilities[]?] | length' trivy-web.json) + if [ "$TOTAL" -eq 0 ]; then + echo "No CRITICAL or HIGH vulnerabilities found." + echo "## Trivy Scan — Web: No CRITICAL or HIGH vulnerabilities found." >> "$GITHUB_STEP_SUMMARY" + else + TABLE=$(jq -r ' + ["SEVERITY","CVE","PACKAGE","VERSION","TITLE"], + (.Results[]?.Vulnerabilities[]? | + [.Severity, .VulnerabilityID, .PkgName, .InstalledVersion, (.Title // "")[0:60]]) | + @tsv + ' trivy-web.json | column -t -s$'\t') + echo "$TABLE" + printf '## Trivy Scan — Web\n```\n%s\n```\n' "$TABLE" >> "$GITHUB_STEP_SUMMARY" + fi + + CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-web.json) + HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-web.json) + echo "Critical: $CRITICAL, High: $HIGH" + echo "**Critical: $CRITICAL, High: $HIGH**" >> "$GITHUB_STEP_SUMMARY" + if [ "$HIGH" -gt 0 ]; then + echo "::warning::Found $HIGH HIGH severity vulnerabilities" + fi + if [ "$CRITICAL" -gt 0 ]; then + echo "::error::Found $CRITICAL CRITICAL vulnerabilities — failing build" + exit 1 + fi + + - name: Upload Trivy report (web) + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - image-ref: ${{ steps.scan-ref.outputs.ref }} - format: table - exit-code: "0" - severity: HIGH - trivyignores: .github/.trivyignore.yaml + name: trivy-web-report + path: trivy-web.json + retention-days: 30 - name: Grype scan uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c # v7.3.2 @@ -263,6 +342,14 @@ jobs: severity-cutoff: critical config: .github/.grype.yaml + - name: CIS Docker Benchmark (web) + continue-on-error: true + env: + IMAGE_REF: ${{ steps.scan-ref.outputs.ref }} + run: | + command -v trivy >/dev/null 2>&1 || { echo "::warning::trivy not on PATH, skipping CIS scan"; exit 0; } + trivy image --compliance docker-cis-1.6.0 --format table "$IMAGE_REF" + # Push only after scans pass — prevents publishing vulnerable images. # NOTE: Separate build invocation; GHA cache ensures deterministic layers, # but manifest digest differs due to SBOM + provenance attestation layers. diff --git a/CLAUDE.md b/CLAUDE.md index 25d88dc2dc..6bd5fbea1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -257,7 +257,7 @@ site/ # Astro landing page (synthorg.io) - Build job runs regardless (catches build failures); deploy job skips on fork PRs (same-repo check via job output) - Cleanup job deletes preview comment and Cloudflare deployments on PR close (pull_request events only) - Concurrency group cancels stale builds on rapid pushes -- **Docker**: `.github/workflows/docker.yml` — builds backend + web images, pushes to GHCR, signs with cosign. SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed, pushed to registry). Scans: Trivy (CRITICAL = hard fail, HIGH = warn-only) + Grype (critical cutoff). CVE triage via `.github/.trivyignore.yaml` and `.github/.grype.yaml`. Images only pushed after scans pass. Triggers on push to main and version tags (`v*`). +- **Docker**: `.github/workflows/docker.yml` — builds backend + web images, pushes to GHCR, signs with cosign. SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed, pushed to registry). Scans: Trivy (CRITICAL = hard fail, HIGH = warn-only) + Grype (critical cutoff) + CIS Docker Benchmark v1.6.0 compliance (informational). CVE triage via `.github/.trivyignore.yaml` and `.github/.grype.yaml`. Images only pushed after scans pass. Triggers on push to main and version tags (`v*`). - **Matrix**: Python 3.14 - **CLI**: `.github/workflows/cli.yml` — Go lint (`golangci-lint` + `go vet`) + test (`-race -coverprofile`) + build (cross-compile matrix: linux/darwin/windows × amd64/arm64) + vulnerability check (`govulncheck`) + fuzz testing (main-only, 30s/target, `continue-on-error`, matrix over 4 packages) on `cli/**` changes. `cli-pass` gate includes fuzz result as informational warning. GoReleaser release on `v*` tags (attaches assets to the draft Release Please release). SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed). Post-release step appends install instructions + checksum table + provenance verification instructions to the draft release notes (while still in draft — before finalize-release publishes). - **Dependabot**: daily uv + github-actions + npm + pre-commit + docker + gomod updates, grouped minor/patch, no auto-merge. Use `/review-dep-pr` to review Dependabot PRs before merging diff --git a/cli/internal/compose/compose.yml.tmpl b/cli/internal/compose/compose.yml.tmpl index dc4110b2b7..92f02df101 100644 --- a/cli/internal/compose/compose.yml.tmpl +++ b/cli/internal/compose/compose.yml.tmpl @@ -18,26 +18,35 @@ services: SYNTHORG_JWT_SECRET: {{yamlStr .JWTSecret}} {{- end}} user: "65532:65532" - # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25) + # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25, 5.28) security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=64m + - /tmp:noexec,nosuid,nodev,size=64m + pids_limit: 256 restart: unless-stopped deploy: resources: limits: - memory: 512M - cpus: "1.0" + memory: 4G + cpus: "2.0" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + # Healthcheck assumes internal port is always 8000 (container-side port + # mapping is fixed). Dockerfile healthcheck reads UVICORN_PORT dynamically, + # but compose-level exec form cannot expand env vars without a shell. healthcheck: - test: ["/usr/bin/python", "-c", "import json,urllib.request,sys; r=urllib.request.urlopen('http://localhost:8000/api/v1/health'); d=json.loads(r.read()); sys.exit(0 if d.get('data',{}).get('status')=='ok' else 1)"] + test: ["CMD", "/usr/bin/python", "-c", "import json,urllib.request,sys; r=urllib.request.urlopen('http://localhost:8000/api/v1/health'); d=json.loads(r.read()); sys.exit(0 if d.get('data',{}).get('status')=='ok' else 1)"] interval: 10s timeout: 5s retries: 3 - start_period: 15s + start_period: 30s web: image: ghcr.io/aureliolo/synthorg-web:{{.ImageTag}} @@ -53,15 +62,21 @@ services: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=16m - - /var/cache/nginx:size=32m - - /var/run:size=1m + - /tmp:noexec,nosuid,nodev,size=16m + - /var/cache/nginx:noexec,nosuid,nodev,size=32m + - /var/run:noexec,nosuid,nodev,size=1m + pids_limit: 64 restart: unless-stopped deploy: resources: limits: - memory: 128M + memory: 256M cpus: "0.5" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" {{- if .Sandbox}} sandbox: @@ -78,14 +93,24 @@ services: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=128m + - /tmp:noexec,nosuid,nodev,size=128m + pids_limit: 128 restart: unless-stopped deploy: resources: limits: memory: 256M cpus: "0.5" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" {{- end}} +networks: + default: + name: synthorg-net + volumes: synthorg-data: diff --git a/cli/testdata/compose_custom_ports.yml b/cli/testdata/compose_custom_ports.yml index 684f10b670..8d7cac617a 100644 --- a/cli/testdata/compose_custom_ports.yml +++ b/cli/testdata/compose_custom_ports.yml @@ -16,26 +16,35 @@ services: SYNTHORG_LOG_LEVEL: "debug" SYNTHORG_JWT_SECRET: "test-secret-value" user: "65532:65532" - # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25) + # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25, 5.28) security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=64m + - /tmp:noexec,nosuid,nodev,size=64m + pids_limit: 256 restart: unless-stopped deploy: resources: limits: - memory: 512M - cpus: "1.0" + memory: 4G + cpus: "2.0" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + # Healthcheck assumes internal port is always 8000 (container-side port + # mapping is fixed). Dockerfile healthcheck reads UVICORN_PORT dynamically, + # but compose-level exec form cannot expand env vars without a shell. healthcheck: - test: ["/usr/bin/python", "-c", "import json,urllib.request,sys; r=urllib.request.urlopen('http://localhost:8000/api/v1/health'); d=json.loads(r.read()); sys.exit(0 if d.get('data',{}).get('status')=='ok' else 1)"] + test: ["CMD", "/usr/bin/python", "-c", "import json,urllib.request,sys; r=urllib.request.urlopen('http://localhost:8000/api/v1/health'); d=json.loads(r.read()); sys.exit(0 if d.get('data',{}).get('status')=='ok' else 1)"] interval: 10s timeout: 5s retries: 3 - start_period: 15s + start_period: 30s web: image: ghcr.io/aureliolo/synthorg-web:v0.2.0 @@ -51,15 +60,25 @@ services: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=16m - - /var/cache/nginx:size=32m - - /var/run:size=1m + - /tmp:noexec,nosuid,nodev,size=16m + - /var/cache/nginx:noexec,nosuid,nodev,size=32m + - /var/run:noexec,nosuid,nodev,size=1m + pids_limit: 64 restart: unless-stopped deploy: resources: limits: - memory: 128M + memory: 256M cpus: "0.5" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + default: + name: synthorg-net volumes: synthorg-data: diff --git a/cli/testdata/compose_default.yml b/cli/testdata/compose_default.yml index e850ea4ad2..e0cfa95e86 100644 --- a/cli/testdata/compose_default.yml +++ b/cli/testdata/compose_default.yml @@ -15,26 +15,35 @@ services: SYNTHORG_MEMORY_DIR: "/data/memory" SYNTHORG_LOG_LEVEL: "info" user: "65532:65532" - # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25) + # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25, 5.28) security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=64m + - /tmp:noexec,nosuid,nodev,size=64m + pids_limit: 256 restart: unless-stopped deploy: resources: limits: - memory: 512M - cpus: "1.0" + memory: 4G + cpus: "2.0" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + # Healthcheck assumes internal port is always 8000 (container-side port + # mapping is fixed). Dockerfile healthcheck reads UVICORN_PORT dynamically, + # but compose-level exec form cannot expand env vars without a shell. healthcheck: - test: ["/usr/bin/python", "-c", "import json,urllib.request,sys; r=urllib.request.urlopen('http://localhost:8000/api/v1/health'); d=json.loads(r.read()); sys.exit(0 if d.get('data',{}).get('status')=='ok' else 1)"] + test: ["CMD", "/usr/bin/python", "-c", "import json,urllib.request,sys; r=urllib.request.urlopen('http://localhost:8000/api/v1/health'); d=json.loads(r.read()); sys.exit(0 if d.get('data',{}).get('status')=='ok' else 1)"] interval: 10s timeout: 5s retries: 3 - start_period: 15s + start_period: 30s web: image: ghcr.io/aureliolo/synthorg-web:latest @@ -50,15 +59,25 @@ services: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=16m - - /var/cache/nginx:size=32m - - /var/run:size=1m + - /tmp:noexec,nosuid,nodev,size=16m + - /var/cache/nginx:noexec,nosuid,nodev,size=32m + - /var/run:noexec,nosuid,nodev,size=1m + pids_limit: 64 restart: unless-stopped deploy: resources: limits: - memory: 128M + memory: 256M cpus: "0.5" + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + default: + name: synthorg-net volumes: synthorg-data: diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 3896ef778e..726ab10c80 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -57,15 +57,11 @@ WORKDIR /app COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/src /app/src -# Fix venv Python symlink: builder points to /usr/local/bin/python3, -# but Chainguard Python is at /usr/bin/python -RUN rm -f /app/.venv/bin/python && ln -s /usr/bin/python /app/.venv/bin/python - -# Create data directory for persistent volumes -RUN mkdir -p /data && chown 65532:65532 /data - -# Set final ownership -RUN chown -R 65532:65532 /app +# Fix venv Python symlink (builder points to /usr/local/bin/python3, +# Chainguard is /usr/bin/python), create data dir, set ownership. +RUN rm -f /app/.venv/bin/python && ln -s /usr/bin/python /app/.venv/bin/python \ + && mkdir -p /data && chown 65532:65532 /data \ + && chown -R 65532:65532 /app # Drop root after setup — all stages must end as non-root USER 65532 @@ -113,7 +109,7 @@ EXPOSE 8000 # Healthcheck — exec form (no shell needed, works with distroless) # Reads UVICORN_PORT so the check stays valid if the port is overridden. -HEALTHCHECK --interval=10s --timeout=5s --retries=3 --start-period=15s \ +HEALTHCHECK --interval=10s --timeout=5s --retries=3 --start-period=30s \ CMD ["/usr/bin/python", "-c", "import json,urllib.request,sys,os; port=os.environ.get('UVICORN_PORT','8000'); r=urllib.request.urlopen(f'http://localhost:{port}/api/v1/health'); d=json.loads(r.read()); sys.exit(0 if d.get('data',{}).get('status')=='ok' else 1)"] # Reset Chainguard base entrypoint (/usr/bin/python) so CMD runs uvicorn diff --git a/docker/compose.yml b/docker/compose.yml index 2252e90f4c..57ec08ad99 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -1,3 +1,9 @@ +x-logging: &logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + services: backend: build: @@ -16,15 +22,25 @@ services: # Bridge SYNTHORG_* to uvicorn's native env vars (exec form CMD, no shell) UVICORN_HOST: "${SYNTHORG_HOST:-0.0.0.0}" UVICORN_PORT: "${SYNTHORG_PORT:-8000}" - # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25) + user: "65532:65532" + # Healthcheck defined in Dockerfile (exec form, works with distroless). + # CLI template overrides it in compose; here we rely on the image-level check. + # CIS Docker Benchmark v1.6.0 hardening (5.3, 5.12, 5.25, 5.28) security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=64m + - /tmp:noexec,nosuid,nodev,size=64m + pids_limit: 256 restart: unless-stopped + deploy: + resources: + limits: + memory: 4G + cpus: "2.0" + logging: *logging web: build: @@ -35,16 +51,28 @@ services: depends_on: backend: condition: service_healthy + user: "101:101" security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - - /tmp:noexec,nosuid,size=16m - - /var/cache/nginx:size=32m - - /var/run:size=1m + - /tmp:noexec,nosuid,nodev,size=16m + - /var/cache/nginx:noexec,nosuid,nodev,size=32m + - /var/run:noexec,nosuid,nodev,size=1m + pids_limit: 64 restart: unless-stopped + deploy: + resources: + limits: + memory: 256M + cpus: "0.5" + logging: *logging + +networks: + default: + name: synthorg-net volumes: synthorg-data: diff --git a/docker/sandbox/Dockerfile b/docker/sandbox/Dockerfile index 43d8efa910..7552bdb866 100644 --- a/docker/sandbox/Dockerfile +++ b/docker/sandbox/Dockerfile @@ -2,16 +2,23 @@ FROM node:25-slim@sha256:7a4ef576a570dad463c2e9744e6df118c9c2a3a03a9219122a9dc83 FROM python:3.14.3-slim@sha256:6a27522252aef8432841f224d9baaa6e9fce07b07584154fa0b9a96603af7456 +LABEL org.opencontainers.image.title="synthorg-sandbox" \ + org.opencontainers.image.description="SynthOrg agent code execution sandbox" \ + org.opencontainers.image.url="https://github.com/Aureliolo/synthorg" \ + org.opencontainers.image.source="https://github.com/Aureliolo/synthorg" \ + org.opencontainers.image.licenses="BUSL-1.1" \ + org.opencontainers.image.vendor="Aureliolo" + COPY --from=node-base /usr/local/bin/node /usr/local/bin/node COPY --from=node-base /usr/local/lib/node_modules /usr/local/lib/node_modules -RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm -# git version tracks base image; updated via Dependabot base image bumps +# git version tracks base image; updated via Dependabot base image bumps. +# Symlink npm, install git, create sandbox user — single layer. # hadolint ignore=DL3008 -RUN apt-get update && apt-get install -y --no-install-recommends git \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin sandbox \ +RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && apt-get update && apt-get install -y --no-install-recommends git \ + && apt-get clean && rm -rf /var/lib/apt/lists/* \ + && useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin sandbox \ && mkdir -p /workspace \ && chown sandbox:sandbox /workspace diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile index 2656b71aaa..f1dd92e247 100644 --- a/docker/web/Dockerfile +++ b/docker/web/Dockerfile @@ -13,9 +13,12 @@ COPY --chown=build:build web/package.json web/package-lock.json ./ # Run npm ci as build user so node_modules (including .vite cache) is owned by # the same user that runs the build — avoids permission errors in Vite. USER build -RUN npm ci +# --ignore-scripts: security hardening; safe for current pure-JS deps. +# If a future dep needs postinstall, add `npm rebuild ` after this step. +RUN npm ci --ignore-scripts COPY --chown=build:build web/ ./ -RUN npm run build +RUN npm run build \ + && find /app/dist -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) -size +256c -exec gzip -9 -k {} \; # Stage 2: Serve with nginx FROM nginxinc/nginx-unprivileged:1.29.5-alpine@sha256:aec540f08f99df3c830549d5dd7bfaf63e01cbbb499e37400c5af9f8e8554e9f diff --git a/docs/security.md b/docs/security.md index 45a60ab019..d8d8df1548 100644 --- a/docs/security.md +++ b/docs/security.md @@ -111,7 +111,10 @@ Both backend and web containers enforce CIS v1.6.0 controls in `compose.yml`: |---------|---------| | **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`) | +| **CIS 5.25** | `read_only: true` with `tmpfs` mounts (`noexec`, `nosuid`, `nodev`) | +| **CIS 5.28** | `pids_limit` per container (256 backend, 64 web) | + +Resource limits (`deploy.resources.limits`) cap memory and CPU per container (4G/2CPU backend, 256M/0.5CPU web). Log rotation (`json-file` driver, `max-size: 10m`, `max-file: 3`) prevents disk exhaustion. ### Artifact Provenance @@ -144,8 +147,9 @@ Every container image is scanned by **two independent tools** before push: - **Trivy** — CRITICAL = hard fail, HIGH = warn-only (`.trivyignore.yaml` for vetted CVEs) - **Grype** — critical severity cutoff (`.grype.yaml` for overrides) +- **CIS Docker Benchmark** — `trivy image --compliance docker-cis-1.6.0` run against both images (informational; will become enforced once baseline is clean) -Images are **only pushed to GHCR after both scanners pass**. +Images are **only pushed to GHCR after both vulnerability scanners pass**. ### Signed Artifacts diff --git a/web/nginx.conf b/web/nginx.conf index 10d63914e1..7cb6c03ce6 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -11,9 +11,12 @@ server { root /usr/share/nginx/html; index index.html; - # Gzip compression + # Gzip: serve pre-compressed .gz files built by Vite stage; fall back to + # on-the-fly compression for anything not pre-compressed. + gzip_static on; gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml; + gzip_vary on; + gzip_types text/plain text/css application/json application/javascript text/xml image/svg+xml; gzip_min_length 256; # Security headers