import json from collections import namedtuple from dataclasses import dataclass, field from datetime import datetime from typing import Any, List, Optional, Set, Union, Dict from dparse.dependencies import Dependency from dparse import parse, filetypes from packaging.specifiers import SpecifierSet from packaging.requirements import Requirement from packaging.utils import canonicalize_name from packaging.version import parse as parse_version from packaging.version import Version from safety.errors import InvalidRequirementError try: from packaging.version import LegacyVersion as legacyType except ImportError: legacyType = None from .requirements import is_pinned_requirement class DictConverter(object): """ A class to convert objects to dictionaries. """ def to_dict(self, **kwargs: Any) -> Dict: pass announcement_nmt = namedtuple('Announcement', ['type', 'message']) remediation_nmt = namedtuple('Remediation', ['Package', 'closest_secure_version', 'secure_versions', 'latest_package_version']) cve_nmt = namedtuple('Cve', ['name', 'cvssv2', 'cvssv3']) severity_nmt = namedtuple('Severity', ['source', 'cvssv2', 'cvssv3']) vulnerability_nmt = namedtuple('Vulnerability', ['vulnerability_id', 'package_name', 'pkg', 'ignored', 'ignored_reason', 'ignored_expires', 'vulnerable_spec', 'all_vulnerable_specs', 'analyzed_version', 'analyzed_requirement', 'advisory', 'is_transitive', 'published_date', 'fixed_versions', 'closest_versions_without_known_vulnerabilities', 'resources', 'CVE', 'severity', 'affected_versions', 'more_info_url']) RequirementFile = namedtuple('RequirementFile', ['path']) class SafetyRequirement(Requirement): """ A subclass of Requirement that includes additional attributes and methods for safety requirements. """ def __init__(self, requirement: Union[str, Dependency], found: Optional[str] = None) -> None: """ Initialize a SafetyRequirement. Args: requirement (Union[str, Dependency]): The requirement as a string or Dependency object. found (Optional[str], optional): Where the requirement was found. Defaults to None. Raises: InvalidRequirementError: If the requirement cannot be parsed. """ dep = requirement if isinstance(requirement, str): deps = parse(requirement, file_type=filetypes.requirements_txt).dependencies dep = deps[0] if deps else None if not dep: raise InvalidRequirementError(line=str(requirement)) raw_line = dep.line to_parse = dep.line # Hash and comments are only a pip feature, so removing them. if '#' in to_parse: to_parse = dep.line.split('#')[0] for req_hash in dep.hashes: to_parse = to_parse.replace(req_hash, '') to_parse = to_parse.replace('\\', '').rstrip() try: # Try to build a PEP Requirement from the cleaned line super(SafetyRequirement, self).__init__(to_parse) except Exception: raise InvalidRequirementError(line=requirement.line if isinstance(requirement, Dependency) else requirement) self.raw = raw_line self.found = found def __eq__(self, other: Any) -> bool: return str(self) == str(other) def to_dict(self, **kwargs: Any) -> Dict: """ Convert the requirement to a dictionary. Args: **kwargs: Additional keyword arguments. Returns: dict: The dictionary representation of the requirement. """ specifier_obj = self.specifier if not "specifier_obj" in kwargs: specifier_obj = str(self.specifier) return { 'raw': self.raw, 'extras': list(self.extras), 'marker': str(self.marker) if self.marker else None, 'name': self.name, 'specifier': specifier_obj, 'url': self.url, 'found': self.found } @dataclass class Package(DictConverter): """ A class representing a package. """ name: str version: Optional[str] requirements: List[SafetyRequirement] found: Optional[str] = None absolute_path: Optional[str] = None insecure_versions: List[str] = field(default_factory=lambda: []) secure_versions: List[str] = field(default_factory=lambda: []) latest_version_without_known_vulnerabilities: Optional[str] = None latest_version: Optional[str] = None more_info_url: Optional[str] = None def has_unpinned_req(self) -> bool: """ Check if the package has unpinned requirements. Returns: bool: True if there are unpinned requirements, False otherwise. """ for req in self.requirements: if not is_pinned_requirement(req.specifier): return True return False def get_unpinned_req(self) -> filter: """ Get the unpinned requirements. Returns: filter: A filter object with the unpinned requirements. """ return filter(lambda r: not is_pinned_requirement(r.specifier), self.requirements) def filter_by_supported_versions(self, versions: List[str]) -> List[str]: """ Filter the versions by supported versions. Args: versions (List[str]): The list of versions. Returns: List[str]: The list of supported versions. """ allowed = [] for version in versions: try: parse_version(version) allowed.append(version) except Exception: pass return allowed def get_versions(self, db_full: Dict) -> Set[str]: """ Get the versions from the database. Args: db_full (Dict): The full database. Returns: Set[str]: The set of versions. """ pkg_meta = db_full.get('meta', {}).get('packages', {}).get(self.name, {}) versions = self.filter_by_supported_versions( pkg_meta.get("insecure_versions", []) + pkg_meta.get("secure_versions", [])) return set(versions) def refresh_from(self, db_full: Dict) -> None: """ Refresh the package information from the database. Args: db_full (Dict): The full database. """ base_domain = db_full.get('meta', {}).get('base_domain') pkg_meta = db_full.get('meta', {}).get('packages', {}).get(canonicalize_name(self.name), {}) kwargs = {'insecure_versions': self.filter_by_supported_versions(pkg_meta.get("insecure_versions", [])), 'secure_versions': self.filter_by_supported_versions(pkg_meta.get("secure_versions", [])), 'latest_version_without_known_vulnerabilities': pkg_meta.get("latest_secure_version", None), 'latest_version': pkg_meta.get("latest_version", None), 'more_info_url': f"{base_domain}{pkg_meta.get('more_info_path', '')}"} self.update(kwargs) def to_dict(self, **kwargs: Any) -> Dict: """ Convert the package to a dictionary. Args: **kwargs: Additional keyword arguments. Returns: dict: The dictionary representation of the package. """ if kwargs.get('short_version', False): return { 'name': self.name, 'version': self.version, 'requirements': self.requirements } return {'name': self.name, 'version': self.version, 'requirements': self.requirements, 'found': None, 'insecure_versions': self.insecure_versions, 'secure_versions': self.secure_versions, 'latest_version_without_known_vulnerabilities': self.latest_version_without_known_vulnerabilities, 'latest_version': self.latest_version, 'more_info_url': self.more_info_url } def update(self, new: Dict) -> None: """ Update the package attributes with new values. Args: new (Dict): The new attribute values. """ for key, value in new.items(): if hasattr(self, key): setattr(self, key, value) class Announcement(announcement_nmt): """ A class representing an announcement. """ pass class Remediation(remediation_nmt, DictConverter): """ A class representing a remediation. """ def to_dict(self) -> Dict: """ Convert the remediation to a dictionary. Returns: Dict: The dictionary representation of the remediation. """ return {'package': self.Package.name, 'closest_secure_version': self.closest_secure_version, 'secure_versions': self.secure_versions, 'latest_package_version': self.latest_package_version } @dataclass class Fix: """ A class representing a fix. """ dependency: Any = None previous_version: Any = None previous_spec: Optional[str] = None other_options: List[str] = field(default_factory=lambda: []) updated_version: Any = None update_type: str = "" package: str = "" status: str = "" applied_at: str = "" fix_type: str = "" more_info_url: str = "" class CVE(cve_nmt, DictConverter): """ A class representing a CVE. """ def to_dict(self) -> Dict: """ Convert the CVE to a dictionary. Returns: Dict: The dictionary representation of the CVE. """ return {'name': self.name, 'cvssv2': self.cvssv2, 'cvssv3': self.cvssv3} class Severity(severity_nmt, DictConverter): """ A class representing the severity of a vulnerability. """ def to_dict(self) -> Dict: """ Convert the severity to a dictionary. Returns: Dict: The dictionary representation of the severity. """ result = {'severity': {'source': self.source}} result['severity']['cvssv2'] = self.cvssv2 result['severity']['cvssv3'] = self.cvssv3 return result class SafetyEncoder(json.JSONEncoder): """ A custom JSON encoder for Safety related objects. """ def default(self, value: Any) -> Any: """ Override the default method to handle custom objects. Args: value (Any): The value to encode. Returns: Any: The encoded value. """ if isinstance(value, SafetyRequirement): return value.to_dict() elif isinstance(value, Version) or (legacyType and isinstance(value, legacyType)): return str(value) else: return super().default(value) class Vulnerability(vulnerability_nmt): """ A class representing a vulnerability. """ def to_dict(self) -> Dict: """ Convert the vulnerability to a dictionary. Returns: Dict: The dictionary representation of the vulnerability. """ empty_list_if_none = ['fixed_versions', 'closest_versions_without_known_vulnerabilities', 'resources'] result = { } ignore = ['pkg'] for field, value in zip(self._fields, self): if field in ignore: continue if value is None and field in empty_list_if_none: value = [] if isinstance(value, set): result[field] = list(value) elif isinstance(value, CVE): val = None if value.name.startswith("CVE"): val = value.name result[field] = val elif isinstance(value, DictConverter): result.update(value.to_dict()) elif isinstance(value, SpecifierSet) or isinstance(value, datetime): result[field] = str(value) else: result[field] = value return result def get_advisory(self) -> str: """ Get the advisory for the vulnerability. Returns: str: The advisory text. """ return self.advisory.replace('\r', '') if self.advisory else "No advisory found for this vulnerability." def to_model_dict(self) -> Dict: """ Convert the vulnerability to a dictionary for the model. Returns: Dict: The dictionary representation of the vulnerability for the model. """ try: affected_spec = next(iter(self.vulnerable_spec)) except Exception: affected_spec = "" repr = { "id": self.vulnerability_id, "package_name": self.package_name, "vulnerable_spec": affected_spec, "analyzed_specification": self.analyzed_requirement.raw } if self.ignored: repr["ignored"] = {"reason": self.ignored_reason, "expires": self.ignored_expires} return repr @dataclass class Safety: """ A class representing Safety settings. """ client: Any keys: Any