Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save BrayansStivens/e85f4b10fdfa5864fac5391703430015 to your computer and use it in GitHub Desktop.

Select an option

Save BrayansStivens/e85f4b10fdfa5864fac5391703430015 to your computer and use it in GitHub Desktop.
#!/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())
@BrayansStivens
Copy link
Author

CVE-2025-55182 Scanner

Scans GitHub org/user repos for vulnerable React Server Components dependencies.

Requirements

pip install requests pandas urllib3 python-dotenv rich
npm install -g @cyclonedx/cdxgen

Setup

Create .env file:

GITHUB_TOKEN=ghp_your_token_here

Usage

# Organization
python cve_2025_55182_scanner.py --org MyOrganization

# Personal repos
python cve_2025_55182_scanner.py --user MyUsername

Output

  • vulnerability-report.csv - Full scan results
  • SECURITY-REPORT.md - Summary report
  • sboms/ - Generated SBOMs per repo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment