1515from ruamel .yaml .compat import StringIO
1616from typing import Any , Callable , Dict , List , Optional , TextIO , Tuple , Union
1717from urllib import request
18+ from urllib .parse import urlparse , ParseResult
1819from xml .etree import ElementTree
1920
2021"""Package is either a Package YML file or Pspec XML file."""
@@ -77,6 +78,50 @@ def files(self) -> List[str]:
7778 return [str (element .text ) for element in self ._xml .findall ('.//Path' )]
7879
7980
81+ class MonitoringYAML :
82+ """Represents a Monitoring YAML file."""
83+
84+ def __init__ (self , stream : Any ):
85+ yaml = YAML (typ = 'safe' , pure = True )
86+ yaml .default_flow_style = False
87+ self ._data = dict (yaml .load (stream ))
88+
89+ @property
90+ def releases (self ) -> Optional [dict ]:
91+ return self ._data .get ('releases' )
92+
93+ @property
94+ def release_id (self ) -> Optional [int ]:
95+ releases = self .releases
96+ if releases :
97+ return releases .get ('id' )
98+
99+ @property
100+ def release_ignore (self ) -> Optional [List [str ]]:
101+ releases = self .releases
102+ if releases and releases .get ('ignore' ):
103+ return releases .get ('ignore' )
104+
105+ @property
106+ def security (self ) -> Optional [dict ]:
107+ return self ._data .get ('security' )
108+
109+ @property
110+ def cpe (self ) -> Optional [List [dict ]]:
111+ security = self .security
112+ if security :
113+ return security .get ('cpe' )
114+
115+ @property
116+ def security_ignore (self ) -> Optional [List [str ]]:
117+ security = self .security
118+ if security and security .get ('ignore' ):
119+ return security .get ('ignore' )
120+
121+ def get (self , key : str , default : Any = None ) -> Any :
122+ return self ._data .get (key , default )
123+
124+
80125@dataclass
81126class FreezeConfig :
82127 start : Optional [datetime ]
@@ -263,6 +308,7 @@ def _record(self) -> logging.LogRecord:
263308
264309class PullRequestCheck :
265310 _package_files = ['package.yml' ]
311+ _monitoring_files = ['monitoring.yaml' ]
266312 _two_letter_dirs = ['py' ]
267313 _config : Optional [Config ] = None
268314
@@ -287,6 +333,10 @@ def config(self) -> Config:
287333 def package_files (self ) -> List [str ]:
288334 return self .filter_files (* self ._package_files )
289335
336+ @property
337+ def monitoring_files (self ) -> List [str ]:
338+ return self .filter_files (* self ._monitoring_files )
339+
290340 def filter_files (self , * allowed : str ) -> List [str ]:
291341 return [f for f in self .files
292342 if os .path .basename (f ) in allowed ]
@@ -317,6 +367,13 @@ def load_pspec_xml(self, file: str) -> PspecXML:
317367 def load_pspec_xml_from_commit (self , ref : str , file : str ) -> PspecXML :
318368 return PspecXML (self .git .file_from_commit (ref , file ))
319369
370+ def load_monitoring_yml (self , file : str ) -> MonitoringYAML :
371+ with self ._open (file ) as f :
372+ return MonitoringYAML (f )
373+
374+ def load_monitoring_yml_from_commit (self , ref : str , file : str ) -> MonitoringYAML :
375+ return MonitoringYAML (self .git .file_from_commit (ref , file ))
376+
320377 def file_line (self , file : str , expr : str ) -> Optional [int ]:
321378 with self ._open (file ) as f :
322379 for i , line in enumerate (f .read ().splitlines ()):
@@ -442,7 +499,7 @@ def run(self) -> List[Result]:
442499 return results
443500
444501
445- class Monitoring (PullRequestCheck ):
502+ class MonitoringExists (PullRequestCheck ):
446503 _error = '`monitoring.yaml` is missing'
447504 _level = Level .WARNING
448505
@@ -455,6 +512,196 @@ def _has_monitoring_yml(self, file: str) -> bool:
455512 return self ._exists (os .path .join (os .path .dirname (file ), 'monitoring.yaml' ))
456513
457514
515+ class MonitoringFormat (PullRequestCheck ):
516+ _error_required_sections = 'monitoring.yaml must contain required sections: releases and security'
517+ _error_cpe_format = "security.cpe must be a list or null (~)"
518+ _error_cpe_entry = "Each CPE entry must have both 'vendor' and 'product' fields with non-null values"
519+ _level = Level .ERROR
520+
521+ def _yml_file (self , file : str ) -> MonitoringYAML :
522+ return self .load_monitoring_yml (file )
523+
524+ def _is_valid_url (self , url : str ) -> bool :
525+ """Check if a string is a valid URL."""
526+ try :
527+ result = urlparse (url )
528+ # Check if scheme and netloc are present
529+ return all ([result .scheme , result .netloc ])
530+ except ParseResult :
531+ return False
532+
533+ def run (self ) -> List [Result ]:
534+ package_files = self .monitoring_files
535+ results = []
536+
537+ for file in package_files :
538+ monitoring = self ._yml_file (file )
539+
540+ # Check required sections
541+ results .extend (self ._check_required_sections (file , monitoring ))
542+
543+ # Check security section
544+ results .extend (self ._check_security_section (file , monitoring ))
545+
546+ # Check releases section
547+ results .extend (self ._check_releases_section (file , monitoring ))
548+
549+ return results
550+
551+ def _check_required_sections (self , file : str , monitoring : MonitoringYAML ) -> List [Result ]:
552+ results = []
553+ if not isinstance (monitoring .releases , dict ) and not isinstance (monitoring .security , dict ):
554+ results .append (Result (
555+ message = self ._error_required_sections ,
556+ file = file ,
557+ level = self ._level
558+ ))
559+ return results
560+
561+ def _check_security_section (self , file : str , monitoring : MonitoringYAML ) -> List [Result ]:
562+ results = []
563+
564+ # Ensure the 'cpe' key exists in the security section
565+ if not isinstance (monitoring .cpe , list ) and monitoring .cpe is not None :
566+ results .append (Result (
567+ message = self ._error_cpe_format ,
568+ file = file ,
569+ level = self ._level ,
570+ line = self .file_line (file , r'^security\s*:' )
571+ ))
572+
573+ # If cpe is a list (not null), validate each entry
574+ if isinstance (monitoring .cpe , list ):
575+ results .extend (self ._check_cpe_entries (file , monitoring .cpe ))
576+ results .extend (self ._check_security_ignore_patterns (file , monitoring .security_ignore ))
577+
578+ return results
579+
580+ def _check_security_ignore_patterns (self , file : str , ignore_patterns : Optional [List [str ]]) -> List [Result ]:
581+ results = []
582+ if not ignore_patterns :
583+ return results
584+
585+ if not all (isinstance (pattern , str ) for pattern in ignore_patterns ):
586+ results .append (Result (
587+ message = "security.ignore must contain string patterns" ,
588+ file = file ,
589+ level = self ._level ,
590+ line = self .file_line (file , r'^ ignore\s*:' )
591+ ))
592+ else :
593+ # Check that all patterns begin with CVE-
594+ invalid_patterns = [pattern for pattern in ignore_patterns
595+ if not pattern .startswith ("CVE-" )]
596+ if invalid_patterns :
597+ results .append (Result (
598+ message = f"security.ignore patterns must begin with 'CVE-': { ', ' .join (invalid_patterns )} " ,
599+ file = file ,
600+ level = self ._level ,
601+ line = self .file_line (file , r'^ security\.ignore\s*:' )
602+ ))
603+ return results
604+
605+ def _check_cpe_entries (self , file : str , cpe_entries : List [dict ]) -> List [Result ]:
606+ results = []
607+ for i , item in enumerate (cpe_entries ):
608+ if not isinstance (item , dict ):
609+ results .append (Result (
610+ message = "Each CPE entry must be a dictionary" ,
611+ file = file ,
612+ level = self ._level ,
613+ line = self .file_line (file , r'^ cpe\s*:' )
614+ ))
615+ elif 'vendor' not in item or 'product' not in item or item ['vendor' ] is None or item ['product' ] is None :
616+ results .append (Result (
617+ message = self ._error_cpe_entry ,
618+ file = file ,
619+ level = self ._level ,
620+ line = self .file_line (file , r'^ cpe\s*:' )
621+ ))
622+ return results
623+
624+ def _check_releases_section (self , file : str , monitoring : MonitoringYAML ) -> List [Result ]:
625+ results = []
626+
627+ # Check releases.id validity
628+ if 'releases' in monitoring ._data :
629+ releases_dict = monitoring ._data .get ('releases' , {})
630+ if isinstance (releases_dict , dict ):
631+ results .extend (self ._check_releases_id (file , releases_dict ))
632+ results .extend (self ._check_releases_rss (file , releases_dict ))
633+ results .extend (self ._check_releases_ignore_patterns (file , monitoring .release_ignore ))
634+
635+ return results
636+
637+ def _check_releases_ignore_patterns (self , file : str , ignore_patterns : Optional [List [str ]]) -> List [Result ]:
638+ results = []
639+ if ignore_patterns and not all (isinstance (pattern , str ) for pattern in ignore_patterns ):
640+ results .append (Result (
641+ message = "releases.ignore must contain string patterns" ,
642+ file = file ,
643+ level = self ._level ,
644+ line = self .file_line (file , r'^ ignore\s*:' )
645+ ))
646+ return results
647+
648+ def _check_releases_rss (self , file : str , releases_dict : dict ) -> List [Result ]:
649+ results = []
650+ if 'rss' not in releases_dict :
651+ results .append (Result (
652+ message = "releases section must contain an `rss` key" ,
653+ file = file ,
654+ level = self ._level ,
655+ line = self .file_line (file , r'^releases\s*:' )
656+ ))
657+ elif releases_dict .get ('rss' ) is None :
658+ # The key exists but has a null value
659+ results .append (Result (
660+ message = "releases.rss is set to null, it should point to a rss feed" ,
661+ file = file ,
662+ level = Level .WARNING ,
663+ line = self .file_line (file , r'^\s+rss\s*:' )
664+ ))
665+ elif releases_dict .get ('rss' ) is not None and not self ._is_valid_url (releases_dict .get ('rss' )):
666+ results .append (Result (
667+ message = "releases.rss must contain a valid URL" ,
668+ file = file ,
669+ level = self ._level ,
670+ line = self .file_line (file , r'^\s+rss\s*:' )
671+ ))
672+ return results
673+
674+ def _check_releases_id (self , file : str , releases_dict : dict ) -> List [Result ]:
675+ results = []
676+ if 'id' not in releases_dict :
677+ results .append (Result (
678+ message = "releases section must contain an `id` key" ,
679+ file = file ,
680+ level = self ._level ,
681+ line = self .file_line (file , r'^releases\s*:' )
682+ ))
683+ elif releases_dict .get ('id' ) is None :
684+ # The key exists but has a null value
685+ results .append (Result (
686+ message = "releases.id is set to null, it should have a numeric value" ,
687+ file = file ,
688+ level = Level .WARNING ,
689+ line = self .file_line (file , r'^\s+id\s*:' )
690+ ))
691+ elif releases_dict .get ('id' ) is not None :
692+ # The key exists with a non-null value, check if it's an integer
693+ try :
694+ int (releases_dict .get ('id' ))
695+ except (ValueError , TypeError ):
696+ results .append (Result (
697+ message = "releases.id must be a number" ,
698+ file = file ,
699+ level = self ._level ,
700+ line = self .file_line (file , r'^\s+id\s*:' )
701+ ))
702+ return results
703+
704+
458705class PackageBumped (PullRequestCheck ):
459706 _msg = 'Package release is not incremented by 1'
460707 _msg_new = 'Package release is not 1'
@@ -843,7 +1090,8 @@ class Checker:
8431090 CommitMessage ,
8441091 FrozenPackage ,
8451092 Homepage ,
846- Monitoring ,
1093+ MonitoringExists ,
1094+ MonitoringFormat ,
8471095 PackageBumped ,
8481096 PackageDependenciesOrder ,
8491097 PackageDirectory ,
0 commit comments