diff --git a/scripts/vulnerability_report.py b/scripts/vulnerability_report.py index 12986fb2c..e59759b47 100644 --- a/scripts/vulnerability_report.py +++ b/scripts/vulnerability_report.py @@ -31,15 +31,15 @@ alerts needs to be provided """ -from statistics import median -from datetime import datetime -from dateutil import parser -from collections import Counter -from argparse import ArgumentParser, Namespace import json - +from argparse import ArgumentParser, Namespace +from collections import Counter +from datetime import datetime +from statistics import median from typing import Any +from dateutil import parser + type DependabotAlert = dict[str, Any] type DependabotAlerts = list[DependabotAlert] @@ -50,11 +50,12 @@ def create_argument_parser() -> ArgumentParser: The parser includes the following options: Returns: - Configured ArgumentParser for parsing the command line options. + An ArgumentParser configured with command-line options for + organization, repository, and report generation settings. """ - parser = ArgumentParser(description="Vulnerability report tool") + cli_parser = ArgumentParser(description="Vulnerability report tool") - parser.add_argument( + cli_parser.add_argument( "-v", "--verbose", dest="verbose", @@ -63,21 +64,21 @@ def create_argument_parser() -> ArgumentParser: default=False, ) - parser.add_argument( + cli_parser.add_argument( "--organization", default="", help="GitHub organization.", required=True, ) - parser.add_argument( + cli_parser.add_argument( "--repository", default="", help="GitHub repository.", required=True, ) - parser.add_argument( + cli_parser.add_argument( "-r", "--retrieve-issues", action="store_true", @@ -85,7 +86,7 @@ def create_argument_parser() -> ArgumentParser: help="Retrieve issues", ) - parser.add_argument( + cli_parser.add_argument( "-g", "--generate-graphs", action="store_true", @@ -93,7 +94,7 @@ def create_argument_parser() -> ArgumentParser: help="Generate graphs with vulnerabilities info", ) - parser.add_argument( + cli_parser.add_argument( "-p", "--generate-page", action="store_true", @@ -101,7 +102,7 @@ def create_argument_parser() -> ArgumentParser: help="Generate page with vulnerabilities info", ) - parser.add_argument( + cli_parser.add_argument( "-c", "--comparison", required=False, @@ -111,17 +112,33 @@ def create_argument_parser() -> ArgumentParser: "Multiple JSON files with Dependabot alerts needs to be provided", ) - return parser + return cli_parser def dependabot_file_name(args: Namespace) -> str: - """Construct file name containing Dependabot alerts.""" + """Construct file name containing Dependabot alerts. + + Build the expected input filename for Dependabot alerts. + + Filename format: {organization}__{repository}.json + """ return f"{args.organization}__{args.repository}.json" def load_dependabot_file(filename: str) -> Any: - """Load JSON file containing Dependabot alerts.""" - with open(filename, "r") as fin: + """ + Load and validate a JSON file containing Dependabot alerts. + + Ensures the JSON is a list of objects, each containing a 'state' field and + a nested 'security_advisory' object with a 'severity' field. + + Returns: + list: A list of validated alert dictionaries. + + Raises: + ValueError: If the JSON structure is invalid. + """ + with open(filename, "r", encoding="utf-8") as fin: data = json.load(fin) # perform sanity check @@ -141,21 +158,40 @@ def load_dependabot_file(filename: str) -> Any: def has_attribute_with_value(item: DependabotAlert, attribute: str, value: str) -> bool: - """Check if dictionary has attribute with given value.""" + """ + Determine if an alert attribute matches a specified value. + + Returns: + `true` if the attribute value equals the specified value, `false` otherwise. + """ return bool(item[attribute] == value) def has_deep_attribute_with_value( item: DependabotAlert, selector: str, attribute: str, value: str ) -> bool: - """Check if dictionary has deep attribute with given value.""" + """ + Determine if a nested dictionary attribute has a specific value. + + Returns: + true if item[selector][attribute] equals the given value, false otherwise. + """ return bool(item[selector][attribute] == value) def count_attribute_with_value( items: DependabotAlerts, attribute: str, value: str ) -> int: - """Count all attributes with given value.""" + """ + Count the number of items where the specified attribute equals the given value. + + Parameters: + attribute (str): The attribute name to check in each item. + value (str): The target value to match. + + Returns: + int: The number of items where the attribute equals the value. + """ cnt: int = 0 for item in items: if has_attribute_with_value(item, attribute, value): @@ -166,7 +202,17 @@ def count_attribute_with_value( def count_deep_attribute_with_value( items: DependabotAlerts, selector: str, attribute: str, value: str ) -> int: - """Count all deep attributes with given value.""" + """ + Count alerts where a nested attribute equals a specified value. + + Parameters: + selector (str): The top-level key within each alert + attribute (str): The key within the nested object accessed by selector + value (str): The value to match against + + Returns: + int: Number of alerts where item[selector][attribute] equals value + """ cnt: int = 0 for item in items: if has_deep_attribute_with_value(item, selector, attribute, value): @@ -180,12 +226,22 @@ def opened_cves(source_data: DependabotAlerts) -> int: def fixed_cves(source_data: DependabotAlerts) -> int: - """Compute how many CVEs has been fixed opened.""" + """ + Count the number of fixed CVEs. + + Returns: + The count of fixed CVEs. + """ return count_attribute_with_value(source_data, "state", "fixed") def with_severity(severity: str, source_data: DependabotAlerts) -> int: - """Count number of CVE having specified severity.""" + """ + Count alerts with the specified severity level. + + Returns: + int: The number of alerts matching the specified severity + """ return count_deep_attribute_with_value( source_data, "security_advisory", "severity", severity ) @@ -194,12 +250,23 @@ def with_severity(severity: str, source_data: DependabotAlerts) -> int: def filter_by( source_data: DependabotAlerts, attribute: str, value: str ) -> DependabotAlerts: - """Filter source data: retrieve only attribute with give value.""" + """ + Return alerts where a specified attribute equals a given value. + + Returns: + A list of alerts where the specified attribute matches the given value. + """ return [item for item in source_data if item[attribute] == value] def fill_in_state(source_data: DependabotAlerts) -> dict[str, int]: - """Fill-in the overall vulnerabilities state.""" + """ + Count the number of open and fixed vulnerabilities across all alerts. + + Returns: + dict[str, int]: A dictionary with 'open' and 'fixed' keys mapping to + their respective alert counts. + """ state = {} state["open"] = opened_cves(source_data) state["fixed"] = fixed_cves(source_data) @@ -207,7 +274,14 @@ def fill_in_state(source_data: DependabotAlerts) -> dict[str, int]: def fill_in_severity(source_data: DependabotAlerts) -> dict[str, int]: - """Fill-in the severity statistic.""" + """ + Compute the count of alerts for each severity level. + + Returns: + severity (dict[str, int]): A dict with keys 'critical', 'high', + 'medium', and 'low', each mapped to the count of alerts with that + severity. + """ severity = {} severity["critical"] = with_severity("critical", source_data) severity["high"] = with_severity("high", source_data) @@ -217,7 +291,12 @@ def fill_in_severity(source_data: DependabotAlerts) -> dict[str, int]: def fill_in_severities_set(source_data: DependabotAlerts) -> set[str]: - """Fill-in the set with severities.""" + """ + Collect all distinct severity levels from Dependabot alerts. + + Returns: + set[str]: A set of unique severity levels found across the alerts. + """ # Severity can be set to: # - low # - medium @@ -230,7 +309,13 @@ def fill_in_severities_set(source_data: DependabotAlerts) -> set[str]: def fill_in_days_statistic(source_data: DependabotAlerts) -> dict[str, Any]: - """Fill-in statistic about days needed to fix the CVEs.""" + """ + Compute statistics on elapsed days from creation to fix for alerts in a fixed state. + + Returns: + dict[str, Any]: Dictionary with "days" (list of elapsed day values), + "avg" (average days to fix), and "median" (median days to fix). + """ fixed = filter_by(source_data, "state", "fixed") days = [] for item in fixed: @@ -238,28 +323,45 @@ def fill_in_days_statistic(source_data: DependabotAlerts) -> dict[str, Any]: dt2 = parser.isoparse(item["fixed_at"]) d = (dt2 - dt1).total_seconds() / 86400 days.append(d) - days_stat = {} + days_stat: dict[str, Any] = {} days_stat["days"] = days - days_stat["avg"] = sum(days) / len(days) - days_stat["median"] = median(days) + # avoid division by zero + if not days: + days_stat["avg"] = sum(days) / len(days) + days_stat["median"] = median(days) + else: + days_stat["avg"] = 0 + days_stat["median"] = 0 return days_stat def fill_in_vulnerable_packages(source_data: DependabotAlerts) -> Counter[Any]: - """Fill-in vulnerable packages with CVE frequency info.""" + """ + Count the frequency of vulnerable packages across all alerts. + + Returns: + A Counter mapping package names to their occurrence counts. + """ package_names = [item["dependency"]["package"]["name"] for item in source_data] return Counter(package_names) def fill_in_cve_created_dates(source_data: DependabotAlerts) -> Counter[datetime]: - """Fill-in dates (freq.) when new CVE was detected by Dependabot.""" + """ + Count the number of CVE alerts by their detection date. + + Returns: + A Counter mapping each creation date to the number of alerts created on that date. + """ dates_str = [item["created_at"][:10] for item in source_data] dates = [datetime.strptime(date_str, "%Y-%m-%d") for date_str in dates_str] return Counter(dates) -def process_dependabot_file(dependabot_file: str, prefix: str) -> dict[str, Any]: - """Read Dependabot alerts and prepare statistic info.""" +def process_dependabot_file(dependabot_file: str) -> dict[str, Any]: + """ + Compute vulnerability statistics from Dependabot alerts stored in a JSON file. + """ source_data = load_dependabot_file(dependabot_file) # dictionary holding the whole statistic about vulnerabilities @@ -293,12 +395,13 @@ def main() -> int: `0` indicates success, `1` indicates any failure """ - parser = create_argument_parser() - args = parser.parse_args() + cli_parser = create_argument_parser() + args = cli_parser.parse_args() dependabot_file = dependabot_file_name(args) prefix = args.repository - stat = process_dependabot_file(dependabot_file, prefix) + stat = process_dependabot_file(dependabot_file) print(stat) + print(prefix) return 0