Plain is headed towards 1.0! Subscribe for development updates →

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