#!/usr/bin/env python3 """CLI tool to retrieve information about Dependabot issues and produces Vulnerability report. Rretrieves Dependabot issues, analyses issues, generates graphs, and generates HTML page with Vulnerability report. Is is also possible to compare two repositories. # Usage: usage: vulnerability_report.py [-h] [-v] --organization ORGANIZATION --repository REPOSITORY [-r] [-g] [-p] [-c COMPARISON [COMPARISON ...]] Vulnerability report tool options: -h, --help show this help message and exit -v, --verbose make it verbose --organization ORGANIZATION GitHub organization. --repository REPOSITORY GitHub repository. -r, --retrieve-issues Retrieve issues -a, --all-actions Retrieve issues for all LCore repositories, generates graphs and reports -g, --generate-graphs Generate graphs with vulnerabilities info -svg, --svg-output Generate graphs in SVG format -png, --png-output Generate graphs in PNG format -p, --generate-page Generate page with vulnerabilities info -c, --comparison COMPARISON [COMPARISON ...] Compare two or more repositories and generate comparison report. Multiple JSON files with Dependabot alerts needs to be provided """ import json import os import re from argparse import ArgumentParser, Namespace from collections import Counter from datetime import datetime from statistics import median from typing import Any import matplotlib.pyplot as plt import pandas as pd import requests from dateutil import parser type DependabotAlert = dict[str, Any] type DependabotAlerts = list[DependabotAlert] def create_argument_parser() -> ArgumentParser: """Create and configure argument parser object. The parser includes the following options: Returns: An ArgumentParser configured with command-line options for organization, repository, and report generation settings. """ cli_parser = ArgumentParser(description="Vulnerability report tool") cli_parser.add_argument( "-v", "--verbose", dest="verbose", help="make it verbose", action="store_true", default=False, ) cli_parser.add_argument( "--organization", default="", help="GitHub organization.", required=True, ) cli_parser.add_argument( "--repository", default="", help="GitHub repository.", required=True, ) cli_parser.add_argument( "-r", "--retrieve-issues", action="store_true", default=False, help="Retrieve issues", ) cli_parser.add_argument( "-a", "--all-actions", action="store_true", default=False, help="Retrieve issues for all LCore repositories, generates graphs and reports", ) cli_parser.add_argument( "-g", "--generate-graphs", action="store_true", default=False, help="Generate graphs with vulnerabilities info", ) cli_parser.add_argument( "-svg", "--svg-output", action="store_true", default=False, help="Generate graphs in SVG format", ) cli_parser.add_argument( "-png", "--png-output", action="store_true", default=False, help="Generate graphs in PNG format", ) cli_parser.add_argument( "-p", "--generate-page", action="store_true", default=False, help="Generate page with vulnerabilities info", ) cli_parser.add_argument( "-c", "--comparison", required=False, nargs="+", default=[], help="Compare two or more repositories and generate comparison report. " "Multiple JSON files with Dependabot alerts needs to be provided", ) return cli_parser def check_args(args: Namespace) -> None: """Check the validity of all command line arguments. Validate command-line argument consistency. Ensures that if graph generation is enabled on command line, at least one output format (SVG or PNG) is specified. Raises: ValueError: If graph generation is requested but neither SVG nor PNG output is selected. """ if args.generate_graphs: # when graphs should be generated, output format need to be specified too if not args.svg_output and not args.png_output: msg = "Graphs generation was requested: you need to select the PNG and/or SVG output." raise ValueError(msg) def dependabot_file_name(args: Namespace) -> str: """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 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, encoding="utf-8") as fin: data = json.load(fin) # perform sanity check if not isinstance(data, list): msg = "Dependabot alerts file must contain a JSON array" raise ValueError(msg) for idx, item in enumerate(data): if not isinstance(item, dict): msg = f"Alert at index {idx} must be an object" raise ValueError(msg) if "state" not in item: msg = f"Alert at index {idx} is missing 'state'" raise ValueError(msg) sa = item.get("security_advisory") if not isinstance(sa, dict) or "severity" not in sa: msg = f"Alert at index {idx} is missing 'security_advisory.severity'" raise ValueError(msg) return data def has_attribute_with_value(item: DependabotAlert, attribute: str, value: str) -> bool: """ 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: """ 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 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): cnt += 1 return cnt def count_deep_attribute_with_value( items: DependabotAlerts, selector: str, attribute: str, value: str ) -> int: """ 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): cnt += 1 return cnt def opened_cves(source_data: DependabotAlerts) -> int: """Compute how many CVEs are opened.""" return count_attribute_with_value(source_data, "state", "open") def fixed_cves(source_data: DependabotAlerts) -> int: """ 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 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 ) def filter_by( source_data: DependabotAlerts, attribute: str, value: str ) -> DependabotAlerts: """ 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]: """ 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) return state def fill_in_severity(source_data: DependabotAlerts) -> dict[str, int]: """ 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) severity["medium"] = with_severity("medium", source_data) severity["low"] = with_severity("low", source_data) return severity def fill_in_severities_set(source_data: DependabotAlerts) -> set[str]: """ 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 # - high # - critical severities = set() for item in source_data: severities.add(item["security_advisory"]["severity"]) return severities def fill_in_days_statistic(source_data: DependabotAlerts) -> dict[str, Any]: """ Compute statistics on elapsed days from creation to fix for alerts in a fixed state. Returns: dict[str, Any]: Dictionary with "days" (list of fractional elapsed days), "avg" (average days to fix), and "median" (median days to fix). With no fixed alerts, "days" is empty and both summary values are zero. """ fixed = filter_by(source_data, "state", "fixed") days = [] for item in fixed: dt1 = parser.isoparse(item["created_at"]) dt2 = parser.isoparse(item["fixed_at"]) d = (dt2 - dt1).total_seconds() / 86400 days.append(d) days_stat: dict[str, Any] = {} days_stat["days"] = days # avoid division by zero if 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]: """ 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]: """ 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) -> dict[str, Any]: """ Compute vulnerability statistics from Dependabot alerts stored in a JSON file. Loads alerts from the specified file and calculates statistics including total count, state breakdown (open/fixed), severity distribution, fix latency metrics, vulnerable package frequencies, and CVE detection timeline. Parameters: dependabot_file (str): Path to the JSON file containing Dependabot alerts. Returns: dict[str, Any]: A dictionary with keys: "count", "state", "severity", "severities", "days", "packages", and "dates", each containing the corresponding computed metrics. Raises: OSError: If the input file cannot be read. ValueError: If JSON decoding, alert validation, or date parsing fails. KeyError: If a required package or timestamp field is missing. TypeError: If timestamp types or timezone awareness are incompatible. """ source_data = load_dependabot_file(dependabot_file) # dictionary holding the whole statistic about vulnerabilities stat: dict[str, Any] = {} stat["count"] = len(source_data) stat["state"] = fill_in_state(source_data) stat["severity"] = fill_in_severity(source_data) stat["severities"] = fill_in_severities_set(source_data) stat["days"] = fill_in_days_statistic(source_data) stat["packages"] = fill_in_vulnerable_packages(source_data) stat["dates"] = fill_in_cve_created_dates(source_data) return stat def save_graph(prefix: str, postfix: str, svg_output: bool, png_output: bool) -> None: """ Save a figure in SVG and/or PNG formats based on output flags. Parameters: prefix (str): Filename prefix postfix (str): Filename suffix appended after the prefix and underscore """ if svg_output: filename = f"{prefix}_{postfix}.svg" plt.savefig(filename, format="svg") if png_output: filename = f"{prefix}_{postfix}.png" plt.savefig(filename, format="png") def generate_overal_state_graph( stat: dict[str, Any], prefix: str, svg_output: bool, png_output: bool ) -> None: """ Generate a bar chart showing the distribution of vulnerability states. Parameters: stat (dict[str, Any]): Statistics dictionary with a "state" key containing alert counts. prefix (str): Filename prefix for the saved graph. svg_output (bool): Whether to save in SVG format. png_output (bool): Whether to save in PNG format. """ _, ax = plt.subplots() data = stat["state"] ax.bar( range(len(data)), list(data.values()), align="center", color=["#c00000", "#00c000"], ) ax.set_ylim(top=400) ax.set_xticks(range(len(data)), list(data.keys())) save_graph(prefix, "state", svg_output, png_output) def generate_severity_graph( stat: dict[str, Any], prefix: str, svg_output: bool, png_output: bool ) -> None: """ Generate a bar chart of vulnerability severity distribution and save it in the specified format. Parameters: stat (dict[str, Any]): Statistics dictionary prefix (str): Prefix for the output file name. svg_output (bool): If true, save the graph as SVG. png_output (bool): If true, save the graph as PNG. """ _, ax = plt.subplots() data = stat["severity"] ax.bar( range(len(data)), list(data.values()), align="center", color=["#c00000", "orange", "#e0e000", "#00c000"], ) ax.set_xticks(range(len(data)), list(data.keys())) save_graph(prefix, "severity", svg_output, png_output) def generate_vuln_for_days_graph( stat: dict[str, Any], prefix: str, svg_output: bool, png_output: bool ) -> None: """ Generate a histogram of the distribution of days required to fix vulnerabilities. Parameters: stat (dict[str, Any]): Statistics dictionary prefix (str): Prefix for the output file name. svg_output (bool): If true, save the graph as SVG. png_output (bool): If true, save the graph as PNG. """ _, ax = plt.subplots() data = stat["days"]["days"] ax.hist(data, bins=30, edgecolor="black") ax.set_ylim(top=150) ax.set_xlabel("Days") ax.set_title("Fixed in day(s)") save_graph(prefix, "days", svg_output, png_output) def generate_vulnerable_packages_graph( stat: dict[str, Any], prefix: str, svg_output: bool, png_output: bool ) -> None: """ Generate a bar chart showing the top 10 packages with the most CVEs. Parameters: stat (dict[str, Any]): Statistics dictionary containing vulnerability data, with "packages" key holding package frequency counts. prefix (str): Prefix for the output file name. svg_output (bool): If true, save the graph as SVG. png_output (bool): If true, save the graph as PNG. """ fig, ax = plt.subplots() data = stat["packages"] names, counts = zip(*data.most_common(10)) ax.bar(names, counts, edgecolor="black") ax.set_ylim(top=100) ax.set_title("CVEs per package") ax.tick_params(axis="x", labelrotation=90) fig.tight_layout() save_graph(prefix, "packages", svg_output, png_output) def generate_new_cve_dates_graph( stat: dict[str, Any], prefix: str, svg_output: bool, png_output: bool ) -> None: """ Create a line chart showing new CVE detection dates over time. Parameters: stat (dict[str, Any]): Statistics dictionary with "dates" key containing a Counter of datetime objects mapped to occurrence counts. prefix (str): Base filename prefix for output files. svg_output (bool): Whether to save the graph as SVG. png_output (bool): Whether to save the graph as PNG. """ _, ax = plt.subplots(figsize=(14, 7)) data = stat["dates"] dates, counts = zip(*sorted(data.items(), key=lambda x: x[0])) ax.plot(dates, counts, label="CVEs per day") ax.set_title("New CVEs timeline") s = pd.Series(counts, index=pd.to_datetime(list(dates))) ma = s.rolling(window=7, min_periods=1, center=True).mean() ax.plot(ma.index, ma.values, label="7-day moving average", linewidth=2) # horizontal line with "total" average avg = s.mean() ax.axhline( avg, color="red", linestyle="--", linewidth=1.5, label=f"Day average ({avg:.2f})", ) ax.legend() plt.setp(ax.get_xticklabels(), rotation=45, ha="right") plt.subplots_adjust(bottom=0.20) save_graph(prefix, "timeline", svg_output, png_output) def generate_graphs( stat: dict[str, Any], prefix: str, svg_output: bool, png_output: bool ) -> None: """ Generate vulnerability visualization graphs. """ generate_overal_state_graph(stat, prefix, svg_output, png_output) generate_severity_graph(stat, prefix, svg_output, png_output) generate_vuln_for_days_graph(stat, prefix, svg_output, png_output) generate_vulnerable_packages_graph(stat, prefix, svg_output, png_output) generate_new_cve_dates_graph(stat, prefix, svg_output, png_output) def generate_page( stat: dict[str, Any], prefix: str, organization: str, repository: str ) -> None: """ Generate HTML page containing visualization graphs. Use the organization and repository in the title and include average and median fix times from ``stat["days"]``. Reference ``{prefix}_*.svg`` charts and ``stylesheet.css`` without generating them. """ filename = f"vulnerabilities_{prefix}.html" output = f"""
„Do You Control Your Dependencies Or Are They Controlling You?“
Average: {stat["days"]["avg"]} days
Median: {stat["days"]["median"]} days