from __future__ import annotations import argparse import os from pathlib import Path from typing import Any from dotenv import load_dotenv from vuln_scan.core.models import ScanConfig from vuln_scan.core.policy import BlockingPolicy, GracePeriods from vuln_scan.core.registry import EcosystemRegistry from vuln_scan.core.scanner import VulnerabilityScanner from vuln_scan.core.time import Clock from vuln_scan.ecosystems.dotnet import DotNetEcosystemHandler from vuln_scan.ecosystems.go import GoEcosystemHandler from vuln_scan.ecosystems.java import JavaEcosystemHandler from vuln_scan.ecosystems.node import NodeEcosystemHandler from vuln_scan.ecosystems.php import PhpEcosystemHandler from vuln_scan.ecosystems.python import PythonEcosystemHandler from vuln_scan.ecosystems.ruby import RubyEcosystemHandler from vuln_scan.ecosystems.rust import RustEcosystemHandler from vuln_scan.github.advisory_client import GitHubAdvisoryClient from vuln_scan.github.base_client import AuthConfig from vuln_scan.github.publisher import GitHubPRPublisher, NullPublisher, StatusPublisher from vuln_scan.github.repo_alerts_client import GithubRepoAlertsClient from vuln_scan.github.repo_client import GitHubRepoClient from vuln_scan.io.default_branch_scanner import DefaultBranchScanner from vuln_scan.io.workspace_scanner import WorkspaceScanner from vuln_scan.reporting.base import Reporter from vuln_scan.reporting.markdown_reporter import MarkdownReporter from vuln_scan.reporting.rich_reporter import RichReporter from vuln_scan.reporting.table_reporter import PrettyTableReporter from vuln_scan.utils.logging import setup_logging def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="gh-vuln-scan", description="Dependabot alert based vulnerability scanner for PRs" ) p.add_argument("--github-token", default=os.environ.get("GITHUB_TOKEN")) p.add_argument("--owner", default=os.environ.get("GITHUB_REPO_OWNER", "theorchard")) p.add_argument("--repo", default=os.environ.get("GITHUB_REPO")) p.add_argument("--base-path", default=os.environ.get("BASE_PATH")) p.add_argument("--project-dir", default=os.environ.get("PROJECT_DIR")) p.add_argument("--pr-number", type=int, default=_env_int("GITHUB_PR_NUM")) p.add_argument("--commit-sha", default=os.environ.get("COMMIT_SHA")) p.add_argument("--build-url", default=os.environ.get("BUILD_URL")) p.add_argument("--pr-comment-text", default=os.environ.get("GITHUB_PR_COMMENT", "")) p.add_argument( "--skip-phrase", default=os.environ.get("SKIP_PHRASE", "skip vulnerability scan"), ) p.add_argument( "--excluded-path", action="append", default=os.environ.get("EXCLUDED_PATH", "vagrant").split(","), ) # Global concurrency p.add_argument( "--max-workers", type=int, default=_env_int("MAX_WORKERS", 4), help="Maximum concurrent GitHub tasks for advisory fetching and default-branch scanning (default: 4, env: MAX_WORKERS).", ) # Grace periods p.add_argument("--grace-critical-days", type=int, default=_env_int("CRITICAL_ERROR_DAYS", 7)) p.add_argument("--grace-high-days", type=int, default=_env_int("HIGH_ERROR_DAYS", 14)) p.add_argument("--grace-moderate-days", type=int, default=_env_int("MODERATE_ERROR_DAYS", 30)) p.add_argument("--grace-low-days", type=int, default=_env_int("LOW_ERROR_DAYS", 60)) p.add_argument("--grace-non-blocking-days", type=int, default=_env_int("NON_BLOCKING", 90)) p.add_argument( "--ux", choices=["table", "rich", "markdown"], default=None, help="Output format: rich (default local), table, markdown (default with --post-comment)", ) p.add_argument( "--post-comment", action="store_true", help="Post PR comment and commit status to GitHub" ) p.add_argument( "--log-level", choices=["debug", "info", "warning", "error", "critical"], default=os.environ.get("LOG_LEVEL", "info").lower(), help="Set logging level (default: info)", ) p.add_argument("--github-app-id", type=int, default=_env_int("GITHUB_APP_ID")) p.add_argument("--github-private-key", default=os.environ.get("GITHUB_PRIVATE_KEY")) p.add_argument("--github-private-key-path", default=os.environ.get("GITHUB_PRIVATE_KEY_PATH")) p.add_argument("--github-installation-id", type=int, default=_env_int("GITHUB_INSTALLATION_ID")) return p def _build_reporter(*, ux: str | None, post_comment: bool) -> Reporter: """ Select the appropriate reporter based on CLI flags. --post-comment mode: - default: table - allowed: markdown (renders natively in PR comments), table - rejected: rich (not suitable for PR comments) Local mode: - default: rich (best terminal experience) - allowed: rich, table, markdown """ if post_comment: if ux == "rich": raise SystemExit( "Error: --ux rich is not compatible with --post-comment. " "Use --ux table (default) or --ux markdown." ) if ux == "markdown": return MarkdownReporter() return PrettyTableReporter() if ux == "table": return PrettyTableReporter() if ux == "markdown": return MarkdownReporter() return RichReporter() def main() -> None: load_dotenv() args = build_parser().parse_args() if args.max_workers is None or args.max_workers < 1: raise SystemExit("--max-workers / MAX_WORKERS must be a positive integer (>= 1).") _require(args.owner, "--owner / GITHUB_REPO_OWNER") _require(args.repo, "--repo / GITHUB_REPO") _require(args.base_path, "--base-path / BASE_PATH") # Require auth (either PAT or App) has_app = bool( args.github_app_id and (args.github_private_key or args.github_private_key_path) and args.github_installation_id ) has_pat = bool(args.github_token) if not (has_app or has_pat): raise SystemExit( "Missing required auth: provide GITHUB_TOKEN, or GITHUB_APP_ID + (GITHUB_PRIVATE_KEY or GITHUB_PRIVATE_KEY_PATH) + GITHUB_INSTALLATION_ID" ) base_path = Path(args.base_path) setup_logging(level=args.log_level) grace = GracePeriods( critical_days=args.grace_critical_days, high_days=args.grace_high_days, moderate_days=args.grace_moderate_days, low_days=args.grace_low_days, non_blocking_days=args.grace_non_blocking_days, ) clock = Clock() policy = BlockingPolicy(grace=grace, clock=clock) registry = EcosystemRegistry( [ DotNetEcosystemHandler(), GoEcosystemHandler(), JavaEcosystemHandler(), NodeEcosystemHandler(), PhpEcosystemHandler(), PythonEcosystemHandler(), RubyEcosystemHandler(), RustEcosystemHandler(), ] ) reporter = _build_reporter(ux=args.ux, post_comment=args.post_comment) if has_app: private_key = args.github_private_key if not private_key and args.github_private_key_path: try: private_key = Path(args.github_private_key_path).read_text() except OSError as exc: raise SystemExit( f"Failed to read GITHUB_PRIVATE_KEY_PATH={args.github_private_key_path}: {exc}" ) from exc if not private_key: raise SystemExit("GitHub App auth selected but private key is empty.") auth = AuthConfig( app_id=args.github_app_id, private_key=private_key, installation_id=args.github_installation_id, ) else: auth = AuthConfig(token=args.github_token) alerts_client = GithubRepoAlertsClient(auth_config=auth, owner=args.owner, repo=args.repo) advisory_client = GitHubAdvisoryClient(auth_config=auth, max_workers=int(args.max_workers)) dependabot_url = f"https://github.com/{args.owner}/{args.repo}/security/dependabot" if args.post_comment: _require(args.pr_number, "--pr-number / GITHUB_PR_NUM") _require(args.commit_sha, "--commit-sha / COMMIT_SHA") _require(args.build_url, "--build-url / BUILD_URL") publisher: StatusPublisher = GitHubPRPublisher( auth_config=auth, owner=args.owner, repo=args.repo, pr_number=int(args.pr_number), commit_sha=str(args.commit_sha), build_url=str(args.build_url), ) else: publisher = NullPublisher() cfg = ScanConfig( owner=args.owner, repo=args.repo, base_path=base_path, project_dir=args.project_dir, excluded_path_fragments=tuple(args.excluded_path or []), skip_phrase=args.skip_phrase, pr_comment_text=args.pr_comment_text or "", dependabot_url=dependabot_url, ) workspace_scanner = WorkspaceScanner( base_path=cfg.base_path, project_dir=cfg.project_dir, excluded_path_fragments=cfg.excluded_path_fragments, ) repo_client = GitHubRepoClient( auth_config=auth, owner=args.owner, repo=args.repo, ) default_branch_scanner = DefaultBranchScanner( github=repo_client, max_workers=int(args.max_workers), ) scanner = VulnerabilityScanner( workspace_scanner=workspace_scanner, default_branch_scanner=default_branch_scanner, alerts_client=alerts_client, advisory_client=advisory_client, registry=registry, policy=policy, reporter=reporter, publisher=publisher, ) result = scanner.scan(cfg) # Exit code behavior matches legacy script: raise SystemExit(1 if result.any_blocking else 0) def _require(v: Any, name: str) -> None: if v is None or v == "": raise SystemExit(f"Missing required value: {name}") def _env_int(name: str, default: int | None = None) -> int | None: val = os.environ.get(name) if val is None or val == "": return default try: return int(val) except ValueError: return default if __name__ == "__main__": main()