CVE scanning enhancement#8437
Conversation
Adds generalized_wheel_scanner.py — a single-script CVE scanner that runs
inside the build container after auditwheel repair to detect vulnerabilities
in bundled .so files that standard scanners cannot identify (metadata stripped,
hash-renamed filenames).
Scanner is invoked automatically from create_wheel_wrapper.sh after auditwheel
repair completes. Failure is non-blocking — build continues regardless.
Scanning strategy (three lanes + phase 1):
Phase 1 — grype scan directly on extracted wheel directory.
Results deduplicated against Lane 1 and Lane 2 before final report.
Lane 1 — Walks METADATA/egg-info inside the wheel to find all Python runtime
dependencies, then queries grype for CVEs.
Lane 2 — Strips auditwheel hash from each bundled .so filename, matches it
against a live RPM inventory (rpm -q --filesbypkg -a) to identify
the source package, then queries grype for CVEs.
Lane 3 — Parses the build script (git clone + git checkout lines) to extract
source-built C/C++ library names and versions. Queries NVD CPE API
to resolve a unique identifier, then NVD CVE API for findings.
All .so files not claimed by Lane 2 are attributed to Lane 3 libs.
Other details:
- grype self-installs at runtime (3 retries, 10s delay); non-blocking if it fails
- Shell variable substitution pre-pass handles $VAR / ${VAR:-default} in build scripts
- Layered NVD CPE search: raw repo name first, stripped name as fallback
- Language-binding CPE variants (-python, -java, etc.) filtered out
- Output: single unified JSON report per wheel saved to disk for COS upload
Fields in final_report:
total_unique_cve — globally distinct CVE IDs across all lanes
total_cve — per-library occurrence count
cpe_resolved — true = unique NVD CPE found; false = flagged, no CVE query
…per.sh
Adds the generalized_wheel_scanner.py invocation block to create_wheel_wrapper.sh.
The scanner is called after auditwheel repair completes and before SHA generation,
passing the repaired wheel and the original build script as arguments.
The call is non-blocking — if the scanner fails or the script is not found,
a warning is printed and the build continues normally.
Scanner invocation:
if [ -f "generalized_wheel_scanner.py" ]; then
python generalized_wheel_scanner.py "${wheel_final}" "${BUILD_SCRIPT_PATH}"
fi
Arguments passed:
$1 — repaired wheel file (manylinux-tagged .whl)
$2 — original build script path (used by Lane 3 to parse source-built libs)
Output: <wheel_stem>_cve_report.json written to the build working directory,
picked up and uploaded to COS by the workflow upload step.
Adds an "Upload CVE report to COS" step to all five wheel build jobs
(py310, py311, py312, py313, py314) in the build workflow.
The step runs after the wheel artifact upload and pushes the
<wheel_stem>_cve_report.json file produced by generalized_wheel_scanner.py
to the same COS bucket and path as the existing build logs:
ose-power-toolci-bucket-production/<PACKAGE_NAME>/<VERSION>/<wheel_stem>_cve_report.json
Example for numpy 2.2.5 (py312):
ose-power-toolci-bucket-production/numpy/2.2.5/
numpy-2.2.5-cp312-cp312-manylinux_2_34_ppc64le_cve_report.json
Design decisions:
- Reuses existing upload_file.sh — no new upload infrastructure needed
- Non-blocking — missing report only prints a warning, build continues
- One report per wheel per Python version, all landing under the same
PACKAGE_NAME/VERSION prefix, no extra collector job required
- No changes to create_wheel_wrapper.sh or any scanner script
Added CVE report renaming logic after wheel post-processing.
| } | ||
|
|
||
| # Grype install URL | ||
| _GRYPE_INSTALL_URL = ( |
There was a problem hiding this comment.
Add a step in workflow to install grype, instead of installing it in the python script?
| try: | ||
| # Step 1: one call — all package names + versions | ||
| meta_result = subprocess.run( | ||
| ['rpm', '-qa', '--queryformat', '%{NAME}|%{VERSION}-%{RELEASE}\n'], |
There was a problem hiding this comment.
Can you try with, it will give package name, version and files all in one go.
rpm -qa --queryformat 'PKG:%{NAME}-%{VERSION}-%{RELEASE}.%{ARCH}\n[%{FILENAMES}\n]'
| print(" Detected RPM-based system") | ||
| inventory['packages'] = self._get_rpm_packages() | ||
| # Check if DEB-based system | ||
| elif shutil.which('dpkg'): |
There was a problem hiding this comment.
Is there a need to prepare inventory for DEB based system, considering that the build happens on RHEL container?
| try: | ||
| # Get all packages | ||
| result = subprocess.run( | ||
| ['dpkg-query', '-W', '-f=${Package}|${Version}\n'], |
There was a problem hiding this comment.
On Debian systems, dpkg stores the list of files for every installed package in plain text files under /var/lib/dpkg/info/.list. Reading these files directly in Python is better compared to executing the dpkg-query and then dpkg for each package.
| 'basename': Path(file_path).name | ||
| }) | ||
| except: | ||
| pass |
There was a problem hiding this comment.
Can use except Exception: here instead of passing silently.
| # libgfortran-2758a8fd.so.5.0.0 -> libgfortran | ||
| # libopenblasp-r0-a8f83a82.3.32.so -> libopenblasp-r0 | ||
| # libabsl_base-eb206faa.so.2401.0.0 -> libabsl_base | ||
| base_name = re.sub(r'-[a-f0-9]{8,}', '', bundled_name) # Remove hex hash |
There was a problem hiding this comment.
Can this be replaced with single regex?
| site_dirs = [] | ||
| try: | ||
| result = subprocess.run( | ||
| ['find', '/', '-name', 'site-packages', '-type', 'd', |
There was a problem hiding this comment.
Can the following be used instead?
import site
dirs = site.getsitepackages() + [site.getusersitepackages()]
| queue = [wheel_pkg_name] | ||
|
|
||
| while queue: | ||
| pkg = queue.pop(0) |
| queue = [wheel_pkg_name] | ||
|
|
||
| while queue: | ||
| pkg = queue.pop(0) |
| if result.returncode == 0 and shutil.which('grype'): | ||
| print(" grype installed successfully.") | ||
| return True | ||
| print(f" Attempt {attempt} failed: {result.stderr.strip()[:200]}") |
There was a problem hiding this comment.
Use constant instead of magic number 200.
| with urllib.request.urlopen(req, timeout=30) as resp: | ||
| return json.loads(resp.read()) | ||
| except Exception as e: | ||
| print(f" Warning: NVD API call failed ({url}): {e}") |
There was a problem hiding this comment.
Check that this does not print the Key here.
Checklist
set -eoption enabled and observe success ?