@@ -77,6 +77,50 @@ def files(self) -> List[str]:
7777 return [str (element .text ) for element in self ._xml .findall ('.//Path' )]
7878
7979
80+ class MonitoringYAML :
81+ """Represents a Monitoring YAML file."""
82+
83+ def __init__ (self , stream : Any ):
84+ yaml = YAML (typ = 'safe' , pure = True )
85+ yaml .default_flow_style = False
86+ self ._data = dict (yaml .load (stream ))
87+
88+ @property
89+ def releases (self ) -> Optional [dict ]:
90+ return self ._data .get ('releases' )
91+
92+ @property
93+ def release_id (self ) -> Optional [int ]:
94+ releases = self .releases
95+ if releases :
96+ return releases .get ('id' )
97+
98+ @property
99+ def release_ignore (self ) -> Optional [List [str ]]:
100+ releases = self .releases
101+ if releases and releases .get ('ignore' ):
102+ return releases .get ('ignore' )
103+
104+ @property
105+ def security (self ) -> Optional [dict ]:
106+ return self ._data .get ('security' )
107+
108+ @property
109+ def cpe (self ) -> Optional [List [dict ]]:
110+ security = self .security
111+ if security :
112+ return security .get ('cpe' )
113+
114+ @property
115+ def security_ignore (self ) -> Optional [List [str ]]:
116+ security = self .security
117+ if security and security .get ('ignore' ):
118+ return security .get ('ignore' )
119+
120+ def get (self , key : str , default : Any = None ) -> Any :
121+ return self ._data .get (key , default )
122+
123+
80124@dataclass
81125class FreezeConfig :
82126 start : Optional [datetime ]
@@ -263,6 +307,7 @@ def _record(self) -> logging.LogRecord:
263307
264308class PullRequestCheck :
265309 _package_files = ['package.yml' ]
310+ _monitoring_files = ['monitoring.yaml' ]
266311 _two_letter_dirs = ['py' ]
267312 _config : Optional [Config ] = None
268313
@@ -287,6 +332,10 @@ def config(self) -> Config:
287332 def package_files (self ) -> List [str ]:
288333 return self .filter_files (* self ._package_files )
289334
335+ @property
336+ def monitoring_files (self ) -> List [str ]:
337+ return self .filter_files (* self ._monitoring_files )
338+
290339 def filter_files (self , * allowed : str ) -> List [str ]:
291340 return [f for f in self .files
292341 if os .path .basename (f ) in allowed ]
@@ -317,6 +366,13 @@ def load_pspec_xml(self, file: str) -> PspecXML:
317366 def load_pspec_xml_from_commit (self , ref : str , file : str ) -> PspecXML :
318367 return PspecXML (self .git .file_from_commit (ref , file ))
319368
369+ def load_monitoring_yml (self , file : str ) -> MonitoringYAML :
370+ with self ._open (file ) as f :
371+ return MonitoringYAML (f )
372+
373+ def load_monitoring_yml_from_commit (self , ref : str , file : str ) -> MonitoringYAML :
374+ return MonitoringYAML (self .git .file_from_commit (ref , file ))
375+
320376 def file_line (self , file : str , expr : str ) -> Optional [int ]:
321377 with self ._open (file ) as f :
322378 for i , line in enumerate (f .read ().splitlines ()):
@@ -442,7 +498,7 @@ def run(self) -> List[Result]:
442498 return results
443499
444500
445- class Monitoring (PullRequestCheck ):
501+ class MonitoringExists (PullRequestCheck ):
446502 _error = '`monitoring.yaml` is missing'
447503 _level = Level .WARNING
448504
@@ -455,6 +511,196 @@ def _has_monitoring_yml(self, file: str) -> bool:
455511 return self ._exists (os .path .join (os .path .dirname (file ), 'monitoring.yaml' ))
456512
457513
514+ class MonitoringFormat (PullRequestCheck ):
515+ _error_required_sections = 'monitoring.yaml must contain required sections: releases and security'
516+ _error_cpe_format = "security.cpe must be a list or null (~)"
517+ _error_cpe_entry = "Each CPE entry must have both 'vendor' and 'product' fields with non-null values"
518+ _level = Level .ERROR
519+
520+ def _yml_file (self , file : str ) -> MonitoringYAML :
521+ return self .load_monitoring_yml (file )
522+
523+ def _is_valid_url (self , url : str ) -> bool :
524+ """Check if a string is a valid URL."""
525+ try :
526+ result = urlparse (url )
527+ # Check if scheme and netloc are present
528+ return all ([result .scheme , result .netloc ])
529+ except :
530+ return False
531+
532+ def run (self ) -> List [Result ]:
533+ package_files = self .monitoring_files
534+ results = []
535+
536+ for file in package_files :
537+ monitoring = self ._yml_file (file )
538+
539+ # Check required sections
540+ results .extend (self ._check_required_sections (file , monitoring ))
541+
542+ # Check security section
543+ results .extend (self ._check_security_section (file , monitoring ))
544+
545+ # Check releases section
546+ results .extend (self ._check_releases_section (file , monitoring ))
547+
548+ return results
549+
550+ def _check_required_sections (self , file : str , monitoring : MonitoringYAML ) -> List [Result ]:
551+ results = []
552+ if not isinstance (monitoring .releases , dict ) and not isinstance (monitoring .security , dict ):
553+ results .append (Result (
554+ message = self ._error_required_sections ,
555+ file = file ,
556+ level = self ._level
557+ ))
558+ return results
559+
560+ def _check_security_section (self , file : str , monitoring : MonitoringYAML ) -> List [Result ]:
561+ results = []
562+
563+ # Ensure the 'cpe' key exists in the security section
564+ if not isinstance (monitoring .cpe , list ) and monitoring .cpe is not None :
565+ results .append (Result (
566+ message = self ._error_cpe_format ,
567+ file = file ,
568+ level = self ._level ,
569+ line = self .file_line (file , r'^security\s*:' )
570+ ))
571+
572+ # If cpe is a list (not null), validate each entry
573+ if isinstance (monitoring .cpe , list ):
574+ results .extend (self ._check_cpe_entries (file , monitoring .cpe ))
575+ results .extend (self ._check_security_ignore_patterns (file , monitoring .security_ignore ))
576+
577+ return results
578+
579+ def _check_security_ignore_patterns (self , file : str , ignore_patterns : Optional [List [str ]]) -> List [Result ]:
580+ results = []
581+ if not ignore_patterns :
582+ return results
583+
584+ if not all (isinstance (pattern , str ) for pattern in ignore_patterns ):
585+ results .append (Result (
586+ message = "security.ignore must contain string patterns" ,
587+ file = file ,
588+ level = self ._level ,
589+ line = self .file_line (file , r'^ ignore\s*:' )
590+ ))
591+ else :
592+ # Check that all patterns begin with CVE-
593+ invalid_patterns = [pattern for pattern in ignore_patterns
594+ if not pattern .startswith ("CVE-" )]
595+ if invalid_patterns :
596+ results .append (Result (
597+ message = f"security.ignore patterns must begin with 'CVE-': { ', ' .join (invalid_patterns )} " ,
598+ file = file ,
599+ level = self ._level ,
600+ line = self .file_line (file , r'^ security\.ignore\s*:' )
601+ ))
602+ return results
603+
604+ def _check_cpe_entries (self , file : str , cpe_entries : List [dict ]) -> List [Result ]:
605+ results = []
606+ for i , item in enumerate (cpe_entries ):
607+ if not isinstance (item , dict ):
608+ results .append (Result (
609+ message = "Each CPE entry must be a dictionary" ,
610+ file = file ,
611+ level = self ._level ,
612+ line = self .file_line (file , r'^ cpe\s*:' )
613+ ))
614+ elif 'vendor' not in item or 'product' not in item or item ['vendor' ] is None or item ['product' ] is None :
615+ results .append (Result (
616+ message = self ._error_cpe_entry ,
617+ file = file ,
618+ level = self ._level ,
619+ line = self .file_line (file , r'^ cpe\s*:' )
620+ ))
621+ return results
622+
623+ def _check_releases_section (self , file : str , monitoring : MonitoringYAML ) -> List [Result ]:
624+ results = []
625+
626+ # Check releases.id validity
627+ if 'releases' in monitoring ._data :
628+ releases_dict = monitoring ._data .get ('releases' , {})
629+ if isinstance (releases_dict , dict ):
630+ results .extend (self ._check_releases_id (file , releases_dict ))
631+ results .extend (self ._check_releases_rss (file , releases_dict ))
632+ results .extend (self ._check_releases_ignore_patterns (file , monitoring .release_ignore ))
633+
634+ return results
635+
636+ def _check_releases_ignore_patterns (self , file : str , ignore_patterns : Optional [List [str ]]) -> List [Result ]:
637+ results = []
638+ if ignore_patterns and not all (isinstance (pattern , str ) for pattern in ignore_patterns ):
639+ results .append (Result (
640+ message = "releases.ignore must contain string patterns" ,
641+ file = file ,
642+ level = self ._level ,
643+ line = self .file_line (file , r'^ ignore\s*:' )
644+ ))
645+ return results
646+
647+ def _check_releases_rss (self , file : str , releases_dict : dict ) -> List [Result ]:
648+ results = []
649+ if 'rss' not in releases_dict :
650+ results .append (Result (
651+ message = "releases section must contain an `rss` key" ,
652+ file = file ,
653+ level = self ._level ,
654+ line = self .file_line (file , r'^releases\s*:' )
655+ ))
656+ elif releases_dict .get ('rss' ) is None :
657+ # The key exists but has a null value
658+ results .append (Result (
659+ message = "releases.rss is set to null, it should point to a rss feed" ,
660+ file = file ,
661+ level = Level .WARNING ,
662+ line = self .file_line (file , r'^\s+rss\s*:' )
663+ ))
664+ elif releases_dict .get ('rss' ) is not None and not self ._is_valid_url (releases_dict .get ('rss' )):
665+ results .append (Result (
666+ message = "releases.rss must contain a valid URL" ,
667+ file = file ,
668+ level = self ._level ,
669+ line = self .file_line (file , r'^\s+rss\s*:' )
670+ ))
671+ return results
672+
673+ def _check_releases_id (self , file : str , releases_dict : dict ) -> List [Result ]:
674+ results = []
675+ if 'id' not in releases_dict :
676+ results .append (Result (
677+ message = "releases section must contain an `id` key" ,
678+ file = file ,
679+ level = self ._level ,
680+ line = self .file_line (file , r'^releases\s*:' )
681+ ))
682+ elif releases_dict .get ('id' ) is None :
683+ # The key exists but has a null value
684+ results .append (Result (
685+ message = "releases.id is set to null, it should have a numeric value" ,
686+ file = file ,
687+ level = Level .WARNING ,
688+ line = self .file_line (file , r'^\s+id\s*:' )
689+ ))
690+ elif releases_dict .get ('id' ) is not None :
691+ # The key exists with a non-null value, check if it's an integer
692+ try :
693+ int (releases_dict .get ('id' ))
694+ except (ValueError , TypeError ):
695+ results .append (Result (
696+ message = "releases.id must be a number" ,
697+ file = file ,
698+ level = self ._level ,
699+ line = self .file_line (file , r'^\s+id\s*:' )
700+ ))
701+ return results
702+
703+
458704class PackageBumped (PullRequestCheck ):
459705 _msg = 'Package release is not incremented by 1'
460706 _msg_new = 'Package release is not 1'
@@ -843,7 +1089,8 @@ class Checker:
8431089 CommitMessage ,
8441090 FrozenPackage ,
8451091 Homepage ,
846- Monitoring ,
1092+ MonitoringExists ,
1093+ MonitoringFormat ,
8471094 PackageBumped ,
8481095 PackageDependenciesOrder ,
8491096 PackageDirectory ,
0 commit comments