1010import dataclasses
1111import datetime
1212import functools
13+ import hashlib
1314import logging
1415import traceback
1516import xml .etree .ElementTree as ET
3738from vulnerabilities .severity_systems import ScoringSystem
3839from vulnerabilities .utils import classproperty
3940from vulnerabilities .utils import get_reference_id
41+ from vulnerabilities .utils import is_commit
4042from vulnerabilities .utils import is_cve
4143from vulnerabilities .utils import nearest_patched_package
4244from vulnerabilities .utils import purl_to_dict
@@ -194,6 +196,97 @@ def from_url(cls, url):
194196 return cls (url = url )
195197
196198
199+ @dataclasses .dataclass (eq = True )
200+ @functools .total_ordering
201+ class PackageCommitPatchData :
202+ vcs_url : str
203+ commit_hash : str
204+ patch_text : Optional [str ] = None
205+
206+ def __post_init__ (self ):
207+ if not self .commit_hash :
208+ raise ValueError ("Commit must have a non-empty commit_hash." )
209+
210+ if not is_commit (self .commit_hash ):
211+ raise ValueError (f"Commit must be a valid a commit_hash: { self .commit_hash } ." )
212+
213+ if not self .vcs_url :
214+ raise ValueError ("Commit must have a non-empty vcs_url." )
215+
216+ def __lt__ (self , other ):
217+ if not isinstance (other , PackageCommitPatchData ):
218+ return NotImplemented
219+ return self ._cmp_key () < other ._cmp_key ()
220+
221+ # TODO: Add cache
222+ def _cmp_key (self ):
223+ return (
224+ self .vcs_url ,
225+ self .commit_hash ,
226+ self .patch_text ,
227+ )
228+
229+ def to_dict (self ) -> dict :
230+ """Return a normalized dictionary representation of the commit."""
231+ return {
232+ "vcs_url" : self .vcs_url ,
233+ "commit_hash" : self .commit_hash ,
234+ "patch_text" : self .patch_text ,
235+ }
236+
237+ @classmethod
238+ def from_dict (cls , data : dict ):
239+ """Create a PackageCommitPatchData instance from a dictionary."""
240+ return cls (
241+ vcs_url = data .get ("vcs_url" ),
242+ commit_hash = data .get ("commit_hash" ),
243+ patch_text = data .get ("patch_text" ),
244+ )
245+
246+
247+ @dataclasses .dataclass (eq = True )
248+ @functools .total_ordering
249+ class PatchData :
250+ patch_url : Optional [str ] = None
251+ patch_text : Optional [str ] = None
252+ patch_checksum : Optional [str ] = dataclasses .field (init = False , default = None )
253+
254+ def __post_init__ (self ):
255+ if not self .patch_url and not self .patch_text :
256+ raise ValueError ("A patch must include either patch_url or patch_text" )
257+
258+ if self .patch_text :
259+ self .patch_checksum = hashlib .sha512 (self .patch_text .encode ()).hexdigest ()
260+
261+ def __lt__ (self , other ):
262+ if not isinstance (other , PatchData ):
263+ return NotImplemented
264+ return self ._cmp_key () < other ._cmp_key ()
265+
266+ def _cmp_key (self ):
267+ return (
268+ self .patch_url ,
269+ self .patch_text ,
270+ self .patch_checksum ,
271+ )
272+
273+ def to_dict (self ) -> dict :
274+ """Return a normalized dictionary representation of the commit."""
275+ return {
276+ "patch_url" : self .patch_url ,
277+ "patch_text" : self .patch_text ,
278+ "patch_checksum" : self .patch_checksum ,
279+ }
280+
281+ @classmethod
282+ def from_dict (cls , data : dict ):
283+ """Create a PatchData instance from a dictionary."""
284+ return cls (
285+ patch_url = data .get ("patch_url" ),
286+ patch_text = data .get ("patch_text" ),
287+ )
288+
289+
197290class UnMergeablePackageError (Exception ):
198291 """
199292 Raised when a package cannot be merged with another one.
@@ -344,21 +437,30 @@ class AffectedPackageV2:
344437 """
345438 Relate a Package URL with a range of affected versions and fixed versions.
346439 The Package URL must *not* have a version.
347- AffectedPackage must contain either ``affected_version_range`` or ``fixed_version_range``.
440+ AffectedPackage must contain either ``affected_version_range`` or ``fixed_version_range`` or ``introduced_by_commits`` or ``fixed_by_commits`` .
348441 """
349442
350443 package : PackageURL
351444 affected_version_range : Optional [VersionRange ] = None
352445 fixed_version_range : Optional [VersionRange ] = None
446+ introduced_by_commit_patches : List [PackageCommitPatchData ] = dataclasses .field (
447+ default_factory = list
448+ )
449+ fixed_by_commit_patches : List [PackageCommitPatchData ] = dataclasses .field (default_factory = list )
353450
354451 def __post_init__ (self ):
355452 if self .package .version :
356453 raise ValueError (f"Affected Package URL { self .package !r} cannot have a version." )
357454
358- if not (self .affected_version_range or self .fixed_version_range ):
455+ if not (
456+ self .affected_version_range
457+ or self .fixed_version_range
458+ or self .introduced_by_commit_patches
459+ or self .fixed_by_commit_patches
460+ ):
359461 raise ValueError (
360- f"Affected Package { self .package !r} should have either fixed version range or an "
361- "affected version range."
462+ f"Affected package { self .package !r} must have either a fixed version range, "
463+ "an affected version range, introduced commit patches, or fixed commit patches ."
362464 )
363465
364466 def __lt__ (self , other ):
@@ -372,6 +474,8 @@ def _cmp_key(self):
372474 str (self .package ),
373475 str (self .affected_version_range or "" ),
374476 str (self .fixed_version_range or "" ),
477+ str (self .introduced_by_commit_patches or []),
478+ str (self .fixed_by_commit_patches or []),
375479 )
376480
377481 def to_dict (self ):
@@ -385,6 +489,12 @@ def to_dict(self):
385489 "package" : purl_to_dict (self .package ),
386490 "affected_version_range" : affected_version_range ,
387491 "fixed_version_range" : fixed_version_range ,
492+ "introduced_by_commit_patches" : [
493+ commit .to_dict () for commit in self .introduced_by_commit_patches
494+ ],
495+ "fixed_by_commit_patches" : [
496+ commit .to_dict () for commit in self .fixed_by_commit_patches
497+ ],
388498 }
389499
390500 @classmethod
@@ -396,6 +506,10 @@ def from_dict(cls, affected_pkg: dict):
396506 fixed_version_range = None
397507 affected_range = affected_pkg ["affected_version_range" ]
398508 fixed_range = affected_pkg ["fixed_version_range" ]
509+ introduced_by_commit_patches = (
510+ affected_pkg .get ("introduced_by_package_commit_patches" ) or []
511+ )
512+ fixed_by_commit_patches = affected_pkg .get ("fixed_by_package_commit_patches" ) or []
399513
400514 try :
401515 affected_version_range = VersionRange .from_string (affected_range )
@@ -417,6 +531,12 @@ def from_dict(cls, affected_pkg: dict):
417531 package = package ,
418532 affected_version_range = affected_version_range ,
419533 fixed_version_range = fixed_version_range ,
534+ introduced_by_commit_patches = [
535+ PackageCommitPatchData .from_dict (commit ) for commit in introduced_by_commit_patches
536+ ],
537+ fixed_by_commit_patches = [
538+ PackageCommitPatchData .from_dict (commit ) for commit in fixed_by_commit_patches
539+ ],
420540 )
421541
422542
@@ -441,6 +561,7 @@ class AdvisoryData:
441561 )
442562 references : List [Reference ] = dataclasses .field (default_factory = list )
443563 references_v2 : List [ReferenceV2 ] = dataclasses .field (default_factory = list )
564+ patches : List [PatchData ] = dataclasses .field (default_factory = list )
444565 date_published : Optional [datetime .datetime ] = None
445566 weaknesses : List [int ] = dataclasses .field (default_factory = list )
446567 severities : List [VulnerabilitySeverity ] = dataclasses .field (default_factory = list )
@@ -473,6 +594,7 @@ def to_dict(self):
473594 "summary" : self .summary ,
474595 "affected_packages" : [pkg .to_dict () for pkg in self .affected_packages ],
475596 "references_v2" : [ref .to_dict () for ref in self .references_v2 ],
597+ "patches" : [patch .to_dict () for patch in self .patches ],
476598 "severities" : [sev .to_dict () for sev in self .severities ],
477599 "date_published" : self .date_published .isoformat () if self .date_published else None ,
478600 "weaknesses" : self .weaknesses ,
@@ -533,6 +655,7 @@ class AdvisoryDataV2:
533655 summary : Optional [str ] = ""
534656 affected_packages : List [AffectedPackage ] = dataclasses .field (default_factory = list )
535657 references : List [ReferenceV2 ] = dataclasses .field (default_factory = list )
658+ patches : List [PatchData ] = dataclasses .field (default_factory = list )
536659 date_published : Optional [datetime .datetime ] = None
537660 weaknesses : List [int ] = dataclasses .field (default_factory = list )
538661 url : Optional [str ] = None
@@ -557,6 +680,7 @@ def to_dict(self):
557680 "summary" : self .summary ,
558681 "affected_packages" : [pkg .to_dict () for pkg in self .affected_packages ],
559682 "references" : [ref .to_dict () for ref in self .references ],
683+ "patches" : [ref .to_dict () for ref in self .patches ],
560684 "date_published" : self .date_published .isoformat () if self .date_published else None ,
561685 "weaknesses" : self .weaknesses ,
562686 "url" : self .url if self .url else "" ,
@@ -574,6 +698,7 @@ def from_dict(cls, advisory_data):
574698 if pkg is not None
575699 ],
576700 "references" : [Reference .from_dict (ref ) for ref in advisory_data ["references" ]],
701+ "patches" : [PatchData .from_dict (ref ) for ref in advisory_data ["patches" ]],
577702 "date_published" : datetime .datetime .fromisoformat (date_published )
578703 if date_published
579704 else None ,
0 commit comments