From 2a99e2a7317877ae9da6fcb6924fe46192eba719 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 16 Sep 2026 13:15:02 +0200 Subject: [PATCH 1/3] fix(bridge): ship Azure Linux distroless application runtimes Preserve Node 22 and Microsoft native-library inventory, qualify actual shipping images, and document the proven Helm 4 namespace bootstrap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/bridge-ci.yml | 72 ++++++ .github/workflows/bridge-native.yml | 11 +- bridge/bff/Dockerfile | 14 +- bridge/docs/deployment.md | 17 ++ bridge/docs/public-beta-helm.md | 31 ++- bridge/teams-gateway/Dockerfile | 51 ++++- .../tests/native-credentials/Dockerfile.bff | 8 - bridge/tests/node-runtime-image.test.mjs | 170 ++++++++++++++ bridge/web/Dockerfile | 39 +++- ci/bridge_image_contract.py | 215 ++++++++++++++++++ ci/test_bridge_image_contract.py | 137 +++++++++++ 11 files changed, 729 insertions(+), 36 deletions(-) delete mode 100644 bridge/tests/native-credentials/Dockerfile.bff create mode 100644 bridge/tests/node-runtime-image.test.mjs create mode 100644 ci/bridge_image_contract.py create mode 100644 ci/test_bridge_image_contract.py diff --git a/.github/workflows/bridge-ci.yml b/.github/workflows/bridge-ci.yml index 3737465c..36137e19 100644 --- a/.github/workflows/bridge-ci.yml +++ b/.github/workflows/bridge-ci.yml @@ -117,6 +117,32 @@ jobs: run: PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=../tests/native-credentials python3 -m unittest discover -s ../tests/native-credentials -p test_credential_review.py - name: Check witness producer and BFF matching agreement run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s src/routes/operator -p datapath_matching_contract_test.py + - name: Check shipping image contract regressions + working-directory: . + run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s ci -p test_bridge_image_contract.py + - name: Build and exercise the shipping BFF image without publishing + run: | + docker build --tag kars-bridge-bff-qualification:latest . + python3 ../../ci/bridge_image_contract.py kars-bridge-bff-qualification:latest --kind bff + - name: Scan the actual BFF runtime including unfixed findings + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + version: v0.70.0 + scan-type: image + image-ref: kars-bridge-bff-qualification:latest + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: false + exit-code: '1' + format: json + output: ${{ runner.temp }}/bridge-bff-image-scan.json + - name: Retain BFF runtime scan evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: bridge-bff-image-scan-${{ github.sha }} + path: ${{ runner.temp }}/bridge-bff-image-scan.json + if-no-files-found: warn web: name: Web build and lint @@ -138,6 +164,8 @@ jobs: run: node --experimental-strip-types --test tests/*.test.mjs - name: Build the production web image without publishing run: docker build --tag kars-bridge-web-qualification:latest . + - name: Inspect the actual web runtime distribution, tools and CA trust + run: python3 ../../ci/bridge_image_contract.py kars-bridge-web-qualification:latest --kind web - name: Start web with an immutable root filesystem run: | container=$(docker run --detach --read-only --cap-drop ALL \ @@ -167,6 +195,25 @@ jobs: assert.equal(metadata.height, 2); assert.equal(metadata.format, "png"); ' + - name: Scan the actual web runtime including unfixed findings + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + version: v0.70.0 + scan-type: image + image-ref: kars-bridge-web-qualification:latest + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: false + exit-code: '1' + format: json + output: ${{ runner.temp }}/bridge-web-image-scan.json + - name: Retain web runtime scan evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: bridge-web-image-scan-${{ github.sha }} + path: ${{ runner.temp }}/bridge-web-image-scan.json + if-no-files-found: warn - name: Remove the qualification container if: always() run: | @@ -190,6 +237,31 @@ jobs: working-directory: bridge/teams-gateway - run: npm run lint && npm run typecheck && npm run build && npm test working-directory: bridge/teams-gateway + - name: Check Node runtime packaging contracts + run: node --test bridge/tests/node-runtime-image.test.mjs + - name: Build and inspect the shipping Teams gateway image + run: | + docker build --tag kars-bridge-gateway-qualification:latest bridge/teams-gateway + python3 ci/bridge_image_contract.py kars-bridge-gateway-qualification:latest --kind gateway + - name: Scan the actual gateway runtime including unfixed findings + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + version: v0.70.0 + scan-type: image + image-ref: kars-bridge-gateway-qualification:latest + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: false + exit-code: '1' + format: json + output: ${{ runner.temp }}/bridge-gateway-image-scan.json + - name: Retain gateway runtime scan evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: bridge-gateway-image-scan-${{ github.sha }} + path: ${{ runner.temp }}/bridge-gateway-image-scan.json + if-no-files-found: warn - run: helm lint bridge/deploy/helm/kars-bridge - name: Optional witness runtime and server-side ownership contracts run: | diff --git a/.github/workflows/bridge-native.yml b/.github/workflows/bridge-native.yml index 47a38718..8ff0a17c 100644 --- a/.github/workflows/bridge-native.yml +++ b/.github/workflows/bridge-native.yml @@ -123,7 +123,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - run: install -d -m 700 .native/scratch .native/evidence .native/bin + - run: install -d -m 700 .native/scratch .native/evidence - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} @@ -134,19 +134,16 @@ jobs: with: workspaces: | bridge/.native/core - bridge/bff key: native-locked-debug-no-symbols cache-on-failure: true - - name: Build core and Bridge from the same exact source on the disposable host + - name: Build core from the same exact source on the disposable host run: | cargo build --manifest-path .native/core/Cargo.toml --locked \ -p kars-controller -p kars-inference-router \ --bin kars-controller --bin kars-inference-router - cargo build --manifest-path bff/Cargo.toml --locked --bin kars-bridge-bff install -d .native/core/bin/amd64 install .native/core/target/debug/kars-controller .native/core/bin/amd64/ install .native/core/target/debug/kars-inference-router .native/core/bin/amd64/ - install bff/target/debug/kars-bridge-bff .native/bin/ - name: Set up the exact operator CLI toolchain uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -165,8 +162,8 @@ jobs: --tag kars-native-runtime-base:latest .native/core/tests/e2e docker build --file tests/native-credentials/Dockerfile.runtime \ --tag kars-native-runtime:latest tests/native-credentials - docker build --file tests/native-credentials/Dockerfile.bff \ - --tag kars-native-bff:latest .native/bin + docker build --file bff/Dockerfile --tag kars-native-bff:latest bff + python3 ../ci/bridge_image_contract.py kars-native-bff:latest --kind bff docker build --file tests/native-credentials/Dockerfile.probe \ --tag kars-native-probe:latest tests/native-credentials - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 diff --git a/bridge/bff/Dockerfile b/bridge/bff/Dockerfile index 9b4ba724..3c3dfe74 100644 --- a/bridge/bff/Dockerfile +++ b/bridge/bff/Dockerfile @@ -1,24 +1,22 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# kars Bridge BFF — container image. Multi-stage: build the Rust binary against a -# glibc base, then ship it on a slim Debian runtime. Cloud-agnostic: the image -# runs identically on AKS, EKS, GKE, and local kind. +# kars Bridge BFF — build with Rust, ship on the same Azure Linux 3 distroless +# runtime as the controller and inference router. The bookworm build's glibc +# is older than the runtime's; no build tools or Debian packages are copied. # # Build from the bff/ directory as context: # docker build -f bff/Dockerfile -t /kars-bridge-bff: bff +ARG AZURELINUX_DISTROLESS=mcr.microsoft.com/azurelinux/distroless/base:3.0 FROM rust:1-bookworm AS build WORKDIR /src # Compile only the real sources against the reviewed lockfile. COPY . . RUN cargo build --release --locked && strip target/release/kars-bridge-bff -FROM debian:bookworm-slim AS runtime -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \ - rm -rf /var/lib/apt/lists/* && \ - useradd --uid 10001 --user-group --home-dir /home/bridge --create-home bridge +FROM ${AZURELINUX_DISTROLESS} AS runtime COPY --from=build /src/target/release/kars-bridge-bff /usr/local/bin/kars-bridge-bff -USER 10001 +USER 10001:10001 EXPOSE 8081 # The BFF binds BRIDGE_BFF_HOST:BRIDGE_BFF_PORT; in-cluster it must listen on all # interfaces. The ServiceAccount token is mounted by Kubernetes and picked up by diff --git a/bridge/docs/deployment.md b/bridge/docs/deployment.md index 4b9540af..bbae3994 100644 --- a/bridge/docs/deployment.md +++ b/bridge/docs/deployment.md @@ -52,6 +52,23 @@ The optional Teams image has a separate `make image-gateway REGISTRY=` target. Build targets do not push images or change the cluster. Publish to your chosen registry separately before installation. +The BFF, web and Teams gateway **shipping runtimes use Microsoft Azure Linux 3 +distroless**, matching Kars core's runtime-image policy. Rust and npm toolchains +belong only in build stages; no shell, package manager or global npm dependency +tree is shipped. The BFF uses the same distroless base as the controller/router. +Node services retain Node 22 by copying the official Node executable onto that +base; this is a Kars application image, not a claim that Microsoft publishes an +Azure Linux distroless Node 22 image. Native library compatibility must pass in +the final image. Third-party Dex and managed-tool images have separate provenance. + +Image qualification must inspect the **final application image**, verify its +actual distribution, non-root identity and CA trust bundle, exercise its +read-only startup (including web native `sharp` support), and scan both OS and +language dependencies. High/Critical findings, including unfixed findings, +block publication. A passing source/configuration scan or a separate test-only +Dockerfile does not qualify a shipping image. Record resolved base and output +digests; selecting distroless alone is not evidence of a clean scan. + The default `namespace: kars-system` and `createNamespace: false` join the Kars-owned namespace without adding it to the Bridge release. Setting `createNamespace: true` for `kars-system` is rejected on new installs, rather diff --git a/bridge/docs/public-beta-helm.md b/bridge/docs/public-beta-helm.md index e0850ed7..230bfe24 100644 --- a/bridge/docs/public-beta-helm.md +++ b/bridge/docs/public-beta-helm.md @@ -94,12 +94,30 @@ schema stage and Helm; see the [core Helm guide](../../docs/how-to/helm-installa Choose explicit absolute file paths and the intended context: ```bash +set -euo pipefail umask 077 export KUBECONFIG=/absolute/path/to/operator.kubeconfig CONTEXT=my-cluster CORE_VALUES=/absolute/path/to/core-values.yaml BRIDGE_VALUES=/absolute/path/to/bridge-values.yaml +# Fresh core only: refuse an existing namespace rather than adopting it. +NAMESPACE_EXISTS=$(kubectl --context "$CONTEXT" get namespace kars-system \ + --ignore-not-found -o name) +test -z "$NAMESPACE_EXISTS" +NAMESPACE_SOURCE=$(mktemp) +NAMESPACE_OWNED=$(mktemp) +trap 'rm -f "$NAMESPACE_SOURCE" "$NAMESPACE_OWNED"' EXIT +helm template kars deploy/helm/kars --namespace kars-system \ + --values "$CORE_VALUES" --show-only templates/namespace.yaml > "$NAMESPACE_SOURCE" +kubectl label --local -f "$NAMESPACE_SOURCE" \ + app.kubernetes.io/managed-by=Helm -o yaml | + kubectl annotate --local -f - meta.helm.sh/release-name=kars \ + meta.helm.sh/release-namespace=kars-system -o yaml > "$NAMESPACE_OWNED" +kubectl --context "$CONTEXT" create --validate=strict --dry-run=server \ + -f "$NAMESPACE_OWNED" -o name +kubectl --context "$CONTEXT" create --validate=strict -f "$NAMESPACE_OWNED" + node cli/dist/index.js schemas prepare \ --release kars --namespace kars-system \ --chart deploy/helm/kars --context "$CONTEXT" \ @@ -107,7 +125,7 @@ node cli/dist/index.js schemas prepare \ helm --kubeconfig "$KUBECONFIG" --kube-context "$CONTEXT" \ upgrade --install kars deploy/helm/kars \ - --namespace kars-system --create-namespace --values "$CORE_VALUES" \ + --namespace kars-system --values "$CORE_VALUES" \ --rollback-on-failure --wait=legacy --timeout 15m node cli/dist/index.js schemas prepare \ @@ -134,6 +152,17 @@ and Helm. A schema/ownership refusal is not permission to force adoption, delete CRDs, strip finalizers, clear private qualification, or edit Helm history. Keep-retention protects CRDs; it does not make all configuration changes atomic. +On the September 16 fresh AKS install, Helm 4's `--create-namespace` conflicted +with the core chart's own Namespace, reporting `original object Namespace with +the name "kars-system" not found`; rollback then reported release-not-found. +The operator checked the failure state before retrying. The successful recovery +used the exact rendered Namespace plus Helm ownership metadata, strict server +dry-run and **CREATE only**, followed by the same schema preparation and Helm +installation without `--create-namespace`. The chart was not patched and no +existing namespace was adopted. The sequence above makes that bootstrap explicit. +If an attempt leaves resources behind, stop and inspect ownership and Helm state; +do not blindly repeat it, delete schemas or relabel an existing namespace. + For local UI access, use a loopback-only tunnel: ```bash diff --git a/bridge/teams-gateway/Dockerfile b/bridge/teams-gateway/Dockerfile index 97f69202..1d76c991 100644 --- a/bridge/teams-gateway/Dockerfile +++ b/bridge/teams-gateway/Dockerfile @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -FROM node:22-slim AS builder +FROM node:22-bookworm-slim AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --ignore-scripts @@ -9,14 +9,51 @@ COPY tsconfig.json ./ COPY src/ src/ RUN npx tsc -FROM node:22-slim +FROM node:22-bookworm-slim AS prod-deps WORKDIR /app -ENV NODE_ENV=production COPY package.json package-lock.json ./ -RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force +RUN npm ci --omit=dev --ignore-scripts + +FROM mcr.microsoft.com/azurelinux/distroless/base:3.0 AS runtime-base + +# Export the complete Microsoft libstdc++ RPM payload and extend, rather than +# replace, the distroless package inventory. No Debian libraries are shipped. +FROM mcr.microsoft.com/azurelinux/base/core:3.0 AS runtime-libs +COPY --from=runtime-base /var/lib/rpmmanifest/ /runtime-libs/var/lib/rpmmanifest/ +RUN set -eu; \ + rpm -V libstdc++; \ + for package in glibc libgcc; do \ + grep -Fx "$(rpm -q "$package")" /runtime-libs/var/lib/rpmmanifest/container-manifest-1; \ + done; \ + rpm -ql libstdc++ > /package-files; \ + while IFS= read -r path; do \ + if [ -d "$path" ]; then \ + mkdir -p "/runtime-libs$(readlink -f "$path")"; \ + else \ + directory="$(readlink -f "$(dirname "$path")")"; \ + mkdir -p "/runtime-libs$directory"; \ + cp -a "$path" "/runtime-libs$directory/"; \ + fi; \ + done < /package-files; \ + rpm -q libstdc++ >> /runtime-libs/var/lib/rpmmanifest/container-manifest-1; \ + rpm -q --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{INSTALLTIME}\t%{BUILDTIME}\t%{VENDOR}\t%{EPOCH}\t%{SIZE}\t%{ARCH}\t%{EPOCHNUM}\t%{SOURCERPM}\n' libstdc++ \ + >> /runtime-libs/var/lib/rpmmanifest/container-manifest-2 + +FROM runtime-base AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV HOME=/home/node NODE_EXTRA_CA_CERTS=/etc/pki/tls/certs/ca-bundle.crt +COPY --from=runtime-libs /runtime-libs/ / +COPY --from=builder /usr/local/bin/node /usr/local/bin/node +COPY --from=builder /usr/local/LICENSE /usr/local/share/licenses/node/LICENSE +COPY package.json ./ +COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist -USER 1000 +RUN ["node", "-e", "const fs = require('node:fs'); if (process.versions.node.split('.')[0] !== '22') throw new Error('Node22 required'); require('node:tls').createSecureContext({ ca: fs.readFileSync(process.env.NODE_EXTRA_CA_CERTS) }); fs.appendFileSync('/etc/passwd', 'node:x:10001:10001::/home/node:/sbin/nologin\\n'); fs.appendFileSync('/etc/group', 'node:x:10001:\\n'); fs.mkdirSync('/home/node', { recursive: true }); fs.chownSync('/home/node', 10001, 10001);"] +USER 10001:10001 +RUN ["node", "--input-type=module", "-e", "await import('./dist/main.js');"] EXPOSE 3978 3979 HEALTHCHECK --interval=15s --timeout=5s --start-period=10s \ - CMD node -e "fetch('http://localhost:3979/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" -CMD ["node", "dist/main.js"] + CMD ["node", "-e", "fetch('http://localhost:3979/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] +ENTRYPOINT ["node"] +CMD ["dist/main.js"] diff --git a/bridge/tests/native-credentials/Dockerfile.bff b/bridge/tests/native-credentials/Dockerfile.bff deleted file mode 100644 index a7637e52..00000000 --- a/bridge/tests/native-credentials/Dockerfile.bff +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# Hosted build-once test packaging; private binaries never leave this runner. -FROM mcr.microsoft.com/azurelinux/distroless/base:3.0 -COPY kars-bridge-bff /usr/local/bin/kars-bridge-bff -USER 10001:10001 -ENTRYPOINT ["/usr/local/bin/kars-bridge-bff"] diff --git a/bridge/tests/node-runtime-image.test.mjs b/bridge/tests/node-runtime-image.test.mjs new file mode 100644 index 00000000..eaa1181b --- /dev/null +++ b/bridge/tests/node-runtime-image.test.mjs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { Script } from "node:vm"; +import test from "node:test"; + +function dockerfile(component) { + const source = readFileSync(new URL(`../${component}/Dockerfile`, import.meta.url), "utf8"); + const instructions = source + .replace(/\\\r?\n/g, " ") + .split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith("#")); + const runtimeStart = instructions.findLastIndex(line => /^FROM /i.test(line)); + assert.ok(runtimeStart >= 0, `${component} must declare a runtime base`); + const stages = new Map(); + for (const instruction of instructions.filter(line => /^FROM /i.test(line))) { + const [, base, name] = /^FROM (\S+)(?: AS (\S+))?$/i.exec(instruction); + if (name) stages.set(name, stages.get(base) ?? base); + } + return { + instructions, + build: instructions.slice(0, runtimeStart).join("\n"), + runtime: instructions.slice(runtimeStart), + runtimeBase: stages.get(instructions[runtimeStart].split(/\s+/)[1]) + ?? instructions[runtimeStart].split(/\s+/)[1], + }; +} + +function execArguments(instructions, name) { + const instruction = instructions.findLast(line => line.startsWith(`${name} `)); + return instruction ? JSON.parse(instruction.slice(name.length).trim()) : []; +} + +for (const component of ["web", "teams-gateway"]) { + test(`${component}: shipping stage uses official Azure Linux distroless, not a Debian or debug runtime`, () => { + const { runtime, runtimeBase } = dockerfile(component); + assert.match( + runtimeBase, + /^mcr\.microsoft\.com\/azurelinux\/distroless\/base:3\.0(?:@sha256:[a-f0-9]{64})?$/, + ); + assert.equal(runtime.findLast(line => /^USER /i.test(line)), "USER 10001:10001"); + assert.ok(runtime.includes("WORKDIR /app")); + }); + + test(`${component}: only Node is executed in the shell-free shipping stage`, () => { + const { runtime } = dockerfile(component); + const entrypoint = execArguments(runtime, "ENTRYPOINT"); + assert.ok(Array.isArray(entrypoint) && entrypoint.length > 0); + assert.match(entrypoint[0], /^(?:\/(?:usr\/(?:local\/)?)?bin\/)?node$/); + assert.deepEqual( + [...entrypoint.slice(1), ...execArguments(runtime, "CMD")], + [component === "web" ? "server.js" : "dist/main.js"], + ); + for (const instruction of runtime.filter(line => /^RUN /i.test(line))) { + const command = JSON.parse(instruction.slice(4)); + assert.ok(Array.isArray(command) && command.length > 0); + assert.match(command[0], /^(?:\/(?:usr\/(?:local\/)?)?bin\/)?node$/); + } + assert.doesNotMatch(runtime.join("\n"), /\b(?:NODE_TLS_REJECT_UNAUTHORIZED=0|--use-bundled-ca)\b/); + }); + + test(`${component}: Node22 dependencies are built outside the runtime without importing global tooling`, () => { + const { build, runtime } = dockerfile(component); + assert.match(build, /^FROM node:22-bookworm-slim(?: AS \S+)?$/m); + assert.match(runtime.join("\n"), /^COPY --from=(?:build|builder) \/usr\/local\/bin\/node \/usr\/local\/bin\/node$/m); + assert.match(runtime.join("\n"), /^COPY --from=(?:build|builder) \/usr\/local\/LICENSE \/usr\/local\/share\/licenses\/node\/LICENSE$/m); + for (const instruction of runtime.filter(line => /^COPY /i.test(line))) { + if (instruction.includes(" /usr/")) { + assert.match(instruction, /^COPY --from=(?:build|builder) \/usr\/local\/(?:bin\/node \/usr\/local\/bin\/node|LICENSE \/usr\/local\/share\/licenses\/node\/LICENSE)$/); + } + assert.doesNotMatch(instruction, /\/(?:npm|npx|corepack)(?:\/|\s|$)/); + } + assert.doesNotMatch(runtime.join("\n"), /\b(?:npm|npx|apt-get|tdnf|useradd|groupadd)\b/); + }); + + test(`${component}: Microsoft libstdc++ is exported by RPM ownership without losing base package inventory`, () => { + const { build, runtime } = dockerfile(component); + assert.match(build, /^FROM mcr\.microsoft\.com\/azurelinux\/base\/core:3\.0 AS runtime-libs$/m); + assert.match(build, /^COPY --from=runtime-base \/var\/lib\/rpmmanifest\/ \/runtime-libs\/var\/lib\/rpmmanifest\/$/m); + assert.match(build, /rpm -V libstdc\+\+/); + assert.match(build, /for package in glibc libgcc;/); + assert.match(build, /grep -Fx "\$\(rpm -q "\$package"\)"/); + assert.match(build, /rpm -ql libstdc\+\+ > \/package-files/); + assert.match(build, /cp -a "\$path" "\/runtime-libs\$directory\/"/); + assert.match(build, /rpm -q libstdc\+\+ >> \/runtime-libs\/var\/lib\/rpmmanifest\/container-manifest-1/); + assert.match(build, /%\{SOURCERPM\}\\n' libstdc\+\+\s+>> \/runtime-libs\/var\/lib\/rpmmanifest\/container-manifest-2/); + assert.ok(runtime.includes("COPY --from=runtime-libs /runtime-libs/ /")); + assert.doesNotMatch(runtime.join("\n"), /\/var\/lib\/rpm(?:\/|\s)|\/rpmdb/); + }); + + test(`${component}: final Microsoft runtime executes Node22 and parses the populated OS CA bundle`, () => { + const { runtime } = dockerfile(component); + assert.match(runtime.join("\n"), /^ENV .*NODE_EXTRA_CA_CERTS=\/etc\/pki\/tls\/certs\/ca-bundle\.crt$/m); + const setup = runtime.filter(line => /^RUN /i.test(line)) + .map(line => JSON.parse(line.slice(4))) + .find(command => command[2]?.includes("Node22 required")); + assert.ok(setup); + assert.match(setup[2], /process\.versions\.node\.split\('\.'\)\[0\] !== '22'/); + assert.match(setup[2], /createSecureContext\(\{ ca: fs\.readFileSync\(process\.env\.NODE_EXTRA_CA_CERTS\) \}\)/); + assert.match(setup[2], /fs\.chownSync\([^;]*10001, 10001\)/); + }); + + test(`${component}: embedded runtime checks remain valid JavaScript after Docker JSON decoding`, () => { + const { runtime } = dockerfile(component); + for (const instruction of runtime.filter(line => /^(?:RUN|HEALTHCHECK) /i.test(line))) { + const command = JSON.parse(instruction.startsWith("RUN ") + ? instruction.slice(4) + : instruction.split(/\sCMD\s/, 2)[1]); + const scriptIndex = command.indexOf("-e") + 1; + assert.ok(scriptIndex > 0); + const source = command[scriptIndex]; + assert.equal(typeof source, "string"); + assert.doesNotThrow(() => new Script(command.includes("--input-type=module") + ? `(async () => { ${source} })` + : source)); + } + }); +} + +test("web: preserve the complete standalone trace, static assets, and native optional dependencies", () => { + const { build, runtime } = dockerfile("web"); + assert.match(build, /^RUN npm ci$/m); + assert.match(build, /^RUN npm run build$/m); + const copy = runtime.filter(line => /^COPY /i.test(line)).join("\n"); + assert.match(copy, /--from=build (?:--chown=\S+ )?\/app\/\.next\/standalone \.\/$/m); + assert.match(copy, /--from=build (?:--chown=\S+ )?\/app\/\.next\/static \.\/\.next\/static$/m); + assert.match(copy, /--from=build (?:--chown=\S+ )?\/app\/public \.\/public$/m); + assert.match(runtime.join("\n"), /^ENV .*NODE_ENV=production.*NEXT_TELEMETRY_DISABLED=1.*PORT=3000$/m); + assert.ok(runtime.includes("EXPOSE 3000")); +}); + +test("web: the final nonroot runtime exercises sharp encoding and decoding with a writable cache", () => { + const { runtime } = dockerfile("web"); + assert.match(runtime.join("\n"), /\/app\/\.next\/cache/); + const userIndex = runtime.indexOf("USER 10001:10001"); + const nativeProbe = runtime.slice(userIndex + 1) + .filter(line => /^RUN /i.test(line)) + .map(line => JSON.parse(line.slice(4))) + .find(command => command[2]?.includes("sharp runtime verification failed")); + assert.ok(nativeProbe); + assert.match(nativeProbe[2], /createRequire\(require\.resolve\('next\/package\.json'\)\)\('sharp'\)/); + assert.match(nativeProbe[2], /\.png\(\)\.toBuffer\(\)/); + assert.match(nativeProbe[2], /sharp\(data\)\.metadata\(\)/); + assert.match(nativeProbe[2], /process\.exit\(1\)/); +}); + +test("teams-gateway: production-only dependency installation stays in a discarded build stage", () => { + const { build, runtime } = dockerfile("teams-gateway"); + assert.match(build, /\bnpm ci --omit=dev --ignore-scripts\b/); + assert.match(runtime.join("\n"), /^COPY --from=\S+ (?:--chown=\S+ )?\/app\/node_modules \.\/node_modules$/m); + assert.match(runtime.join("\n"), /^COPY --from=builder (?:--chown=\S+ )?\/app\/dist \.\/dist$/m); + assert.ok(runtime.includes("ENV NODE_ENV=production")); + assert.ok(runtime.includes("EXPOSE 3978 3979")); + assert.ok(runtime.includes('RUN ["node", "--input-type=module", "-e", "await import(\'./dist/main.js\');"]')); +}); + +test("teams-gateway: Docker health checks use exec-form Node without requiring a shell", () => { + const { runtime } = dockerfile("teams-gateway"); + const healthcheck = runtime.find(line => /^HEALTHCHECK /i.test(line)); + assert.ok(healthcheck); + const command = JSON.parse(healthcheck.split(/\sCMD\s/, 2)[1]); + assert.ok(Array.isArray(command)); + assert.match(command[0], /^(?:\/(?:usr\/(?:local\/)?)?bin\/)?node$/); + assert.equal(command[1], "-e"); + assert.match(command[2], /http:\/\/(?:localhost|127\.0\.0\.1):3979\/healthz/); + assert.match(command[2], /process\.exit\(1\)/); +}); diff --git a/bridge/web/Dockerfile b/bridge/web/Dockerfile index c555d795..ea93e8e4 100644 --- a/bridge/web/Dockerfile +++ b/bridge/web/Dockerfile @@ -16,20 +16,49 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# The in-cluster BFF is reached at the Service DNS name; the browser talks to the -# same-origin /api/* rewrite, so this is baked at build for the rewrite target. ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build -FROM node:22-bookworm-slim AS runtime +FROM mcr.microsoft.com/azurelinux/distroless/base:3.0 AS runtime-base + +# The upstream Node22 binary and sharp need libstdc++. Export the complete +# Microsoft RPM payload, not Debian libraries, and preserve the base inventory. +FROM mcr.microsoft.com/azurelinux/base/core:3.0 AS runtime-libs +COPY --from=runtime-base /var/lib/rpmmanifest/ /runtime-libs/var/lib/rpmmanifest/ +RUN set -eu; \ + rpm -V libstdc++; \ + for package in glibc libgcc; do \ + grep -Fx "$(rpm -q "$package")" /runtime-libs/var/lib/rpmmanifest/container-manifest-1; \ + done; \ + rpm -ql libstdc++ > /package-files; \ + while IFS= read -r path; do \ + if [ -d "$path" ]; then \ + mkdir -p "/runtime-libs$(readlink -f "$path")"; \ + else \ + directory="$(readlink -f "$(dirname "$path")")"; \ + mkdir -p "/runtime-libs$directory"; \ + cp -a "$path" "/runtime-libs$directory/"; \ + fi; \ + done < /package-files; \ + rpm -q libstdc++ >> /runtime-libs/var/lib/rpmmanifest/container-manifest-1; \ + rpm -q --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{INSTALLTIME}\t%{BUILDTIME}\t%{VENDOR}\t%{EPOCH}\t%{SIZE}\t%{ARCH}\t%{EPOCHNUM}\t%{SOURCERPM}\n' libstdc++ \ + >> /runtime-libs/var/lib/rpmmanifest/container-manifest-2 + +FROM runtime-base AS runtime WORKDIR /app ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 -RUN useradd --uid 10001 --user-group --home-dir /home/next --create-home next +ENV HOME=/home/next NODE_EXTRA_CA_CERTS=/etc/pki/tls/certs/ca-bundle.crt +COPY --from=runtime-libs /runtime-libs/ / +COPY --from=build /usr/local/bin/node /usr/local/bin/node +COPY --from=build /usr/local/LICENSE /usr/local/share/licenses/node/LICENSE # Standalone output: server + traced deps + static assets. COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/public ./public -USER 10001 +RUN ["node", "-e", "const fs = require('node:fs'); if (process.versions.node.split('.')[0] !== '22') throw new Error('Node22 required'); require('node:tls').createSecureContext({ ca: fs.readFileSync(process.env.NODE_EXTRA_CA_CERTS) }); fs.appendFileSync('/etc/passwd', 'next:x:10001:10001::/home/next:/sbin/nologin\\n'); fs.appendFileSync('/etc/group', 'next:x:10001:\\n'); for (const path of ['/home/next', '/app/.next/cache']) { fs.mkdirSync(path, { recursive: true }); fs.chownSync(path, 10001, 10001); }"] +USER 10001:10001 +# Exercise the native addon against the final Microsoft libraries, not Debian. +RUN ["node", "-e", "const sharp = require('node:module').createRequire(require.resolve('next/package.json'))('sharp'); sharp({ create: { width: 2, height: 2, channels: 4, background: '#000000' } }).png().toBuffer().then(data => sharp(data).metadata()).then(info => { if (info.format !== 'png' || info.width !== 2 || info.height !== 2) throw new Error('sharp runtime verification failed'); }).catch(error => { console.error(error); process.exit(1); });"] EXPOSE 3000 # BRIDGE_BFF_URL points at the in-cluster BFF Service (set by the Helm chart). ENTRYPOINT ["node", "server.js"] diff --git a/ci/bridge_image_contract.py b/ci/bridge_image_contract.py new file mode 100644 index 00000000..24607864 --- /dev/null +++ b/ci/bridge_image_contract.py @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Inspect the shipping image, not a test-only Dockerfile or its base label.""" + +import argparse +import json +import pathlib +import re +import shlex +import ssl +import subprocess +import tarfile +import tempfile +import time +import urllib.error +import urllib.request + + +TOOLS = frozenset({ + "sh", "bash", "ash", "dash", "zsh", "ksh", "busybox", + "apt", "apt-get", "apk", "tdnf", "dnf", "yum", "rpm", + "npm", "npx", "yarn", "yarnpkg", "pnpm", "corepack", + "cargo", "rustc", "gcc", "cc", "make", +}) +BIN_DIRS = frozenset({"bin", "sbin", "usr/bin", "usr/sbin", + "usr/local/bin", "usr/local/sbin", "busybox"}) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def check_config(config, kind): + require(config["Os"] == "linux", "Shipping image must be Linux") + require(config["Architecture"] in {"amd64", "arm64"}, "Unsupported image platform") + runtime = config["Config"] + require(runtime["User"] == "10001:10001", "Bridge runtime must use UID/GID 10001") + entry = runtime.get("Entrypoint") or [] + binary = "kars-bridge-bff" if kind == "bff" else "node" + require(entry and pathlib.PurePosixPath(entry[0]).name == binary, + "Runtime entrypoint must execute the application directly") + return {"imageId": config["Id"], "architecture": config["Architecture"], + "user": runtime["User"], "entrypoint": entry} + + +def check_rootfs(archive): + releases = [] + bundles = [] + for member in archive: + path = pathlib.PurePosixPath(member.name) + require(not path.is_absolute() and ".." not in path.parts, "Invalid image archive path") + name = str(path) + require(not (str(path.parent) in BIN_DIRS and path.name in TOOLS), + f"Shipping image contains a shell, build tool or package manager: {name}") + require(not name.startswith(("usr/local/lib/node_modules/npm/", + "usr/lib/node_modules/npm/", + "opt/yarn-", "root/.cargo/")), + f"Shipping image contains build-tool dependencies: {name}") + if not member.isfile(): + continue + is_release = name in {"etc/os-release", "usr/lib/os-release"} + is_bundle = (name.startswith(("etc/", "usr/share/")) and + path.name in {"ca-bundle.crt", "ca-certificates.crt", + "tls-ca-bundle.pem"}) + if not (is_release or is_bundle): + continue + require(member.size <= 4 * 1024 * 1024, "Unexpected metadata or CA bundle size") + data = archive.extractfile(member).read().decode("utf-8") + if is_release: + fields = dict(line.split("=", 1) for line in shlex.split(data, comments=True)) + require(fields.get("ID") == "azurelinux" and fields.get("VERSION_ID") == "3.0", + "Shipping runtime must be Azure Linux 3") + releases.append(name) + else: + certificates = re.findall( + r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", + data, re.DOTALL) + require(certificates and + len(certificates) == data.count("-----BEGIN CERTIFICATE-----") == + data.count("-----END CERTIFICATE-----"), "Malformed runtime CA certificates") + trust = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + trust.load_verify_locations(cadata="\n".join(certificates)) + require(trust.cert_store_stats()["x509_ca"] > 0, "Empty runtime CA trust store") + bundles.append(name) + require(releases, "Actual Azure Linux os-release is required") + require(bundles, "A readable populated runtime CA trust bundle is required") + return {"osRelease": releases, "caBundles": bundles, "distrolessToolInventory": "passed"} + + +def docker(*args): + return subprocess.run(["docker", *args], check=True, capture_output=True, + text=True, timeout=180).stdout.strip() + + +def inspect_image(image, kind): + config = json.loads(docker("image", "inspect", image))[0] + proof = check_config(config, kind) + container = docker("create", image) + try: + with tempfile.TemporaryFile() as output: + subprocess.run(["docker", "export", container], stdout=output, + check=True, timeout=180) + output.seek(0) + with tarfile.open(fileobj=output, mode="r|") as archive: + proof.update(check_rootfs(archive)) + finally: + docker("rm", container) + return proof + + +def smoke_bff(image): + container = docker("run", "--detach", "--read-only", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--pids-limit", "256", + "--memory", "1g", "--publish", "127.0.0.1::8081", + "--env", "KUBECONFIG=/nonexistent", image) + try: + port = docker("port", container, "8081/tcp") + require(port.startswith("127.0.0.1:") and port.count(":") == 1, + "Health probe must remain loopback-only") + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + origin = f"http://{port}" + deadline = time.monotonic() + 60 + while True: + try: + with opener.open(origin + "/healthz", timeout=2) as response: + health = json.load(response) + require(health.get("status") == "ok" and + health.get("service") == "kars-bridge-bff", + "Actual BFF health contract failed") + break + except urllib.error.URLError: + if time.monotonic() >= deadline: + raise + time.sleep(1) + try: + opener.open(origin + "/readyz", timeout=2) + except urllib.error.HTTPError as error: + require(error.code == 503, "Unconfigured BFF must not claim readiness") + require(json.load(error).get("cluster_configured") is False, + "Readiness must disclose missing cluster configuration") + else: + raise ValueError("Unconfigured BFF incorrectly reported readiness") + return {"readonlyHealth": "passed", "unconfiguredReadiness": 503} + finally: + docker("rm", "--force", container) + + +def smoke_gateway(image): + roles = json.dumps([{"entra_subject": "00000000-0000-4000-8000-000000000003", + "bridge_subject": "offline-image-proof", + "roles": ["operator", "user"], "name": "Offline image proof"}]) + container = docker( + "run", "--detach", "--network", "none", "--read-only", "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", "--pids-limit", "256", "--memory", "1g", + "--tmpfs", "/tmp:rw,nosuid,noexec,size=16m", "--env", "KUBERNETES_SERVICE_HOST=", + "--env", "TEAMS_CLIENT_ID=00000000-0000-4000-8000-000000000001", + "--env", "TEAMS_TENANT_ID=00000000-0000-4000-8000-000000000002", + "--env", "TEAMS_CLIENT_SECRET=offline-proof-not-a-real-client-secret", + "--env", "TEAMS_BFF_BASE_URL=http://127.0.0.1:8081", + "--env", "TEAMS_BFF_INTERNAL_SECRET=offline-proof-not-a-real-hmac-secret", + "--env", f"TEAMS_ENTRA_ROLE_MAP={roles}", image) + script = r""" +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const tls = require("node:tls"); +(async () => { + assert.equal(process.versions.node.split(".")[0], "22"); + assert.equal(process.getuid(), 10001); + assert.equal(process.getgid(), 10001); + const ca = fs.readFileSync(process.env.NODE_EXTRA_CA_CERTS, "utf8"); + assert.match(ca, /BEGIN CERTIFICATE/); + tls.createSecureContext({ ca }); + let failure; + for (let attempt = 0; attempt < 30; attempt++) { + try { + const app = await fetch("http://127.0.0.1:3978/__image_startup_probe__", { + signal: AbortSignal.timeout(1000) + }); + assert.equal(app.status, 404); + const health = await fetch("http://127.0.0.1:3979/healthz", { + signal: AbortSignal.timeout(1000) + }); + assert.equal(health.status, 200); + assert.equal((await health.json()).status, "ok"); + console.log(JSON.stringify({nodeMajor: 22, readonly: true, network: "none", + sdkListener: 404, health: "ok", caTlsContext: "passed", + teamsAuthenticationQualified: false})); + return; + } catch (error) { + failure = error; + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + throw failure; +})().catch(error => { console.error(error); process.exit(1); }); +""" + try: + return json.loads(docker("exec", container, "node", "-e", script)) + finally: + docker("rm", "--force", container) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("image") + parser.add_argument("--kind", choices=("bff", "web", "gateway"), required=True) + args = parser.parse_args() + result = inspect_image(args.image, args.kind) + if args.kind == "bff": + result.update(smoke_bff(args.image)) + elif args.kind == "gateway": + result.update(smoke_gateway(args.image)) + print(json.dumps(result, sort_keys=True)) diff --git a/ci/test_bridge_image_contract.py b/ci/test_bridge_image_contract.py new file mode 100644 index 00000000..43d4b8e8 --- /dev/null +++ b/ci/test_bridge_image_contract.py @@ -0,0 +1,137 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import io +import pathlib +import ssl +import tarfile +import unittest +from unittest.mock import patch + +from bridge_image_contract import check_config, check_rootfs, smoke_gateway + + +class ImageContractTests(unittest.TestCase): + def rootfs(self, files): + stream = io.BytesIO() + with tarfile.open(fileobj=stream, mode="w") as archive: + for name, data in files.items(): + entry = tarfile.TarInfo(name) + entry.size = len(data) + archive.addfile(entry, io.BytesIO(data)) + stream.seek(0) + with tarfile.open(fileobj=stream, mode="r|") as archive: + return check_rootfs(archive) + + def base(self): + certs = ssl.create_default_context().get_ca_certs(binary_form=True) + self.assertTrue(certs, "Tests need the host's real CA trust store") + return { + "usr/lib/os-release": b'ID=azurelinux\nVERSION_ID="3.0"\nNAME="Azure Linux"\n', + "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem": + ssl.DER_cert_to_PEM_cert(certs[0]).encode(), + } + + def test_requires_actual_azure_linux_and_real_ca(self): + self.assertEqual(self.rootfs(self.base())["distrolessToolInventory"], "passed") + files = self.base() + bundle = "etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + files[bundle] = "# Autorit\u00e9 de certification\n".encode() + files[bundle] + self.assertTrue(self.rootfs(files)["caBundles"]) + for data in (b"ID=debian\nVERSION_ID=12", b"ID=azurelinux\nVERSION_ID=4.0"): + files = self.base() + files["usr/lib/os-release"] = data + with self.assertRaisesRegex(ValueError, "Azure Linux 3"): + self.rootfs(files) + with self.assertRaisesRegex(ValueError, "os-release"): + self.rootfs({}) + with self.assertRaisesRegex(ValueError, "CA trust"): + self.rootfs({"etc/os-release": self.base()["usr/lib/os-release"]}) + + def test_rejects_shells_managers_and_unused_npm_packages(self): + for path in ("bin/sh", "usr/bin/bash", "usr/bin/tdnf", "usr/bin/rpm", + "usr/local/bin/npm", "usr/local/bin/cargo", + "usr/local/lib/node_modules/npm/node_modules/tar/package.json"): + with self.subTest(path=path), self.assertRaisesRegex(ValueError, "contains"): + self.rootfs({**self.base(), path: b"not allowed"}) + + def test_keeps_application_dependencies_and_package_inventory(self): + files = {**self.base(), "var/lib/rpm/rpmdb.sqlite": b"inventory", + "app/node_modules/sharp/package.json": b"{}"} + self.assertEqual(self.rootfs(files)["distrolessToolInventory"], "passed") + + def test_refuses_traversal_and_fake_certificates(self): + with self.assertRaisesRegex(ValueError, "archive path"): + self.rootfs({"../etc/os-release": b"invalid"}) + files = self.base() + files["etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem"] = b"not a certificate" + with self.assertRaisesRegex(ValueError, "CA certificates"): + self.rootfs(files) + files = self.base() + files["etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem"] += b"-----BEGIN CERTIFICATE-----" + with self.assertRaisesRegex(ValueError, "CA certificates"): + self.rootfs(files) + + def test_requires_real_nonroot_direct_application_entrypoint(self): + config = {"Os": "linux", "Architecture": "amd64", "Id": "sha256:test", + "Config": {"User": "10001:10001", + "Entrypoint": ["/usr/local/bin/kars-bridge-bff"]}} + self.assertEqual(check_config(config, "bff")["user"], "10001:10001") + for user in ("", "root", "0", "10001"): + with self.subTest(user=user), self.assertRaisesRegex(ValueError, "UID/GID"): + check_config({**config, "Config": {**config["Config"], "User": user}}, "bff") + with self.assertRaisesRegex(ValueError, "directly"): + check_config({**config, "Config": {**config["Config"], + "Entrypoint": ["/bin/sh", "-c"]}}, "bff") + + def test_native_lane_builds_the_shipping_bff_dockerfile(self): + root = pathlib.Path(__file__).resolve().parents[1] + workflow = (root / ".github/workflows/bridge-native.yml").read_text() + self.assertIn("docker build --file bff/Dockerfile", workflow) + self.assertNotIn("Dockerfile.bff", workflow) + dockerfile = (root / "bridge/bff/Dockerfile").read_text() + self.assertIn("FROM ${AZURELINUX_DISTROLESS} AS runtime", dockerfile) + runtime = dockerfile.split("FROM ${AZURELINUX_DISTROLESS} AS runtime", 1)[1] + self.assertNotIn("RUN ", runtime) + self.assertIn("USER 10001:10001", runtime) + + def test_each_shipping_image_has_a_fatal_complete_scan(self): + root = pathlib.Path(__file__).resolve().parents[1] + workflow = (root / ".github/workflows/bridge-ci.yml").read_text() + for image in ("bff", "web", "gateway"): + marker = f"image-ref: kars-bridge-{image}-qualification:latest" + self.assertEqual(workflow.count(marker), 1) + scan = workflow.split(marker, 1)[1].split(" - name:", 1)[0] + for requirement in ("scanners: vuln", "severity: HIGH,CRITICAL", + "ignore-unfixed: false", "exit-code: '1'", + "format: json"): + self.assertIn(requirement, scan) + self.assertNotIn("continue-on-error:", workflow) + + def test_gateway_requires_sdk_listener_and_health_without_external_network(self): + def execute(*args): + if args[0] == "run": + self.assertIn("--read-only", args) + self.assertEqual(args[args.index("--network") + 1], "none") + return "owned-test-container" + if args[0] == "exec": + self.assertEqual(args[1:4], ("owned-test-container", "node", "-e")) + self.assertIn("127.0.0.1:3978/__image_startup_probe__", args[4]) + self.assertIn("assert.equal(app.status, 404)", args[4]) + self.assertIn("assert.equal(health.status, 200)", args[4]) + self.assertIn("teamsAuthenticationQualified: false", args[4]) + return '{"health":"ok","teamsAuthenticationQualified":false}' + self.assertEqual(args, ("rm", "--force", "owned-test-container")) + return "" + + with patch("bridge_image_contract.docker", side_effect=execute): + self.assertFalse(smoke_gateway("test-image")["teamsAuthenticationQualified"]) + with patch("bridge_image_contract.docker", + side_effect=["owned-test-container", RuntimeError("startup failed"), ""]) as tool: + with self.assertRaisesRegex(RuntimeError, "startup failed"): + smoke_gateway("test-image") + self.assertEqual(tool.call_args.args, ("rm", "--force", "owned-test-container")) + + +if __name__ == "__main__": + unittest.main() From a8d4585c842e5a7e584c3c9b0c8295ece777eba1 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 16 Sep 2026 13:16:20 +0200 Subject: [PATCH 2/3] docs(security): record bounded Bridge runtime source review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../2026-09-16-bridge-distroless-runtime.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/security-audits/2026-09-16-bridge-distroless-runtime.md diff --git a/docs/security-audits/2026-09-16-bridge-distroless-runtime.md b/docs/security-audits/2026-09-16-bridge-distroless-runtime.md new file mode 100644 index 00000000..4f1e1e3b --- /dev/null +++ b/docs/security-audits/2026-09-16-bridge-distroless-runtime.md @@ -0,0 +1,109 @@ + + +# Bridge shipping runtimes: bounded delegated source review + +Base: `d01d0e258454f4ed61c6788c7ca3bf07f2fa93ab`. +Reviewed source: `2a99e2a7317877ae9da6fcb6924fe46192eba719`. + +Status: **Scoped source-approved for qualification; shipping-image builds, +scans and current-head native acceptance remain required.** +This record does not approve deployment or waive an image finding. + +## Observed packaging and qualification gap + +The imported shipping BFF and web Dockerfiles retained Debian runtimes while +Kars core used Microsoft Azure Linux 3 distroless. The native BFF lane used a +separate distroless test Dockerfile, so that successful native result did not +qualify the shipping runtime. The source/configuration security job did not +scan the final application images. + +Actual September 16 remote builds of the preceding source succeeded and passed +their non-root/read-only smoke. Image qualification then rejected 58 Debian +High/Critical findings in the BFF, the same OS findings plus 11 global-npm +findings in the web image, and separate Alpine/Go findings in the optional +witness image. The web language findings were in global npm, not the traced +application dependency tree. These failed outcomes are retained; no source-CI +success is substituted for a passing final-image scan. + +## T1: New capability or attack surface? + +No application routes, roles, credential handling or cluster authority change. +The BFF now ships on the same Azure Linux distroless base as the controller and +router. Native BFF qualification builds that shipping Dockerfile rather than a +different test image. Rust toolchains remain in a discarded build stage. + +Node services retain Node 22 and the existing application locks. Microsoft +does not publish an Azure Linux distroless Node 22 image. The images therefore +copy the official Node executable and license from the Node 22 build stage, +not npm or Debian libraries, onto the Microsoft distroless base. + +The Node executable and native web addon require Microsoft `libstdc++`. +Its complete RPM-owned payload is exported from the official Azure Linux core +image after RPM verification and exact glibc/libgcc inventory matching. +Both original distroless package manifests are preserved and extended with the +added package's actual metadata. Package records are not removed to hide scan +findings. Mutable upstream tags still require resolved-digest qualification. + +## T2: Security-control change? + +Shipping runtimes retain numeric non-root identity and direct executable +entrypoints. Build-stage checks execute Node 22, parse the Microsoft CA bundle +and exercise native sharp encoding/decoding on the final runtime libraries. +The gateway imports its compiled application in the final runtime. + +The actual final-image inspector checks distribution, identity, entrypoint, +shell/package-manager absence and populated CA trust. It retains package +inventory and application dependencies. BFF smoke requires real health and +explicitly unconfigured readiness, not a fabricated cluster connection. + +The offline gateway smoke uses deliberately synthetic settings with networking +disabled. It requires both the SDK application listener and the separate health +listener: health alone can hide a caught SDK startup failure. Node version, +identity and CA/TLS-context checks remain required. This is not Teams +authentication, outbound TLS or Kubernetes integration qualification. + +Existing mandatory component jobs now build and scan the shipping BFF, web and +gateway images. High/Critical OS and language findings, including unfixed ones, +remain fatal. JSON scan evidence is retained. There are no ignore files, +severity waivers or successful fallbacks for unavailable scans. + +## T3: Availability, compatibility and evidence limits + +Node major version, application locks, ports, static/standalone content and +web cache behavior are preserved. The Microsoft CA bundle is explicitly +available to Node; no TLS verification is disabled. Native library and +read-only startup behavior still require actual hosted execution. + +Sixteen Node packaging contracts and eight image-contract/orchestration tests +passed locally. Workflow YAML parsed with the verified existing locked parser. +The parent also verified the real digest-matched Microsoft base filesystem and +CA bundle. The implementation owner checked amd64 ELF dependency/version closure; +that inspection is not native execution or arm64 qualification. + +The updated public Helm guide records the actual fresh-install Helm 4 +Namespace conflict and create-only recovery. Its local render/metadata and +shell-syntax checks passed without cluster mutation. It refuses an existing +namespace rather than granting permission to adopt or reset it. + +Two independent AI review contexts covered the parent BFF/native/image-gate/ +documentation changes and the separate Node composition/gateway-smoke changes. +Both reported no significant issue within their bounded source scope. +They did not establish runtime compatibility or blanket application security. +Actual Docker builds, final-image vulnerability scans and the updated native +lane have not completed for this source. Existing core resources were not +changed by this correction. Optional witness remediation remains separate. + +## Delegation and verdict + +This uses the maintainer's explicit +[publication-review delegation](https://github.com/Azure/kars/pull/551#issuecomment-5615522306). +Implementation and independent review occurred in separate AI contexts, not +two human reviews. The parent owns composition and this record. + +Verdict: accept this bounded source correction for qualification. All +current-head technical/security checks and supported installation prerequisites +remain mandatory before merging into `kars-bridge` or deploying images. + +Signed-off-by: pallakatos (author source attestation through explicit maintainer-delegated AI review, not a claim of personal code review) <191481949+pallakatos@users.noreply.github.com> +Signed-off-by: GitHub Copilot (independent-context delegated AI source review, not a second human) <223556219+Copilot@users.noreply.github.com> From 10943389e5b74b22b129b782838ca7377125d774 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 16 Sep 2026 13:25:13 +0200 Subject: [PATCH 3/3] test(bridge): require explicit distroless runtime uid and gid Retain the failed legacy fixture result and the actual clean BFF/web image scans. Shipping image contents are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- bridge/teams-gateway/tests/packaging.test.ts | 8 +++++++- .../2026-09-16-bridge-distroless-runtime.md | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/bridge/teams-gateway/tests/packaging.test.ts b/bridge/teams-gateway/tests/packaging.test.ts index f84c647e..f6eea93f 100644 --- a/bridge/teams-gateway/tests/packaging.test.ts +++ b/bridge/teams-gateway/tests/packaging.test.ts @@ -26,7 +26,13 @@ describe("Private BFF image build contract", () => { expect(dockerfile).toContain( "COPY --from=build /src/target/release/kars-bridge-bff /usr/local/bin/kars-bridge-bff", ); - expect(dockerfile).toMatch(/^USER 10001$/m); + expect(dockerfile).toMatch(/^USER 10001:10001$/m); + expect(dockerfile).toContain("FROM ${AZURELINUX_DISTROLESS} AS runtime"); + expect(dockerfile).toContain( + "ARG AZURELINUX_DISTROLESS=mcr.microsoft.com/azurelinux/distroless/base:3.0", + ); + const runtime = dockerfile.split("FROM ${AZURELINUX_DISTROLESS} AS runtime")[1]; + expect(runtime).not.toMatch(/^RUN /m); expect(dockerfile).toContain('ENTRYPOINT ["/usr/local/bin/kars-bridge-bff"]'); }); }); diff --git a/docs/security-audits/2026-09-16-bridge-distroless-runtime.md b/docs/security-audits/2026-09-16-bridge-distroless-runtime.md index 4f1e1e3b..8582d282 100644 --- a/docs/security-audits/2026-09-16-bridge-distroless-runtime.md +++ b/docs/security-audits/2026-09-16-bridge-distroless-runtime.md @@ -94,6 +94,25 @@ Actual Docker builds, final-image vulnerability scans and the updated native lane have not completed for this source. Existing core resources were not changed by this correction. Optional witness remediation remains separate. +### First hosted outcome and test-only correction + +[Bridge CI 35089486444](https://github.com/Azure/kars/actions/runs/35089486444) +at audit-only head `a8d4585c842e5a7e584c3c9b0c8295ece777eba1` subsequently +built and qualified the actual BFF and web images. Both final runtimes were +identified as Azure Linux 3, passed their image/startup contracts, and returned +zero High/Critical image findings with Trivy 0.70.0. Web native sharp execution +also passed. The downloaded scan artifacts' server digests and head identity +were verified; the workflow's synthetic merge tree equals the candidate tree. + +The overall component workflow still failed: an older packaging test expected +the literal `USER 10001` rather than the intended explicit `USER 10001:10001`. +The test-only correction requires the exact UID/GID, Microsoft distroless base +and absence of runtime shell instructions. It does not change image contents +or relax non-root behavior. The first failed result remains failed, and the +gateway image steps were not reached. Matching local Vitest dependencies were +unavailable, so the corrected test still requires actual locked hosted execution. +Native acceptance and the complete current-head component gate remain required. + ## Delegation and verdict This uses the maintainer's explicit