diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f79508..70afb63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,14 +183,27 @@ 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 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; + # 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 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 +212,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 +237,40 @@ 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 failure, and the package list of the + # clean image is removed + - 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 + uses: ./actions/scan-image + with: + 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: + 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/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 d54d896..74e51cf 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,59 @@ 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 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: + +```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 and fails if pip is +among them: + +- 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` + 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 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. + ## 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 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 4c1b553..5e9920d 100644 --- a/actions/scan-image/action.yml +++ b/actions/scan-image/action.yml @@ -7,10 +7,12 @@ # `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 -# 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. +# 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 (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 @@ -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, conda or 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,27 @@ 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 + 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..2964355 --- /dev/null +++ b/actions/scan-image/check-pip.sh @@ -0,0 +1,69 @@ +#!/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 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 ! 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") + 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 +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 == "pip") + or ($result.Class == "os-pkgs" + 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, and escape `%` as + # the runner expects in a command's message + found="${found//::/}" + 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" + echo + # As code, so that the image's metadata isn't rendered as Markdown + 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/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 new file mode 100755 index 0000000..e10ae09 --- /dev/null +++ b/tests/scan-image/test.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Tests of `actions/scan-image/check-pip.sh`: runs it on Trivy reports (JSON, +# 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 + +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}" +} + +# 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-}" + 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" + +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)" \ + "$(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": "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)" +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"}]}]')" +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" +# 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 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" "$(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" +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 -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 + echo "ok: step summary" +fi + +if ((failures > 0)); then + echo "${failures} test(s) failed" + exit 1 +fi +echo "All tests passed"