feat(ci): add Python dependency scanning to OSV-Scanner workflow (#3530)

* feat(ci): add Python dependency scanning to OSV-Scanner workflow

Add lockfiles and scanning for Python dependencies in the initializers
and python_api. This extends the nightly OSV-Scanner workflow to cover
Python in addition to Go.

- Generate requirements-lock.txt files via pip-compile for:
  - cmd/initializers/dataset/
  - cmd/initializers/model/
  - api/python_api/
- Add osv-scan-python job with SARIF scanning and auto-fix
- Add validate-lockfile.yaml to ensure lockfiles stay in sync on PRs
- Handle both direct and transitive dependency upgrades

Closes #3528

Signed-off-by: Fiona-Waters <[email protected]>

* fix(ci): address review feedback on Python OSV-Scanner workflow

- Fix validate-lockfile false positives by stripping header comments
  before diffing (pip-compile embeds output filename in header)
- Regenerate lockfiles in clean Python 3.12 venv with --strip-extras
  to include all transitive deps and silence pip-tools deprecation warning
- Fix PR body table formatting: use Markdown table rows instead of
  bullet items in both Go and Python auto-fix jobs
- Escape dots in package names for grep/sed regex safety

Signed-off-by: Fiona-Waters <[email protected]>

* fix(ci): address review feedback on OSV-Scanner Python workflow

- Replace shell grep/sed with Python helper script using
  packaging.requirements.Requirement for exact name matching,
  fixing prefix collision (requests vs requests-oauthlib),
  constraint dropping, and extras stripping
- Skip entry when package not found in lockfile after pip-compile
  instead of recording a false version
- Bump actions/setup-python from v5 to v6 for consistency

Signed-off-by: Fiona-Waters <[email protected]>

* fix(ci): pin pip-tools version in CI workflows

Pin pip-tools to 7.4.1 in osv-scanner and validate-lockfile workflows
to mitigate supply-chain risk in workflows with elevated permissions.

Signed-off-by: Fiona-Waters <[email protected]>

* fix(deps): update lockfiles for certifi 2026.5.20

Signed-off-by: Fiona-Waters <[email protected]>

---------

Signed-off-by: Fiona-Waters <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Fiona Waters
2026-05-20 13:34:59 +00:00
committed by GitHub
co-authored by Hanzo Dev
parent 8704ba0bd4
commit 4af1d0a81b
6 changed files with 567 additions and 1 deletions
+238 -1
View File
@@ -161,7 +161,7 @@ jobs:
fi
echo "Installed version: v${INSTALLED_VER}"
printf '%s\n' "- ${pkg}@v${INSTALLED_VER} | [$advisory_id]($advisory_url)" >> applied_fixes.txt
printf '%s\n' "| ${pkg}@v${INSTALLED_VER} | [$advisory_id]($advisory_url) |" >> applied_fixes.txt
done <<< "$FIX_DATA"
if [ ! -s applied_fixes.txt ]; then
@@ -209,3 +209,240 @@ jobs:
delete-branch: true
labels: |
"area/security"
osv-scan-python:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install OSV-Scanner
run: |
OSV_VERSION="2.3.8"
EXPECTED_SHA="bc98e15319ed0d515e3f9235287ba53cdc5535d576d24fd573978ecfe9ab92dc"
curl -fsSL "https://github.com/google/osv-scanner/releases/download/v${OSV_VERSION}/osv-scanner_linux_amd64" -o osv-scanner
ACTUAL_SHA=$(sha256sum osv-scanner | awk '{print $1}')
if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
echo "::error::OSV-Scanner checksum mismatch! Expected ${EXPECTED_SHA}, got ${ACTUAL_SHA}"
exit 1
fi
chmod +x osv-scanner
sudo mv osv-scanner /usr/local/bin/
- name: Install pip-tools
run: pip install 'pip-tools==7.5.3'
- name: Run OSV-Scanner (SARIF for Security Tab)
continue-on-error: true
run: |
rc=0
osv-scanner scan \
--format sarif \
--output osv-results-python.sarif \
-L cmd/initializers/dataset/requirements-lock.txt \
-L cmd/initializers/model/requirements-lock.txt \
-L api/python_api/requirements-lock.txt || rc=$?
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
echo "::error::OSV-Scanner failed with unexpected exit code $rc"
exit 1
fi
- name: Upload SARIF to GitHub Security tab
if: always() && hashFiles('osv-results-python.sarif') != ''
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: osv-results-python.sarif
category: kubeflow-trainer-osv-scanner-python
- name: Run OSV-Scanner (JSON for auto-fix)
run: |
rc=0
osv-scanner scan \
--format json \
--output osv-results-python.json \
-L cmd/initializers/dataset/requirements-lock.txt \
-L cmd/initializers/model/requirements-lock.txt \
-L api/python_api/requirements-lock.txt || rc=$?
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
echo "::error::OSV-Scanner failed with unexpected exit code $rc"
exit 1
fi
- name: Process vulnerabilities and apply fixes
id: fixer
run: |
set -euo pipefail
if [ ! -f osv-results-python.json ] || [ ! -s osv-results-python.json ]; then
echo "No scan results found."
echo "updates_found=false" >> $GITHUB_OUTPUT
exit 0
fi
FIX_DATA=$(jq -r '
[
.results[]?.packages[]? |
.package as $pkg |
.vulnerabilities[]? |
. as $vuln |
.affected[]? |
select(.package.name == $pkg.name) |
.ranges[]? |
.events[] |
select(.fixed != null) |
{
pkg: $pkg.name,
ver: $pkg.version,
fix: (.fixed | sub("^v"; "")),
id: $vuln.id,
url: ($vuln.references[0].url // "https://osv.dev/vulnerability/\($vuln.id)")
}
] |
.[] |
"\(.pkg)|\(.ver)|\(.fix)|\(.id)|\(.url)"
' osv-results-python.json \
| sort -t'|' -k1,1 -k4,4 -k3,3Vr \
| awk -F'|' '!seen[$1,$4]++' \
)
if [ -z "$FIX_DATA" ]; then
echo "No fixable vulnerabilities found."
echo "updates_found=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "Fixable vulnerabilities found:"
echo "$FIX_DATA"
echo ""
# Map lockfiles back to their source requirements files
LOCKFILES=(
"cmd/initializers/dataset/requirements-lock.txt|cmd/initializers/dataset/requirements.txt"
"cmd/initializers/model/requirements-lock.txt|cmd/initializers/model/requirements.txt"
"api/python_api/requirements-lock.txt|api/python_api/pyproject.toml"
)
rm -f applied_fixes.txt
while IFS='|' read -r pkg current_ver fix_ver advisory_id advisory_url; do
[ -z "$pkg" ] && continue
echo ""
echo "=== Processing: $pkg $current_ver -> $fix_ver ($advisory_id) ==="
# Escape dots in package name for safe use in grep/sed regex
pkg_re=$(echo "$pkg" | sed 's/\./\\./g')
FIXED=false
for mapping in "${LOCKFILES[@]}"; do
lockfile="${mapping%%|*}"
source_file="${mapping##*|}"
# Check if this lockfile contains the vulnerable package (case-insensitive)
if ! grep -qi "^${pkg_re}==" "$lockfile"; then
continue
fi
echo "Found $pkg in $lockfile (source: $source_file)"
cp "$lockfile" "${lockfile}.bak"
cp "$source_file" "${source_file}.bak"
# Detect if pkg is a direct dependency and update the constraint in-place.
# Uses packaging.requirements.Requirement for exact name matching (no prefix
# collision) and specifier intersection (preserves extras and upper bounds).
UPDATE_RESULT=$(python3 hack/scripts/update-source-dep.py "$pkg" "$fix_ver" "$source_file")
if [ "$UPDATE_RESULT" = "direct" ]; then
echo "Direct dependency — updated version constraint in $source_file"
else
echo "Transitive dependency — upgrading via pip-compile"
fi
# Regenerate the lockfile, upgrading the specific package
COMPILE_RC=0
pip-compile "$source_file" -o "$lockfile" --upgrade-package "${pkg}>=${fix_ver}" --strip-extras --quiet 2>&1 || COMPILE_RC=$?
if [ "$COMPILE_RC" -ne 0 ]; then
echo "::warning::pip-compile failed for $source_file (exit code $COMPILE_RC), skipping"
mv "${lockfile}.bak" "$lockfile"
mv "${source_file}.bak" "$source_file"
continue
fi
# Verify the fix was applied (case-insensitive)
INSTALLED_VER=$(grep -i "^${pkg_re}==" "$lockfile" | sed 's/.*==//' || true)
if [ -z "$INSTALLED_VER" ]; then
echo "::warning::$pkg not found in $lockfile after pip-compile (may have been removed), skipping"
rm -f "${lockfile}.bak" "${source_file}.bak"
continue
fi
rm -f "${lockfile}.bak" "${source_file}.bak"
echo "Installed version: ${INSTALLED_VER}"
printf '%s\n' "| ${pkg}==${INSTALLED_VER} | [$advisory_id]($advisory_url) |" >> applied_fixes.txt
FIXED=true
done
if [ "$FIXED" = false ]; then
echo "::warning::Could not find $pkg in any lockfile, skipping"
fi
done <<< "$FIX_DATA"
if [ ! -s applied_fixes.txt ]; then
echo "No fixes were applied."
echo "updates_found=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "updates_found=true" >> $GITHUB_OUTPUT
{
echo "fix_details<<EOF"
cat applied_fixes.txt
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Create Pull Request
if: steps.fixer.outputs.updates_found == 'true'
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "fix: nightly automated Python dependency update for security vulnerabilities"
title: "fix: nightly security Python dependency updates"
add-paths: |
cmd/initializers/dataset/requirements.txt
cmd/initializers/dataset/requirements-lock.txt
cmd/initializers/model/requirements.txt
cmd/initializers/model/requirements-lock.txt
api/python_api/pyproject.toml
api/python_api/requirements-lock.txt
body: |
## Security Update (Python)
This is an automated PR triggered by the nightly OSV-Scanner security scan.
The following Python dependencies were updated to resolve known vulnerabilities:
| Package & Version | Advisory Link |
| :--- | :--- |
${{ steps.fixer.outputs.fix_details }}
**Verification:** Updated via `pip-compile` lockfile regeneration.
> **Note:** This PR was created with `GITHUB_TOKEN`, which does not trigger `pull_request` workflows.
> CI checks will not run automatically. Push an empty commit (`git commit --allow-empty -m "trigger CI"`)
> to kick off CI before merging.
branch: security-nightly-python-updates
delete-branch: true
labels: |
"area/security"
+82
View File
@@ -0,0 +1,82 @@
name: Validate Python Lockfiles
on:
pull_request:
paths:
- 'cmd/initializers/dataset/requirements.txt'
- 'cmd/initializers/dataset/requirements-lock.txt'
- 'cmd/initializers/model/requirements.txt'
- 'cmd/initializers/model/requirements-lock.txt'
- 'api/python_api/pyproject.toml'
- 'api/python_api/requirements-lock.txt'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
validate-lockfiles:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install pip-tools
run: pip install 'pip-tools==7.5.3'
- name: Verify lockfiles are in sync
run: |
set -euo pipefail
LOCKFILE_PAIRS=(
"cmd/initializers/dataset/requirements.txt|cmd/initializers/dataset/requirements-lock.txt"
"cmd/initializers/model/requirements.txt|cmd/initializers/model/requirements-lock.txt"
"api/python_api/pyproject.toml|api/python_api/requirements-lock.txt"
)
FAILED=false
for pair in "${LOCKFILE_PAIRS[@]}"; do
source_file="${pair%%|*}"
lockfile="${pair##*|}"
echo "=== Checking $source_file -> $lockfile ==="
pip-compile "$source_file" -o "${lockfile}.check" --strip-extras --quiet 2>&1
# Strip auto-generated header comments before comparing — pip-compile
# embeds the output filename in the header, which differs between the
# committed lockfile and the .check file.
grep -v '^#' "$lockfile" > "${lockfile}.stripped"
grep -v '^#' "${lockfile}.check" > "${lockfile}.check.stripped"
if ! diff -q "${lockfile}.stripped" "${lockfile}.check.stripped" > /dev/null 2>&1; then
echo "::error::$lockfile is out of sync with $source_file. Please run 'pip-compile $source_file -o $lockfile --strip-extras' and commit the changes."
diff "${lockfile}.stripped" "${lockfile}.check.stripped" || true
FAILED=true
else
echo "In sync"
fi
rm -f "${lockfile}.check" "${lockfile}.stripped" "${lockfile}.check.stripped"
done
if [ "$FAILED" = true ]; then
echo ""
echo "One or more lockfiles are out of sync."
echo "To fix, run the following and commit the updated lockfiles:"
echo ""
echo " pip install pip-tools"
echo " pip-compile cmd/initializers/dataset/requirements.txt -o cmd/initializers/dataset/requirements-lock.txt --strip-extras"
echo " pip-compile cmd/initializers/model/requirements.txt -o cmd/initializers/model/requirements-lock.txt --strip-extras"
echo " pip-compile api/python_api/pyproject.toml -o api/python_api/requirements-lock.txt --strip-extras"
exit 1
fi
+19
View File
@@ -0,0 +1,19 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --output-file=api/python_api/requirements-lock.txt --strip-extras api/python_api/pyproject.toml
#
annotated-types==0.7.0
# via pydantic
pydantic==2.13.4
# via kubeflow_trainer_api (api/python_api/pyproject.toml)
pydantic-core==2.46.4
# via pydantic
typing-extensions==4.15.0
# via
# pydantic
# pydantic-core
# typing-inspection
typing-inspection==0.4.2
# via pydantic
@@ -0,0 +1,89 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --output-file=cmd/initializers/dataset/requirements-lock.txt --strip-extras cmd/initializers/dataset/requirements.txt
#
annotated-doc==0.0.4
# via typer
anyio==4.13.0
# via httpx
certifi==2026.5.20
# via
# httpcore
# httpx
# kubernetes
# requests
charset-normalizer==3.4.7
# via requests
click==8.4.0
# via typer
durationpy==0.10
# via kubernetes
filelock==3.29.0
# via huggingface-hub
fsspec==2026.4.0
# via huggingface-hub
h11==0.16.0
# via httpcore
hf-xet==1.5.0
# via huggingface-hub
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via huggingface-hub
huggingface-hub==1.14.0
# via -r cmd/initializers/dataset/requirements.txt
idna==3.15
# via
# anyio
# httpx
# requests
kubernetes==35.0.0
# via -r cmd/initializers/dataset/requirements.txt
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
oauthlib==3.3.1
# via requests-oauthlib
opendal==0.47.1
# via -r cmd/initializers/dataset/requirements.txt
packaging==26.2
# via huggingface-hub
pygments==2.20.0
# via rich
python-dateutil==2.9.0.post0
# via kubernetes
pyyaml==6.0.3
# via
# huggingface-hub
# kubernetes
requests==2.34.2
# via
# kubernetes
# requests-oauthlib
requests-oauthlib==2.0.0
# via kubernetes
rich==15.0.0
# via typer
shellingham==1.5.4
# via typer
six==1.17.0
# via
# kubernetes
# python-dateutil
tqdm==4.67.3
# via huggingface-hub
typer==0.25.1
# via huggingface-hub
typing-extensions==4.15.0
# via
# anyio
# huggingface-hub
urllib3==2.7.0
# via
# kubernetes
# requests
websocket-client==1.9.0
# via kubernetes
@@ -0,0 +1,58 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --output-file=cmd/initializers/model/requirements-lock.txt --strip-extras cmd/initializers/model/requirements.txt
#
annotated-doc==0.0.4
# via typer
anyio==4.13.0
# via httpx
certifi==2026.5.20
# via
# httpcore
# httpx
click==8.4.0
# via typer
filelock==3.29.0
# via huggingface-hub
fsspec==2026.4.0
# via huggingface-hub
h11==0.16.0
# via httpcore
hf-xet==1.5.0
# via huggingface-hub
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via huggingface-hub
huggingface-hub==1.14.0
# via -r cmd/initializers/model/requirements.txt
idna==3.15
# via
# anyio
# httpx
markdown-it-py==4.2.0
# via rich
mdurl==0.1.2
# via markdown-it-py
opendal==0.47.1
# via -r cmd/initializers/model/requirements.txt
packaging==26.2
# via huggingface-hub
pygments==2.20.0
# via rich
pyyaml==6.0.3
# via huggingface-hub
rich==15.0.0
# via typer
shellingham==1.5.4
# via typer
tqdm==4.67.3
# via huggingface-hub
typer==0.25.1
# via huggingface-hub
typing-extensions==4.15.0
# via
# anyio
# huggingface-hub
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Update a direct dependency's version constraint in a requirements.txt or pyproject.toml.
Uses packaging.requirements.Requirement for exact name matching (no prefix collision)
and specifier intersection (preserves extras and upper bounds).
Usage:
python3 update-source-dep.py <package> <fix_version> <source_file>
Prints "direct" if the package was found and updated, "transitive" otherwise.
"""
import re
import sys
import tomllib
from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
def normalize(name):
return re.sub(r"[-_.]+", "-", name).lower()
def update_requirements_txt(target, fix_ver, source_file):
with open(source_file, "r") as f:
lines = f.readlines()
found = False
new_lines = []
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
try:
req = Requirement(stripped)
if normalize(req.name) == target:
found = True
req.specifier = req.specifier & SpecifierSet(f">={fix_ver}")
new_lines.append(str(req) + "\n")
continue
except Exception:
pass
new_lines.append(line)
if found:
with open(source_file, "w") as f:
f.writelines(new_lines)
return found
def update_pyproject_toml(target, fix_ver, source_file):
with open(source_file, "rb") as f:
deps = tomllib.load(f).get("project", {}).get("dependencies", [])
found = False
with open(source_file, "r") as f:
content = f.read()
for dep in deps:
req = Requirement(dep)
if normalize(req.name) == target:
found = True
new_req = Requirement(dep)
new_req.specifier = req.specifier & SpecifierSet(f">={fix_ver}")
content = content.replace(f'"{dep}"', f'"{new_req}"')
if found:
with open(source_file, "w") as f:
f.write(content)
return found
def main():
pkg_name, fix_ver, source_file = sys.argv[1], sys.argv[2], sys.argv[3]
target = normalize(pkg_name)
if source_file.endswith(".toml"):
found = update_pyproject_toml(target, fix_ver, source_file)
else:
found = update_requirements_txt(target, fix_ver, source_file)
print("direct" if found else "transitive")
if __name__ == "__main__":
main()