Skip to content

CVE scanning enhancement - #8437

Open
stutiibm wants to merge 10 commits into
ppc64le:masterfrom
stutiibm:cve_scanning_enhancement
Open

CVE scanning enhancement#8437
stutiibm wants to merge 10 commits into
ppc64le:masterfrom
stutiibm:cve_scanning_enhancement

Conversation

@stutiibm

Copy link
Copy Markdown
Contributor

Checklist

  • Have you checked and followed all the points mention in the CONTRIBUTING.MD
  • Have you validated script on UBI 9 container
  • Did you run the script(s) on fresh container with set -e option enabled and observe success ?
  • Did you have Legal approvals for patch files ?

stutiibm added 7 commits July 20, 2026 00:37
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.
@stutiibm
stutiibm marked this pull request as ready for review July 23, 2026 13:37
Comment thread gha-script/generalized_wheel_scanner.py Outdated
}

# Grype install URL
_GRYPE_INSTALL_URL = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a step in workflow to install grype, instead of installing it in the python script?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
try:
# Step 1: one call — all package names + versions
meta_result = subprocess.run(
['rpm', '-qa', '--queryformat', '%{NAME}|%{VERSION}-%{RELEASE}\n'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
print(" Detected RPM-based system")
inventory['packages'] = self._get_rpm_packages()
# Check if DEB-based system
elif shutil.which('dpkg'):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a need to prepare inventory for DEB based system, considering that the build happens on RHEL container?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
try:
# Get all packages
result = subprocess.run(
['dpkg-query', '-W', '-f=${Package}|${Version}\n'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed as part of Comment 3 — _get_dpkg_packages() has been removed entirely since DEB inventory is not needed for RHEL-based builds.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
'basename': Path(file_path).name
})
except:
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use except Exception: here instead of passing silently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be replaced with single regex?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
site_dirs = []
try:
result = subprocess.run(
['find', '/', '-name', 'site-packages', '-type', 'd',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the following be used instead?

import site
dirs = site.getsitepackages() + [site.getusersitepackages()] 

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
queue = [wheel_pkg_name]

while queue:
pkg = queue.pop(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use deque instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
queue = [wheel_pkg_name]

while queue:
pkg = queue.pop(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use deque instead.

@stutiibm stutiibm Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gha-script/generalized_wheel_scanner.py Outdated
if result.returncode == 0 and shutil.which('grype'):
print(" grype installed successfully.")
return True
print(f" Attempt {attempt} failed: {result.stderr.strip()[:200]}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use constant instead of magic number 200.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check that this does not print the Key here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

stutiibm added 3 commits July 27, 2026 20:40
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants