Created
December 4, 2025 02:54
-
-
Save BrayansStivens/e85f4b10fdfa5864fac5391703430015 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| CVE-2025-55182 Scanner | |
| Scans GitHub organization repositories for vulnerable React Server Components dependencies | |
| """ | |
| import os | |
| import json | |
| import subprocess | |
| import tempfile | |
| import time | |
| import argparse | |
| from datetime import datetime | |
| from pathlib import Path | |
| from dotenv import load_dotenv | |
| import requests | |
| from requests.adapters import HTTPAdapter | |
| from urllib3.util.retry import Retry | |
| import pandas as pd | |
| from rich.console import Console | |
| from rich.table import Table | |
| from rich.progress import Progress, BarColumn, TextColumn, TimeRemainingColumn | |
| GRAPHQL_URL = "https://api.github.com/graphql" | |
| VULNERABLE_PACKAGES = [ | |
| "react-server-dom-webpack", | |
| "react-server-dom-parcel", | |
| "react-server-dom-turbopack" | |
| ] | |
| VULNERABLE_VERSIONS = {"19.0.0", "19.1.0", "19.1.1", "19.2.0"} | |
| PATCHED_VERSIONS = {"19.0.1", "19.1.2", "19.2.1"} | |
| console = Console() | |
| def create_session() -> requests.Session: | |
| session = requests.Session() | |
| retry_strategy = Retry( | |
| total=5, | |
| backoff_factor=2, | |
| status_forcelist=[429, 500, 502, 503, 504], | |
| ) | |
| adapter = HTTPAdapter(max_retries=retry_strategy) | |
| session.mount("https://", adapter) | |
| session.mount("http://", adapter) | |
| return session | |
| def get_js_repos(session: requests.Session, token: str, owner: str, is_user: bool = False) -> list[dict]: | |
| if is_user: | |
| query = """ | |
| query($login: String!, $cursor: String) { | |
| user(login: $login) { | |
| repositories(first: 100, after: $cursor, ownerAffiliations: OWNER) { | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| nodes { | |
| name | |
| url | |
| isArchived | |
| primaryLanguage { | |
| name | |
| } | |
| defaultBranchRef { | |
| name | |
| } | |
| } | |
| } | |
| } | |
| } | |
| """ | |
| root_key = "user" | |
| else: | |
| query = """ | |
| query($login: String!, $cursor: String) { | |
| organization(login: $login) { | |
| repositories(first: 100, after: $cursor) { | |
| pageInfo { | |
| hasNextPage | |
| endCursor | |
| } | |
| nodes { | |
| name | |
| url | |
| isArchived | |
| primaryLanguage { | |
| name | |
| } | |
| defaultBranchRef { | |
| name | |
| } | |
| } | |
| } | |
| } | |
| } | |
| """ | |
| root_key = "organization" | |
| headers = { | |
| "Authorization": f"Bearer {token}", | |
| "Content-Type": "application/json" | |
| } | |
| repos = [] | |
| cursor = None | |
| page = 1 | |
| console.print(f"[bold blue]Fetching repository list from {'user' if is_user else 'organization'}...[/bold blue]") | |
| while True: | |
| console.print(f" Page {page}...", end=" ") | |
| try: | |
| response = session.post( | |
| GRAPHQL_URL, | |
| json={"query": query, "variables": {"login": owner, "cursor": cursor}}, | |
| headers=headers, | |
| timeout=60 | |
| ) | |
| response.raise_for_status() | |
| result = response.json() | |
| except Exception as e: | |
| console.print(f"[red]Error: {e}[/red]") | |
| break | |
| if "errors" in result: | |
| console.print(f"[red]GraphQL error: {result['errors']}[/red]") | |
| break | |
| repo_data = result["data"][root_key]["repositories"] | |
| count = 0 | |
| for repo in repo_data["nodes"]: | |
| if (repo["primaryLanguage"] and | |
| repo["primaryLanguage"]["name"] in ("JavaScript", "TypeScript") and | |
| not repo["isArchived"]): | |
| repos.append({ | |
| "name": repo["name"], | |
| "url": repo["url"], | |
| "default_branch": repo["defaultBranchRef"]["name"] if repo["defaultBranchRef"] else "main" | |
| }) | |
| count += 1 | |
| console.print(f"[green]{count} JS/TS repos[/green]") | |
| if not repo_data["pageInfo"]["hasNextPage"]: | |
| break | |
| cursor = repo_data["pageInfo"]["endCursor"] | |
| page += 1 | |
| time.sleep(1) | |
| return repos | |
| def clone_and_scan(token: str, owner: str, repo: dict, sbom_dir: Path) -> dict | None: | |
| repo_name = repo["name"] | |
| clone_url = f"https://{token}@github.com/{owner}/{repo_name}.git" | |
| try: | |
| with tempfile.TemporaryDirectory() as temp_dir: | |
| result = subprocess.run( | |
| [ | |
| "git", "clone", | |
| "--depth", "1", | |
| "--filter=blob:none", | |
| "--sparse", | |
| clone_url, | |
| temp_dir | |
| ], | |
| capture_output=True, | |
| timeout=60, | |
| text=True | |
| ) | |
| if result.returncode != 0: | |
| return None | |
| subprocess.run( | |
| ["git", "sparse-checkout", "set", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "package.json"], | |
| cwd=temp_dir, | |
| capture_output=True, | |
| timeout=30 | |
| ) | |
| has_lockfile = any([ | |
| (Path(temp_dir) / "package-lock.json").exists(), | |
| (Path(temp_dir) / "yarn.lock").exists(), | |
| (Path(temp_dir) / "pnpm-lock.yaml").exists() | |
| ]) | |
| if not has_lockfile: | |
| return {"status": "no_lockfile"} | |
| sbom_path = sbom_dir / f"{repo_name}-sbom.json" | |
| result = subprocess.run( | |
| ["cdxgen", "-o", str(sbom_path), temp_dir], | |
| capture_output=True, | |
| timeout=180, | |
| text=True | |
| ) | |
| if result.returncode != 0 or not sbom_path.exists(): | |
| return {"status": "sbom_error"} | |
| findings = analyze_sbom(str(sbom_path)) | |
| return { | |
| "status": "scanned", | |
| "findings": findings | |
| } | |
| except subprocess.TimeoutExpired: | |
| return {"status": "timeout"} | |
| except Exception as e: | |
| return {"status": "error", "error": str(e)} | |
| def analyze_sbom(sbom_path: str) -> list[dict]: | |
| findings = [] | |
| try: | |
| with open(sbom_path) as f: | |
| sbom = json.load(f) | |
| except (json.JSONDecodeError, FileNotFoundError): | |
| return findings | |
| components = sbom.get("components", []) | |
| for component in components: | |
| name = component.get("name", "") | |
| version = component.get("version", "") | |
| if name in VULNERABLE_PACKAGES: | |
| is_vulnerable = version in VULNERABLE_VERSIONS | |
| is_patched = version in PATCHED_VERSIONS | |
| if is_vulnerable: | |
| status = "VULNERABLE" | |
| elif is_patched: | |
| status = "PATCHED" | |
| else: | |
| status = "REVIEW" | |
| findings.append({ | |
| "package": name, | |
| "version": version, | |
| "vulnerable": is_vulnerable, | |
| "patched": is_patched, | |
| "status": status, | |
| "purl": component.get("purl", "") | |
| }) | |
| return findings | |
| def generate_markdown_report(df: pd.DataFrame, output_path: Path, stats: dict, total: int, org: str): | |
| vulnerable_df = df[df["vulnerable"] == True] if not df.empty else pd.DataFrame() | |
| md_content = f"""# Security Report - CVE-2025-55182 | |
| **Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | |
| **Organization:** {org} | |
| **Severity:** CRITICAL (CVSS 10.0) | |
| ## Summary | |
| | Metric | Value | | |
| |--------|-------| | |
| | Total JS/TS repositories | {total} | | |
| | Successfully scanned | {stats['scanned']} | | |
| | No lockfile | {stats['no_lockfile']} | | |
| | With React Server DOM | {stats['with_react_server']} | | |
| | **Vulnerable** | **{len(vulnerable_df)}** | | |
| ## Vulnerability Details | |
| **CVE-2025-55182** - Unauthenticated remote code execution in React Server Components. | |
| ### Affected Packages | |
| - `react-server-dom-webpack` | |
| - `react-server-dom-parcel` | |
| - `react-server-dom-turbopack` | |
| ### Vulnerable Versions | |
| `19.0.0`, `19.1.0`, `19.1.1`, `19.2.0` | |
| ### Patched Versions | |
| `19.0.1`, `19.1.2`, `19.2.1` | |
| ## Vulnerable Repositories | |
| """ | |
| if not vulnerable_df.empty: | |
| md_content += "| Repository | Package | Version | URL |\n" | |
| md_content += "|------------|---------|---------|-----|\n" | |
| for _, row in vulnerable_df.iterrows(): | |
| md_content += f"| {row['repo']} | {row['package']} | {row['version']} | [Link]({row['repo_url']}) |\n" | |
| md_content += """ | |
| ## Remediation | |
| ```bash | |
| npm install react-server-dom-webpack@19.2.1 | |
| npm install react-server-dom-parcel@19.2.1 | |
| npm install react-server-dom-turbopack@19.2.1 | |
| ``` | |
| """ | |
| else: | |
| md_content += "*No vulnerable repositories found*\n" | |
| with open(output_path, "w") as f: | |
| f.write(md_content) | |
| def main(): | |
| load_dotenv() | |
| parser = argparse.ArgumentParser( | |
| description="Scan GitHub organization or user for CVE-2025-55182 vulnerable dependencies" | |
| ) | |
| parser.add_argument("--org", help="GitHub organization name") | |
| parser.add_argument("--user", help="GitHub username (for personal repos)") | |
| parser.add_argument("--token", default=os.getenv("GITHUB_TOKEN", ""), help="GitHub token (or set GITHUB_TOKEN in .env)") | |
| parser.add_argument("--output-dir", default=None, help="Output directory for reports") | |
| args = parser.parse_args() | |
| token = args.token.strip() if args.token else "" | |
| if not token: | |
| console.print("[red]Error: GitHub token required. Use --token or set GITHUB_TOKEN in .env[/red]") | |
| return 1 | |
| if not args.org and not args.user: | |
| console.print("[red]Error: Must specify --org or --user[/red]") | |
| return 1 | |
| if args.org and args.user: | |
| console.print("[red]Error: Use either --org or --user, not both[/red]") | |
| return 1 | |
| is_user = args.user is not None | |
| owner = args.user if is_user else args.org | |
| if args.output_dir: | |
| output_dir = Path(args.output_dir) | |
| else: | |
| output_dir = Path(f"cve-2025-55182-scan-{datetime.now().strftime('%Y%m%d-%H%M%S')}") | |
| output_dir.mkdir(exist_ok=True) | |
| sbom_dir = output_dir / "sboms" | |
| sbom_dir.mkdir(exist_ok=True) | |
| console.print(f"\n[bold cyan]CVE-2025-55182 Scanner[/bold cyan]") | |
| console.print(f"[dim]{'User' if is_user else 'Organization'}: {owner}[/dim]\n") | |
| session = create_session() | |
| repos = get_js_repos(session, token, owner, is_user) | |
| if not repos: | |
| console.print("[red]No repositories found or error occurred[/red]") | |
| return 1 | |
| console.print(f"\n[green]Total: {len(repos)} JS/TS repositories[/green]\n") | |
| all_findings = [] | |
| stats = { | |
| "scanned": 0, | |
| "no_lockfile": 0, | |
| "errors": 0, | |
| "with_react_server": 0 | |
| } | |
| console.print("[bold]Scanning repositories (clone + SBOM)...[/bold]\n") | |
| with Progress( | |
| TextColumn("[progress.description]{task.description}"), | |
| BarColumn(), | |
| TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), | |
| TimeRemainingColumn(), | |
| console=console | |
| ) as progress: | |
| task = progress.add_task("Progress", total=len(repos)) | |
| for repo in repos: | |
| progress.update(task, description=f"[cyan]{repo['name'][:40]}[/cyan]") | |
| result = clone_and_scan(token, owner, repo, sbom_dir) | |
| if result is None: | |
| stats["errors"] += 1 | |
| elif result["status"] == "no_lockfile": | |
| stats["no_lockfile"] += 1 | |
| elif result["status"] in ("sbom_error", "timeout", "error"): | |
| stats["errors"] += 1 | |
| elif result["status"] == "scanned": | |
| stats["scanned"] += 1 | |
| if result["findings"]: | |
| stats["with_react_server"] += 1 | |
| for finding in result["findings"]: | |
| finding["repo"] = repo["name"] | |
| finding["repo_url"] = repo["url"] | |
| all_findings.append(finding) | |
| progress.advance(task) | |
| df = pd.DataFrame(all_findings) | |
| csv_path = output_dir / "vulnerability-report.csv" | |
| if not df.empty: | |
| df.to_csv(csv_path, index=False) | |
| else: | |
| pd.DataFrame(columns=["repo", "repo_url", "package", "version", "vulnerable", "patched", "status", "purl"]).to_csv(csv_path, index=False) | |
| console.print("\n" + "=" * 60) | |
| console.print("[bold cyan]SUMMARY - CVE-2025-55182[/bold cyan]") | |
| console.print("=" * 60) | |
| console.print(f"\n[bold]Scan statistics:[/bold]") | |
| console.print(f" Total JS/TS repos: {len(repos)}") | |
| console.print(f" Successfully scanned: {stats['scanned']}") | |
| console.print(f" No lockfile: {stats['no_lockfile']}") | |
| console.print(f" Errors: {stats['errors']}") | |
| console.print(f" With React Server DOM: {stats['with_react_server']}") | |
| if not df.empty: | |
| vulnerable_df = df[df["vulnerable"] == True] | |
| console.print(f"\n[bold]Vulnerability results:[/bold]") | |
| console.print(f" React Server DOM deps found: {len(df)}") | |
| console.print(f" [bold red]VULNERABLE instances: {len(vulnerable_df)}[/bold red]") | |
| if not vulnerable_df.empty: | |
| console.print("\n[bold red]REPOSITORIES REQUIRING IMMEDIATE ACTION:[/bold red]\n") | |
| table = Table(show_header=True, header_style="bold red") | |
| table.add_column("Repository") | |
| table.add_column("Package") | |
| table.add_column("Version") | |
| table.add_column("Action") | |
| for _, row in vulnerable_df.iterrows(): | |
| table.add_row( | |
| row["repo"], | |
| row["package"], | |
| row["version"], | |
| "Update to 19.0.1/19.1.2/19.2.1" | |
| ) | |
| console.print(table) | |
| else: | |
| console.print("\n[green]No React Server DOM dependencies found[/green]") | |
| console.print("[dim]No repos appear to use React Server Components[/dim]") | |
| md_path = output_dir / "SECURITY-REPORT.md" | |
| generate_markdown_report(df, md_path, stats, len(repos), owner) | |
| console.print(f"\n[dim]CSV report: {csv_path}[/dim]") | |
| console.print(f"[dim]Markdown report: {md_path}[/dim]") | |
| console.print(f"[dim]SBOMs: {sbom_dir}[/dim]") | |
| return 0 | |
| if __name__ == "__main__": | |
| exit(main()) |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
CVE-2025-55182 Scanner
Scans GitHub org/user repos for vulnerable React Server Components dependencies.
Requirements
Setup
Create
.envfile:Usage
Output
vulnerability-report.csv- Full scan resultsSECURITY-REPORT.md- Summary reportsboms/- Generated SBOMs per repo