diff --git a/Dockerfile b/Dockerfile index ce337f6..3ac9571 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,35 @@ FROM python:3.12-slim -# Pinned tool versions. Unpinned installs make the image non-reproducible and -# let an upstream release change scan results without a DockSec change. -ARG TRIVY_VERSION=0.74.0 -ARG HADOLINT_VERSION=2.15.1 +ARG DOCKER_GID=999 +ARG DOCKER_UID=10001 +ARG DOCKER_USER_GID=10001 -# Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - git \ + curl \ + git \ + ca-certificates \ + gosu \ && rm -rf /var/lib/apt/lists/* -# Install Hadolint. -# -f is required: without it curl exits 0 on an HTTP error and writes the error -# page to the destination, producing a "successful" build with a text file where -# the binary should be. +# Docker CLI (GPG-signed APT repository) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gnupg \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg \ + -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \ + https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli \ + && rm -rf /var/lib/apt/lists/* \ + && docker --version + +# Linter install +ARG HADOLINT_VERSION=2.15.1 RUN set -eux; \ arch="$(dpkg --print-architecture)"; \ case "$arch" in \ @@ -26,9 +41,8 @@ RUN set -eux; \ "https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-${hadolint_arch}"; \ chmod +x /usr/local/bin/hadolint -# Install Trivy from the release tarball rather than the install script, so the -# download is version-addressed and a redirect or a moved script cannot silently -# change what lands in the image. +# Scanner install +ARG TRIVY_VERSION=0.74.0 RUN set -eux; \ arch="$(dpkg --print-architecture)"; \ case "$arch" in \ @@ -41,32 +55,34 @@ RUN set -eux; \ tar -xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy; \ rm -f /tmp/trivy.tar.gz -# Fail the build if either tool is missing or not executable. The previous -# install steps could fail silently, shipping an image whose every scan errored -# with "Missing required tools: trivy". -RUN trivy --version && hadolint --version +# Fail the build if any tool is missing +RUN trivy --version && hadolint --version && docker --version -# Build and install from a dedicated directory. /github/workspace is where the -# Action mounts the user's repository, so installing there both shipped the -# DockSec source to every user and left files that a bind mount then shadowed. WORKDIR /src COPY . . - -# Install DockSec with the AI extra: the image backs the GitHub Action, which -# exposes AI analysis inputs, so a scan-only install would make those inputs fail. RUN pip install --no-cache-dir ".[ai]" - -# Fail the build if the CLI itself is not importable and runnable. RUN docksec --version -# The Action mounts the repository being scanned here; it must start empty. -# Leaving /src first so the build sources can be removed rather than left behind. WORKDIR /github/workspace RUN rm -rf /src -# Copy and set permissions for entrypoint +RUN groupadd -g ${DOCKER_USER_GID} docksec && \ + useradd -m -u ${DOCKER_UID} -g docksec -s /sbin/nologin -c "DockSec App User" docksec + +RUN if getent group ${DOCKER_GID} >/dev/null; then \ + existing=$(getent group ${DOCKER_GID} | cut -d: -f1); \ + usermod -aG "$existing" docksec; \ + else \ + groupadd -g ${DOCKER_GID} docker_host && \ + usermod -aG docker_host docksec; \ + fi + +RUN mkdir -p /home/docksec/.docksec/results && \ + chown -R docksec:docksec /home/docksec/.docksec + COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN chmod +x /entrypoint.sh && \ + chown docksec:docksec /entrypoint.sh && \ + chown -R docksec:docksec /github/workspace -# Set entrypoint ENTRYPOINT ["/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index 73afa99..e887789 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,6 +1,22 @@ #!/bin/bash set -e +# --------------------------------------------------------------------------- +# Privilege dropping (non-root hardening) +# --------------------------------------------------------------------------- +# GitHub Docker container actions MUST run as root to access GITHUB_WORKSPACE. +# For standalone `docker run`, we drop to the non-root `docksec` user for +# least-privilege execution. Users can override with `--user`. +# +# Detection: +# - GITHUB_WORKSPACE is set -> GitHub Action mode -> stay root +# - UID != 0 -> already non-root -> continue +# - otherwise -> standalone -> drop to docksec +if [ "$(id -u)" = "0" ] && [ -z "${GITHUB_WORKSPACE:-}" ]; then + echo "Standalone run detected: dropping privileges to 'docksec' user." + exec gosu docksec "$0" "$@" +fi + # Set environment variables from inputs. # Only export values that were actually provided: exporting an empty string is # not the same as leaving a variable unset. An empty LLM_PROVIDER fails diff --git a/tests/smoke-test-nonroot.sh b/tests/smoke-test-nonroot.sh new file mode 100755 index 0000000..10f9f64 --- /dev/null +++ b/tests/smoke-test-nonroot.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Smoke tests for non-root container hardening. +# +# These tests exercise the entrypoint.sh privilege-drop logic. They do NOT +# use --entrypoint to override the entrypoint, because doing so would bypass +# the very code path being tested. +set -euo pipefail + +IMAGE="${IMAGE:-docksec:smoke-test}" +DOCKER_GID="$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo 999)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cleanup() { + docker rmi -f "$IMAGE" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Building image (DOCKER_GID=$DOCKER_GID)..." +docker build \ + --build-arg "DOCKER_GID=$DOCKER_GID" \ + -t "$IMAGE" "$REPO_ROOT" + +echo "==> Test 1: standalone run must drop privileges (entrypoint.sh)" +OUT="$(docker run --rm "$IMAGE" --version 2>&1)" +echo " $OUT" | head -3 +if ! echo "$OUT" | grep -q "dropping privileges to 'docksec'"; then + echo "FAIL: entrypoint.sh did not drop privileges in standalone mode" >&2 + exit 1 +fi +echo " OK: standalone run drops privileges" + +echo "==> Test 2: CLI works as non-root" +docker run --rm "$IMAGE" --version >/dev/null +echo " OK: --version" +docker run --rm "$IMAGE" --help >/dev/null +echo " OK: --help" + +echo "==> Test 3: GitHub Action mode (GITHUB_WORKSPACE set) must NOT drop" +GH_OUT="$(docker run --rm -e GITHUB_WORKSPACE=/github/workspace "$IMAGE" --version 2>&1)" +echo " $GH_OUT" | head -3 +if echo "$GH_OUT" | grep -q "dropping privileges"; then + echo "FAIL: GH Action mode should NOT drop privileges" >&2 + exit 1 +fi +echo " OK: GH Action mode stays root" + +echo "==> Test 4: explicit --user override" +docker run --rm --user 0 "$IMAGE" --version >/dev/null +echo " OK: --user 0 override works" + +echo "" +echo "All smoke tests passed."