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 URL for security 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)