diff --git a/.gitignore b/.gitignore index 95afcd34..521adcca 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,8 @@ VERCEL_MIGRATION_GUIDE.md node_modules Agents.md report-analyst.code-workspace -.vscode/launch.json \ No newline at end of file +.vscode/launch.json + +# Dependabot +depenabot_alerts.csv +/depenabot_alerts \ No newline at end of file diff --git a/scripts/export_dependabot_alerts.py b/scripts/export_dependabot_alerts.py new file mode 100644 index 00000000..c2ef3110 --- /dev/null +++ b/scripts/export_dependabot_alerts.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +import csv +import datetime +import json +import logging +import subprocess +from pathlib import Path + +logger = logging.getLogger(__name__) + + +def get_repo_info() -> tuple[str, str]: + """Retrieve the current GitHub owner and repository name using gh CLI.""" + result = subprocess.run( + ["gh", "repo", "view", "--json", "owner,name"], + check=True, + capture_output=True, + text=True, + ) + data = json.loads(result.stdout) + owner = data["owner"]["login"] + repo = data["name"] + return owner, repo + + +def run_gh_api(owner: str, repo: str) -> list[dict]: + """Retrieve all Dependabot alerts for a GitHub repository.""" + + # Build the GitHub CLI command for the Dependabot Alerts API + # --paginate requests all result pages instead of only the first page + command = [ + "gh", + "api", + f"repos/{owner}/{repo}/dependabot/alerts", + "--paginate", + ] + + # Execute the GitHub CLI command as a subprocess. + # + # check=True: + # Raises an exception if the command exits with an error. + # capture_output=True: + # Captures stdout and stderr instead of printing them to the terminal. + # text=True: + # Returns the captured output as strings instead of bytes. + result = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ) + + # Convert the JSON response returned by the GitHub API + # into a Python list containing one dictionary per alert. + return json.loads(result.stdout) + + +def main() -> None: + owner, repo = get_repo_info() + date = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M") + output_path = Path(f"../{repo}/data/dependabot_alerts/{date}_dependabot_alerts.csv") + + # Retrieve all Dependabot alerts from the selected repository + alerts = run_gh_api(owner, repo) + + # Transform the nested GitHub API response into flat dictionaries + # that can be written directly as rows in a CSV file. + rows = [] + + for alert in alerts: + # Extract nested objects from the alert. + # Empty dictionaries prevent errors when optional fields are missing. + advisory = alert.get("security_advisory", {}) + vulnerability = alert.get("security_vulnerability", {}) + dependency = alert.get("dependency", {}) + package = dependency.get("package", {}) + + # first_patched_version can be null when no patched version exists. + # Using `or {}` ensures that `.get()` can still be called safely. + patched = vulnerability.get("first_patched_version") or {} + + # Select and flatten the relevant alert properties for the CSV output. + # Missing values are replaced with empty strings. + rows.append( + { + "number": alert.get("number", ""), + "severity": advisory.get("severity", ""), + "package": package.get("name", ""), + "ecosystem": package.get("ecosystem", ""), + "manifest": dependency.get("manifest_path", ""), + "scope": dependency.get("scope", ""), + "state": alert.get("state", ""), + "ghsa_id": advisory.get("ghsa_id", ""), + "cve_id": advisory.get("cve_id", ""), + "summary": advisory.get("summary", ""), + "vulnerable_range": vulnerability.get( + "vulnerable_version_range", + "", + ), + "patched_version": patched.get("identifier", ""), + "created_at": alert.get("created_at", ""), + "url": alert.get("html_url", ""), + } + ) + + # Define the column names and their order in the generated CSV file. + fieldnames = [ + "number", + "severity", + "package", + "ecosystem", + "manifest", + "scope", + "state", + "ghsa_id", + "cve_id", + "summary", + "vulnerable_range", + "patched_version", + "created_at", + "url", + ] + + # Create or overwrite the CSV file. + # + # newline="" prevents additional blank lines on some operating systems. + # UTF-8 ensures that special characters are written correctly. + with output_path.open("w", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=fieldnames) + + # Write the column names as the first CSV row. + writer.writeheader() + + # Write all transformed Dependabot alerts to the CSV file. + writer.writerows(rows) + + # Print a short confirmation including the number of exported alerts. + print(f"Exported {len(rows)} alerts to {output_path}") + + +# Run main() only when this file is executed directly. +# It will not run automatically when the file is imported as a module. +if __name__ == "__main__": + main()