# Copyright (C) 2026 The Software Heritage developers
# See the AUTHORS file at the top-level directory of this distribution
# License: GNU General Public License version 3, or any later version
# See top-level LICENSE file for more information
from __future__ import annotations
from collections import defaultdict
import copy
import logging
import string
from typing import TYPE_CHECKING, Any, Dict, List, Optional
import requests
from swh.core.config import load_from_envvar
from swh.model.hashutil import hash_to_bytes
from swh.model.model import ReleaseTargetType, Snapshot
from swh.model.swhids import CoreSWHID, ObjectType
from swh.storage import get_storage
from swh.storage.algos.snapshot import snapshot_get_all_branches, snapshot_get_latest
from swh.storage.interface import StorageInterface
from swh.vulns.osv.model import OSVVulnerabilityEvent
from swh.vulns.storage import get_vulnerabilities_storage
from .utils import find_release_from_version, utc_now
if TYPE_CHECKING:
from swh.vulns.storage import VulnerabilitiesStorageInterface
logger = logging.getLogger(__name__)
[docs]
def is_sha1_hex_str(s):
return set(s).issubset(string.hexdigits) and len(s) == 40
[docs]
class OSVReportParser:
"""Class dedicated to the parsing of and OSV report so that vulnerability
events are extracted then persisted to a storage.
Args:
storage: instance of SWH archive main storage
vulnerabilities_storage: instance of OSV vulnerabilities storage
"""
OSV_REPORT_BASE_URL = "https://api.osv.dev/v1/vulns"
def __init__(
self,
storage: StorageInterface,
vulnerabilities_storage: VulnerabilitiesStorageInterface,
**kwargs,
):
self.storage = storage
self.vulnerabilities_storage = vulnerabilities_storage
self.session = requests.Session()
[docs]
@classmethod
def from_config(
cls,
storage: Dict[str, Any],
vulnerabilities_storage: Dict[str, Any],
**kwargs,
) -> OSVReportParser:
"""Instantiate an OSV report parser from configuration.
Args:
storage: Configuration for SWH main storage
vulnerabilities_storage: Configuration for OSV vulnerabilities storage
Returns:
an instance of the OSV report parser
"""
return cls(
storage=get_storage(**storage),
vulnerabilities_storage=get_vulnerabilities_storage(
**vulnerabilities_storage
),
**kwargs,
)
[docs]
@classmethod
def from_configfile(cls, **kwargs: Any) -> OSVReportParser:
"""Instantiate a parser from the configuration loaded from the
SWH_CONFIG_FILENAME envvar, with potential extra keyword arguments if their
value is not None.
Args:
kwargs: kwargs passed to the parser instantiation
Returns:
an instance of the OSV report parser
"""
config = dict(load_from_envvar())
config.update({k: v for k, v in kwargs.items() if v is not None})
return cls.from_config(**config)
[docs]
def process_osv_report(
self, osv_vulnerability_id: str, store_vulnerability_events: bool = True
) -> List[OSVVulnerabilityEvent]:
"""Process an OSV report by parsing it and persisting vulnerability events
in storage.
Args:
vulnerability_id: Identifier of vulnerability in OSV database
store_vulnerability_events: Whether to persist or not extracted vulnerability
events in storage
Returns:
list of extracted vulnerability events
"""
osv_report_url = f"{self.OSV_REPORT_BASE_URL}/{osv_vulnerability_id}"
response = self.session.get(osv_report_url)
response.raise_for_status()
osv_report = response.json()
vuln_events = parse_osv_report(osv_report, self.storage)
if store_vulnerability_events:
self.vulnerabilities_storage.osv_vulnerability_event_add(vuln_events)
self.vulnerabilities_storage.osv_vulnerability_report_add(
osv_vulnerability_id, utc_now(), osv_report
)
return vuln_events
[docs]
def parse_osv_report(
osv_report: Dict[str, Any], storage: StorageInterface
) -> List[OSVVulnerabilityEvent]:
"""Parse an OSV report: extract vulnerability events and find related SWHIDs
into the SWH archive.
Args:
osv_report: OSV report parsed from JSON
storage: instance of SWH main storage
Returns:
list of extracted vulnerability events
"""
if "affected" not in osv_report:
return []
vuln_id = osv_report["id"]
vuln_type = osv_report.get("severity", [{}])[0].get("type")
vulnerability_events: List[OSVVulnerabilityEvent] = []
snapshots = {}
snapshot_branches: Dict[str, Dict[bytes, Optional[Snapshot]]] = defaultdict(dict)
for aff in osv_report["affected"]:
if "ranges" not in aff:
continue
for range_ in aff["ranges"]:
if not range_["type"] == "GIT":
logger.warning(
"[%s] Skipping range of unknown type: %s",
vuln_id,
range_["type"],
)
continue
origin_url = range_["repo"]
origin_info = storage.origin_get([origin_url])
if not origin_info:
logger.warning(
"[%s] Could not find origin %s in the archive",
vuln_id,
origin_url,
)
continue
if origin_url not in snapshots:
snapshots[origin_url] = snapshot_get_latest(
storage, origin_url, branches_count=1, visit_type="git"
)
snapshot = snapshots[origin_url]
if snapshot is not None:
database_specific = range_.get("database_specific", {})
version_events = copy.deepcopy(
database_specific.get("versions", [])
if "versions" in database_specific
else database_specific.get("extracted_events", [])
)
for branch_prefix in (
b"refs/tags/",
b"refs/heads/",
b"refs/remotes/tags/",
):
release_swhid = None
if (
origin_url not in snapshot_branches
or branch_prefix not in snapshot_branches[origin_url]
):
snapshot_branches[origin_url][branch_prefix] = (
snapshot_get_all_branches(
storage,
snapshot.id,
branch_name_include_substring=branch_prefix,
)
)
snapshot_content = snapshot_branches[origin_url][branch_prefix]
assert snapshot_content
for version_event in version_events:
for k in version_event:
if version_event[k] == "0":
vulnerability_events.append(
OSVVulnerabilityEvent(
vulnerability_id=vuln_id,
vulnerability_severity=vuln_type,
event_type=k,
origin_url=origin_url,
)
)
continue
elif isinstance(version_event[k], CoreSWHID):
continue
version = (
version_event[k].lstrip("=").strip().replace("\\", "")
)
release_swhid = find_release_from_version(
version,
snapshot_content,
branch_prefix=branch_prefix,
)
if release_swhid:
vulnerability_events.append(
OSVVulnerabilityEvent(
vulnerability_id=vuln_id,
vulnerability_severity=vuln_type,
event_type=k,
origin_url=origin_url,
swhid=release_swhid,
version=version,
)
)
if release_swhid.object_type == ObjectType.RELEASE:
release = storage.release_get(
[release_swhid.object_id]
)
if (
release
and release[0]
and release[0].target
and release[0].target_type
== ReleaseTargetType.REVISION
):
vulnerability_events.append(
OSVVulnerabilityEvent(
vulnerability_id=vuln_id,
vulnerability_severity=vuln_type,
event_type=k,
origin_url=origin_url,
swhid=CoreSWHID(
object_type=ObjectType.REVISION,
object_id=release[0].target,
),
version=version,
)
)
version_event[k] = release_swhid
break
if all(
isinstance(v, CoreSWHID) or v == "0"
for version_event in version_events
for v in version_event.values()
):
break
for version_event in version_events:
for k, v in version_event.items():
if not isinstance(v, CoreSWHID) and v != "0":
logger.warning(
"[%s] Could not find release or revision SWHID for release named %s in origin %s",
vuln_id,
v,
origin_url,
)
vuln_events = range_["events"]
for vuln_event in vuln_events:
for k in vuln_event:
if is_sha1_hex_str(vuln_event[k]):
swhid = CoreSWHID(
object_type=ObjectType.REVISION,
object_id=hash_to_bytes(vuln_event[k]),
)
if not any(
vuln.swhid == swhid for vuln in vulnerability_events
):
vulnerability_events.append(
OSVVulnerabilityEvent(
vulnerability_id=vuln_id,
vulnerability_severity=vuln_type,
event_type=k,
origin_url=origin_url,
swhid=swhid,
)
)
return vulnerability_events