Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Keep the build context to what `cargo build --locked -p bugwarden` reads.
# Nothing here copies README.md or LICENSE, so the crates/*/ symlinks to them
# land dangling in the build stage; cargo never reads `readme =`, so the build
# is unaffected.
target
.git
.github
docs
assets
examples
# Local run artifacts from the README/compose examples. No COPY reaches them,
# but a token file has no business in a build context.
bugwarden.env
policy.toml
bugzilla-key
audit/
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ updates:
update-types:
- "minor"
- "patch"

# Base images in the Dockerfile. Both FROMs are digest-pinned, so without
# this they would never be updated at all.
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,53 @@ jobs:
echo "$untracked"
exit 1
fi

changes:
# Gate for the docker job: a full image build on every PR would be the
# slowest leg in CI for changes that cannot affect it.
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
# This job's checkout is shallow, so paths-filter compares against the
# PR base through the API rather than with git.
pull-requests: read
outputs:
docker: ${{ steps.filter.outputs.docker }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter
with:
filters: |
docker:
- 'Dockerfile'
- '.dockerignore'
- 'Cargo.toml'
- 'Cargo.lock'
- 'crates/**'
# The workflows themselves: without ci.yml a change to the
# docker job below cannot retrigger it.
- '.github/workflows/ci.yml'
- '.github/workflows/release.yml'

docker:
# Build-only proof that the image still builds; release.yml owns the
# push. amd64 only — the arm64 leg needs a native runner and exists
# there.
needs: changes
if: needs.changes.outputs.docker == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64
push: false
104 changes: 104 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,110 @@ jobs:
--generate-notes
dist/*

container:
# Native runners per architecture: an emulated aws-lc-sys compile under
# QEMU takes tens of minutes, and ubuntu-24.04-arm makes it unnecessary.
# Sibling of `publish`, not upstream of it: a broken image build must not
# hold back the crates.io release of a tag whose binaries already shipped.
needs: release
runs-on: ${{ matrix.os }}
timeout-minutes: 60
permissions:
contents: read
# Push the per-architecture images to ghcr.io.
packages: write
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
platform: linux/amd64
- os: ubuntu-24.04-arm
platform: linux/arm64
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Prepare
shell: bash
run: |
set -euo pipefail
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >>"$GITHUB_ENV"
# Tags here are the bare version, no "v" prefix (see AGENTS.md), and
# the `build` job already refused any tag that disagrees with the
# workspace manifest.
echo "VERSION=$GITHUB_REF_NAME" >>"$GITHUB_ENV"
- uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: ${{ matrix.platform }}
build-args: VERSION=${{ env.VERSION }}
# No cache-from/cache-to: releases build hermetically, same rule as
# the cargo-cache-free `build` job above.
outputs: type=image,name=ghcr.io/plusky/bugwarden,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
shell: bash
run: |
set -euo pipefail
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1

container-manifest:
# The tags only ever appear once every architecture pushed its digest, so
# ghcr.io never serves a half-populated multi-arch tag.
needs: container
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push the multi-arch manifest
working-directory: /tmp/digests
shell: bash
run: |
set -euo pipefail
tags=(-t "ghcr.io/plusky/bugwarden:$GITHUB_REF_NAME")
# The tag glob admits pre-releases (0.2.0rc1); those must not
# become `latest`.
if [[ "$GITHUB_REF_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
tags+=(-t ghcr.io/plusky/bugwarden:latest)
fi
# shellcheck disable=SC2046 # splitting the digest list is intended
docker buildx imagetools create "${tags[@]}" \
$(printf 'ghcr.io/plusky/bugwarden@sha256:%s ' *)
- name: Inspect the published manifest
shell: bash
run: |
set -euo pipefail
docker buildx imagetools inspect "ghcr.io/plusky/bugwarden:$GITHUB_REF_NAME"

publish:
# crates.io last: a version can be yanked but never replaced, so it only
# goes out once every platform built and the GitHub release exists.
Expand Down
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1

# Pinned to the channel in rust-toolchain.toml, which this stage never COPYs —
# so a floating `rust:1` would silently build releases with an unpinned
# compiler. Bump this tag and rust-toolchain.toml together.
FROM rust:1.97.0-alpine@sha256:ec9c91e77119ce498cd1e87d96d77e0f75b2cee21655a29bc2bf75a51a2b20a4 AS build
# gcc and musl-dev are already in the base; aws-lc-sys (the rustls crypto
# provider) compiles C from source and needs cmake plus a make generator.
RUN apk add --no-cache cmake make
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release --locked -p bugwarden && \
cp target/release/bugwarden /out

# Both FROMs carry a digest for the same reason the workflows SHA-pin actions:
# the release's two architecture legs run on separate runners, and a tag
# republished between them would build each from different base contents.
FROM gcr.io/distroless/static-debian13:nonroot@sha256:f7f8f729987ad0fdf6b05eeeae94b26e6a0f613bdf46feea7fc40f7bd72953e6
ARG VERSION=dev
LABEL org.opencontainers.image.source="https://github.com/plusky/bugwarden" \
org.opencontainers.image.description="MCP server for Bugzilla with operator-controlled security guards" \
org.opencontainers.image.licenses="Apache-2.0" \
org.opencontainers.image.version="${VERSION}"
COPY --from=build /out /usr/local/bin/bugwarden
# The bare binary falls back to a built-in allow-all policy when --policy is
# absent; naming the mount point here makes the image refuse to start instead.
ENV MCP_TRANSPORT=http \
MCP_HOST=0.0.0.0 \
MCP_PORT=8000 \
BUGWARDEN_POLICY=/etc/bugwarden/policy.toml
EXPOSE 8000
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/bugwarden"]
122 changes: 110 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ one is named via `--policy` / `BUGWARDEN_POLICY`, and an audit configuration
only via `--audit-config` / `BUGWARDEN_AUDIT_CONFIG`, so installing the
package does not by itself activate anything.

### Container image

```bash
podman pull ghcr.io/plusky/bugwarden
```

A multi-architecture (amd64/arm64) image for HTTP-transport deployments — no
Rust toolchain needed. See [Container](#container) for the run contract.

### crates.io (cargo)

```bash
Expand Down Expand Up @@ -196,18 +205,11 @@ bugwarden validates the Bugzilla server's certificate against the **OS
trust store**, not a bundled root set. A Bugzilla instance behind a
corporate or internal CA works as soon as that CA is installed
system-wide — no bugwarden-side configuration is needed. The corollary: an
environment with no CA bundle at all — a `scratch` or `distroless` image,
some minimal base images — fails every HTTPS request to Bugzilla with a TLS
handshake error. Install `ca-certificates` in the image, or mount the
host's bundle into it:

```dockerfile
FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY bugwarden /usr/local/bin/bugwarden
ENTRYPOINT ["/usr/local/bin/bugwarden"]
```
environment with no CA bundle at all — a `scratch` image, some minimal base
images — fails every HTTPS request to Bugzilla with a TLS handshake error.
Install `ca-certificates` in the image, or mount the host's bundle into it.
The [published image](#container) needs neither: its
`gcr.io/distroless/static-debian13` base ships the `ca-certificates` bundle.

`HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` from the environment are honored
for outbound Bugzilla traffic. Every request to Bugzilla — authenticated
Expand Down Expand Up @@ -413,6 +415,102 @@ MCP client configuration:
}
```

### Container

`ghcr.io/plusky/bugwarden` runs the HTTP transport on a distroless base with
no shell, as `nonroot` (uid 65532), built from the [`Dockerfile`](Dockerfile)
in this repository. It presets `MCP_TRANSPORT=http`, `MCP_HOST=0.0.0.0`,
`MCP_PORT=8000` and `BUGWARDEN_POLICY=/etc/bugwarden/policy.toml`; everything
else comes from the environment variables in the
[CLI reference](#cli-reference) below. The bearer tokens are environment-only
by design, and `--insecure-no-auth` is the one setting with no environment
variable at all — see
[Authentication and scopes](#authentication-and-scopes), which applies here
unchanged.

Mint the token once into a file — generating it inline would start a server
nobody can talk to — and hand the file to the runtime, so the value never
reaches argv or a shell history:

```bash
umask 077
printf 'BUGWARDEN_HTTP_TOKEN=%s\n' "$(openssl rand -hex 32)" > ./bugwarden.env
```

```bash
podman run --init --rm \
--env-file ./bugwarden.env \
-v "$PWD/policy.toml:/etc/bugwarden/policy.toml:ro" \
-v "$PWD/bugzilla-key:/run/secrets/bugzilla-key:ro" \
-e BUGZILLA_SERVER=https://bugzilla.opensuse.org \
-e BUGZILLA_API_KEY_FILE=/run/secrets/bugzilla-key \
-p 127.0.0.1:8000:8000 \
ghcr.io/plusky/bugwarden
```

`docker run` takes the same arguments. Use `BUGWARDEN_HTTP_READ_TOKEN`
instead to hand out a read-scope credential. Drop `BUGZILLA_API_KEY_FILE` and
its mount to serve each client with the key it sends in the `ApiKey` header
instead (the two modes never fall back to each other — see
[server-held key mode](#server-held-key-mode-fleet-deployments)). Or use the
bundled [`compose.yaml`](compose.yaml), which reads the same `bugwarden.env`
and adds a read-only root filesystem, `cap_drop: ALL` and
`no-new-privileges`:

```bash
docker compose up
```

Gotchas specific to the image:

- **The policy mount is mandatory.** Run without `--policy` and the bare
binary falls back to a built-in allow-all policy; the image instead
presets `BUGWARDEN_POLICY`, so a missing mount is a startup error and the
container never binds a port. Mount it `:ro` — the guard reads it at
startup and nothing, least of all an MCP client, may reach it afterwards
(I1). Start from
[`examples/policy.toml`](examples/policy.toml), which is not baked into
the image precisely so it cannot be mistaken for a default.
- **The token is the container's, not the caller's.** Delivering it by
environment puts its custody in the runtime: anyone who can
`docker inspect` the container or read `/proc/<pid>/environ` holds it, and
`--env-file` keeps it out of argv but not out of either of those. It
authenticates the deployment, so one token is shared by every client that
talks to this container; rotate by rewriting the file and restarting.
- **`0.0.0.0` is a container necessity, not a widening of trust.** The
binary's own default is `127.0.0.1`; the image widens it only because a
bind inside the container's network namespace is unreachable from
anywhere else. It grants no `Host` authority — that is
`MCP_ALLOWED_HOSTS` / `--allowed-hosts`, and without it any `Host` header
is served:

```sh
-e MCP_ALLOWED_HOSTS=mcp.example.org:8000
```
- **The Bugzilla key is a mounted file.** `BUGZILLA_API_KEY_FILE` reads it
once at startup from a bind mount or a container secret, and nothing is
baked into the image. (Do not also set `BUGZILLA_API_KEY` here: alone over
http it is ignored with a warning — it never silently becomes a
server-held key — but alongside `BUGZILLA_API_KEY_FILE` the two are
mutually exclusive and the container exits at startup.) The mounted file
has to be readable by uid 65532 — a host-side `0600` file owned by your
account is not, so `chown 65532` it or run with `podman --userns=keep-id`.
- **The audit stream needs a persistent volume.** With
`BUGWARDEN_AUDIT_CONFIG` set, the guard writes JSONL files itself — it
exports nothing — so the directory in `path` must be a volume writable by
uid 65532. Without one the records live in the container's writable layer,
which `--rm` throws away; with one that uid 65532 cannot write, startup
fails outright, and over HTTP the default fail mode is `closed_all`, so a
directory that becomes unwritable later stops the server serving.
- **Use an init process.** bugwarden installs no `SIGTERM` handler, and PID 1
does not get the default terminate action, so without `--init` (or
`init: true` in compose) `docker stop` waits out its full timeout and ends
in `SIGKILL`.

There is no `HEALTHCHECK`: `/bin` and `/usr/bin` are empty in this base, so
there is no binary to run one with. Use a TCP check on the port, or an
authenticated `initialize` request from outside the container.

### MCP protocol revisions

bugwarden serves four revisions of the Model Context Protocol —
Expand Down
Loading