Skip to content

Commit eebc74b

Browse files
feat(vulns): add check-sca-patches command for osv patch validation (#20)
# Description This PR introduces a new `check-sca-patches` command to `python -m conviso.app vulns` designed to identify and cross-reference available patches for open SCA vulnerabilities. It automatically queries the Conviso Platform for open `SCA_FINDING` vulnerabilities missing a `patchedVersion` and integrates with the public Open Source Vulnerabilities (OSV) API (`api.osv.dev`) to discover fixed versions. Key features include: - **Smart OSV Validation:** Queries OSV using CVEs or Package Name + Version. - **Alias Fallback Mechanism:** Automatically follows OSV aliases (such as `GHSA-*` from the GitHub Advisory Database) if the primary CVE entry lacks patch details, ensuring high discovery rates. - **Advanced Extraction Logic:** Prioritizes actual semantic versions from `ECOSYSTEM` and `database_specific` ranges instead of raw git commit hashes. - **List Compatibility:** Accepts standard server-side filters (`--severities`, `--status`, `--cves`, `--asset-tags`, `--asset-ids`) leveraging native GraphQL parameters. # How to Test 1. Check that the command exists: Run `python -m conviso.app vulns check-sca-patches --help` Confirm the new command and standard filtering options are listed. 2. Validate the OSV fetching and table output (Dry Run): Run `python -m conviso.app vulns check-sca-patches --company-id <ID>` Expected behavior: The CLI should query the company's open SCA vulnerabilities, query OSV resolving any aliases for missing patches, and render a formatted table displaying the new `OSV Patched Version` column. 3. Validate server-side filtering: Run `python -m conviso.app vulns check-sca-patches --company-id <ID> --status IDENTIFIED --severities HIGH` Expected behavior: The tool must only process and query patches for `IDENTIFIED` and `HIGH` severity vulnerabilities.
2 parents 5335018 + e8e18ce commit eebc74b

3 files changed

Lines changed: 292 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ conviso --help
103103
- 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`
104104
- 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`
105105
- Vulnerabilities with forced deep local search: `python -m conviso.app vulns list --company-id 443 --all --contains codeSnippet=eval( --deep-search --workers 8`
106+
- Vulnerabilities (SCA) checking patches against OSV: `python -m conviso.app vulns check-sca-patches --company-id 443 --severities HIGH,CRITICAL --status RISK_ACCEPTED --all`
106107
- Vulnerability timeline (by vulnerability ID): `python -m conviso.app vulns timeline --id 12345`
107108
- Vulnerabilities timeline by project: `python -m conviso.app vulns timeline --company-id 443 --project-id 26102`
108109
- Last user who changed vuln status: `python -m conviso.app vulns timeline --id 12345 --last-status-change-only`

src/conviso/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.7
1+
0.3.8

src/conviso/commands/vulnerabilities.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import json
1111
import re
1212
from datetime import date, datetime, timedelta, timezone
13+
from collections import defaultdict
14+
import itertools
1315
import requests
1416
import time
1517
from conviso.core.notifier import info, error, summary, success, warning, timed_summary
@@ -2100,3 +2102,291 @@ def clean_source_payload(base):
21002102
except Exception as exc:
21012103
error(f"Error updating vulnerability: {exc}")
21022104
raise typer.Exit(code=1)
2105+
2106+
2107+
2108+
# ---------------------- CHECK SCA PATCHES ---------------------- #
2109+
@app.command("check-sca-patches", help="Check OSV for available patches for SCA vulnerabilities and optionally update them.")
2110+
def check_sca_patches(
2111+
company_id: int = typer.Option(..., "--company-id", "-c", help="Company ID."),
2112+
asset_ids: Optional[str] = typer.Option(None, "--asset-ids", "-a", help="Comma-separated asset IDs to filter."),
2113+
severities: Optional[str] = typer.Option(None, "--severities", "-s", help="Comma-separated severities (NOTIFICATION,LOW,MEDIUM,HIGH,CRITICAL)."),
2114+
status: Optional[str] = typer.Option(None,"--status",help="Comma-separated vulnerability status labels."),
2115+
asset_tags: Optional[str] = typer.Option(None, "--asset-tags", "-t", help="Comma-separated asset tags."),
2116+
cves: Optional[str] = typer.Option(None, "--cves", help="Comma-separated CVE identifiers."),
2117+
page: int = typer.Option(1, "--page", "-p", help="Page number."),
2118+
per_page: int = typer.Option(50, "--per-page", "-l", help="Items per page."),
2119+
all_pages: bool = typer.Option(False, "--all", help="Fetch all pages."),
2120+
fmt: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv."),
2121+
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file for json/csv."),
2122+
):
2123+
"""Check OSV for available patches for open SCA vulnerabilities without a patched version."""
2124+
info(f"Checking SCA patches for company {company_id}...")
2125+
2126+
def _split_ids(value: Optional[str]):
2127+
if not value: return None
2128+
ids = []
2129+
for raw in value.split(","):
2130+
raw = raw.strip()
2131+
if not raw: continue
2132+
try: ids.append(int(raw))
2133+
except ValueError: continue
2134+
return ids or None
2135+
2136+
def _split_strs(value: Optional[str]):
2137+
if not value: return None
2138+
vals = [v.strip() for v in value.split(",") if v.strip()]
2139+
return vals or None
2140+
2141+
query_sca = """
2142+
query IssuesSca($companyId: ID!, $pagination: PaginationInput!, $filters: IssuesFiltersInput) {
2143+
issues(companyId: $companyId, pagination: $pagination, filters: $filters) {
2144+
collection {
2145+
id
2146+
title
2147+
status
2148+
type
2149+
asset {
2150+
id
2151+
name
2152+
assetsTagList
2153+
}
2154+
... on ScaFinding {
2155+
severity
2156+
detail {
2157+
package
2158+
affectedVersion
2159+
patchedVersion
2160+
cve
2161+
}
2162+
}
2163+
}
2164+
metadata {
2165+
currentPage
2166+
totalPages
2167+
}
2168+
}
2169+
}
2170+
"""
2171+
2172+
SEVERITY_ALLOWED = {"NOTIFICATION", "LOW", "MEDIUM", "HIGH", "CRITICAL"}
2173+
STATUS_ALLOWED = {"CREATED", "DRAFT", "IDENTIFIED", "IN_PROGRESS", "AWAITING_VALIDATION", "FIX_ACCEPTED", "RISK_ACCEPTED", "FALSE_POSITIVE", "SUPPRESSED"}
2174+
2175+
assets_list = _split_ids(asset_ids)
2176+
2177+
severities_list = None
2178+
if severities:
2179+
severities_list = [s.strip().upper() for s in severities.split(",") if s.strip()]
2180+
for s in severities_list:
2181+
if s not in SEVERITY_ALLOWED:
2182+
error(f"Invalid severity '{s}'. Allowed: {', '.join(SEVERITY_ALLOWED)}")
2183+
raise typer.Exit(code=1)
2184+
2185+
status_list = None
2186+
if status:
2187+
status_list = [s.strip().upper() for s in status.split(",") if s.strip()]
2188+
for s in status_list:
2189+
if s not in STATUS_ALLOWED:
2190+
error(f"Invalid status '{s}'. Allowed: {', '.join(STATUS_ALLOWED)}")
2191+
raise typer.Exit(code=1)
2192+
2193+
asset_tags_list = _split_strs(asset_tags)
2194+
cves_list = _split_strs(cves)
2195+
2196+
filters = {
2197+
"failureTypes": ["SCA_FINDING"],
2198+
"statuses": status_list or ["CREATED", "IDENTIFIED", "IN_PROGRESS", "AWAITING_VALIDATION"]
2199+
}
2200+
if assets_list: filters["assetIds"] = assets_list
2201+
if severities_list: filters["severities"] = severities_list
2202+
if asset_tags_list: filters["assetTags"] = asset_tags_list
2203+
if cves_list: filters["cves"] = cves_list
2204+
2205+
fetch_all = all_pages
2206+
current_page = page
2207+
2208+
vars_base = {
2209+
"companyId": str(company_id),
2210+
"pagination": {"page": current_page, "perPage": per_page if not fetch_all else 200},
2211+
"filters": filters
2212+
}
2213+
2214+
grouped_issues = defaultdict(list)
2215+
total_issues_count = 0
2216+
2217+
def _fetch_page(page_num: int):
2218+
vars_page = dict(vars_base)
2219+
vars_page["pagination"]["page"] = page_num
2220+
res = graphql_request(query_sca, vars_page, log_request=True, verbose_only=True)
2221+
return page_num, res
2222+
2223+
def _process_collection(collection):
2224+
nonlocal total_issues_count
2225+
for vuln in collection:
2226+
if vuln.get("type") != "SCA_FINDING":
2227+
continue
2228+
2229+
detail = vuln.get("detail") or {}
2230+
patched_ver = detail.get("patchedVersion")
2231+
cve = detail.get("cve")
2232+
package = detail.get("package")
2233+
2234+
if not patched_ver and (cve or package):
2235+
asset = vuln.get("asset") or {}
2236+
tags = ", ".join(asset.get("assetsTagList") or [])
2237+
severity_value = vuln.get("severity") or ""
2238+
2239+
sev_color_map = {
2240+
"CRITICAL": "bold white on red",
2241+
"HIGH": "bold red",
2242+
"MEDIUM": "yellow",
2243+
"LOW": "green",
2244+
"NOTIFICATION": "cyan"
2245+
}
2246+
sev_display = severity_value
2247+
sev_style = sev_color_map.get(severity_value.upper(), None)
2248+
if sev_style:
2249+
sev_display = f"[{sev_style}]{severity_value}[/{sev_style}]"
2250+
2251+
current_version = detail.get("affectedVersion")
2252+
2253+
issue_data = {
2254+
"Vuln ID": str(vuln.get("id")),
2255+
"Asset ID": str(asset.get("id") or "-"),
2256+
"Asset Name": asset.get("name") or "-",
2257+
"Asset Tags": tags or "-",
2258+
"Package": package or "-",
2259+
"Status": vuln.get("status") or "-",
2260+
"Severity": sev_display,
2261+
"CVE": cve or "-",
2262+
"Current Version": current_version or "-",
2263+
}
2264+
2265+
query_key = (cve, package, current_version)
2266+
grouped_issues[query_key].append(issue_data)
2267+
total_issues_count += 1
2268+
2269+
try:
2270+
page_num, res = _fetch_page(current_page)
2271+
issues_data = res.get("issues") or {}
2272+
_process_collection(issues_data.get("collection") or [])
2273+
2274+
total_p = (issues_data.get("metadata") or {}).get("totalPages") or 1
2275+
2276+
if fetch_all and total_p > current_page:
2277+
page_numbers = list(range(current_page + 1, total_p + 1))
2278+
page_results = parallel_map(_fetch_page, page_numbers)
2279+
2280+
for _, p_res in sorted(page_results, key=lambda x: x[0]):
2281+
p_coll = (p_res.get("issues") or {}).get("collection") or []
2282+
_process_collection(p_coll)
2283+
2284+
except Exception as e:
2285+
error(f"Error fetching vulnerabilities: {e}")
2286+
raise typer.Exit(code=1)
2287+
2288+
if total_issues_count == 0:
2289+
success("No open SCA vulnerabilities missing a patched version found.")
2290+
return
2291+
2292+
info(f"Found {total_issues_count} SCA vulnerabilities missing patchedVersion. Querying OSV in parallel...")
2293+
2294+
def _extract_fixes(affected):
2295+
ranges = affected.get("ranges", [])
2296+
2297+
yield from (
2298+
("DB_SPEC", v["fixed"])
2299+
for r in ranges
2300+
for v in (r.get("database_specific") or {}).get("versions", [])
2301+
if "fixed" in v
2302+
)
2303+
yield from (
2304+
(r.get("type"), e["fixed"])
2305+
for r in ranges
2306+
for e in r.get("events", [])
2307+
if "fixed" in e
2308+
)
2309+
yield from (
2310+
("DB_SPEC", v["fixed"])
2311+
for v in (affected.get("database_specific") or {}).get("versions", [])
2312+
if "fixed" in v
2313+
)
2314+
2315+
def _get_best_fixed_version(affected_list, pkg_match=None):
2316+
def _matches(a):
2317+
name = (a.get("package") or {}).get("name")
2318+
return not pkg_match or not name or name == pkg_match
2319+
2320+
all_fixes = list(itertools.chain.from_iterable(
2321+
_extract_fixes(a) for a in affected_list if _matches(a)
2322+
))
2323+
2324+
eco_fixes = {v for t, v in all_fixes if t in ("ECOSYSTEM", "SEMVER")}
2325+
db_spec_fixes = {v for t, v in all_fixes if t == "DB_SPEC"}
2326+
git_fixes = [v for t, v in all_fixes if t == "GIT"]
2327+
2328+
if eco_fixes: return ", ".join(eco_fixes)
2329+
if db_spec_fixes: return ", ".join(db_spec_fixes)
2330+
return git_fixes[-1] if git_fixes else None
2331+
2332+
http_session = requests.Session()
2333+
2334+
def _fetch_osv_patch(query_key):
2335+
cve, package, current_version = query_key
2336+
found_patch = None
2337+
2338+
try:
2339+
if cve:
2340+
resp = http_session.get(f"https://api.osv.dev/v1/vulns/{cve}", timeout=10)
2341+
if resp.status_code == 200:
2342+
data = resp.json()
2343+
found_patch = _get_best_fixed_version(data.get("affected", []))
2344+
if not found_patch:
2345+
found_patch = next(
2346+
(
2347+
patch
2348+
for alias in data.get("aliases", [])
2349+
for alias_resp in [http_session.get(f"https://api.osv.dev/v1/vulns/{alias}", timeout=10)]
2350+
if alias_resp.status_code == 200
2351+
for patch in [_get_best_fixed_version(alias_resp.json().get("affected", []))]
2352+
if patch
2353+
),
2354+
None,
2355+
)
2356+
elif package and current_version:
2357+
payload = {"version": current_version, "package": {"name": package}}
2358+
resp = http_session.post("https://api.osv.dev/v1/query", json=payload, timeout=10)
2359+
if resp.status_code == 200:
2360+
found_patch = next(
2361+
(
2362+
patch
2363+
for vuln in resp.json().get("vulns", [])
2364+
for patch in [_get_best_fixed_version(vuln.get("affected", []), pkg_match=package)]
2365+
if patch
2366+
),
2367+
None,
2368+
)
2369+
except Exception as e:
2370+
warning(f"Error querying OSV for {cve or package}: {e}")
2371+
2372+
return query_key, found_patch
2373+
2374+
raw_results = parallel_map(_fetch_osv_patch, grouped_issues.keys())
2375+
2376+
formatted_issues = [
2377+
{**issue_data, "OSV Patched Version": patch}
2378+
for query_key, patch in raw_results if patch
2379+
for issue_data in grouped_issues[query_key]
2380+
]
2381+
2382+
if not formatted_issues:
2383+
warning("No patched versions found via OSV.")
2384+
return
2385+
2386+
export_data(
2387+
data=formatted_issues,
2388+
fmt=fmt.lower(),
2389+
output=output,
2390+
title="OSV Patches Found"
2391+
)
2392+
summary(f"Found patched versions for {len(formatted_issues)} vulnerabilities.")

0 commit comments

Comments
 (0)