Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 143 additions & 40 deletions scripts/vulnerability_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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",
Expand All @@ -63,45 +64,45 @@ 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",
default=False,
help="Retrieve issues",
)

parser.add_argument(
cli_parser.add_argument(
"-g",
"--generate-graphs",
action="store_true",
default=False,
help="Generate graphs with vulnerabilities info",
)

parser.add_argument(
cli_parser.add_argument(
"-p",
"--generate-page",
action="store_true",
default=False,
help="Generate page with vulnerabilities info",
)

parser.add_argument(
cli_parser.add_argument(
"-c",
"--comparison",
required=False,
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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
)
Expand All @@ -194,20 +250,38 @@ 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)
return state


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)
Expand All @@ -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
Expand All @@ -230,36 +309,59 @@ 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:
dt1 = parser.isoparse(item["created_at"])
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
Comment on lines +328 to +334

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Inverted division-by-zero guard breaks both branches.

The condition is reversed. When days is empty, if not days: is true and sum(days) / len(days) evaluates 0 / 0, raising ZeroDivisionError — the exact case the comment says it avoids. When days is non-empty, the else branch forces avg/median to 0, discarding the real values. So this function crashes on empty input and returns incorrect statistics for every non-empty input.

🐛 Proposed fix
     # avoid division by zero
-    if not days:
+    if days:
         days_stat["avg"] = sum(days) / len(days)
         days_stat["median"] = median(days)
     else:
         days_stat["avg"] = 0
         days_stat["median"] = 0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 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
# 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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/vulnerability_report.py` around lines 328 - 334, The guard in
vulnerability_report.py is inverted in the days statistics block, so the
empty-input case still divides by zero and the non-empty case returns zeros. Fix
the logic around the days_stat assignment so the sum/len and median calculations
in the relevant branch run only when days has values, and the fallback zero
values are used only when days is empty. Use the days_stat handling near the
existing median(days) call to locate and correct the branch condition.

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
Expand Down Expand Up @@ -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

Expand Down
Loading