diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ae3c15d995..8aad33a339 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -138,6 +138,22 @@ updates: - minor - patch + # Slack bot release image (both Chainguard Node stages are digest-pinned). + - package-ecosystem: docker + directory: "/sdk/typescript/examples/slack-bot/docker" + schedule: + interval: weekly + open-pull-requests-limit: 10 + commit-message: + prefix: "chore(deps)" + labels: + - dependencies + groups: + docker-minor-patch: + update-types: + - minor + - patch + # SHA-pinned GitHub Actions (bumps the pin SHA + its `# vX.Y.Z` comment). - package-ecosystem: github-actions directory: "/" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1ee5b4729..fa75af631a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -391,6 +391,78 @@ jobs: - name: Test Slack bot example (offline, spawns mecated --mock) run: task slack-bot:test + - name: Build hardened Slack bot image (no push) + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: docker build --load --tag mecatl-slack-bot:ci --secret id=npm_token,env=NODE_AUTH_TOKEN --file sdk/typescript/examples/slack-bot/docker/bot.Dockerfile sdk/typescript/examples/slack-bot + + - name: Validate Slack bot runtime image + run: | + # Use the image's real ENTRYPOINT and CMD. A missing app token makes + # dist/index.js fail before Socket Mode can contact Slack, while its + # module imports still prove the compiled entrypoint and runtime deps load. + mkdir -p .scratch + if docker run --rm \ + --env SLACK_BOT_TOKEN=ci-test-token \ + --env MECATL_GRPC_ADDRESS=127.0.0.1:1 \ + mecatl-slack-bot:ci > .scratch/runtime-image.log 2>&1; then + cat .scratch/runtime-image.log + echo "container unexpectedly started without SLACK_APP_TOKEN" >&2 + exit 1 + fi + grep --fixed-strings "Missing required environment variable: SLACK_APP_TOKEN" .scratch/runtime-image.log + + # Exercise the bridge compiled into the final image against a real, + # offline mecated --mock. Socket Mode itself requires Slack's network + # service and credentials, so this intentionally stops at the bot's + # lowest independently testable integration boundary. + mkdir -p .scratch/slack-image-workspace + bin/mecated serve --mock \ + --workspace="$PWD/.scratch/slack-image-workspace" \ + --ready-file="$PWD/.scratch/slack-image-ready.json" \ + --grpc-addr=127.0.0.1:0 \ + --http-addr= \ + --metrics-addr= \ + --no-soul \ + --no-user-model \ + --no-scheduler \ + --flight-recorder=false \ + > .scratch/slack-image-mecated.log 2>&1 & + daemon_pid=$! + trap 'kill "$daemon_pid" 2>/dev/null || true; wait "$daemon_pid" 2>/dev/null || true' EXIT + for _ in {1..500}; do + test -s .scratch/slack-image-ready.json && break + if ! kill -0 "$daemon_pid" 2>/dev/null; then + cat .scratch/slack-image-mecated.log + exit 1 + fi + sleep 0.02 + done + test -s .scratch/slack-image-ready.json + grpc_address=$(jq --exit-status --raw-output '.grpc_address' .scratch/slack-image-ready.json) + docker run --rm --network host \ + --env BRIDGE_BASE_URL="http://$grpc_address" \ + --entrypoint /usr/bin/node \ + mecatl-slack-bot:ci --input-type=module --eval ' + import { MecatlBridge } from "./dist/bridge.js"; + const bridge = new MecatlBridge({ baseUrl: process.env.BRIDGE_BASE_URL }); + try { + const result = await bridge.handlePrompt("ci:final-image", "hello"); + const expected = "Mock provider: no real model is configured. Set OPENAI_API_KEY for live use."; + if (result.text !== expected || result.stopReason !== "end_turn" || !result.sessionId) { + throw new Error(`unexpected bridge result: ${JSON.stringify(result)}`); + } + } finally { + await bridge.close(); + } + ' + + docker run --rm --entrypoint /usr/bin/node mecatl-slack-bot:ci --input-type=module --eval ' + import { existsSync } from "node:fs"; + const forbidden = ["/bin/sh", "/usr/bin/corepack", "/usr/bin/pnpm", "node_modules/tsx", "node_modules/typescript", "node_modules/vitest", "node_modules/@biomejs"]; + if (process.getuid() === 0 || forbidden.some(existsSync)) process.exit(1); + ' + build: name: Build needs: changes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21540526d8..14d0b93599 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -397,12 +397,12 @@ jobs: subject-digest: ${{ steps.build.outputs.image_digest }} push-to-registry: true - # Not a Go binary, so this doesn't go through ko like the jobs above — a - # plain docker/build-push-action build of the existing dev/Compose - # Dockerfile. The bot's TypeScript source still lives under + # Not a Go binary, so this doesn't go through ko like the jobs above. The + # multi-stage Dockerfile builds the bot, then copies only production artifacts + # into its digest-pinned Chainguard runtime image. The bot's source lives under # sdk/typescript/examples/ and isn't a published npm package (see - # ../sdk/typescript/examples/slack-bot/README.md); only this container - # image is released, from the same v* tag as mecated/mecatui/mecak8s. + # ../sdk/typescript/examples/slack-bot/README.md); only this container image is + # released from the same v* tag as the Go images. publish-slack-bot: name: Build, publish, sign, attest Slack bot example image runs-on: ubuntu-24.04 diff --git a/sdk/typescript/.dockerignore b/sdk/typescript/.dockerignore index ae5269ecfd..0835a06b37 100644 --- a/sdk/typescript/.dockerignore +++ b/sdk/typescript/.dockerignore @@ -1,5 +1,14 @@ # Keeps the Slack bot demo image's build context (examples/slack-bot/docker-compose.yml) # small and avoids pnpm choking on a locally-built node_modules/dist getting copied in. +# Never send local runtime configuration or private-key credentials to the builder. **/node_modules **/dist **/.api-extractor-temp +**/.env +**/.env.local +**/.env.*.local +**/.npmrc +**/*.pem +**/*.key +**/*.p12 +**/*.pfx diff --git a/sdk/typescript/examples/slack-bot/README.md b/sdk/typescript/examples/slack-bot/README.md index aa370792b8..dd844b799f 100644 --- a/sdk/typescript/examples/slack-bot/README.md +++ b/sdk/typescript/examples/slack-bot/README.md @@ -219,9 +219,12 @@ Wait for `mecatl Slack bot is running (Socket Mode)` in the log, same as step 3, then verify per step 4 above. Notes: -- Both Dockerfiles (`docker/mecated.Dockerfile`, `docker/bot.Dockerfile`) - are **local dev/demo images only** — not the project's official release - artifact (that's `ko`, see `.ko.yaml`). +- `docker/bot.Dockerfile` is also the source of the official + `ghcr.io/stacklok/mecatl/slack-bot` release image. It builds the linked SDK + and bot in a digest-pinned Chainguard development stage, then runs the + compiled bot as non-root on a production-only, shell-less Chainguard stage. + `docker/mecated.Dockerfile` remains a local dev/demo image; official mecatl + server images are built with `ko` (see `.ko.yaml`). - Neither service publishes a port to the host — the bot only needs outbound Socket Mode, and `mecated`'s gRPC port only needs to be reached by the bot over the internal compose network. mecated logs a WARN about diff --git a/sdk/typescript/examples/slack-bot/biome.json b/sdk/typescript/examples/slack-bot/biome.json index 7db9bb06fd..d5804d45a1 100644 --- a/sdk/typescript/examples/slack-bot/biome.json +++ b/sdk/typescript/examples/slack-bot/biome.json @@ -1,7 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/2.5.11/schema.json", "files": { - "includes": ["**"] + "includes": ["**", "!dist"] }, "formatter": { "enabled": true, diff --git a/sdk/typescript/examples/slack-bot/docker-compose.yml b/sdk/typescript/examples/slack-bot/docker-compose.yml index d01e55420e..2f4607a0dc 100644 --- a/sdk/typescript/examples/slack-bot/docker-compose.yml +++ b/sdk/typescript/examples/slack-bot/docker-compose.yml @@ -1,8 +1,8 @@ # Runs the whole Slack bot demo: a real `mecated` plus the bot, wired # together on an internal-only Docker network (neither port is published to # the host — the bot reaches Slack outbound over Socket Mode; nothing needs -# to reach in). Both Dockerfiles under docker/ are local dev/demo images, -# not the project's official release artifact — see their header comments. +# to reach in). The bot Dockerfile is shared with the official release image; +# the mecated Dockerfile is local-dev-only — see their header comments. # # Usage: copy .env.example to .env, fill it in, then `docker compose up --build`. services: diff --git a/sdk/typescript/examples/slack-bot/docker/bot.Dockerfile b/sdk/typescript/examples/slack-bot/docker/bot.Dockerfile index 0104b28d6f..10abcc028a 100644 --- a/sdk/typescript/examples/slack-bot/docker/bot.Dockerfile +++ b/sdk/typescript/examples/slack-bot/docker/bot.Dockerfile @@ -22,48 +22,35 @@ # .npmrc (see that file's own comment), so this can't just be an ARG # substituted into the checked-in file. # -# NOT a hardened base image (deliberately, for now). node:24-slim is Debian -# + a shell + apt + a full toolchain, which is exactly what this single-stage -# build needs: corepack/pnpm/build run INSIDE the final image below, and a -# hardened runtime image (Chainguard's default tag, Docker Hardened Images' -# default tag) is minimal/distroless-style and almost certainly has none of -# that. Adopting one is a multi-stage rewrite, not a one-line FROM swap: -# a builder stage on a "-dev"/full variant (has the shell + toolchain) would -# produce dist/ + a pruned node_modules, then a second, minimal runtime -# stage would COPY just those artifacts in and run `node dist/index.js` -# directly, no corepack/pnpm/shell needed at runtime at all. -# -# Two hardened options were evaluated, favoring Chainguard when this gets -# picked up: -# - cgr.dev/chainguard/node: free, no registry login to pull. mecatl -# already depends on this vendor for every OTHER released image - -# mecated/mecak8s/mecatui build on cgr.dev/chainguard/static (.ko.yaml) -# - so adopting it here is zero new vendor relationship and zero new CI -# credentials. The one real constraint: only the `latest`/`latest-dev` -# tags are pullable for free; a specific pinned version tag (`node:24`, -# etc.) requires contacting Chainguard's sales. Workaround, matching -# .ko.yaml's own existing pattern: pin the DIGEST of `latest` at build -# time, not the moving tag - reproducible, no sales conversation needed. -# - Docker Hardened Images (docker.com/products/hardened-images): also -# genuinely free (Apache 2.0, no paywalled catalog) and has a Node.js -# image, but pulling from dhi.io requires `docker login dhi.io` with a -# Docker account even on the free tier - a new credential that would -# need to be provisioned and stored as a GitHub secret in this repo's -# CI. Marketed as "change one line in your Dockerfile"; the registry -# login requirement is the part that pitch leaves out. -# -# Tracked as a deliberate follow-up, not blocking: stacklok/mecatl#1054's -# PR description has the same writeup for anyone picking this up. -FROM node:24-slim -RUN corepack enable +# Chainguard's free Node images expose moving latest tags, so both stages are +# pinned by multi-architecture digest, matching the convention in .ko.yaml. +FROM cgr.dev/chainguard/node@sha256:dcb7cf99cf3eaf95bad12812e4233a2b534e464a277611287c3392d2171d662c AS builder WORKDIR /app -COPY . . -RUN --mount=type=secret,id=npm_token,required=true \ +COPY --chown=65532:65532 . . +RUN --mount=type=secret,id=npm_token,required=true,uid=65532 \ export NODE_AUTH_TOKEN="$(cat /run/secrets/npm_token)" \ && export NPM_CONFIG_USERCONFIG=/tmp/npmrc-build-only \ && printf '//npm.pkg.github.com/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$NPM_CONFIG_USERCONFIG" \ - && pnpm install --frozen-lockfile \ - && rm -f "$NPM_CONFIG_USERCONFIG" + && corepack pnpm@11.25.0 install --frozen-lockfile \ + && rm -f "$NPM_CONFIG_USERCONFIG" \ + && corepack pnpm@11.25.0 run build + +FROM builder AS production-dependencies +RUN corepack pnpm@11.25.0 prune --prod + +FROM cgr.dev/chainguard/node@sha256:753a66014b1310b8f93c76d4cac41d039958b9a86dd44a245289d6cb85455582 + +# The current runtime image includes BusyBox. Remove its single executable (all +# applet links, including /bin/sh, then become inert) before dropping privileges. +USER 0 +RUN ["/bin/busybox", "rm", "/bin/busybox"] +USER 65532 + +WORKDIR /app +COPY --from=production-dependencies /app/package.json ./package.json +COPY --from=production-dependencies /app/dist ./dist +COPY --from=production-dependencies /app/node_modules ./node_modules -CMD ["pnpm", "run", "start"] +ENTRYPOINT ["/usr/bin/node"] +CMD ["dist/index.js"] diff --git a/sdk/typescript/examples/slack-bot/package.json b/sdk/typescript/examples/slack-bot/package.json index ffb14321dc..66aef15242 100644 --- a/sdk/typescript/examples/slack-bot/package.json +++ b/sdk/typescript/examples/slack-bot/package.json @@ -14,7 +14,8 @@ "format": "biome check --write .", "typecheck": "tsc --noEmit", "test": "vitest run", - "start": "tsx src/index.ts", + "build": "node --input-type=module --eval \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });\" && tsc -p tsconfig.build.json", + "start": "node dist/index.js", "dev": "tsx watch src/index.ts" }, "dependencies": { diff --git a/sdk/typescript/examples/slack-bot/tsconfig.build.json b/sdk/typescript/examples/slack-bot/tsconfig.build.json new file mode 100644 index 0000000000..a5794b1db4 --- /dev/null +++ b/sdk/typescript/examples/slack-bot/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "noEmit": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**/*.ts"] +}