name: Image Scan on: pull_request: branches: - main - litellm_oss_branch - "litellm_**" paths: - Dockerfile - .dockerignore - docker-entrypoint.sh - migrations/run.py - gateway/main.py - gateway/launch.py - gateway/routes/allowlist.py - backend/main.py - backend/routes/allowlist.py - litellm-rust/** - litellm/proxy/lens/** - litellm/proxy/prisma_migration.py - litellm/proxy/admin_mcp.py - litellm/proxy/proxy_server.py - pyproject.toml - litellm-proxy-extras/** - tests/integration/database/image/** - uv.lock - ui/litellm-dashboard/package-lock.json - ui/nginx.conf - .github/workflows/image-scan.yml - .grype.yaml schedule: - cron: "41 6 * * *" workflow_dispatch: permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: IMAGE: litellm-image-scan:${{ github.sha }} ADMIN_MCP_SCHEMA_IMAGE: litellm-admin-mcp-schema:${{ github.sha }} jobs: build: name: build runs-on: ubuntu-latest if: >- github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository timeout-minutes: 30 permissions: contents: read steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - name: Build the image run: docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci -t "$IMAGE" . - name: Save the image for the verify matrix run: docker save "$IMAGE" | zstd -T0 -3 -o "$RUNNER_TEMP/litellm-image.tar.zst" - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: litellm-image path: ${{ runner.temp }}/litellm-image.tar.zst retention-days: 1 compression-level: 0 - name: Build the Admin MCP schema image run: docker build --build-arg LITELLM_RELEASE_TAG=v0.0.0-lens-ci --target builder -t "$ADMIN_MCP_SCHEMA_IMAGE" . - name: Save the Admin MCP schema image for the verify matrix run: docker save "$ADMIN_MCP_SCHEMA_IMAGE" | zstd -T0 -3 -o "$RUNNER_TEMP/litellm-admin-mcp-schema.tar.zst" - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 with: name: litellm-admin-mcp-schema path: ${{ runner.temp }}/litellm-admin-mcp-schema.tar.zst retention-days: 1 compression-level: 0 verify: name: verify (${{ matrix.check }}) needs: build runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: read strategy: fail-fast: false matrix: check: [scan, migrations, gateway, backend, ui, collector] steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: litellm-image path: ${{ runner.temp }} - name: Load the image run: zstd -d --stdout "$RUNNER_TEMP/litellm-image.tar.zst" | docker load - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 if: matrix.check == 'migrations' || matrix.check == 'backend' with: name: litellm-admin-mcp-schema path: ${{ runner.temp }} - name: Load the Admin MCP schema image if: matrix.check == 'migrations' || matrix.check == 'backend' run: zstd -d --stdout "$RUNNER_TEMP/litellm-admin-mcp-schema.tar.zst" | docker load - name: Set up Python if: matrix.check != 'scan' uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Install pytest if: matrix.check != 'scan' run: python -m pip install "pytest==9.0.3" # The prisma bake must migrate a fresh DB with no egress as an arbitrary # non-root uid (OpenShift restricted-v2 / air-gapped / readOnlyFilesystem). # `docker run` as the default uid with network hides a broken bake because # the migration entrypoint exits 0 even when it applied nothing; asserting # the schema was created is what catches it. - name: Verify offline migration as a non-root uid if: matrix.check == 'migrations' env: LITELLM_IMAGE: ${{ env.IMAGE }} LITELLM_ADMIN_MCP_SCHEMA_IMAGE: ${{ env.ADMIN_MCP_SCHEMA_IMAGE }} run: | python -m pytest --noconftest -v \ tests/integration/database/image/test_offline_image_migration.py \ tests/integration/database/image/test_image_bedrock_realtime_extra.py \ tests/integration/database/image/test_image_admin_mcp.py - name: Verify the gateway component serves offline as a non-root uid if: matrix.check == 'gateway' env: LITELLM_IMAGE: ${{ env.IMAGE }} LITELLM_COMPONENT: gateway LITELLM_IMAGE_COMPONENT: gateway run: | python -m pytest --noconftest -v \ tests/integration/database/image/test_component_image_serves_offline.py \ tests/integration/database/image/test_image_admin_mcp.py - name: Verify the backend component serves offline as a non-root uid if: matrix.check == 'backend' env: LITELLM_IMAGE: ${{ env.IMAGE }} LITELLM_COMPONENT: backend LITELLM_IMAGE_COMPONENT: backend LITELLM_ADMIN_MCP_SCHEMA_IMAGE: ${{ env.ADMIN_MCP_SCHEMA_IMAGE }} run: | python -m pytest --noconftest -v \ tests/integration/database/image/test_component_image_serves_offline.py \ tests/integration/database/image/test_image_admin_mcp.py - name: Verify the ui component serves as an arbitrary uid with a read-only root fs if: matrix.check == 'ui' env: LITELLM_IMAGE: ${{ env.IMAGE }} run: python -m pytest --noconftest -v tests/integration/database/image/test_ui_image_serves_offline.py - name: Verify the collector creates its socket as the image user if: matrix.check == 'collector' env: LITELLM_IMAGE: ${{ env.IMAGE }} run: python -m pytest --noconftest -v tests/integration/database/image/test_collector_image_serves_socket.py - name: Download Grype v0.114.0 if: matrix.check == 'scan' run: | curl -fsSL --retry 3 -o "$RUNNER_TEMP/grype.tar.gz" \ https://github.com/anchore/grype/releases/download/v0.114.0/grype_0.114.0_linux_amd64.tar.gz echo "edda0968d8827daab01d32b3cd7de192ae0915005e7bbfcfef9e68e79bc43343 $RUNNER_TEMP/grype.tar.gz" | sha256sum -c - tar xzf "$RUNNER_TEMP/grype.tar.gz" -C "$RUNNER_TEMP" grype chmod +x "$RUNNER_TEMP/grype" # Scans the whole shipped artifact: OS/apk plus every language package # baked into the image, including ones no lockfile declares (e.g. prisma's # vendored node engine) that osv-scan cannot see. osv-scan stays the fast # source-level gate; this is the customer's-eye-view backstop. Credential- # free OSS, run as a pinned, checksum-verified binary; no GitHub Action # dependency and no vendor SaaS callout. - name: Scan image for fixable HIGH/CRITICAL CVEs if: matrix.check == 'scan' env: GRYPE_MATCH_PYTHON_USING_CPES: "true" run: | "$RUNNER_TEMP/grype" "$IMAGE" \ --config .grype.yaml \ --only-fixed \ --fail-on high \ --output table