From d3613649322158f620d88a87d2a0784b8d0378f7 Mon Sep 17 00:00:00 2001 From: 7000pctAUTO Date: Mon, 2 Feb 2026 21:31:33 +0000 Subject: [PATCH] Add core CLI and configuration modules --- depaudit/cli.py | 299 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 depaudit/cli.py diff --git a/depaudit/cli.py b/depaudit/cli.py new file mode 100644 index 0000000..4bf968f --- /dev/null +++ b/depaudit/cli.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path +from typing import Any + +import click +import requests + +from depaudit import __version__ +from depaudit.checks.outdated import OutdatedPackage +from depaudit.checks.licenses import LicenseInfo +from depaudit.checks.unused import UnusedDependency +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 import ParsedManifest +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 = f"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()