v0.163.0
 1from __future__ import annotations
 2
 3import json
 4import sys
 5
 6import click
 7from plain.cli import register_cli
 8from plain.cli.runtime import without_runtime_setup
 9
10from .output import format_human_readable, to_markdown
11from .scanner import Scanner
12
13
14def normalize_url(url: str) -> str:
15    """Normalize URL by adding https:// scheme if missing."""
16    if not url.startswith(("http://", "https://")):
17        return f"https://{url}"
18    return url
19
20
21@without_runtime_setup
22@register_cli("scan")
23@click.command()
24@click.argument("url")
25@click.option(
26    "--format",
27    type=click.Choice(["cli", "json", "markdown"]),
28    default="cli",
29    help="Output format (default: cli)",
30)
31@click.option(
32    "--verbose",
33    "-v",
34    is_flag=True,
35    help="Show detailed request information and headers",
36)
37@click.option(
38    "--disable",
39    "-d",
40    multiple=True,
41    type=click.Choice(
42        [
43            "csp",
44            "hsts",
45            "redirects",
46            "content-type-options",
47            "frame-options",
48            "referrer-policy",
49            "cookies",
50            "cors",
51            "tls",
52        ],
53        case_sensitive=False,
54    ),
55    help="Disable specific security audits (can be used multiple times)",
56)
57def cli(
58    url: str,
59    format: str,
60    verbose: bool,
61    disable: tuple[str, ...],
62) -> None:
63    """Scan URL for security issues"""
64
65    # Normalize URL (add https:// if no scheme provided)
66    url = normalize_url(url)
67
68    # Build list of disabled audits (using slugs)
69    disabled = {slug.lower() for slug in disable}
70
71    # Create scanner and run checks
72    scanner = Scanner(url, disabled_audits=disabled)
73
74    # Run scan
75    try:
76        result = scanner.scan()
77    except Exception as e:
78        click.secho(f"Error scanning {url}: {e}", fg="red", err=True)
79        sys.exit(1)
80
81    # Output results
82    if format == "json":
83        click.echo(json.dumps(result.to_dict(), indent=2))
84    elif format == "markdown":
85        click.echo(to_markdown(result, verbose=verbose))
86    elif format == "cli":
87        click.echo(format_human_readable(result, verbose=verbose))
88
89    # Exit with error code if scan failed (but not if all audits were ignored)
90    if not result.passed and result.audits:
91        sys.exit(1)