diff --git a/.dockerignore b/.dockerignore index c0836803b..43eba28aa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,7 +8,6 @@ Dockerfile **/.git **/.gitattributes **/.gitignore -**/.gitmodules **/.github # dev diff --git a/Dockerfile b/Dockerfile index b9085b342..a564f6bf8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,122 +1,196 @@ -# This file uses a staged build, using a different stage to build the UI (magma) -# Build the UI -FROM node:23 AS ui-build +ARG PYTHON_VERSION=3.13 +ARG GO_VERSION=1.26.1 +ARG NODE_VERSION=23.9.0 -WORKDIR /usr/src/app - -ADD . . -# Build VueJS front-end -RUN (cd plugins/magma; npm install && npm run build) - -# This is the runtime stage -# It containes all dependencies required by caldera -FROM debian:bookworm-slim AS runtime +#----( Build Stage )-------------------------------- +FROM python:${PYTHON_VERSION}-slim-bookworm AS build # There are two variants - slim and full -# The slim variant excludes some dependencies of *emu* and *atomic* that can be downloaded on-demand if needed -# They are very large +# The slim variant excludes some dependencies of *emu* and *atomic* that +# can be downloaded on-demand if needed. ARG VARIANT=full -ENV VARIANT=${VARIANT} - -# Display an error if variant is set incorrectly, otherwise just print information regarding which variant is in use RUN if [ "$VARIANT" = "full" ]; then \ - echo "Building \"full\" container suitable for offline use!"; \ + echo "Building full Caldera container - downloading emu and atomic dependencies for offline use"; \ elif [ "$VARIANT" = "slim" ]; then \ - echo "Building slim container - some plugins (emu, atomic) may not be available without an internet connection!"; \ + echo "Building slim Caldera container - emu and atomic may not be available without an internet connection"; \ else \ echo "Invalid Docker build-arg for VARIANT! Please provide either \"full\" or \"slim\"."; \ exit 1; \ fi -WORKDIR /usr/src/app +RUN apt-get update -qy \ + && apt-get install -y --no-install-recommends git ca-certificates curl bash xz-utils build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Install Node (with SHA256 checksum verification) +ARG TARGETARCH +ARG NODE_VERSION +RUN set -eux; \ + arch="${TARGETARCH:-amd64}"; \ + case "$arch" in \ + amd64) node_arch="x64" ;; \ + arm64) node_arch="arm64" ;; \ + *) node_arch="x64" ;; \ + esac; \ + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" -o /tmp/node.tar.xz; \ + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" -o /tmp/node.SHASUMS256.txt; \ + grep "node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" /tmp/node.SHASUMS256.txt | sha256sum -c -; \ + mkdir -p /usr/local/lib/node; \ + tar -xJf /tmp/node.tar.xz -C /usr/local/lib/node --strip-components=1; \ + rm -f /tmp/node.tar.xz /tmp/node.SHASUMS256.txt + +# Install Go (with SHA256 checksum verification) +ARG GO_VERSION +RUN set -eux; \ + arch="${TARGETARCH:-amd64}"; \ + case "$arch" in \ + amd64|arm64) \ + go_arch="$arch"; \ + echo "Installing Go ${GO_VERSION} for ${go_arch}"; \ + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${go_arch}.tar.gz" -o /tmp/go.tgz; \ + curl -fsSL "https://dl.google.com/go/go${GO_VERSION}.linux-${go_arch}.tar.gz.sha256" -o /tmp/go.tgz.sha256; \ + echo "$(cat /tmp/go.tgz.sha256) /tmp/go.tgz" | sha256sum -c -; \ + tar -C /usr/local -xzf /tmp/go.tgz; \ + rm -f /tmp/go.tgz /tmp/go.tgz.sha256 ;; \ + *) \ + echo "Unsupported arch ${arch}, ignoring Go install"; \ + mkdir -p /usr/local/go/bin && touch /usr/local/go/bin/.install_failed ;; \ + esac + +ENV APP_DIR=/usr/src/app +ENV VENV_DIR=/usr/local/venv +RUN python3 -m venv ${VENV_DIR} +ENV PATH="/usr/local/go/bin:${PATH}" +ENV PATH="${VENV_DIR}/bin:$PATH" +ENV PATH="/usr/local/lib/node/bin:${PATH}" + +ADD . ${APP_DIR} +WORKDIR ${APP_DIR} + +# Ensure plugin submodules have been cloned +RUN git config --global --add safe.directory ${APP_DIR} \ + && git submodule sync --recursive \ + && git submodule update --init --recursive + +# Install Python dependencies, allowing failed installs for plugin requirements +RUN pip install --upgrade pip \ + && sed -i '/^lxml.*/d' ${APP_DIR}/requirements.txt \ + && pip install -r ${APP_DIR}/requirements.txt \ + && find ${APP_DIR}/plugins/ -type f -name 'requirements.txt' -print0 | xargs -0 -n1 pip install --no-cache-dir -r || true + +# Rebuild Sandcat agents if Go is installed +RUN set -eux; \ + if command -v go >/dev/null 2>&1; then \ + echo "Building Sandcat agents"; \ + cd ${APP_DIR}/plugins/sandcat/gocat; \ + go mod tidy; \ + go mod download; \ + cd ${APP_DIR}/plugins/sandcat; \ + sed -i 's/\r$//' update-agents.sh; \ + chmod +x update-agents.sh; \ + ./update-agents.sh; \ + fi + +# Fetch atomic data or disable it in slim +RUN if [ "$VARIANT" = "full" ] && [ ! -d "${APP_DIR}/plugins/atomic/data/atomic-red-team" ]; then \ + git clone --depth 1 https://github.com/redcanaryco/atomic-red-team.git ${APP_DIR}/plugins/atomic/data/atomic-red-team; \ + elif [ "$VARIANT" != "full" ]; then \ + sed -i '/\- atomic/d' ${APP_DIR}/conf/default.yml; \ + fi + +# Fetch emu data +# (Emu is not enabled by default, no need to disable it if slim variant is being built) +RUN if [ "$VARIANT" = "full" ] && [ ! -d "${APP_DIR}/plugins/emu/data/adversary-emulation-plans" ]; then \ + git clone --depth 1 https://github.com/center-for-threat-informed-defense/adversary_emulation_library.git ${APP_DIR}/plugins/emu/data/adversary-emulation-plans; \ + fi + +# Remove .git folders +RUN find ${APP_DIR} -type d -name ".git" | xargs -r rm -rf \ + && rm -f ${APP_DIR}/.gitmodules + +# Build VueJS front-end in the build stage so the production image only +# receives the compiled dist output rather than node_modules and build tooling. +RUN if [ -d "${APP_DIR}/plugins/magma" ] && [ -f "${APP_DIR}/plugins/magma/package-lock.json" ]; then \ + cd ${APP_DIR}/plugins/magma && npm ci && npm run build; \ + fi + + +#----( Dev Stage )-------------------------------- +FROM python:${PYTHON_VERSION}-slim-bookworm AS dev -# Copy in source code and compiled UI -# IMPORTANT NOTE: the .dockerignore file is very important in preventing weird issues. -# Especially if caldera was ever compiled outside of Docker - we don't want those files to interfere with this build process, -# which should be repeatable. -ADD . . -COPY --from=ui-build /usr/src/app/plugins/magma/dist /usr/src/app/plugins/magma/dist +# Set timezone (default to UTC) +ARG TZ="UTC" +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \ + echo $TZ > /etc/timezone -# From https://docs.docker.com/build/building/best-practices/ # Install caldera dependencies -RUN apt-get update && \ -apt-get --no-install-recommends -y install git curl unzip python3-dev python3-pip mingw-w64 zlib1g gcc && \ -rm -rf /var/lib/apt/lists/* +RUN apt-get update -qy \ + && apt-get --no-install-recommends -y install git curl ca-certificates unzip mingw-w64 zlib1g \ + && rm -rf /var/lib/apt/lists/* -# Install Golang from source (apt version is too out-of-date) -RUN curl -k -L https://go.dev/dl/go1.25.0.linux-amd64.tar.gz -o go1.25.0.linux-amd64.tar.gz && \ -tar -C /usr/local -xzf go1.25.0.linux-amd64.tar.gz && rm go1.25.0.linux-amd64.tar.gz -ENV PATH="$PATH:/usr/local/go/bin" -RUN go version +COPY --from=build /usr/local/go /usr/local/go +COPY --from=build /usr/local/lib/node /usr/local/lib/node +COPY --from=build /usr/local/venv /usr/local/venv -# Fix line ending error that can be caused by cloning the project in a Windows environment -RUN cd /usr/src/app/plugins/sandcat && \ -cp ./update-agents.sh ./update-agents_orig.sh && \ -tr -d '\15\32' < ./update-agents_orig.sh > ./update-agents.sh +ENV APP_DIR=/usr/src/app +ENV VENV_DIR=/usr/local/venv +ENV PATH="/usr/local/go/bin:${PATH}" +ENV PATH="${VENV_DIR}/bin:${PATH}" +ENV PATH="/usr/local/lib/node/bin:${PATH}" + +WORKDIR ${APP_DIR} + + +#----( Production Stage )-------------------------- +FROM python:${PYTHON_VERSION}-slim-bookworm AS prod # Set timezone (default to UTC) ARG TZ="UTC" RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \ echo $TZ > /etc/timezone -# Install pip requirements -RUN pip3 install --break-system-packages --no-cache-dir -r requirements.txt - -# For offline atomic (disable it by default in slim image) -# Disable atomic if this is not downloaded -RUN if [ ! -d "/usr/src/app/plugins/atomic/data/atomic-red-team" ] && [ "$VARIANT" = "full" ]; then \ - git clone --depth 1 https://github.com/redcanaryco/atomic-red-team.git \ - /usr/src/app/plugins/atomic/data/atomic-red-team; \ - else \ - sed -i '/\- atomic/d' conf/default.yml; \ -fi - -# For offline emu -# (Emu is disabled by default, no need to disable it if slim variant is being built) -RUN if [ ! -d "/usr/src/app/plugins/emu/data/adversary-emulation-plans" ] && [ "$VARIANT" = "full" ]; then \ - git clone --depth 1 https://github.com/center-for-threat-informed-defense/adversary_emulation_library \ - /usr/src/app/plugins/emu/data/adversary-emulation-plans; \ -fi - -# Download emu payloads -# emu doesn't seem capable of running this itself - always download -RUN cd /usr/src/app/plugins/emu; ./download_payloads.sh - -# The commands above (git clone) will generate *huge* .git folders - remove them -RUN (find . -type d -name ".git") | xargs rm -rf +# Install caldera dependencies +RUN apt-get update -qy \ + && apt-get --no-install-recommends -y install git curl ca-certificates unzip mingw-w64 zlib1g \ + && rm -rf /var/lib/apt/lists/* -# Install Go dependencies -RUN cd /usr/src/app/plugins/sandcat/gocat; go mod tidy && go mod download +ARG APP_UID=1001 +ARG APP_GID=1001 +ENV APP_DIR=/usr/src/app +ENV VENV_DIR=/usr/local/venv -# Update sandcat agents -RUN cd /usr/src/app/plugins/sandcat; ./update-agents.sh +# Create runtime user: app +RUN groupadd --system --gid ${APP_GID} app \ + && useradd --system --home-dir ${APP_DIR} --uid ${APP_UID} --gid ${APP_GID} -N app -# Make sure emu can always be used in container (even if not enabled right now) -RUN cd /usr/src/app/plugins/emu; \ - pip3 install --break-system-packages -r requirements.txt +COPY --from=build /usr/local/go /usr/local/go +COPY --from=build /usr/local/lib/node /usr/local/lib/node +COPY --from=build /usr/local/venv /usr/local/venv +COPY --from=build --chown=app:app /usr/src/app ${APP_DIR} STOPSIGNAL SIGINT # Default HTTP port for web interface and agent beacons over HTTP EXPOSE 8888 - # Default HTTPS port for web interface and agent beacons over HTTPS (requires SSL plugin to be enabled) EXPOSE 8443 - # TCP and UDP contact ports EXPOSE 7010 EXPOSE 7011/udp - # Websocket contact port EXPOSE 7012 - # Default port to listen for DNS requests for DNS tunneling C2 channel EXPOSE 8853 - # Default port to listen for SSH tunneling requests EXPOSE 8022 - # Default FTP port for FTP C2 channel EXPOSE 2222 -ENTRYPOINT ["python3", "server.py"] +# Run as user: app +USER app +WORKDIR ${APP_DIR} +ENV PATH="/usr/local/go/bin:${PATH}" +ENV PATH="${VENV_DIR}/bin:${PATH}" +ENV PATH="/usr/local/lib/node/bin:${PATH}" + +CMD ["python3", "/usr/src/app/server.py"] diff --git a/docker-compose.yml b/docker-compose.yml index de739a067..728b285ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,12 @@ -version: '3' - services: caldera: build: context: . dockerfile: Dockerfile + target: dev args: TZ: "UTC" # Timezone to use in container - VARIANT: "full" - image: caldera:latest + VARIANT: "slim" ports: - "8888:8888" - "8443:8443" @@ -20,4 +18,4 @@ services: - "2222:2222" volumes: - ./:/usr/src/app - command: --log DEBUG + command: ["python", "/usr/src/app/server.py", "--build"]