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?
There was a problem hiding this comment.
Addressed. Removed _ensure_grype() method and _GRYPE_INSTALL_URL entirely from the Python script. Grype is now installed once in a dedicated install_scan_tools workflow job using wget with checksum verification, cached as a scan-tools-cache artifact, and downloaded by each wheel_build_pyXXX job. The Python script now simply checks shutil.which('grype') and skips grype-dependent lanes gracefully if not found.
| 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]'
There was a problem hiding this comment.
Addressed. Replaced the two-call approach with a single rpm -qa --queryformat 'PKG:%{NAME}|%{VERSION}-%{RELEASE}\n[%{FILENAMES}\n]' call as suggested. Lines starting with PKG: are parsed as package headers; subsequent lines are file paths belonging to that package.
| 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?
There was a problem hiding this comment.
Addressed. Removed the elif shutil.which('dpkg') branch from _create_system_inventory() and deleted _get_dpkg_packages() entirely. The builds run on RHEL containers so only RPM-based inventory is needed.
| 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.
There was a problem hiding this comment.
Addressed as part of Comment 3 — _get_dpkg_packages() has been removed entirely since DEB inventory is not needed for RHEL-based builds.
| 'basename': Path(file_path).name | ||
| }) | ||
| except: | ||
| pass |
There was a problem hiding this comment.
Can use except Exception: here instead of passing silently.
There was a problem hiding this comment.
Addressed. The bare except: pass was inside _get_dpkg_packages() which has been removed entirely (Comment 3). All remaining exception handlers in the file already use except Exception as e:.
| # 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?
There was a problem hiding this comment.
Addressed. Replaced three chained re.sub() calls with a single regex: re.sub(r'(-[a-f0-9]{8,})?(.\d[^/])?.so.$', '', bundled_name) which strips the auditwheel hash, version suffix, and .so extension in one pass.
| 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()]
There was a problem hiding this comment.
Addressed. Replaced the find / subprocess in _find_site_packages() with the suggested approach: site.getsitepackages() + [site.getusersitepackages()] using Python's built-in site module. Added import site at the top of the file.
| queue = [wheel_pkg_name] | ||
|
|
||
| while queue: | ||
| pkg = queue.pop(0) |
There was a problem hiding this comment.
Addressed. Added from collections import deque import, changed queue = [wheel_pkg_name] to queue: deque = deque([wheel_pkg_name]), and queue.pop(0) to queue.popleft() for O(1) dequeue instead of O(n) list shift.
| queue = [wheel_pkg_name] | ||
|
|
||
| while queue: | ||
| pkg = queue.pop(0) |
There was a problem hiding this comment.
Addressed. Added from collections import deque import, changed queue = [wheel_pkg_name] to queue: deque = deque([wheel_pkg_name]), and queue.pop(0) to queue.popleft() for O(1) dequeue instead of O(n) list shift.
| 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.
There was a problem hiding this comment.
Addressed. Added module-level constant MAX_STDERR_LOG_CHARS = 200 and replaced all [:200] slices throughout the file with [:MAX_STDERR_LOG_CHARS].
| 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.
There was a problem hiding this comment.
Addressed. Confirmed and documented: the API key is read from os.environ.get("NVD_API_KEY", "") and passed only via req.add_header("apiKey", api_key) as an HTTP request header. It is never interpolated into any print() or log statement. Added an explicit comment in the code confirming this.
Added install_scan_tools job to install grype and scancode-toolkit, and updated wheel build jobs to include scanning steps.
Removed grype installation logic and updated checks for availability. Adjusted package inventory creation to only check for RPM-based systems.
Checklist
set -eoption enabled and observe success ?