436 lines
13 KiB
Python
436 lines
13 KiB
Python
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
|
|
|