Skip to content

Commit edd2deb

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

File tree

1 file changed

+250
-2
lines changed

1 file changed

+250
-2
lines changed

common/CI/package_checks.py

Lines changed: 250 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ruamel.yaml.compat import StringIO
1616
from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Union
1717
from urllib import request
18+
from urllib.parse import urlparse, ParseResult
1819
from 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
81126
class FreezeConfig:
82127
start: Optional[datetime]
@@ -263,6 +308,7 @@ def _record(self) -> logging.LogRecord:
263308

264309
class 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+
458705
class 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

Comments
 (0)