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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
55 changes: 52 additions & 3 deletions actions/scan-image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<category>` (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`).

Expand Down Expand Up @@ -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@<commit SHA> # 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`).
34 changes: 30 additions & 4 deletions actions/scan-image/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<category>`
# (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
Expand All @@ -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
Expand Down Expand Up @@ -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'
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ inputs.image }}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
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
Expand Down
69 changes: 69 additions & 0 deletions actions/scan-image/check-pip.sh
Original file line number Diff line number Diff line change
@@ -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
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
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 // "?"))"
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
| 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"
30 changes: 30 additions & 0 deletions tests/scan-image/fixtures/alpine-py3-pip.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
19 changes: 19 additions & 0 deletions tests/scan-image/fixtures/scratch-pip-dist-info.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
Loading
Loading