Files
depaudit-cli/depaudit/cli.py

300 lines
8.6 KiB
Python

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()