diff --git a/.github/workflows/auto-update.yml b/.github/workflows/auto-update.yml index 7ea6d8d..9cd6871 100644 --- a/.github/workflows/auto-update.yml +++ b/.github/workflows/auto-update.yml @@ -105,7 +105,7 @@ jobs: latest_alpine_version=$(jq -r '.[] | select(.tags[] == "latest") | .alpineVersion' image-matrix.json) sed -i "s/ARG BASE_VERSION=.*/ARG BASE_VERSION=${latest_node_version}-alpine${latest_alpine_version}/" Dockerfile sed -i "s/ARG DEFRA_VERSION=.*/ARG DEFRA_VERSION=${new_defra_version}/" Dockerfile - sed -i "s/ARG BASE_VERSION=.*/ARG BASE_VERSION=${new_defra_version}-node${latest_node_version}/" examples/Dockerfile.web examples/Dockerfile.service + sed -i "s/ARG PARENT_VERSION=.*/ARG PARENT_VERSION=${new_defra_version}-node${latest_node_version}/" examples/Dockerfile echo "Updated Dockerfile and examples" - name: Prepare pull request details diff --git a/Dockerfile b/Dockerfile index e592183..ad5da95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Set default values for build arguments -ARG DEFRA_VERSION=3.1.3 +ARG DEFRA_VERSION=3.1.4 ARG BASE_VERSION=24.19.0-alpine3.24 ARG NPM_VERSION=12.0.2 @@ -16,13 +16,13 @@ ENV NPM_CONFIG_PREFIX=/home/node/.npm-global ENV PATH=$PATH:/home/node/.npm-global/bin ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/internal-ca.crt -RUN apk add --no-cache tini ca-certificates - # Upgrade the bundled npm CLI to a pinned version to clear vulnerabilities in the # libraries npm ships with. Target /usr/local explicitly so the base image's own npm # is replaced rather than a second copy installed under NPM_CONFIG_PREFIX. # NPM_VERSION is kept current by the auto-update workflow. -RUN npm install -g --prefix=/usr/local npm@${NPM_VERSION} && npm cache clean --force +RUN apk add --no-cache tini ca-certificates \ + && npm install -g --ignore-scripts --prefix=/usr/local "npm@${NPM_VERSION}" \ + && npm cache clean --force # Install Internal CA certificate for firewall and Zscaler proxy COPY certificates/internal-ca.crt /usr/local/share/ca-certificates/internal-ca.crt diff --git a/JOB.env b/JOB.env index f76c9d3..c69962e 100644 --- a/JOB.env +++ b/JOB.env @@ -1,3 +1,3 @@ -DEFRA_VERSION=3.1.3 +DEFRA_VERSION=3.1.4 IMAGE_NAME=node NPM_VERSION=12.0.2 diff --git a/README.md b/README.md index 1f76024..938ed9e 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,9 @@ Two parent images are created for each version: It is recommended that services use [multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build) to produce production and development images, each extending the appropriate parent, from a single Dockerfile. -### Example files +### Example file -[Examples](https://github.com/DEFRA/defra-docker-node/tree/main/examples) are provided to show how parent images can be extended for different types of services. These should be a good starting point for building Node services conforming to Defra standards. - -`Dockerfile.web` - This is an example web project, that requires a build step to create some static files that are used by the web front end. - -`Dockerfile.service` - This is an example project that doesn't expose any external ports (a message based service). There is also no build step in this Dockerfile. +[`examples/Dockerfile`](https://github.com/DEFRA/defra-docker-node/tree/main/examples) shows how the parent images can be extended for a Node service, with `development` and `production` targets. There's no separate stage for running tests — run `npm test` directly, on the host or in CI, against the same source tree used by the `development` stage. If your service has no build step, delete the `RUN npm run build` lines and copy from the `development` stage directly in the production stage instead of from `production-build`. ## Supported Node.js versions @@ -64,6 +60,34 @@ A build is only blocked by vulnerabilities that have a fix available, so unpatch For more details see [Image Scanning](IMAGE_SCANNING.md) +## Software Bill of Materials (SBOM) + +On every push to `main`, each production image variant has an SBOM generated from its actual container contents using [Syft](https://github.com/anchore/syft) (via [anchore/sbom-action](https://github.com/anchore/sbom-action)), which is: + +- submitted to this repository's Dependency graph, so vulnerable OS packages and runtime dependencies show up alongside Dependabot alerts, and +- uploaded as a downloadable workflow artifact for that run. + +The image pushed to Docker Hub also carries the same SBOM as a build attestation (`docker buildx build --sbom=true`). You can inspect it directly from the published image without pulling it: + +``` +docker buildx imagetools inspect defradigital/node: --format '{{json (index .SBOM "linux/amd64").SPDX}}' +``` + +### Retiring a version + +Each supported Node major version submits its SBOM to the Dependency Graph under its own +correlator (`docker-image-node-`), and GitHub only ever shows the *latest* submission +for a given correlator. So if a version is simply deleted from [image-matrix.json](image-matrix.json) +once it drops out of LTS, nothing ever submits again for that correlator, and the Dependency +Graph (and any Dependabot alerts derived from it) would keep showing that version's packages +forever, frozen at whatever they were on its last build. + +To retire a version cleanly, run [`scripts/retire-version.sh`](scripts/retire-version.sh) with +the major version, e.g. `./scripts/retire-version.sh 22`. It removes the version from +`image-matrix.json` and the table above, and submits an empty snapshot for that version's +correlator to clear it from the Dependency Graph. Review the resulting diff, then commit it and +open a PR as normal. Requires `jq` and an authenticated `gh` CLI. + ## Automated version updates The [auto-update](/.github/workflows/auto-update.yml) workflow runs nightly. It checks for new releases of Node.js (and their Alpine images) and of the npm CLI, and when it finds one it opens a pull request that bumps the affected versions across the [image-matrix.json](image-matrix.json), [JOB.env](JOB.env), [Dockerfile](Dockerfile), [README.md](README.md) and the [examples](examples). diff --git a/examples/Dockerfile b/examples/Dockerfile new file mode 100644 index 0000000..00d9c05 --- /dev/null +++ b/examples/Dockerfile @@ -0,0 +1,48 @@ +ARG PARENT_VERSION=3.1.4-node24.19.0 +ARG PORT=3000 + +FROM defradigital/node-development:$PARENT_VERSION AS development + +ENV TZ="Europe/London" + +ARG PORT +ENV PORT=${PORT} +EXPOSE ${PORT} 9229 + +COPY --chown=node:node package*.json ./ +RUN npm ci --ignore-scripts +COPY --chown=node:node src/ ./src/ + +RUN npm run build +CMD [ "npm", "run", "start:watch" ] + +# Re-runs the build with NODE_ENV=production set, for an optimised production bundle. +FROM development AS production-build +ENV NODE_ENV=production +RUN npm run build + +# Production stage: fresh production-only install, read-only application files. +FROM defradigital/node:$PARENT_VERSION AS production + +ENV TZ="Europe/London" + +ARG PORT +EXPOSE ${PORT} + +# Switch to root temporarily to install production dependencies and lock down file permissions. +USER root + +# Copy application artifacts and assign root ownership to prevent modification by other users. +COPY --from=production-build --chown=root:root /home/node/package*.json ./ +COPY --from=production-build --chown=root:root /home/node/src/ ./src/ + +# Install node modules, remove write permissions, then drop back to the unprivileged user. +RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node +USER node + +# This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight +# init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and +# orphaned processes +CMD [ "node", "src/index" ] + + diff --git a/examples/Dockerfile.service b/examples/Dockerfile.service deleted file mode 100644 index ac51204..0000000 --- a/examples/Dockerfile.service +++ /dev/null @@ -1,45 +0,0 @@ -# This assumes that the parent image has been built locally using production and development build configuration as defra-node -# and defra-node-development tagged with a version. - -ARG BASE_VERSION=3.1.3-node24.19.0 -FROM defra-node:$BASE_VERSION AS base - -# Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages -# listed in package-lock.json, and does not update either the package-lock.json or the package.json file. -# Our production dependencies are now installed. -COPY --chown=node:node package*.json ./ -RUN npm ci --ignore-scripts - -# Development stage installs devDependencies, builds app from source and declares a file watcher as the default command. -# We name this stage so we can refer to it in later stages -FROM defra-node-development:$BASE_VERSION AS development - -# Expose some development debugging ports that are used during development -EXPOSE 9229 9230 - -# We copy the installed packages from the production "base" install that was the first stage. -COPY --from=base --chown=node:node /home/node/package*.json ./ - -# We run a full dev install to bring in any development packages -RUN npm install --production=false --ignore-scripts -COPY --chown=node:node app/ ./app/ - -# We specify a command here, as we can run this stage directly using either docker-compose files, or passing --target to the docker build command -CMD [ "npm", "run", "start:watch" ] - -# Test stage copied in Jest configuration and declares the test task as the default command. We use the development stage -# for this as it will have all the tools required installed and have everything ready to run tests -FROM development AS test - -# We copy the extra files needed for the tests into this image -COPY --chown=node:node jest.config.js ./jest.config.js -COPY --chown=node:node test/ ./test/ -CMD [ "npm", "run", "test" ] - -# Production stage exposes service port, copies in built app code and declares the Node app as the default command -FROM base AS production - -# This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight -# init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and -# orphaned processes -CMD [ "node", "app/index" ] diff --git a/examples/Dockerfile.web b/examples/Dockerfile.web deleted file mode 100644 index 1cb9b04..0000000 --- a/examples/Dockerfile.web +++ /dev/null @@ -1,62 +0,0 @@ -# This assumes that the parent image has been built locally using production and development build configuration as defra-node -# and defra-node-development tagged with a version. - -ARG BASE_VERSION=3.1.3-node24.19.0 -FROM defra-node:$BASE_VERSION AS base - -# Set the port that is going to be exposed later on in the Dockerfile as well. -ARG PORT=3000 -ENV PORT=${PORT} - -# Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages -# listed in package-lock.json, and does not update either the package-lock.json or the package.json file. -# Our production dependencies are now installed. -COPY --chown=node:node package*.json ./ -RUN npm ci --ignore-scripts - -# Development stage installs devDependencies, builds app from source and declares a file watcher as the default command. -# We name this stage so we can refer to it in later stages -FROM defra-node-development:$BASE_VERSION AS development - -# Expose the PORT passed in to the Dockerfile, and also some development debugging ports that are used during development -EXPOSE ${PORT} 9229 - -# We copy the installed packages from the production "base" install that was the first stage. -COPY --from=base --chown=node:node /home/node/package*.json ./ - -# We run a full dev install to bring in any development packages -RUN npm install --production=false --ignore-scripts -COPY --chown=node:node app/ ./app/ - -# Run the build command to get the extra files needed for production. We also specify a command here, -# as we can run this stage directly using either docker-compose files, or passing --target to the docker build command -RUN npm run build -CMD [ "npm", "run", "start:watch" ] - -# Test stage copied in Jest configuration and declares the test task as the default command. We use the development stage -# for this as it will have all the tools required installed and have everything ready to run tests -FROM development AS test - -# We copy the extra files needed for the tests into this image -COPY --chown=node:node jest.config.js ./jest.config.js -COPY --chown=node:node test/ ./test/ -CMD [ "npm", "run", "test" ] - -# Production stage exposes service port, copies in built app code and declares the Node app as the default command -FROM base AS production - -# Again, be explicit about the permissions we want for this stage -USER node -WORKDIR /home/node - -# Expose the PORT passed in at the start of the file -EXPOSE ${PORT} - -# Copy in the files that we built using the tools in the development stage. The final production stage will have the built files, -# but none of the tools required to build those files. This reduces the attack surface, and also the size of the final production image -COPY --from=development /home/node/app/ ./app/ - -# This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight -# init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and -# orphaned processes -CMD [ "node", "app/index" ] diff --git a/scripts/retire-version.sh b/scripts/retire-version.sh new file mode 100755 index 0000000..7f16f94 --- /dev/null +++ b/scripts/retire-version.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Retires a Node.js major version once it drops out of LTS: removes it from +# image-matrix.json and README.md, and clears its entry from the GitHub +# Dependency Graph (which would otherwise keep showing that version's +# packages forever, since nothing else would ever resubmit for it). +# +# Usage: scripts/retire-version.sh +# Example: scripts/retire-version.sh 22 + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi +if ! command -v jq >/dev/null || ! command -v gh >/dev/null; then + echo "This script requires both jq and the GitHub CLI (gh, authenticated)." >&2 + exit 1 +fi + +MAJOR="$1" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MATRIX_FILE="$REPO_ROOT/image-matrix.json" +README_FILE="$REPO_ROOT/README.md" +REPO_SLUG="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" + +FULL_VERSION=$(jq -r --arg major "$MAJOR" '.[] | select(.nodeVersion | startswith($major + ".")) | .nodeVersion' "$MATRIX_FILE") +if [[ -z "$FULL_VERSION" ]]; then + echo "No entry for Node $MAJOR found in $MATRIX_FILE" >&2 + exit 1 +fi + +echo "Removing Node $FULL_VERSION from image-matrix.json" +jq --arg major "$MAJOR" '[.[] | select((.nodeVersion | startswith($major + ".")) | not)]' "$MATRIX_FILE" > "$MATRIX_FILE.tmp" +mv "$MATRIX_FILE.tmp" "$MATRIX_FILE" + +echo "Removing the $FULL_VERSION row from README.md" +grep -v "| ${FULL_VERSION} " "$README_FILE" > "$README_FILE.tmp" +mv "$README_FILE.tmp" "$README_FILE" + +CORRELATOR="docker-image-node-$MAJOR" +echo "Clearing Dependency Graph entry for correlator $CORRELATOR" +# job.correlator + detector.name (not detector.version) are what GitHub uses to +# decide which snapshot is "current"; an empty manifest set clears that entry. +# detector.name must match what anchore/sbom-action's syft integration submits. +gh api "repos/$REPO_SLUG/dependency-graph/snapshots" --input - < Dependency graph > Dependencies that the Node $FULL_VERSION packages are gone."