Skip to content

Commit daeeeed

Browse files
committed
common/CI: Add monitoring checks
**Summary** - Adds more expansive monitoring.yaml checks
1 parent ace4d5d commit daeeeed

File tree

1 file changed

+249
-2
lines changed

1 file changed

+249
-2
lines changed

common/CI/package_checks.py

Lines changed: 249 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
81125
class FreezeConfig:
82126
start: Optional[datetime]
@@ -263,6 +307,7 @@ def _record(self) -> logging.LogRecord:
263307

264308
class 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+
458704
class 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

Comments
 (0)