From d6835620aa1604c41080cd292fac19086eb48694 Mon Sep 17 00:00:00 2001 From: gabrielvmayer Date: Fri, 20 Mar 2026 18:12:19 -0300 Subject: [PATCH 1/3] feat(vulns): add check-sca-patches command for osv patch validation --- README.md | 1 + src/conviso/VERSION | 2 +- src/conviso/commands/vulnerabilities.py | 275 ++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e012134..c988dd7 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ conviso --help - Vulnerabilities with local field filter (auto deep for deep fields): `python -m conviso.app vulns list --company-id 443 --all --contains codeSnippet=eval( --contains fileName=app.py` - Vulnerabilities (DAST/WEB) search by request/response: `python -m conviso.app vulns list --company-id 443 --types DAST_FINDING,WEB_VULNERABILITY --all --contains request=Authorization --contains response=stacktrace` - Vulnerabilities with forced deep local search: `python -m conviso.app vulns list --company-id 443 --all --contains codeSnippet=eval( --deep-search --workers 8` +- Vulnerabilities (SCA) checking patches against OSV: `python -m conviso.app vulns check-sca-patches --company-id 443 --severities HIGH,CRITICAL --status RISK_ACCEPTED --all` - Vulnerability timeline (by vulnerability ID): `python -m conviso.app vulns timeline --id 12345` - Vulnerabilities timeline by project: `python -m conviso.app vulns timeline --company-id 443 --project-id 26102` - Last user who changed vuln status: `python -m conviso.app vulns timeline --id 12345 --last-status-change-only` diff --git a/src/conviso/VERSION b/src/conviso/VERSION index 0f82685..6678432 100644 --- a/src/conviso/VERSION +++ b/src/conviso/VERSION @@ -1 +1 @@ -0.3.7 +0.3.8 diff --git a/src/conviso/commands/vulnerabilities.py b/src/conviso/commands/vulnerabilities.py index 17ac69b..089ab5d 100644 --- a/src/conviso/commands/vulnerabilities.py +++ b/src/conviso/commands/vulnerabilities.py @@ -2100,3 +2100,278 @@ def clean_source_payload(base): except Exception as exc: error(f"Error updating vulnerability: {exc}") raise typer.Exit(code=1) + + + +# ---------------------- CHECK SCA PATCHES ---------------------- # +@app.command("check-sca-patches", help="Check OSV for available patches for SCA vulnerabilities and optionally update them.") +def check_sca_patches( + company_id: int = typer.Option(..., "--company-id", "-c", help="Company ID."), + asset_ids: Optional[str] = typer.Option(None, "--asset-ids", "-a", help="Comma-separated asset IDs to filter."), + severities: Optional[str] = typer.Option(None, "--severities", "-s", help="Comma-separated severities (NOTIFICATION,LOW,MEDIUM,HIGH,CRITICAL)."), + status: Optional[str] = typer.Option(None,"--status",help="Comma-separated vulnerability status labels."), + asset_tags: Optional[str] = typer.Option(None, "--asset-tags", "-t", help="Comma-separated asset tags."), + cves: Optional[str] = typer.Option(None, "--cves", help="Comma-separated CVE identifiers."), + page: int = typer.Option(1, "--page", "-p", help="Page number."), + per_page: int = typer.Option(50, "--per-page", "-l", help="Items per page."), + all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."), + fmt: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv."), + output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file for json/csv."), +): + """Check OSV for available patches for open SCA vulnerabilities without a patched version.""" + info(f"Checking SCA patches for company {company_id}...") + + def _split_ids(value: Optional[str]): + if not value: return None + ids = [] + for raw in value.split(","): + raw = raw.strip() + if not raw: continue + try: ids.append(int(raw)) + except ValueError: continue + return ids or None + + def _split_strs(value: Optional[str]): + if not value: return None + vals = [v.strip() for v in value.split(",") if v.strip()] + return vals or None + + query_sca = """ + query IssuesSca($companyId: ID!, $pagination: PaginationInput!, $filters: IssuesFiltersInput) { + issues(companyId: $companyId, pagination: $pagination, filters: $filters) { + collection { + id + title + status + type + asset { + id + name + assetsTagList + } + ... on ScaFinding { + severity + detail { package affectedVersion patchedVersion cve } + } + } + metadata { + currentPage + totalPages + } + } + } + """ + + SEVERITY_ALLOWED = {"NOTIFICATION", "LOW", "MEDIUM", "HIGH", "CRITICAL"} + STATUS_ALLOWED = { + "CREATED", + "DRAFT", + "IDENTIFIED", + "IN_PROGRESS", + "AWAITING_VALIDATION", + "FIX_ACCEPTED", + "RISK_ACCEPTED", + "FALSE_POSITIVE", + "SUPPRESSED", + } + + assets_list = _split_ids(asset_ids) + + severities_list = None + if severities: + severities_list = [s.strip().upper() for s in severities.split(",") if s.strip()] + for s in severities_list: + if s not in SEVERITY_ALLOWED: + error(f"Invalid severity '{s}'. Allowed: {', '.join(SEVERITY_ALLOWED)}") + raise typer.Exit(code=1) + + status_list = None + if status: + status_list = [s.strip().upper() for s in status.split(",") if s.strip()] + for s in status_list: + if s not in STATUS_ALLOWED: + error(f"Invalid status '{s}'. Allowed: {', '.join(STATUS_ALLOWED)}") + raise typer.Exit(code=1) + + asset_tags_list = _split_strs(asset_tags) + cves_list = _split_strs(cves) + + filters = { + "failureTypes": ["SCA_FINDING"], + "statuses": status_list or ["CREATED", "IDENTIFIED", "IN_PROGRESS", "AWAITING_VALIDATION"] + } + if assets_list: filters["assetIds"] = assets_list + if severities_list: filters["severities"] = severities_list + if asset_tags_list: filters["assetTags"] = asset_tags_list + if cves_list: filters["cves"] = cves_list + + fetch_all = all_pages + current_page = page + all_issues = [] + + while True: + vars_page = { + "companyId": str(company_id), + "pagination": {"page": current_page, "perPage": per_page if not fetch_all else 200}, + "filters": filters + } + try: + res = graphql_request(query_sca, vars_page, log_request=True, verbose_only=True) + issues_data = res.get("issues") or {} + collection = issues_data.get("collection") or [] + metadata = issues_data.get("metadata") or {} + + for vuln in collection: + if vuln.get("type") == "SCA_FINDING": + all_issues.append(vuln) + + total_p = metadata.get("totalPages") or 1 + if not fetch_all or current_page >= total_p: + break + current_page += 1 + except Exception as e: + error(f"Error fetching vulnerabilities: {e}") + break + + target_issues = [] + for vuln in all_issues: + detail = vuln.get("detail") or {} + patched_ver = detail.get("patchedVersion") + cve = detail.get("cve") + package = detail.get("package") + asset = vuln.get("asset") or {} + tags = ", ".join(asset.get("assetsTagList") or []) + severity_value = vuln.get("severity") or "" + sev_color_map = { + "CRITICAL": "bold white on red", + "HIGH": "bold red", + "MEDIUM": "yellow", + "LOW": "green", + "NOTIFICATION": "cyan", + } + sev_display = severity_value + sev_style = sev_color_map.get(severity_value.upper(), None) + if sev_style: + sev_display = f"[{sev_style}]{severity_value}[/{sev_style}]" + + if not patched_ver and (cve or package): + target_issues.append({ + "id": vuln.get("id"), + "asset_id": asset.get("id") or "-", + "title": vuln.get("title"), + "cve": cve, + "package": package, + "affectedVersion": detail.get("affectedVersion"), + "asset_name": asset.get("name") or "-", + "asset_tags": tags or "-", + "status": vuln.get("status") or "-", + "severity_display": sev_display, + }) + + if not target_issues: + success("No open SCA vulnerabilities missing a patched version found.") + return + + info(f"Found {len(target_issues)} SCA vulnerabilities missing patchedVersion. Querying OSV...") + + formatted_issues = [] + + def _get_best_fixed_version(affected_list, pkg_match=None): + git_fixes = [] + db_spec_fixes = [] + eco_fixes = [] + + for affected in affected_list: + if pkg_match: + pkg_name = (affected.get("package") or {}).get("name") + if pkg_name and pkg_name != pkg_match: + continue + + for r in affected.get("ranges", []): + rtype = r.get("type") + db_spec = r.get("database_specific") or {} + for v_event in db_spec.get("versions", []): + if "fixed" in v_event and v_event["fixed"] not in db_spec_fixes: + db_spec_fixes.append(v_event["fixed"]) + + for event in r.get("events", []): + if "fixed" in event: + val = event["fixed"] + if rtype in ("ECOSYSTEM", "SEMVER"): + if val not in eco_fixes: + eco_fixes.append(val) + elif rtype == "GIT": + if val not in git_fixes: + git_fixes.append(val) + + db_spec2 = affected.get("database_specific") or {} + for v_event in db_spec2.get("versions", []): + if "fixed" in v_event and v_event["fixed"] not in db_spec_fixes: + db_spec_fixes.append(v_event["fixed"]) + + if eco_fixes: + return ", ".join(eco_fixes) + if db_spec_fixes: + return ", ".join(db_spec_fixes) + if git_fixes: + return git_fixes[-1] + return None + + updates_to_make = [] + + for issue in target_issues: + cve = issue["cve"] + package = issue["package"] + current_version = issue["affectedVersion"] + found_patch = None + + try: + if cve: + resp = requests.get(f"https://api.osv.dev/v1/vulns/{cve}", timeout=10) + if resp.status_code == 200: + data = resp.json() + found_patch = _get_best_fixed_version(data.get("affected", [])) + if not found_patch: + for alias in data.get("aliases", []): + alias_resp = requests.get(f"https://api.osv.dev/v1/vulns/{alias}", timeout=10) + if alias_resp.status_code == 200: + alias_data = alias_resp.json() + found_patch = _get_best_fixed_version(alias_data.get("affected", [])) + if found_patch: + break + elif package and current_version: + payload = {"version": current_version, "package": {"name": package}} + resp = requests.post("https://api.osv.dev/v1/query", json=payload, timeout=10) + if resp.status_code == 200: + for vuln in resp.json().get("vulns", []): + found_patch = _get_best_fixed_version(vuln.get("affected", []), pkg_match=package) + if found_patch: + break + except Exception as e: + warning(f"Error querying OSV for issue {issue['id']}: {e}") + + if found_patch: + formatted_issues.append({ + "Vuln ID": str(issue["id"]), + "Asset ID": str(issue["asset_id"]), + "Asset Name": issue["asset_name"], + "Asset Tags": issue["asset_tags"], + "Package": package or "-", + "Status": issue["status"], + "Severity": issue["severity_display"], + "CVE": cve or "-", + "Current Version": current_version or "-", + "OSV Patched Version": found_patch + }) + + if not formatted_issues: + warning("No patched versions found via OSV.") + return + + export_data( + data=formatted_issues, + fmt=fmt.lower(), + output=output, + title="OSV Patches Found" + ) + summary(f"Found patched versions for {len(formatted_issues)} vulnerabilities.") From f0413c68019d472998d89efc459dfefe46b5302d Mon Sep 17 00:00:00 2001 From: gabrielvmayer Date: Fri, 20 Mar 2026 22:59:09 -0300 Subject: [PATCH 2/3] refactor(vulns): parallelize OSV API queries and optimize JSON parsing when running check-sca-patches --- src/conviso/commands/vulnerabilities.py | 154 +++++++++++++----------- 1 file changed, 82 insertions(+), 72 deletions(-) diff --git a/src/conviso/commands/vulnerabilities.py b/src/conviso/commands/vulnerabilities.py index 089ab5d..a4ba455 100644 --- a/src/conviso/commands/vulnerabilities.py +++ b/src/conviso/commands/vulnerabilities.py @@ -2144,36 +2144,31 @@ def _split_strs(value: Optional[str]): title status type - asset { - id - name - assetsTagList + asset { + id + name + assetsTagList } ... on ScaFinding { severity - detail { package affectedVersion patchedVersion cve } + detail { + package + affectedVersion + patchedVersion + cve + } } } - metadata { - currentPage - totalPages + metadata { + currentPage + totalPages } } } """ SEVERITY_ALLOWED = {"NOTIFICATION", "LOW", "MEDIUM", "HIGH", "CRITICAL"} - STATUS_ALLOWED = { - "CREATED", - "DRAFT", - "IDENTIFIED", - "IN_PROGRESS", - "AWAITING_VALIDATION", - "FIX_ACCEPTED", - "RISK_ACCEPTED", - "FALSE_POSITIVE", - "SUPPRESSED", - } + STATUS_ALLOWED = {"CREATED", "DRAFT", "IDENTIFIED", "IN_PROGRESS", "AWAITING_VALIDATION", "FIX_ACCEPTED", "RISK_ACCEPTED", "FALSE_POSITIVE", "SUPPRESSED"} assets_list = _split_ids(asset_ids) @@ -2209,29 +2204,44 @@ def _split_strs(value: Optional[str]): current_page = page all_issues = [] - while True: - vars_page = { - "companyId": str(company_id), - "pagination": {"page": current_page, "perPage": per_page if not fetch_all else 200}, - "filters": filters - } - try: - res = graphql_request(query_sca, vars_page, log_request=True, verbose_only=True) - issues_data = res.get("issues") or {} - collection = issues_data.get("collection") or [] - metadata = issues_data.get("metadata") or {} + vars_base = { + "companyId": str(company_id), + "pagination": {"page": current_page, "perPage": per_page if not fetch_all else 200}, + "filters": filters + } + + def _fetch_page(page_num: int): + vars_page = dict(vars_base) + vars_page["pagination"]["page"] = page_num + res = graphql_request(query_sca, vars_page, log_request=True, verbose_only=True) + return page_num, res + + try: + page_num, res = _fetch_page(current_page) + issues_data = res.get("issues") or {} + collection = issues_data.get("collection") or [] + metadata = issues_data.get("metadata") or {} + + for vuln in collection: + if vuln.get("type") == "SCA_FINDING": + all_issues.append(vuln) + + total_p = metadata.get("totalPages") or 1 + + if fetch_all and total_p > current_page: + page_numbers = list(range(current_page + 1, total_p + 1)) + page_results = parallel_map(_fetch_page, page_numbers) - for vuln in collection: - if vuln.get("type") == "SCA_FINDING": - all_issues.append(vuln) - - total_p = metadata.get("totalPages") or 1 - if not fetch_all or current_page >= total_p: - break - current_page += 1 - except Exception as e: - error(f"Error fetching vulnerabilities: {e}") - break + for _, p_res in sorted(page_results, key=lambda x: x[0]): + p_issues_data = p_res.get("issues") or {} + p_coll = p_issues_data.get("collection") or [] + for vuln in p_coll: + if vuln.get("type") == "SCA_FINDING": + all_issues.append(vuln) + + except Exception as e: + error(f"Error fetching vulnerabilities: {e}") + raise typer.Exit(code=1) target_issues = [] for vuln in all_issues: @@ -2242,12 +2252,13 @@ def _split_strs(value: Optional[str]): asset = vuln.get("asset") or {} tags = ", ".join(asset.get("assetsTagList") or []) severity_value = vuln.get("severity") or "" + sev_color_map = { "CRITICAL": "bold white on red", "HIGH": "bold red", "MEDIUM": "yellow", "LOW": "green", - "NOTIFICATION": "cyan", + "NOTIFICATION": "cyan" } sev_display = severity_value sev_style = sev_color_map.get(severity_value.upper(), None) @@ -2272,14 +2283,12 @@ def _split_strs(value: Optional[str]): success("No open SCA vulnerabilities missing a patched version found.") return - info(f"Found {len(target_issues)} SCA vulnerabilities missing patchedVersion. Querying OSV...") - - formatted_issues = [] - + info(f"Found {len(target_issues)} SCA vulnerabilities missing patchedVersion. Querying OSV in parallel...") + def _get_best_fixed_version(affected_list, pkg_match=None): git_fixes = [] - db_spec_fixes = [] - eco_fixes = [] + db_spec_fixes = set() + eco_fixes = set() for affected in affected_list: if pkg_match: @@ -2289,25 +2298,21 @@ def _get_best_fixed_version(affected_list, pkg_match=None): for r in affected.get("ranges", []): rtype = r.get("type") - db_spec = r.get("database_specific") or {} - for v_event in db_spec.get("versions", []): - if "fixed" in v_event and v_event["fixed"] not in db_spec_fixes: - db_spec_fixes.append(v_event["fixed"]) + for v_event in (r.get("database_specific") or {}).get("versions", []): + if "fixed" in v_event: + db_spec_fixes.add(v_event["fixed"]) for event in r.get("events", []): if "fixed" in event: val = event["fixed"] if rtype in ("ECOSYSTEM", "SEMVER"): - if val not in eco_fixes: - eco_fixes.append(val) - elif rtype == "GIT": - if val not in git_fixes: - git_fixes.append(val) + eco_fixes.add(val) + elif rtype == "GIT" and val not in git_fixes: + git_fixes.append(val) - db_spec2 = affected.get("database_specific") or {} - for v_event in db_spec2.get("versions", []): - if "fixed" in v_event and v_event["fixed"] not in db_spec_fixes: - db_spec_fixes.append(v_event["fixed"]) + for v_event in (affected.get("database_specific") or {}).get("versions", []): + if "fixed" in v_event: + db_spec_fixes.add(v_event["fixed"]) if eco_fixes: return ", ".join(eco_fixes) @@ -2317,9 +2322,9 @@ def _get_best_fixed_version(affected_list, pkg_match=None): return git_fixes[-1] return None - updates_to_make = [] - - for issue in target_issues: + http_session = requests.Session() + + def _process_osv_issue(issue): cve = issue["cve"] package = issue["package"] current_version = issue["affectedVersion"] @@ -2327,31 +2332,31 @@ def _get_best_fixed_version(affected_list, pkg_match=None): try: if cve: - resp = requests.get(f"https://api.osv.dev/v1/vulns/{cve}", timeout=10) + resp = http_session.get(f"https://api.osv.dev/v1/vulns/{cve}", timeout=10) if resp.status_code == 200: data = resp.json() found_patch = _get_best_fixed_version(data.get("affected", [])) if not found_patch: for alias in data.get("aliases", []): - alias_resp = requests.get(f"https://api.osv.dev/v1/vulns/{alias}", timeout=10) + alias_resp = http_session.get(f"https://api.osv.dev/v1/vulns/{alias}", timeout=10) if alias_resp.status_code == 200: alias_data = alias_resp.json() found_patch = _get_best_fixed_version(alias_data.get("affected", [])) if found_patch: - break + break elif package and current_version: payload = {"version": current_version, "package": {"name": package}} - resp = requests.post("https://api.osv.dev/v1/query", json=payload, timeout=10) + resp = http_session.post("https://api.osv.dev/v1/query", json=payload, timeout=10) if resp.status_code == 200: for vuln in resp.json().get("vulns", []): found_patch = _get_best_fixed_version(vuln.get("affected", []), pkg_match=package) if found_patch: - break + break except Exception as e: warning(f"Error querying OSV for issue {issue['id']}: {e}") if found_patch: - formatted_issues.append({ + return { "Vuln ID": str(issue["id"]), "Asset ID": str(issue["asset_id"]), "Asset Name": issue["asset_name"], @@ -2362,7 +2367,12 @@ def _get_best_fixed_version(affected_list, pkg_match=None): "CVE": cve or "-", "Current Version": current_version or "-", "OSV Patched Version": found_patch - }) + } + return None + + raw_results = parallel_map(_process_osv_issue, target_issues) + + formatted_issues = [r for r in raw_results if r is not None] if not formatted_issues: warning("No patched versions found via OSV.") @@ -2374,4 +2384,4 @@ def _get_best_fixed_version(affected_list, pkg_match=None): output=output, title="OSV Patches Found" ) - summary(f"Found patched versions for {len(formatted_issues)} vulnerabilities.") + summary(f"Found patched versions for {len(formatted_issues)} vulnerabilities.") \ No newline at end of file From e8e18cec91c1258b11a85b093e9af78ed3c4f7d0 Mon Sep 17 00:00:00 2001 From: gabrielvmayer Date: Tue, 24 Mar 2026 12:28:50 -0300 Subject: [PATCH 3/3] refactor(vulns): parallelize OSV queries, pool connections and cache responses in check-sca-patches --- src/conviso/commands/vulnerabilities.py | 241 ++++++++++++------------ 1 file changed, 123 insertions(+), 118 deletions(-) diff --git a/src/conviso/commands/vulnerabilities.py b/src/conviso/commands/vulnerabilities.py index a4ba455..727e22f 100644 --- a/src/conviso/commands/vulnerabilities.py +++ b/src/conviso/commands/vulnerabilities.py @@ -10,6 +10,8 @@ import json import re from datetime import date, datetime, timedelta, timezone +from collections import defaultdict +import itertools import requests import time from conviso.core.notifier import info, error, summary, success, warning, timed_summary @@ -2202,7 +2204,6 @@ def _split_strs(value: Optional[str]): fetch_all = all_pages current_page = page - all_issues = [] vars_base = { "companyId": str(company_id), @@ -2210,124 +2211,128 @@ def _split_strs(value: Optional[str]): "filters": filters } + grouped_issues = defaultdict(list) + total_issues_count = 0 + def _fetch_page(page_num: int): vars_page = dict(vars_base) vars_page["pagination"]["page"] = page_num res = graphql_request(query_sca, vars_page, log_request=True, verbose_only=True) return page_num, res + def _process_collection(collection): + nonlocal total_issues_count + for vuln in collection: + if vuln.get("type") != "SCA_FINDING": + continue + + detail = vuln.get("detail") or {} + patched_ver = detail.get("patchedVersion") + cve = detail.get("cve") + package = detail.get("package") + + if not patched_ver and (cve or package): + asset = vuln.get("asset") or {} + tags = ", ".join(asset.get("assetsTagList") or []) + severity_value = vuln.get("severity") or "" + + sev_color_map = { + "CRITICAL": "bold white on red", + "HIGH": "bold red", + "MEDIUM": "yellow", + "LOW": "green", + "NOTIFICATION": "cyan" + } + sev_display = severity_value + sev_style = sev_color_map.get(severity_value.upper(), None) + if sev_style: + sev_display = f"[{sev_style}]{severity_value}[/{sev_style}]" + + current_version = detail.get("affectedVersion") + + issue_data = { + "Vuln ID": str(vuln.get("id")), + "Asset ID": str(asset.get("id") or "-"), + "Asset Name": asset.get("name") or "-", + "Asset Tags": tags or "-", + "Package": package or "-", + "Status": vuln.get("status") or "-", + "Severity": sev_display, + "CVE": cve or "-", + "Current Version": current_version or "-", + } + + query_key = (cve, package, current_version) + grouped_issues[query_key].append(issue_data) + total_issues_count += 1 + try: page_num, res = _fetch_page(current_page) issues_data = res.get("issues") or {} - collection = issues_data.get("collection") or [] - metadata = issues_data.get("metadata") or {} - - for vuln in collection: - if vuln.get("type") == "SCA_FINDING": - all_issues.append(vuln) + _process_collection(issues_data.get("collection") or []) - total_p = metadata.get("totalPages") or 1 + total_p = (issues_data.get("metadata") or {}).get("totalPages") or 1 if fetch_all and total_p > current_page: page_numbers = list(range(current_page + 1, total_p + 1)) page_results = parallel_map(_fetch_page, page_numbers) for _, p_res in sorted(page_results, key=lambda x: x[0]): - p_issues_data = p_res.get("issues") or {} - p_coll = p_issues_data.get("collection") or [] - for vuln in p_coll: - if vuln.get("type") == "SCA_FINDING": - all_issues.append(vuln) + p_coll = (p_res.get("issues") or {}).get("collection") or [] + _process_collection(p_coll) except Exception as e: error(f"Error fetching vulnerabilities: {e}") raise typer.Exit(code=1) - target_issues = [] - for vuln in all_issues: - detail = vuln.get("detail") or {} - patched_ver = detail.get("patchedVersion") - cve = detail.get("cve") - package = detail.get("package") - asset = vuln.get("asset") or {} - tags = ", ".join(asset.get("assetsTagList") or []) - severity_value = vuln.get("severity") or "" - - sev_color_map = { - "CRITICAL": "bold white on red", - "HIGH": "bold red", - "MEDIUM": "yellow", - "LOW": "green", - "NOTIFICATION": "cyan" - } - sev_display = severity_value - sev_style = sev_color_map.get(severity_value.upper(), None) - if sev_style: - sev_display = f"[{sev_style}]{severity_value}[/{sev_style}]" - - if not patched_ver and (cve or package): - target_issues.append({ - "id": vuln.get("id"), - "asset_id": asset.get("id") or "-", - "title": vuln.get("title"), - "cve": cve, - "package": package, - "affectedVersion": detail.get("affectedVersion"), - "asset_name": asset.get("name") or "-", - "asset_tags": tags or "-", - "status": vuln.get("status") or "-", - "severity_display": sev_display, - }) - - if not target_issues: + if total_issues_count == 0: success("No open SCA vulnerabilities missing a patched version found.") return - info(f"Found {len(target_issues)} SCA vulnerabilities missing patchedVersion. Querying OSV in parallel...") + info(f"Found {total_issues_count} SCA vulnerabilities missing patchedVersion. Querying OSV in parallel...") + + def _extract_fixes(affected): + ranges = affected.get("ranges", []) + + yield from ( + ("DB_SPEC", v["fixed"]) + for r in ranges + for v in (r.get("database_specific") or {}).get("versions", []) + if "fixed" in v + ) + yield from ( + (r.get("type"), e["fixed"]) + for r in ranges + for e in r.get("events", []) + if "fixed" in e + ) + yield from ( + ("DB_SPEC", v["fixed"]) + for v in (affected.get("database_specific") or {}).get("versions", []) + if "fixed" in v + ) def _get_best_fixed_version(affected_list, pkg_match=None): - git_fixes = [] - db_spec_fixes = set() - eco_fixes = set() - - for affected in affected_list: - if pkg_match: - pkg_name = (affected.get("package") or {}).get("name") - if pkg_name and pkg_name != pkg_match: - continue - - for r in affected.get("ranges", []): - rtype = r.get("type") - for v_event in (r.get("database_specific") or {}).get("versions", []): - if "fixed" in v_event: - db_spec_fixes.add(v_event["fixed"]) - - for event in r.get("events", []): - if "fixed" in event: - val = event["fixed"] - if rtype in ("ECOSYSTEM", "SEMVER"): - eco_fixes.add(val) - elif rtype == "GIT" and val not in git_fixes: - git_fixes.append(val) - - for v_event in (affected.get("database_specific") or {}).get("versions", []): - if "fixed" in v_event: - db_spec_fixes.add(v_event["fixed"]) - - if eco_fixes: - return ", ".join(eco_fixes) - if db_spec_fixes: - return ", ".join(db_spec_fixes) - if git_fixes: - return git_fixes[-1] - return None + def _matches(a): + name = (a.get("package") or {}).get("name") + return not pkg_match or not name or name == pkg_match + + all_fixes = list(itertools.chain.from_iterable( + _extract_fixes(a) for a in affected_list if _matches(a) + )) + + eco_fixes = {v for t, v in all_fixes if t in ("ECOSYSTEM", "SEMVER")} + db_spec_fixes = {v for t, v in all_fixes if t == "DB_SPEC"} + git_fixes = [v for t, v in all_fixes if t == "GIT"] + + if eco_fixes: return ", ".join(eco_fixes) + if db_spec_fixes: return ", ".join(db_spec_fixes) + return git_fixes[-1] if git_fixes else None http_session = requests.Session() - def _process_osv_issue(issue): - cve = issue["cve"] - package = issue["package"] - current_version = issue["affectedVersion"] + def _fetch_osv_patch(query_key): + cve, package, current_version = query_key found_patch = None try: @@ -2337,42 +2342,42 @@ def _process_osv_issue(issue): data = resp.json() found_patch = _get_best_fixed_version(data.get("affected", [])) if not found_patch: - for alias in data.get("aliases", []): - alias_resp = http_session.get(f"https://api.osv.dev/v1/vulns/{alias}", timeout=10) - if alias_resp.status_code == 200: - alias_data = alias_resp.json() - found_patch = _get_best_fixed_version(alias_data.get("affected", [])) - if found_patch: - break + found_patch = next( + ( + patch + for alias in data.get("aliases", []) + for alias_resp in [http_session.get(f"https://api.osv.dev/v1/vulns/{alias}", timeout=10)] + if alias_resp.status_code == 200 + for patch in [_get_best_fixed_version(alias_resp.json().get("affected", []))] + if patch + ), + None, + ) elif package and current_version: payload = {"version": current_version, "package": {"name": package}} resp = http_session.post("https://api.osv.dev/v1/query", json=payload, timeout=10) if resp.status_code == 200: - for vuln in resp.json().get("vulns", []): - found_patch = _get_best_fixed_version(vuln.get("affected", []), pkg_match=package) - if found_patch: - break + found_patch = next( + ( + patch + for vuln in resp.json().get("vulns", []) + for patch in [_get_best_fixed_version(vuln.get("affected", []), pkg_match=package)] + if patch + ), + None, + ) except Exception as e: - warning(f"Error querying OSV for issue {issue['id']}: {e}") + warning(f"Error querying OSV for {cve or package}: {e}") - if found_patch: - return { - "Vuln ID": str(issue["id"]), - "Asset ID": str(issue["asset_id"]), - "Asset Name": issue["asset_name"], - "Asset Tags": issue["asset_tags"], - "Package": package or "-", - "Status": issue["status"], - "Severity": issue["severity_display"], - "CVE": cve or "-", - "Current Version": current_version or "-", - "OSV Patched Version": found_patch - } - return None + return query_key, found_patch - raw_results = parallel_map(_process_osv_issue, target_issues) + raw_results = parallel_map(_fetch_osv_patch, grouped_issues.keys()) - formatted_issues = [r for r in raw_results if r is not None] + formatted_issues = [ + {**issue_data, "OSV Patched Version": patch} + for query_key, patch in raw_results if patch + for issue_data in grouped_issues[query_key] + ] if not formatted_issues: warning("No patched versions found via OSV.")