from __future__ import annotations import sys import time from pathlib import Path import click import requests from depaudit import __version__ from depaudit.checks.vulnerabilities import Vulnerability from depaudit.checks.outdated import check_outdated from depaudit.checks.licenses import check_license, validate_license_compliance from depaudit.checks.unused import check_unused_dependencies from depaudit.config import config from depaudit.output import AuditResult from depaudit.output.factory import FormatterFactory from depaudit.parsers.factory import ParserFactory @click.group() @click.version_option(version=__version__) @click.option( "--config", type=click.Path(exists=True), help="Path to configuration file", ) @click.option( "--no-color", is_flag=True, default=False, help="Disable colored output", ) @click.option( "--verbose", is_flag=True, default=False, help="Enable verbose output", ) @click.pass_context def main(ctx: click.Context, config: str | None, no_color: bool, verbose: bool) -> None: ctx.ensure_object(dict) if config: from depaudit.config import Config ctx.obj["config"] = Config(Path(config)) else: ctx.obj["config"] = config ctx.obj["no_color"] = no_color ctx.obj["verbose"] = verbose @main.command() @click.argument( "path", type=click.Path(exists=True, file_okay=True, dir_okay=True), default=".", ) @click.option( "--format", type=click.Choice(["table", "json", "quiet"]), default=None, help="Output format", ) @click.option( "--output", type=click.Path(), help="Output file path", ) @click.option( "--no-cache", is_flag=True, default=False, help="Disable caching", ) @click.option( "--severity", type=click.Choice(["critical", "high", "medium", "low", "all"]), default="all", help="Minimum severity level to report", ) @click.option( "--skip-vulnerabilities", is_flag=True, default=False, help="Skip vulnerability scanning", ) @click.option( "--skip-outdated", is_flag=True, default=False, help="Skip outdated package checking", ) @click.option( "--skip-licenses", is_flag=True, default=False, help="Skip license compliance checking", ) @click.option( "--skip-unused", is_flag=True, default=False, help="Skip unused dependency checking", ) @click.pass_context def audit( ctx: click.Context, path: str, format: str, output: str | None, no_cache: bool, severity: str, skip_vulnerabilities: bool, skip_outdated: bool, skip_licenses: bool, skip_unused: bool, ) -> None: start_time = time.time() ctx_obj = ctx.obj if ctx.obj else {} cfg = ctx_obj.get("config") if ctx_obj else None if cfg is None: cfg = config use_color = not ctx_obj.get("no_color", False) and sys.stdout.isatty() verbosity = "debug" if ctx_obj.get("verbose", False) else "info" target_path = Path(path) if target_path.is_file(): paths_to_scan = [target_path] else: paths_to_scan = list(target_path.rglob("*")) paths_to_scan = [p for p in paths_to_scan if p.is_file()] output_format = format or cfg.output_format result = AuditResult() for file_path in paths_to_scan: manifest = ParserFactory.parse(file_path) if manifest is None: continue result.scanned_files.append(str(file_path)) result.scanned_count += 1 if not skip_vulnerabilities and cfg.vulnerabilities_enabled: for dep in manifest.dependencies: vuln = check_vulnerability(dep, severity) if vuln: result.vulnerabilities.append(vuln.to_dict()) if not skip_outdated and cfg.outdated_enabled: for dep in manifest.dependencies: outdated = check_outdated( dep.name, dep.version, dep.language, cfg.network_timeout, ) if outdated: result.outdated.append(outdated.to_dict()) if not skip_licenses and cfg.licenses_enabled: for dep in manifest.dependencies: license_info = check_license(dep.name, dep.license, source=str(file_path)) is_compliant, message = validate_license_compliance( license_info, cfg.license_allowlist, cfg.license_blocklist, ) if not is_compliant: result.license_issues.append({ "package_name": dep.name, "license": license_info.license_type, "status": message, "file": str(file_path), }) if not skip_unused and cfg.unused_enabled: for file_path in paths_to_scan: manifest = ParserFactory.parse(file_path) if manifest: deps = [(d.name, d.version) for d in manifest.dependencies] unused = check_unused_dependencies( deps, file_path.parent, manifest.language, ) for u in unused: result.unused.append(u.to_dict()) result.scan_duration = time.time() - start_time formatter = FormatterFactory.get_formatter( output_format, use_color=use_color, verbosity=verbosity, ) output_text = formatter.format(result) if output: with open(output, "w") as f: f.write(output_text) else: click.echo(output_text) exit_code = calculate_exit_code(result, cfg) sys.exit(exit_code) def check_vulnerability(dep, severity_filter: str) -> Vulnerability | None: try: url = "https://api.osv.dev/v1/query" payload = { "package": {"name": dep.name}, "version": dep.version, } response = requests.post(url, json=payload, timeout=30) if response.status_code == 200: data = response.json() vulnerabilities = data.get("vulns", []) if vulnerabilities: vuln_data = vulnerabilities[0] vuln = Vulnerability.from_osv(vuln_data, dep.name, dep.version) if severity_filter == "all" or severity_order(vuln.severity) >= severity_order(severity_filter): return vuln except Exception: pass return None def severity_order(severity: str) -> int: order = {"critical": 4, "high": 3, "medium": 2, "low": 1, "unknown": 0} return order.get(severity, 0) def calculate_exit_code(result: AuditResult, cfg) -> int: severity_filter = cfg.cicd_fail_on for vuln in result.vulnerabilities: if vuln.get("severity") in severity_filter: return 1 return 0 @main.command() @click.argument("provider", type=click.Choice(["github", "gitlab"])) @click.argument("output_dir", type=click.Path(exists=True), default=".") @click.option("--schedule", help="Cron schedule for scans", default="0 0 * * 0") @click.option("--fail-critical/--no-fail-critical", default=True) @click.option("--fail-high/--no-fail-high", default=True) @click.pass_context def generate_cicd( ctx: click.Context, provider: str, output_dir: str, schedule: str, fail_critical: bool, fail_high: bool, ) -> None: from depaudit.cicd import generate_cicd_config, CICDConfig cfg = CICDConfig( provider=provider, schedule=schedule, fail_on_critical=fail_critical, fail_on_high=fail_high, ) output_path = generate_cicd_config(provider, Path(output_dir), cfg) click.echo(f"Generated CI/CD configuration at: {output_path}") @main.command() @click.argument("file_path", type=click.Path(exists=True)) @click.pass_context def parse(ctx: click.Context, file_path: str) -> None: manifest = ParserFactory.parse(Path(file_path)) if manifest is None: click.echo("No parser found for this file type") return click.echo(f"Language: {manifest.language}") click.echo(f"Project: {manifest.project_name or 'Unknown'}") click.echo(f"Version: {manifest.project_version or 'Unknown'}") click.echo(f"Dependencies: {len(manifest.dependencies)}") for dep in manifest.dependencies: click.echo(f" - {dep.name}@{dep.version}") if __name__ == "__main__": main()