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)