From f6b3cddcfd12b5ad0c972e5acfbdb6d1ccece3c7 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 07:35:19 +0100 Subject: [PATCH 1/5] feat(scan-image): fail scans of images with pip Add the input fail-on-pip (default false) to the shared scan action. If true, the action lists all packages of the image with Trivy and fails if pip is installed, as a Python package (a pip-*.dist-info anywhere in the image) or as an OS package (python3-pip, py3-pip and the like). The check runs before the check of the findings and lists the packages found in the log and the step summary. Test the check on Trivy reports (tests/scan-image/test.sh) and the action on a clean image and on an image with pip in CI, and document the input, what counts as pip and which published images contain it. Closes #55 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 42 +++++++++++-- actions/scan-image/README.md | 53 +++++++++++++++- actions/scan-image/action.yml | 24 +++++++- actions/scan-image/check-pip.sh | 50 +++++++++++++++ tests/scan-image/test.sh | 104 ++++++++++++++++++++++++++++++++ 5 files changed, 265 insertions(+), 8 deletions(-) create mode 100755 actions/scan-image/check-pip.sh create mode 100755 tests/scan-image/test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f79508..4a12cc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,14 +183,26 @@ jobs: echo "::error::Expected the check of the released tag to fail, got ${OUTCOME}" exit 1 fi - # scan-image: a clean image passes and yields the full report; an image - # with fixable critical and high findings fails. No upload to code - # scanning (the job has no `security-events: write`) + # scan-image: the pip check (`check-pip.sh`) on Trivy reports + - name: Test scan-image pip check + run: bash tests/scan-image/test.sh + # scan-image: a clean image passes (also the pip check) and yields the + # full report; an image with fixable critical and high findings fails, + # and so does an image with pip (here only its `.dist-info`) if + # `fail-on-pip` is true. No upload to code scanning (the job has no + # `security-events: write`) - name: Prepare images to scan run: | printf 'vulnerabilities: []\n' > .trivyignore.yaml printf 'FROM scratch\nCOPY .trivyignore.yaml /\n' \ | docker build --tag scan-image-test:clean --file - . + pip_context="${RUNNER_TEMP}/pip-image" + mkdir -p "${pip_context}/pip-26.0.dist-info" + printf 'Metadata-Version: 2.1\nName: pip\nVersion: 26.0\n' \ + > "${pip_context}/pip-26.0.dist-info/METADATA" + printf 'FROM scratch\nCOPY pip-26.0.dist-info %s\n' \ + /usr/local/lib/python3.13/site-packages/pip-26.0.dist-info \ + | docker build --tag scan-image-test:pip --file - "${pip_context}" docker pull alpine:3.10.0 - name: Scan a clean image uses: ./actions/scan-image @@ -199,9 +211,11 @@ jobs: category: test-clean upload: "false" report: "true" - - name: Expect the full report + fail-on-pip: "true" + - name: Expect the full report and the package list run: | jq -e '.ArtifactName == "scan-image-test:clean"' trivy-report.json + jq -e '.ArtifactName == "scan-image-test:clean"' trivy-packages.json test -s trivy-results.sarif test -s trivy-report.txt - name: Scan an image with fixable findings @@ -222,6 +236,26 @@ jobs: fi # Failed because of findings, not before the scan jq -e '[.runs[].results[]] | length > 0' trivy-results.sarif + - name: Scan an image with pip + id: pip-image + continue-on-error: true + uses: ./actions/scan-image + with: + image: scan-image-test:pip + category: test-pip + upload: "false" + fail-on-pip: "true" + - name: Expect the scan of the image with pip to fail + env: + OUTCOME: ${{ steps.pip-image.outcome }} + run: | + if [[ "${OUTCOME}" != failure ]]; then + echo "::error::Expected the scan of the image with pip to fail, got ${OUTCOME}" + exit 1 + fi + # Failed because of pip, not before the package list + jq -e '[.Results[]?.Packages[]? | select(.Name == "pip")] | length > 0' \ + trivy-packages.json passed: name: CI passed diff --git a/actions/scan-image/README.md b/actions/scan-image/README.md index d54d896..c571362 100644 --- a/actions/scan-image/README.md +++ b/actions/scan-image/README.md @@ -8,6 +8,8 @@ Scans a Docker image for known vulnerabilities (and secrets) with - if `report` is `true`, a full report, findings without a fix included, is uploaded as the workflow artifact `trivy-report-` (JSON and table); +- if `fail-on-pip` is `true`, the step fails if the image contains pip (see + [pip in images](#pip-in-images)); - then the step fails on critical and high findings that have a fix, and on critical and high secrets (unless `fail` is `false`). @@ -53,12 +55,57 @@ steps: (set it to `false`, e.g., for pull requests from forks). - `report`: upload the full report as a workflow artifact; default `false`. - `fail`: fail on fixable critical and high findings; default `true`. +- `fail-on-pip`: fail if the image contains pip; default `false` (see + [pip in images](#pip-in-images)). - `trivy-version`: the version of the Trivy binary; default `v0.75.0`. Dependabot bumps the actions pinned inside this action, but not this version: update it by hand. +## pip in images + +pip brings vendored packages that image scans flag, and app images don't +need it at runtime: install the packages with [uv](https://docs.astral.sh/uv/) +instead (as FOCA's image does since 0.20.0). To keep pip from coming back, +e.g., through a new base image or an install step, set `fail-on-pip` to +`true` once an image is free of it: + +```yaml +- name: Scan image + uses: elixir-cloud-aai/.github/actions/scan-image@ # vX.Y.Z + with: + image: my-app:ci + category: trivy-my-app + fail-on-pip: "true" +``` + +The check lists all packages of the image with Trivy (written to +`trivy-packages.json` in the workspace) and fails if pip is among them: + +- as an installed Python package, i.e., a `pip-*.dist-info` anywhere in the + image (site-packages of the system Python, of a virtual environment or of + a user installation); +- as an OS package, e.g., `python3-pip` (Debian, Ubuntu, Fedora) or + `py3-pip` (Alpine); the wheel-only `python3-pip-whl` that Debian's `venv` + module needs doesn't count. + +A `pip` executable without its package can't run, so the check doesn't look +for one on the `PATH`. Wheels of pip that aren't installed (e.g., the one +that `ensurepip` bundles) don't count either. The check fails before the +check of the findings, so a scan with pip shows only that failure; the +log and the step summary list the packages found. + +The default is `false`, so that callers keep passing when they bump the pin +and turn the check on per image. The published images of the latest +releases that contain pip (checked on 2026-11-13) are those of +`cloud-registry` and `pubgrade-updater` (from `Pubgrade`); the images of +`foca`, `protes`, `trs-filer`, `pubgrade`, `pubgrade-sidecar`, `tesk-api` +and `tesk-core` (taskmaster and filer) contain none, so their scans can turn +the check on now. + ## Tests -The `test` job of this repository's CI scans a clean image (and checks the -full report) and an image with fixable findings (and expects the step to -fail). +The `test` job of this repository's CI tests the pip check on Trivy reports +(`tests/scan-image/test.sh`), and scans a clean image (and checks the full +report and the package list, with `fail-on-pip`), an image with fixable +findings and an image with pip (and expects the step to fail in both +cases). diff --git a/actions/scan-image/action.yml b/actions/scan-image/action.yml index 4c1b553..f222666 100644 --- a/actions/scan-image/action.yml +++ b/actions/scan-image/action.yml @@ -7,7 +7,9 @@ # `report` is true (weekly scan), a full report, findings without a fix # included, is uploaded as the workflow artifact `trivy-report-` # (JSON and table), not to code scanning. Then the step fails on critical -# and high findings that have a fix (unless `fail` is false). Accepted +# and high findings that have a fix (unless `fail` is false). If +# `fail-on-pip` is true, the step fails before that if the image contains +# pip (installed as a Python or an OS package). Accepted # findings are listed in `.trivyignore.yaml` (of the checked-out commit) and # left out of all results. The job needs the `security-events: write` # permission to upload to code scanning. See README.md for usage. @@ -31,6 +33,9 @@ inputs: fail: description: Fail on fixable critical and high findings ("true" or "false") default: "true" + fail-on-pip: + description: Fail if the image contains pip, as a Python or an OS package ("true" or "false") + default: "false" trivy-version: description: Version of the Trivy binary (not bumped by Dependabot) default: v0.75.0 @@ -82,6 +87,23 @@ runs: trivy-report.json trivy-report.txt if-no-files-found: error + - name: List packages of the image + if: inputs.fail-on-pip == 'true' + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ inputs.image }} + scanners: vuln + list-all-pkgs: "true" + format: json + output: trivy-packages.json + version: ${{ inputs.trivy-version }} + skip-setup-trivy: true + - name: Fail if the image contains pip + if: inputs.fail-on-pip == 'true' + shell: bash + env: + SCAN_PACKAGES: trivy-packages.json + run: bash "${GITHUB_ACTION_PATH}/check-pip.sh" - name: Fail on fixable critical and high findings if: inputs.fail == 'true' uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 diff --git a/actions/scan-image/check-pip.sh b/actions/scan-image/check-pip.sh new file mode 100755 index 0000000..b1a596c --- /dev/null +++ b/actions/scan-image/check-pip.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Checks that an image doesn't contain pip. Input in the environment: +# SCAN_PACKAGES, the path of Trivy's JSON report of the image with all +# packages listed (`--list-all-pkgs`). Fails if the report lists pip as an +# installed Python package (a `pip-*.dist-info` anywhere in the image) or as +# an OS package (`python3-pip`, `py3-pip` and the like; not the wheel-only +# `python3-pip-whl`), and if the report can't be read; lists the packages +# found in the log and, if available, the step summary +set -euo pipefail + +report="${SCAN_PACKAGES:-}" + +if [[ -z "${report}" || ! -f "${report}" ]] \ + || ! jq -e 'type == "object" and has("ArtifactName")' "${report}" >/dev/null 2>&1; then + echo "::error title=Scan image::No package list of the image to check for pip" + exit 1 +fi + +found="$(jq -r ' + [.Results[]? + | . as $result + | .Packages[]? + | select( + (($result.Class == "lang-pkgs" and $result.Type == "python-pkg") + and ((.Name // "") | ascii_downcase) == "pip") + or ($result.Class == "os-pkgs" + and ((.Name // "") | ascii_downcase + | test("^(py[0-9]*|python[0-9.]*)-pip$")))) + | "\(.Name) \(.Version // "?") (\(.FilePath // $result.Target // "?"))"] + | unique[]' "${report}")" + +if [[ -n "${found}" ]]; then + # Strip characters that could start a workflow command + found="${found//::/}" + echo "::error title=Scan image::The image contains pip: ${found//$'\n'/, }" + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "### The image contains pip" + echo + while IFS= read -r line; do + echo "- ${line}" + done <<< "${found}" + echo + echo "Remove pip from the image (e.g., install packages with uv), or" + echo "set the input \`fail-on-pip\` of the scan action to \`false\`." + } >> "${GITHUB_STEP_SUMMARY}" + fi + exit 1 +fi +echo "No pip in the image" diff --git a/tests/scan-image/test.sh b/tests/scan-image/test.sh new file mode 100755 index 0000000..5594d56 --- /dev/null +++ b/tests/scan-image/test.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Tests of `actions/scan-image/check-pip.sh`: runs it on Trivy reports (JSON, +# all packages listed) and compares the exit status, and the reported +# packages, with the expected ones. Run from the repository root: +# `bash tests/scan-image/test.sh` +set -euo pipefail + +check="actions/scan-image/check-pip.sh" +failures=0 +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +# report : a Trivy report with these results, as a file +report() { + local file + file="$(mktemp -p "${tmp}")" + printf '{"SchemaVersion": 2, "ArtifactName": "my-app:ci", "Results": %s}\n' \ + "$1" > "${file}" + echo "${file}" +} + +# expect [expected output] +expect() { + local want="$1" description="$2" file="$3" message="${4-}" + local got output + if output="$(SCAN_PACKAGES="${file}" GITHUB_STEP_SUMMARY="" \ + bash "${check}" 2>&1)"; then + got=pass + else + got=fail + fi + if [[ "${got}" != "${want}" ]]; then + echo "NOT OK: expected ${want}, got ${got}: ${description}" + failures=$((failures + 1)) + elif [[ -n "${message}" && "${output}" != *"${message}"* ]]; then + echo "NOT OK: expected output ${message@Q}, got ${output@Q}" + failures=$((failures + 1)) + else + echo "ok: ${want}: ${description}" + fi +} + +python_pkgs() { + printf '{"Target": "Python", "Class": "lang-pkgs", "Type": "python-pkg", "Packages": %s}' "$1" +} +os_pkgs() { + printf '{"Target": "my-app:ci (debian 13.1)", "Class": "os-pkgs", "Type": "debian", "Packages": %s}' "$1" +} +site="usr/local/lib/python3.13/site-packages" + +expect pass "no results (e.g., an image from scratch)" \ + "$(printf '{"SchemaVersion": 2, "ArtifactName": "my-app:ci"}' > "${tmp}/empty"; echo "${tmp}/empty")" +expect pass "empty results" "$(report '[]')" +expect pass "other packages" "$(report "[$(python_pkgs \ + '[{"Name": "uv", "Version": "0.9.0"}, {"Name": "pipdeptree", "Version": "2.0"}]'), $(os_pkgs \ + '[{"Name": "python3", "Version": "3.13"}, {"Name": "python3-pip-whl", "Version": "25.1"}]')]")" +expect fail "pip as a Python package" "$(report "[$(python_pkgs \ + "[{\"Name\": \"uv\"}, {\"Name\": \"pip\", \"Version\": \"26.0\", \"FilePath\": \"${site}/pip-26.0.dist-info/METADATA\"}]")]")" \ + "The image contains pip: pip 26.0 (${site}/pip-26.0.dist-info/METADATA)" +expect fail "pip as a Python package, other case" "$(report "[$(python_pkgs \ + '[{"Name": "Pip", "Version": "26.0"}]')]")" "Pip 26.0 (Python)" +# Packages of other ecosystems named pip aren't Python's pip +expect pass "a package named pip of another ecosystem" "$(report \ + '[{"Target": "package-lock.json", "Class": "lang-pkgs", "Type": "npm", "Packages": [{"Name": "pip"}]}]')" +expect fail "python3-pip as an OS package" "$(report "[$(os_pkgs \ + '[{"Name": "python3-pip", "Version": "25.1"}]')]")" \ + "python3-pip 25.1 (my-app:ci (debian 13.1))" +expect fail "py3-pip as an OS package" "$(report "[$(os_pkgs \ + '[{"Name": "py3-pip", "Version": "25.1.1-r0"}]')]")" "py3-pip 25.1.1-r0" +expect fail "python3.12-pip as an OS package" "$(report "[$(os_pkgs \ + '[{"Name": "python3.12-pip", "Version": "23.2"}]')]")" "python3.12-pip 23.2" +expect fail "pip in several places, each listed once" "$(report "[$(python_pkgs \ + "[{\"Name\": \"pip\", \"Version\": \"26.0\", \"FilePath\": \"a/METADATA\"}, {\"Name\": \"pip\", \"Version\": \"24.0\", \"FilePath\": \"b/METADATA\"}]"), $(os_pkgs \ + '[{"Name": "python3-pip", "Version": "25.1"}]')]")" \ + "pip 24.0 (b/METADATA), pip 26.0 (a/METADATA), python3-pip 25.1" +# Package names can't inject workflow commands +expect fail "a package name with a workflow command" "$(report "[$(python_pkgs \ + '[{"Name": "pip", "Version": "::warning::x"}]')]")" "pip warningx" +# Missing or unreadable reports fail, rather than pass unchecked +expect fail "no report" "" "No package list" +expect fail "a missing report" "${tmp}/missing" "No package list" +expect fail "a report that isn't JSON" \ + "$(printf 'not JSON\n' > "${tmp}/bad"; echo "${tmp}/bad")" "No package list" +expect fail "a report that isn't Trivy's" \ + "$(printf '[]\n' > "${tmp}/array"; echo "${tmp}/array")" "No package list" + +# The step summary lists the packages found +summary="${tmp}/summary" +if SCAN_PACKAGES="$(report "[$(os_pkgs '[{"Name": "py3-pip", "Version": "25.1"}]')]")" \ + GITHUB_STEP_SUMMARY="${summary}" bash "${check}" >/dev/null 2>&1; then + echo "NOT OK: expected fail with a step summary" + failures=$((failures + 1)) +elif ! grep -q -- "- py3-pip 25.1" "${summary}"; then + echo "NOT OK: step summary lacks the package: $(cat "${summary}")" + failures=$((failures + 1)) +else + echo "ok: step summary" +fi + +if ((failures > 0)); then + echo "${failures} test(s) failed" + exit 1 +fi +echo "All tests passed" From 23e314aca7ce72dde90d12f7294074f07733dac7 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 07:44:00 +0100 Subject: [PATCH 2/5] fix(scan-image): count conda pip packages Also fail on pip installed as a conda package (Trivy type conda-pkg). Co-Authored-By: Claude Opus 5.5 --- actions/scan-image/README.md | 2 +- actions/scan-image/check-pip.sh | 7 ++++--- tests/scan-image/test.sh | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/actions/scan-image/README.md b/actions/scan-image/README.md index c571362..776f214 100644 --- a/actions/scan-image/README.md +++ b/actions/scan-image/README.md @@ -83,7 +83,7 @@ The check lists all packages of the image with Trivy (written to - as an installed Python package, i.e., a `pip-*.dist-info` anywhere in the image (site-packages of the system Python, of a virtual environment or of - a user installation); + a user installation), or a conda package; - as an OS package, e.g., `python3-pip` (Debian, Ubuntu, Fedora) or `py3-pip` (Alpine); the wheel-only `python3-pip-whl` that Debian's `venv` module needs doesn't count. diff --git a/actions/scan-image/check-pip.sh b/actions/scan-image/check-pip.sh index b1a596c..31860bd 100755 --- a/actions/scan-image/check-pip.sh +++ b/actions/scan-image/check-pip.sh @@ -2,8 +2,8 @@ # Checks that an image doesn't contain pip. Input in the environment: # SCAN_PACKAGES, the path of Trivy's JSON report of the image with all # packages listed (`--list-all-pkgs`). Fails if the report lists pip as an -# installed Python package (a `pip-*.dist-info` anywhere in the image) or as -# an OS package (`python3-pip`, `py3-pip` and the like; not the wheel-only +# installed Python package (a `pip-*.dist-info` anywhere in the image, or a +# conda package) or as an OS package (`python3-pip`, `py3-pip` and the like; not the wheel-only # `python3-pip-whl`), and if the report can't be read; lists the packages # found in the log and, if available, the step summary set -euo pipefail @@ -21,7 +21,8 @@ found="$(jq -r ' | . as $result | .Packages[]? | select( - (($result.Class == "lang-pkgs" and $result.Type == "python-pkg") + (($result.Class == "lang-pkgs" + and ($result.Type | IN("python-pkg", "conda-pkg"))) and ((.Name // "") | ascii_downcase) == "pip") or ($result.Class == "os-pkgs" and ((.Name // "") | ascii_downcase diff --git a/tests/scan-image/test.sh b/tests/scan-image/test.sh index 5594d56..b14a0b5 100755 --- a/tests/scan-image/test.sh +++ b/tests/scan-image/test.sh @@ -59,6 +59,9 @@ expect fail "pip as a Python package" "$(report "[$(python_pkgs \ "The image contains pip: pip 26.0 (${site}/pip-26.0.dist-info/METADATA)" expect fail "pip as a Python package, other case" "$(report "[$(python_pkgs \ '[{"Name": "Pip", "Version": "26.0"}]')]")" "Pip 26.0 (Python)" +expect fail "pip as a conda package" "$(report \ + '[{"Target": "Conda", "Class": "lang-pkgs", "Type": "conda-pkg", "Packages": [{"Name": "pip", "Version": "25.0"}]}]')" \ + "pip 25.0 (Conda)" # Packages of other ecosystems named pip aren't Python's pip expect pass "a package named pip of another ecosystem" "$(report \ '[{"Target": "package-lock.json", "Class": "lang-pkgs", "Type": "npm", "Packages": [{"Name": "pip"}]}]')" From 6c02d20fc821fef1aa3ec87f1190be7b282fcef9 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 08:30:56 +0100 Subject: [PATCH 3/5] fix(scan-image): address review of the pip check - Match more OS package names of pip (py3.13-pip, platform-python-pip, a bare pip) and strip control characters and escape % in the output. - Test on reports cut down from real Trivy scans, results without packages, and in CI that the check is off by default; scan the pip image with fail false so that only the pip check can fail it. - Keep the README generic (no per-repo status), document the zipapp gap and that the package list stays in the workspace; list all test scripts in the development section. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 +++++++-- README.md | 2 + actions/scan-image/README.md | 58 +++++++++---------- actions/scan-image/action.yml | 10 ++-- actions/scan-image/check-pip.sh | 33 ++++++----- tests/scan-image/fixtures/alpine-py3-pip.json | 30 ++++++++++ .../fixtures/scratch-pip-dist-info.json | 19 ++++++ tests/scan-image/test.sh | 42 ++++++++++---- 8 files changed, 157 insertions(+), 63 deletions(-) create mode 100644 tests/scan-image/fixtures/alpine-py3-pip.json create mode 100644 tests/scan-image/fixtures/scratch-pip-dist-info.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a12cc2..02a3f38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,14 +183,15 @@ jobs: echo "::error::Expected the check of the released tag to fail, got ${OUTCOME}" exit 1 fi - # scan-image: the pip check (`check-pip.sh`) on Trivy reports + # scan-image: the pip check (`check-pip.sh`), on made-up reports and + # on reports cut down from real Trivy scans - name: Test scan-image pip check run: bash tests/scan-image/test.sh # scan-image: a clean image passes (also the pip check) and yields the - # full report; an image with fixable critical and high findings fails, - # and so does an image with pip (here only its `.dist-info`) if - # `fail-on-pip` is true. No upload to code scanning (the job has no - # `security-events: write`) + # full report; an image with fixable critical and high findings fails; + # an image with pip (here only its `.dist-info`) passes by default and + # fails if `fail-on-pip` is true. No upload to code scanning (the job + # has no `security-events: write`) - name: Prepare images to scan run: | printf 'vulnerabilities: []\n' > .trivyignore.yaml @@ -236,6 +237,20 @@ jobs: fi # Failed because of findings, not before the scan jq -e '[.runs[].results[]] | length > 0' trivy-results.sarif + # The check is off by default: no package list, no failure + - name: Remove the package list of the clean image + run: rm trivy-packages.json + - name: Scan an image with pip (check off) + uses: ./actions/scan-image + with: + image: scan-image-test:pip + category: test-pip-default + upload: "false" + # Not to depend on pip 26.0 staying free of fixable findings + fail: "false" + - name: Expect no package list + run: test ! -e trivy-packages.json + # Only the pip check can fail this scan (`fail` is false) - name: Scan an image with pip id: pip-image continue-on-error: true @@ -244,6 +259,7 @@ jobs: image: scan-image-test:pip category: test-pip upload: "false" + fail: "false" fail-on-pip: "true" - name: Expect the scan of the image with pip to fail env: diff --git a/README.md b/README.md index f77e8f1..018901e 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,9 @@ pre-commit run --all-files pre-commit run --all-files --hook-stage manual gitleaks-all bash tests/pr-title/test.sh bash tests/gate/test.sh +bash tests/labels/test.sh bash tests/publish-release/test.sh +bash tests/scan-image/test.sh bash tests/release/test.sh ``` diff --git a/actions/scan-image/README.md b/actions/scan-image/README.md index 776f214..48fa34a 100644 --- a/actions/scan-image/README.md +++ b/actions/scan-image/README.md @@ -63,9 +63,9 @@ steps: ## pip in images -pip brings vendored packages that image scans flag, and app images don't -need it at runtime: install the packages with [uv](https://docs.astral.sh/uv/) -instead (as FOCA's image does since 0.20.0). To keep pip from coming back, +pip brings vendored packages that image scans flag, and app images rarely +need it at runtime: install the packages with +[uv](https://docs.astral.sh/uv/) instead. To keep pip from coming back, e.g., through a new base image or an install step, set `fail-on-pip` to `true` once an image is free of it: @@ -78,34 +78,34 @@ e.g., through a new base image or an install step, set `fail-on-pip` to fail-on-pip: "true" ``` -The check lists all packages of the image with Trivy (written to -`trivy-packages.json` in the workspace) and fails if pip is among them: - -- as an installed Python package, i.e., a `pip-*.dist-info` anywhere in the - image (site-packages of the system Python, of a virtual environment or of - a user installation), or a conda package; -- as an OS package, e.g., `python3-pip` (Debian, Ubuntu, Fedora) or - `py3-pip` (Alpine); the wheel-only `python3-pip-whl` that Debian's `venv` - module needs doesn't count. - -A `pip` executable without its package can't run, so the check doesn't look -for one on the `PATH`. Wheels of pip that aren't installed (e.g., the one -that `ensurepip` bundles) don't count either. The check fails before the -check of the findings, so a scan with pip shows only that failure; the -log and the step summary list the packages found. +The check lists all packages of the image with Trivy and fails if pip is +among them: + +- as a Python package, i.e., a `pip-*.dist-info` anywhere in the image + (site-packages of the system Python, of a virtual environment or of a + user installation), or as a conda package; +- as an OS package: `pip`, `py*-pip` or `python*-pip` (e.g., `python3-pip` + on Debian and Ubuntu, `py3-pip` on Alpine, `py3.13-pip` on Wolfi, + `python3.12-pip` on Fedora), also with the prefix `platform-` (RHEL 8); + packages that only ship pip's wheel or bytecode, such as Debian's + `python3-pip-whl` (which the `venv` module needs) or Alpine's + `py3-pip-pyc`, don't count. + +Not found: pip's zipapp (`pip.pyz`) and other copies of pip that aren't +installed as a package, e.g., the wheel that `ensurepip` bundles. The check +fails before the check of the findings, so a scan with pip shows only that +failure; the log and the step summary list the packages found. The package +list stays in the workspace as `trivy-packages.json` (it isn't uploaded). The default is `false`, so that callers keep passing when they bump the pin -and turn the check on per image. The published images of the latest -releases that contain pip (checked on 2026-11-13) are those of -`cloud-registry` and `pubgrade-updater` (from `Pubgrade`); the images of -`foca`, `protes`, `trs-filer`, `pubgrade`, `pubgrade-sidecar`, `tesk-api` -and `tesk-core` (taskmaster and filer) contain none, so their scans can turn -the check on now. +and can turn the check on one image at a time. ## Tests -The `test` job of this repository's CI tests the pip check on Trivy reports -(`tests/scan-image/test.sh`), and scans a clean image (and checks the full -report and the package list, with `fail-on-pip`), an image with fixable -findings and an image with pip (and expects the step to fail in both -cases). +The `test` job of this repository's CI tests the pip check on made-up +Trivy reports and on reports cut down from real scans +(`tests/scan-image/test.sh`, `tests/scan-image/fixtures/`), and scans a +clean image (and checks the full report and the package list, with +`fail-on-pip`), an image with fixable findings (and expects the step to +fail) and an image with pip (which passes by default and fails with +`fail-on-pip`). diff --git a/actions/scan-image/action.yml b/actions/scan-image/action.yml index f222666..78090eb 100644 --- a/actions/scan-image/action.yml +++ b/actions/scan-image/action.yml @@ -9,10 +9,10 @@ # (JSON and table), not to code scanning. Then the step fails on critical # and high findings that have a fix (unless `fail` is false). If # `fail-on-pip` is true, the step fails before that if the image contains -# pip (installed as a Python or an OS package). Accepted -# findings are listed in `.trivyignore.yaml` (of the checked-out commit) and -# left out of all results. The job needs the `security-events: write` -# permission to upload to code scanning. See README.md for usage. +# pip (as a Python, conda or OS package). Accepted findings are listed in +# `.trivyignore.yaml` (of the checked-out commit) and left out of all +# results. The job needs the `security-events: write` permission to upload +# to code scanning. See README.md for usage. name: Scan image description: Scan a Docker image with Trivy and fail on fixable critical and high findings @@ -34,7 +34,7 @@ inputs: description: Fail on fixable critical and high findings ("true" or "false") default: "true" fail-on-pip: - description: Fail if the image contains pip, as a Python or an OS package ("true" or "false") + description: Fail if the image contains pip, as a Python, conda or OS package ("true" or "false") default: "false" trivy-version: description: Version of the Trivy binary (not bumped by Dependabot) diff --git a/actions/scan-image/check-pip.sh b/actions/scan-image/check-pip.sh index 31860bd..e6f8ff4 100755 --- a/actions/scan-image/check-pip.sh +++ b/actions/scan-image/check-pip.sh @@ -1,39 +1,44 @@ #!/usr/bin/env bash # Checks that an image doesn't contain pip. Input in the environment: # SCAN_PACKAGES, the path of Trivy's JSON report of the image with all -# packages listed (`--list-all-pkgs`). Fails if the report lists pip as an -# installed Python package (a `pip-*.dist-info` anywhere in the image, or a -# conda package) or as an OS package (`python3-pip`, `py3-pip` and the like; not the wheel-only -# `python3-pip-whl`), and if the report can't be read; lists the packages -# found in the log and, if available, the step summary +# packages listed (`--list-all-pkgs`). Fails if the report lists pip as a +# Python, conda or OS package (see "pip in images" in README.md for what +# counts), and if the report can't be read; lists the packages found in the +# log and, if available, the step summary set -euo pipefail report="${SCAN_PACKAGES:-}" if [[ -z "${report}" || ! -f "${report}" ]] \ - || ! jq -e 'type == "object" and has("ArtifactName")' "${report}" >/dev/null 2>&1; then + || ! jq -e 'type == "object" and has("ArtifactName")' "${report}" \ + >/dev/null 2>&1; then echo "::error title=Scan image::No package list of the image to check for pip" exit 1 fi +# One line per package; control characters (from the image's metadata) are +# removed, so that a field can't start a new log line found="$(jq -r ' [.Results[]? | . as $result | .Packages[]? + | ((.Name // "") | tostring | ascii_downcase) as $name | select( - (($result.Class == "lang-pkgs" - and ($result.Type | IN("python-pkg", "conda-pkg"))) - and ((.Name // "") | ascii_downcase) == "pip") + ($result.Class == "lang-pkgs" + and ($result.Type | IN("python-pkg", "conda-pkg")) + and $name == "pip") or ($result.Class == "os-pkgs" - and ((.Name // "") | ascii_downcase - | test("^(py[0-9]*|python[0-9.]*)-pip$")))) - | "\(.Name) \(.Version // "?") (\(.FilePath // $result.Target // "?"))"] + and ($name | test("^((platform-)?(py|python)[0-9.]*-)?pip$")))) + | "\(.Name) \(.Version // "?") (\(.FilePath // $result.Target // "?"))" + | gsub("[[:cntrl:]]"; "")] | unique[]' "${report}")" if [[ -n "${found}" ]]; then - # Strip characters that could start a workflow command + # Strip characters that could start a workflow command, and escape `%` as + # the runner expects in a command's message found="${found//::/}" - echo "::error title=Scan image::The image contains pip: ${found//$'\n'/, }" + message="${found//%/%25}" + echo "::error title=Scan image::The image contains pip: ${message//$'\n'/, }" if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { echo "### The image contains pip" diff --git a/tests/scan-image/fixtures/alpine-py3-pip.json b/tests/scan-image/fixtures/alpine-py3-pip.json new file mode 100644 index 0000000..746243b --- /dev/null +++ b/tests/scan-image/fixtures/alpine-py3-pip.json @@ -0,0 +1,30 @@ +{ + "SchemaVersion": 2, + "ArtifactName": "fixture-alpine-py3-pip", + "ArtifactType": "container_image", + "Results": [ + { + "Target": "pip-os-test:latest (alpine 3.22.8)", + "Class": "os-pkgs", + "Type": "alpine", + "Packages": [ + { + "Name": "alpine-baselayout", + "Version": "3.7.0-r0" + }, + { + "Name": "alpine-baselayout-data", + "Version": "3.7.0-r0" + }, + { + "Name": "py3-pip", + "Version": "25.1.1-r0" + }, + { + "Name": "py3-pip-pyc", + "Version": "25.1.1-r0" + } + ] + } + ] +} diff --git a/tests/scan-image/fixtures/scratch-pip-dist-info.json b/tests/scan-image/fixtures/scratch-pip-dist-info.json new file mode 100644 index 0000000..169b305 --- /dev/null +++ b/tests/scan-image/fixtures/scratch-pip-dist-info.json @@ -0,0 +1,19 @@ +{ + "SchemaVersion": 2, + "ArtifactName": "fixture-scratch-pip-dist-info", + "ArtifactType": "container_image", + "Results": [ + { + "Target": "Python", + "Class": "lang-pkgs", + "Type": "python-pkg", + "Packages": [ + { + "Name": "pip", + "Version": "26.0", + "FilePath": "usr/local/lib/python3.13/site-packages/pip-26.0.dist-info/METADATA" + } + ] + } + ] +} diff --git a/tests/scan-image/test.sh b/tests/scan-image/test.sh index b14a0b5..6573062 100755 --- a/tests/scan-image/test.sh +++ b/tests/scan-image/test.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Tests of `actions/scan-image/check-pip.sh`: runs it on Trivy reports (JSON, -# all packages listed) and compares the exit status, and the reported +# all packages listed; made up, and in `fixtures/` cut down from real +# reports of Trivy v0.75.0) and compares the exit status, and the reported # packages, with the expected ones. Run from the repository root: # `bash tests/scan-image/test.sh` set -euo pipefail @@ -19,6 +20,14 @@ report() { echo "${file}" } +# raw : a file with this content +raw() { + local file + file="$(mktemp -p "${tmp}")" + printf '%s\n' "$1" > "${file}" + echo "${file}" +} + # expect [expected output] expect() { local want="$1" description="$2" file="$3" message="${4-}" @@ -48,12 +57,20 @@ os_pkgs() { } site="usr/local/lib/python3.13/site-packages" +fixtures="tests/scan-image/fixtures" +expect fail "real report: pip-*.dist-info in an image from scratch" \ + "${fixtures}/scratch-pip-dist-info.json" \ + "pip 26.0 (${site}/pip-26.0.dist-info/METADATA)" +expect fail "real report: py3-pip in an Alpine image" \ + "${fixtures}/alpine-py3-pip.json" "py3-pip 25.1.1-r0 (pip-os-test:latest (alpine 3.22.8))" expect pass "no results (e.g., an image from scratch)" \ - "$(printf '{"SchemaVersion": 2, "ArtifactName": "my-app:ci"}' > "${tmp}/empty"; echo "${tmp}/empty")" + "$(raw '{"SchemaVersion": 2, "ArtifactName": "my-app:ci"}')" expect pass "empty results" "$(report '[]')" +expect pass "results without packages" "$(report "[$(python_pkgs null)]")" expect pass "other packages" "$(report "[$(python_pkgs \ '[{"Name": "uv", "Version": "0.9.0"}, {"Name": "pipdeptree", "Version": "2.0"}]'), $(os_pkgs \ - '[{"Name": "python3", "Version": "3.13"}, {"Name": "python3-pip-whl", "Version": "25.1"}]')]")" + '[{"Name": "python3", "Version": "3.13"}, {"Name": "python3-pip-whl", "Version": "25.1"}, + {"Name": "py3-pip-pyc", "Version": "25.1"}, {"Name": "pipx", "Version": "1.7"}]')]")" expect fail "pip as a Python package" "$(report "[$(python_pkgs \ "[{\"Name\": \"uv\"}, {\"Name\": \"pip\", \"Version\": \"26.0\", \"FilePath\": \"${site}/pip-26.0.dist-info/METADATA\"}]")]")" \ "The image contains pip: pip 26.0 (${site}/pip-26.0.dist-info/METADATA)" @@ -70,22 +87,27 @@ expect fail "python3-pip as an OS package" "$(report "[$(os_pkgs \ "python3-pip 25.1 (my-app:ci (debian 13.1))" expect fail "py3-pip as an OS package" "$(report "[$(os_pkgs \ '[{"Name": "py3-pip", "Version": "25.1.1-r0"}]')]")" "py3-pip 25.1.1-r0" -expect fail "python3.12-pip as an OS package" "$(report "[$(os_pkgs \ - '[{"Name": "python3.12-pip", "Version": "23.2"}]')]")" "python3.12-pip 23.2" +# OS package names of pip in other distributions +for name in python3.12-pip python39-pip python-pip py3.13-pip platform-python-pip pip; do + expect fail "${name} as an OS package" "$(report "[$(os_pkgs \ + "[{\"Name\": \"${name}\", \"Version\": \"23.2\"}]")]")" "${name} 23.2" +done expect fail "pip in several places, each listed once" "$(report "[$(python_pkgs \ "[{\"Name\": \"pip\", \"Version\": \"26.0\", \"FilePath\": \"a/METADATA\"}, {\"Name\": \"pip\", \"Version\": \"24.0\", \"FilePath\": \"b/METADATA\"}]"), $(os_pkgs \ '[{"Name": "python3-pip", "Version": "25.1"}]')]")" \ "pip 24.0 (b/METADATA), pip 26.0 (a/METADATA), python3-pip 25.1" # Package names can't inject workflow commands -expect fail "a package name with a workflow command" "$(report "[$(python_pkgs \ +expect fail "a version with a workflow command" "$(report "[$(python_pkgs \ '[{"Name": "pip", "Version": "::warning::x"}]')]")" "pip warningx" +expect fail "a version with a line break" "$(report "[$(python_pkgs \ + '[{"Name": "pip", "Version": "1\r##[warning]x\n::warning::y"}]')]")" "pip 1##[warning]xwarningy (Python)" +expect fail "a version with a percent sign" "$(report "[$(python_pkgs \ + '[{"Name": "pip", "Version": "100%0A"}]')]")" "pip 100%250A" # Missing or unreadable reports fail, rather than pass unchecked expect fail "no report" "" "No package list" expect fail "a missing report" "${tmp}/missing" "No package list" -expect fail "a report that isn't JSON" \ - "$(printf 'not JSON\n' > "${tmp}/bad"; echo "${tmp}/bad")" "No package list" -expect fail "a report that isn't Trivy's" \ - "$(printf '[]\n' > "${tmp}/array"; echo "${tmp}/array")" "No package list" +expect fail "a report that isn't JSON" "$(raw 'not JSON')" "No package list" +expect fail "a report that isn't Trivy's" "$(raw '[]')" "No package list" # The step summary lists the packages found summary="${tmp}/summary" From ddedf067dc4eabd63b174bc5aa9c204ff6c8829a Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 09:01:35 +0100 Subject: [PATCH 4/5] fix(scan-image): show pip findings as code Render the packages in the step summary as code, so that metadata from the image is not rendered as Markdown, and widen the README wording. Co-Authored-By: Claude Opus 5.5 --- actions/scan-image/README.md | 2 +- actions/scan-image/check-pip.sh | 3 ++- tests/scan-image/test.sh | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/actions/scan-image/README.md b/actions/scan-image/README.md index 48fa34a..b722a12 100644 --- a/actions/scan-image/README.md +++ b/actions/scan-image/README.md @@ -81,7 +81,7 @@ e.g., through a new base image or an install step, set `fail-on-pip` to The check lists all packages of the image with Trivy and fails if pip is among them: -- as a Python package, i.e., a `pip-*.dist-info` anywhere in the image +- as a Python package, e.g., a `pip-*.dist-info` anywhere in the image (site-packages of the system Python, of a virtual environment or of a user installation), or as a conda package; - as an OS package: `pip`, `py*-pip` or `python*-pip` (e.g., `python3-pip` diff --git a/actions/scan-image/check-pip.sh b/actions/scan-image/check-pip.sh index e6f8ff4..a8a4bc1 100755 --- a/actions/scan-image/check-pip.sh +++ b/actions/scan-image/check-pip.sh @@ -43,8 +43,9 @@ if [[ -n "${found}" ]]; then { echo "### The image contains pip" echo + # As code, so that the image's metadata isn't rendered as Markdown while IFS= read -r line; do - echo "- ${line}" + echo "- \`${line//\`/}\`" done <<< "${found}" echo echo "Remove pip from the image (e.g., install packages with uv), or" diff --git a/tests/scan-image/test.sh b/tests/scan-image/test.sh index 6573062..49da4c7 100755 --- a/tests/scan-image/test.sh +++ b/tests/scan-image/test.sh @@ -115,7 +115,7 @@ if SCAN_PACKAGES="$(report "[$(os_pkgs '[{"Name": "py3-pip", "Version": "25.1"}] GITHUB_STEP_SUMMARY="${summary}" bash "${check}" >/dev/null 2>&1; then echo "NOT OK: expected fail with a step summary" failures=$((failures + 1)) -elif ! grep -q -- "- py3-pip 25.1" "${summary}"; then +elif ! grep -qF -- "- \`py3-pip 25.1 (my-app:ci (debian 13.1))\`" "${summary}"; then echo "NOT OK: step summary lacks the package: $(cat "${summary}")" failures=$((failures + 1)) else From 76fd80ca85b48ee48041422d70d98ec5edb14130 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Fri, 13 Nov 2026 09:21:15 +0100 Subject: [PATCH 5/5] fix(scan-image): harden the pip check inputs Fail on malformed package reports (results, packages or entries of the wrong type) and on runners without jq, and remove an earlier package list at the start of every scan, so that none is left from another image. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 ++--- actions/scan-image/README.md | 4 +++- actions/scan-image/action.yml | 4 ++++ actions/scan-image/check-pip.sh | 14 +++++++++++++- tests/scan-image/test.sh | 8 ++++++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02a3f38..70afb63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,9 +237,8 @@ jobs: fi # Failed because of findings, not before the scan jq -e '[.runs[].results[]] | length > 0' trivy-results.sarif - # The check is off by default: no package list, no failure - - name: Remove the package list of the clean image - run: rm trivy-packages.json + # The check is off by default: no failure, and the package list of the + # clean image is removed - name: Scan an image with pip (check off) uses: ./actions/scan-image with: diff --git a/actions/scan-image/README.md b/actions/scan-image/README.md index b722a12..74e51cf 100644 --- a/actions/scan-image/README.md +++ b/actions/scan-image/README.md @@ -95,7 +95,9 @@ Not found: pip's zipapp (`pip.pyz`) and other copies of pip that aren't installed as a package, e.g., the wheel that `ensurepip` bundles. The check fails before the check of the findings, so a scan with pip shows only that failure; the log and the step summary list the packages found. The package -list stays in the workspace as `trivy-packages.json` (it isn't uploaded). +list stays in the workspace as `trivy-packages.json` (it isn't uploaded; +the action removes the file at the start of the check, also when the check +is off). The check needs `jq` on the runner, as GitHub-hosted runners have. The default is `false`, so that callers keep passing when they bump the pin and can turn the check on one image at a time. diff --git a/actions/scan-image/action.yml b/actions/scan-image/action.yml index 78090eb..5e9920d 100644 --- a/actions/scan-image/action.yml +++ b/actions/scan-image/action.yml @@ -87,6 +87,10 @@ runs: trivy-report.json trivy-report.txt if-no-files-found: error + # Not to leave the package list of an earlier scan in the workspace + - name: Remove an earlier package list + shell: bash + run: rm -f trivy-packages.json - name: List packages of the image if: inputs.fail-on-pip == 'true' uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 diff --git a/actions/scan-image/check-pip.sh b/actions/scan-image/check-pip.sh index a8a4bc1..2964355 100755 --- a/actions/scan-image/check-pip.sh +++ b/actions/scan-image/check-pip.sh @@ -9,8 +9,20 @@ set -euo pipefail report="${SCAN_PACKAGES:-}" +if ! command -v jq >/dev/null; then + echo "::error title=Scan image::The pip check needs jq on the runner" + exit 1 +fi + +# A Trivy report: results and their packages are absent, null or arrays +# (anything else could hide packages from the check below) if [[ -z "${report}" || ! -f "${report}" ]] \ - || ! jq -e 'type == "object" and has("ArtifactName")' "${report}" \ + || ! jq -e ' + type == "object" and has("ArtifactName") + and (.Results | type | IN("null", "array")) + and all(.Results[]?; + type == "object" and (.Packages | type | IN("null", "array")) + and all(.Packages[]?; type == "object"))' "${report}" \ >/dev/null 2>&1; then echo "::error title=Scan image::No package list of the image to check for pip" exit 1 diff --git a/tests/scan-image/test.sh b/tests/scan-image/test.sh index 49da4c7..e10ae09 100755 --- a/tests/scan-image/test.sh +++ b/tests/scan-image/test.sh @@ -108,6 +108,14 @@ expect fail "no report" "" "No package list" expect fail "a missing report" "${tmp}/missing" "No package list" expect fail "a report that isn't JSON" "$(raw 'not JSON')" "No package list" expect fail "a report that isn't Trivy's" "$(raw '[]')" "No package list" +expect fail "results that aren't an array" \ + "$(raw '{"ArtifactName": "my-app:ci", "Results": {"Packages": [{"Name": "pip"}]}}')" \ + "No package list" +expect fail "packages that aren't an array" "$(report "[$(python_pkgs '{"Name": "pip"}')]")" \ + "No package list" +expect fail "a package that isn't an object" "$(report "[$(python_pkgs '["pip"]')]")" \ + "No package list" +expect fail "a result that isn't an object" "$(report '["pip"]')" "No package list" # The step summary lists the packages found summary="${tmp}/summary"