diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index c9595d6b0f..faf0cab5e4 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -1,7 +1,7 @@ --- # This workflow builds and pushes Docker images to GHCR name: Build Docker Images -permissions: {} +permissions: { } "on": workflow_call: inputs: @@ -12,7 +12,7 @@ permissions: {} apps: required: true type: string - description: 'JSON array of apps to build (e.g., [{"name": "testapp", "dockerfile": "apps/testapp/Dockerfile"}])' + description: 'JSON array of apps (e.g., [{"name": "testapp", "dockerfile": "apps/testapp/Dockerfile"}])' jobs: build-images: @@ -48,3 +48,13 @@ jobs: push: true platforms: linux/amd64,linux/arm64 tags: ghcr.io/${{ github.repository_owner }}/${{ matrix.app.name }}:${{ inputs.image-tag }} + + - name: Scan pushed image with Trivy + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-type: image + image-ref: ghcr.io/${{ github.repository_owner }}/${{ matrix.app.name }}:${{ inputs.image-tag }} + scanners: vuln,secret + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: "1" diff --git a/.just/security.just b/.just/security.just new file mode 100644 index 0000000000..662de1185e --- /dev/null +++ b/.just/security.just @@ -0,0 +1,37 @@ +# Security scanning with Trivy (https://trivy.dev) + +trivy_image := "aquasec/trivy:latest" +trivy_severity := env("TRIVY_SEVERITY", "CRITICAL,HIGH") +trivy_cache_volume := "trivy-cache" +scan_images := env("SCAN_IMAGES", "evstack:local-dev") + +trivy_run := "docker run --rm -v " + trivy_cache_volume + ":/root/.cache/ -e TRIVY_SEVERITY=" + trivy_severity + +# Run all Trivy security scans (filesystem + Docker images) +trivy-scan: trivy-scan-fs trivy-scan-image + +# Scan repo for dependency vulnerabilities, misconfigs, and secrets +trivy-scan-fs: + @echo "--> Scanning repository filesystem with Trivy" + @{{trivy_run}} \ + -v {{justfile_directory()}}:/workspace \ + {{trivy_image}} \ + fs --scanners vuln,misconfig,secret \ + --severity {{trivy_severity}} \ + --exit-code 1 \ + /workspace + @echo "--> Filesystem scan complete" + +# Scan built Docker images for vulnerabilities +trivy-scan-image: + @echo "--> Scanning Docker images with Trivy" + @for img in {{scan_images}}; do \ + echo "--> Scanning image: $img"; \ + {{trivy_run}} \ + -v /var/run/docker.sock:/var/run/docker.sock \ + {{trivy_image}} \ + image --severity {{trivy_severity}} \ + --exit-code 1 \ + $img; \ + done + @echo "--> Image scan complete" diff --git a/CHANGELOG.md b/CHANGELOG.md index 52894d9d00..3523d22d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changes - Store pending blocks separately from executed blocks key. [#3073](https://github.com/evstack/ev-node/pull/3073) +- **BREAKING:** Docker images for `evm`, `testapp`, and `local-da` now run as non-root user `ev-node` (uid 1000) instead of `root`. Existing volumes or bind mounts with root-owned files may require a `chown` to uid 1000. See the [migration guide](https://ev.xyz/guides/migrate-docker-nonroot). [`#3082`](https://github.com/evstack/ev-node/pull/3082) - Fixes issues with force inclusion verification on sync nodes. [#3057](https://github.com/evstack/ev-node/pull/3057) - Add flag to `local-da` to produce empty DA blocks (closer to the real system). [#3057](https://github.com/evstack/ev-node/pull/3057) diff --git a/apps/evm/Dockerfile b/apps/evm/Dockerfile index 009111fce8..2f378ed127 100644 --- a/apps/evm/Dockerfile +++ b/apps/evm/Dockerfile @@ -1,26 +1,32 @@ -FROM golang:1.25-alpine AS build-env +FROM golang:1.26-bookworm AS build-env WORKDIR /src COPY core core COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download COPY . . -WORKDIR /src/apps/evm -RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o evm . +RUN --mount=type=cache,target=/go/pkg/mod \ + cd ./apps/evm && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/evm . -FROM alpine:3.22.2 -#hadolint ignore=DL3018 -RUN apk --no-cache add ca-certificates curl +FROM busybox:1.36.1-musl AS busybox -WORKDIR /root +FROM scratch -COPY --from=build-env /src/apps/evm/evm /usr/bin/evm -COPY apps/evm/entrypoint.sh /usr/bin/entrypoint.sh -RUN chmod +x /usr/bin/entrypoint.sh -ENTRYPOINT ["/usr/bin/entrypoint.sh"] +COPY --from=build-env /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build-env /out/evm /usr/bin/evm +COPY --from=build-env /src/apps/evm/entrypoint.sh /usr/bin/entrypoint.sh +COPY --from=busybox /bin/busybox /bin/busybox + +ENV HOME=/home/ev-node +WORKDIR /home/ev-node +USER 10001:10001 + +ENTRYPOINT ["/bin/busybox", "sh", "/usr/bin/entrypoint.sh"] diff --git a/apps/evm/docker-compose.yml b/apps/evm/docker-compose.yml index 4931aa2df9..4043f43df9 100644 --- a/apps/evm/docker-compose.yml +++ b/apps/evm/docker-compose.yml @@ -14,7 +14,7 @@ services: volumes: - ./chain:/root/chain:ro - ./jwttoken:/root/jwt:ro - - reth:/home/reth/eth-home + - ev-reth-data:/home/reth/eth-home entrypoint: /bin/sh -c command: - | @@ -39,6 +39,13 @@ services: --txpool.max-account-slots 2048 \ --txpool.max-new-txns 2048 \ --txpool.additional-validation-tasks 16 \ + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp networks: - evolve-network @@ -47,6 +54,13 @@ services: ports: - "7980:7980" command: ["-listen-all"] + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp networks: - evolve-network @@ -61,7 +75,7 @@ services: - "7676:7676" # p2p - "7331:7331" # rpc volumes: - - evm-single-data:/root/.evm/ + - ev-node-evm-data:/home/ev-node/.evm/ restart: always entrypoint: /usr/bin/entrypoint.sh command: start @@ -75,12 +89,19 @@ services: - DA_ADDRESS=http://local-da:7980 # http://localhost:26658 (Use if not using local-da) # - DA_AUTH_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJBbGxvdyI6WyJwdWJsaWMiLCJyZWFkIiwid3JpdGUiXSwiTm9uY2UiOiJQcEswTmhyWi9IY05NWkVtUG9sSXNpRTRDcUpMdE9mbWtBMW0zMWFUaEswPSIsIkV4cGlyZXNBdCI6IjAwMDEtMDEtMDFUMDA6MDA6MDBaIn0.gaWh6tS6Rel1XFYclDkapNnZlaZVjrikCRNBxSDkCGk # - DA_NAMESPACE=00000000000000000000000000000000000000000008e5f679bf7116c1 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp networks: - evolve-network volumes: - evm-single-data: - reth: + ev-node-evm-data: + ev-reth-data: networks: evolve-network: diff --git a/apps/grpc/Dockerfile b/apps/grpc/Dockerfile index e926eadfaa..6f195e8924 100644 --- a/apps/grpc/Dockerfile +++ b/apps/grpc/Dockerfile @@ -1,61 +1,34 @@ -# Build stage -FROM golang:1.25-alpine AS builder +FROM golang:1.26-bookworm AS builder -#hadolint ignore=DL3018 -RUN apk add --no-cache git gcc musl-dev linux-headers - -# Set working directory WORKDIR /ev-node -# Copy go mod files COPY go.mod go.sum ./ COPY apps/grpc/go.mod apps/grpc/go.sum ./apps/grpc/ COPY core/go.mod core/go.sum ./core/ COPY execution/grpc/go.mod execution/grpc/go.sum ./execution/grpc/ -# Download dependencies -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download -# Copy source code COPY . . -# Build the application -WORKDIR /ev-node/apps/grpc -RUN go build -o evgrpc . - -# Runtime stage -FROM alpine:3.22.2 - -#hadolint ignore=DL3018 -RUN apk add --no-cache ca-certificates curl +RUN mkdir -p /home/ev-node +RUN --mount=type=cache,target=/go/pkg/mod \ + cd ./apps/grpc && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/evgrpc . -# Create non-root user -RUN addgroup -g 1000 ev-node && \ - adduser -u 1000 -G ev-node -s /bin/sh -D ev-node +FROM scratch -# Set working directory +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder --chown=10001:10001 /home/ev-node /home/ev-node WORKDIR /home/ev-node -# Copy binary from builder -COPY --from=builder /ev-node/apps/grpc/evgrpc /usr/local/bin/ +COPY --from=builder /out/evgrpc /usr/local/bin/evgrpc +USER 10001:10001 -# Create necessary directories -RUN mkdir -p /home/ev-node/.evgrpc && \ - chown -R ev-node:ev-node /home/ev-node - -# Switch to non-root user -USER ev-node - -# Expose ports -# P2P port EXPOSE 26656 -# RPC port EXPOSE 26657 -# Prometheus metrics EXPOSE 26660 -# Set entrypoint -ENTRYPOINT ["evgrpc"] - -# Default command +ENTRYPOINT ["/usr/local/bin/evgrpc"] CMD ["start"] diff --git a/apps/grpc/docker-compose.yml b/apps/grpc/docker-compose.yml index bcb1072697..bcbaeee951 100644 --- a/apps/grpc/docker-compose.yml +++ b/apps/grpc/docker-compose.yml @@ -4,17 +4,19 @@ services: # Local DA service for development local-da: build: - context: ../../../ + context: ../../ dockerfile: tools/local-da/Dockerfile ports: - "7980:7980" environment: - DA_NAMESPACE=00000000000000000000000000000000000000000000000000000000deadbeef - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:7980/health"] - interval: 5s - timeout: 3s - retries: 5 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp # Example gRPC execution service (replace with your implementation) # execution-service: @@ -32,11 +34,11 @@ services: # Evolve node with gRPC execution client evolve-grpc: build: - context: ../../../ + context: ../../ dockerfile: apps/grpc/Dockerfile depends_on: local-da: - condition: service_healthy + condition: service_started # execution-service: # condition: service_healthy ports: @@ -48,10 +50,10 @@ services: - DA_NAMESPACE=00000000000000000000000000000000000000000000000000deadbeef - GRPC_EXECUTOR_URL=http://host.docker.internal:50051 # Change to your execution service volumes: - - evolve-data:/home/evolve/.evgrpc + - evolve-data:/home/ev-node/.evgrpc command: - start - - --root-dir=/home/evolve/.evgrpc + - --root-dir=/home/ev-node/.evgrpc - --da.address=${DA_ADDRESS:-http://local-da:7980} - --da.namespace=${DA_NAMESPACE} - --grpc-executor-url=${GRPC_EXECUTOR_URL:-http://host.docker.internal:50051} @@ -59,6 +61,13 @@ services: - --rpc.laddr=tcp://0.0.0.0:26657 - --instrumentation.prometheus=true - --instrumentation.prometheus-listen-addr=0.0.0.0:26660 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp volumes: evolve-data: diff --git a/apps/testapp/Dockerfile b/apps/testapp/Dockerfile index fe3701947c..46d5c9eaa1 100644 --- a/apps/testapp/Dockerfile +++ b/apps/testapp/Dockerfile @@ -1,34 +1,27 @@ -FROM golang:1.25 AS base - -#hadolint ignore=DL3018 -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - build-essential \ - ca-certificates \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# enable faster module downloading. -ENV GOPROXY=https://proxy.golang.org - -## builder stage. -# -FROM base AS builder +FROM golang:1.26-bookworm AS builder WORKDIR /ev-node -# Copy all source code first +COPY go.mod go.sum ./ +COPY apps/testapp/go.mod apps/testapp/go.sum ./apps/testapp/ +COPY core/go.mod core/go.sum ./core/ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + COPY . . +RUN mkdir -p /home/ev-node +RUN --mount=type=cache,target=/go/pkg/mod \ + cd ./apps/testapp && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/testapp . -# Now download dependencies and build -RUN go mod download && cd apps/testapp && go install . +FROM scratch -## prep the final image. -# -FROM base +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder --chown=10001:10001 /home/ev-node /home/ev-node +COPY --from=builder /out/testapp /usr/bin/testapp -COPY --from=builder /go/bin/testapp /usr/bin +WORKDIR /home/ev-node -WORKDIR /apps +USER 10001:10001 ENTRYPOINT ["testapp"] diff --git a/apps/testapp/out/testapp b/apps/testapp/out/testapp new file mode 100755 index 0000000000..a75432a581 Binary files /dev/null and b/apps/testapp/out/testapp differ diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9b1def7546..34753c21fb 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -309,6 +309,10 @@ function sidebarHome() { text: "Migrating to ev-abci", link: "/guides/migrating-to-ev-abci", }, + { + text: "Migrate Docker to non-root", + link: "/guides/migrate-docker-nonroot", + }, { text: "Create genesis for your chain", link: "/guides/create-genesis", diff --git a/docs/guides/migrate-docker-nonroot.md b/docs/guides/migrate-docker-nonroot.md new file mode 100644 index 0000000000..8f0f41a7d9 --- /dev/null +++ b/docs/guides/migrate-docker-nonroot.md @@ -0,0 +1,112 @@ +# Migrating Docker Containers to Non-Root User + +Starting with this release, the `evm`, `testapp`, and `local-da` Docker images run as a non-root user (`ev-node`, uid/gid 1000) instead of `root`. This aligns with the `grpc` image, which already ran as non-root. + +If you are running any of these containers with **persistent volumes or bind mounts**, you need to fix file ownership before upgrading. Containers running without persistent storage (ephemeral) require no action. + +## Who is affected + +You are affected if **all** of the following are true: + +- You run `evm`, `testapp`, or `local-da` via Docker (or docker-compose / Kubernetes) +- You use a volume or bind mount for the container's data directory +- The files in that volume were created by a previous (root-based) image + +## Migration steps + +### 1. Stop the running container + +```bash +docker stop +``` + +### 2. Fix file ownership on the volume + +For **bind mounts** (host directory), run `chown` directly on the host: + +```bash +# Replace /path/to/data with your actual data directory +sudo chown -R 1000:1000 /path/to/data +``` + +For **named Docker volumes**, use a temporary container: + +```bash +# Replace with your Docker volume name +docker run --rm -v :/data alpine chown -R 1000:1000 /data +``` + +### 3. Pull the new image and restart + +```bash +docker pull +docker start +``` + +### Kubernetes / docker-compose + +If you manage containers through orchestration, you have two options: + +**Option A: Init container (recommended for Kubernetes)** + +Add an init container that fixes ownership before the main container starts: + +```yaml +initContainers: + - name: fix-permissions + image: alpine:3.22 + command: ["chown", "-R", "1000:1000", "/home/ev-node"] + volumeMounts: + - name: data + mountPath: /home/ev-node +securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 +``` + +**Option B: Set `fsGroup` in the pod security context** + +If your volume driver supports it, setting `fsGroup: 1000` will automatically fix ownership on mount: + +```yaml +securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 +``` + +**docker-compose**: update your `docker-compose.yml` to set the user: + +```yaml +services: + evm: + image: evm:latest + user: "1000:1000" + volumes: + - evm-data:/home/ev-node +``` + +## Verifying the migration + +After restarting, confirm the container runs as the correct user: + +```bash +docker exec id +# Expected: uid=1000(ev-node) gid=1000(ev-node) +``` + +Check that the process can read and write its data directory: + +```bash +docker exec ls -la /home/ev-node +# All files should be owned by ev-node:ev-node +``` + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `Permission denied` on startup | Volume files still owned by root | Re-run the `chown` step above | +| Container exits immediately | Data directory not writable | Check ownership and directory permissions (`drwxr-xr-x` or more permissive for uid 1000) | +| Application writes to wrong path | Old `WORKDIR` was `/root` or `/apps` | Update any custom volume mounts to target `/home/ev-node` instead | diff --git a/execution/evm/docker/docker-compose-full-node.yml b/execution/evm/docker/docker-compose-full-node.yml index 9cfe826508..3f38de66a2 100644 --- a/execution/evm/docker/docker-compose-full-node.yml +++ b/execution/evm/docker/docker-compose-full-node.yml @@ -62,6 +62,13 @@ services: --txpool.max-new-txns 2048 \ --txpool.additional-validation-tasks 16 \ --engine.always-process-payload-attributes-on-canonical-head \ + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp volumes: reth: diff --git a/execution/evm/docker/docker-compose.yml b/execution/evm/docker/docker-compose.yml index 9520f0e89b..5bbc1d452c 100644 --- a/execution/evm/docker/docker-compose.yml +++ b/execution/evm/docker/docker-compose.yml @@ -62,6 +62,13 @@ services: --txpool.max-new-txns 2048 \ --txpool.additional-validation-tasks 16 \ --engine.always-process-payload-attributes-on-canonical-head \ + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp volumes: reth: diff --git a/justfile b/justfile index 4259dcc461..805b6e372b 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,7 @@ import '.just/lint.just' import '.just/codegen.just' import '.just/run.just' import '.just/tools.just' +import '.just/security.just' # List available recipes when running `just` with no args default: diff --git a/tools/local-da/Dockerfile b/tools/local-da/Dockerfile index 134eeaa6b1..4a55a9e966 100644 --- a/tools/local-da/Dockerfile +++ b/tools/local-da/Dockerfile @@ -1,25 +1,21 @@ -FROM golang:1.25-alpine AS build-env +FROM golang:1.26-bookworm AS build-env WORKDIR /src -#hadolint ignore=DL3018 -COPY core core - COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download COPY . . -RUN mkdir -p build && cd tools/local-da && go build -o /src/build/local-da . - -FROM alpine:3.22.2 - -#hadolint ignore=DL3018 -RUN apk --no-cache add ca-certificates curl +RUN --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/local-da ./tools/local-da -WORKDIR /root +FROM scratch -COPY --from=build-env /src/build/local-da /usr/bin/local-da +COPY --from=build-env /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build-env /out/local-da /usr/bin/local-da +USER 10001:10001 ENTRYPOINT ["/usr/bin/local-da"] CMD ["-listen-all"]