Plain is headed towards 1.0! Subscribe for development updates →

  1from __future__ import annotations
  2
  3import click
  4
  5from .metadata import ScanMetadata
  6from .results import CheckResult, ScanResult
  7
  8
  9def format_check_result(check: CheckResult, indent: int = 0) -> list[str]:
 10    """Format a single check result with nested checks."""
 11    prefix = "  " * indent
 12    icon = "✓" if check.passed else "✗"
 13    icon_color = "green" if check.passed else "red"
 14
 15    # Icon is colored, name is bold, message is dim
 16    line = (
 17        f"{prefix}"
 18        + click.style(icon, fg=icon_color)
 19        + " "
 20        + click.style(check.name, bold=True)
 21        + ": "
 22        + click.style(check.message, dim=True)
 23    )
 24    return [line]
 25
 26
 27def format_verbose_metadata(metadata: ScanMetadata | None) -> str:
 28    """Format metadata for verbose output."""
 29    lines = []
 30
 31    # Response chain
 32    if not metadata or not metadata.responses:
 33        return ""
 34
 35    lines.append(click.style("Response Chain:", bold=True))
 36    lines.append("")
 37
 38    # Display each response in the chain
 39    for i, response_data in enumerate(metadata.responses, 1):
 40        is_final = i == len(metadata.responses)
 41        is_redirect = not is_final
 42
 43        # Response header
 44        if is_redirect:
 45            lines.append(
 46                click.style(
 47                    f"Response {i} (Redirect): {response_data.url}{response_data.status_code}",
 48                    bold=True,
 49                )
 50            )
 51        else:
 52            lines.append(
 53                click.style(
 54                    f"Response {i} (Final): {response_data.url}{response_data.status_code}",
 55                    bold=True,
 56                )
 57            )
 58        lines.append("")
 59
 60        # Headers
 61        if response_data.headers:
 62            lines.append("  Headers:")
 63            for header, value in response_data.headers.items():
 64                # Show full header values with bold+dim header name and dim value
 65                header_line = (
 66                    "    "
 67                    + click.style(header, bold=True, dim=True)
 68                    + click.style(": ", dim=True)
 69                    + click.style(value, dim=True)
 70                )
 71                lines.append(header_line)
 72            lines.append("")
 73
 74        # Cookies
 75        if response_data.cookies:
 76            lines.append("  Cookies:")
 77            for cookie in response_data.cookies:
 78                attrs = []
 79                if cookie.secure:
 80                    attrs.append(click.style("Secure", fg="green"))
 81                else:
 82                    attrs.append(click.style("Not Secure", fg="red"))
 83                if cookie.httponly:
 84                    attrs.append(click.style("HttpOnly", fg="green"))
 85                if cookie.samesite:
 86                    attrs.append(click.style(f"SameSite={cookie.samesite}", fg="green"))
 87
 88                lines.append(f"    {cookie.name}: {' · '.join(attrs)}")
 89            lines.append("")
 90
 91    return "\n".join(lines)
 92
 93
 94def format_human_readable(scan_result: ScanResult, verbose: bool = False) -> str:
 95    """Format scan results for human-readable output."""
 96    lines = []
 97
 98    # Verbose metadata first
 99    if verbose and scan_result.metadata:
100        lines.append(format_verbose_metadata(scan_result.metadata))
101
102    # Scan results
103    lines.append(click.style(f"Scan Results for: {scan_result.url}", bold=True))
104    lines.append("")
105
106    if not scan_result.audits:
107        lines.append(click.style("No audits to check (all disabled)", fg="yellow"))
108        lines.append("")
109        return "\n".join(lines)
110
111    for audit in scan_result.audits:
112        # Audit header
113        if audit.detected:
114            icon = "✓" if audit.passed else "✗"
115            icon_color = "green" if audit.passed else "red"
116            # Icon is colored, audit name is bold
117            audit_line = (
118                click.style(icon, fg=icon_color)
119                + " "
120                + click.style(audit.name, bold=True)
121            )
122            # Add "required" badge only for required audits
123            if audit.required:
124                audit_line += " " + click.style("(required)", fg="yellow", dim=True)
125            lines.append(audit_line)
126
127            # Show description in verbose mode
128            if verbose and audit.description:
129                lines.append(
130                    "  " + click.style(audit.description, dim=True, italic=True)
131                )
132
133            # Audit checks
134            for check in audit.checks:
135                lines.extend(format_check_result(check, indent=1))
136        else:
137            # Security feature not detected - check if user disabled or just not found
138            if audit.disabled:
139                # User disabled via --disable flag
140                audit_line = (
141                    click.style("○", fg="bright_black")
142                    + " "
143                    + click.style(audit.name, bold=True)
144                    + " "
145                    + click.style("(disabled)", dim=True)
146                )
147                lines.append(audit_line)
148            elif audit.required:
149                # Required but not detected - show as failed
150                audit_line = (
151                    click.style("✗", fg="red")
152                    + " "
153                    + click.style(audit.name, bold=True)
154                    + " "
155                    + click.style("(required, not detected)", dim=True)
156                )
157                lines.append(audit_line)
158            else:
159                # Not detected optional audit - show without "optional" label
160                audit_line = (
161                    click.style("○", fg="yellow")
162                    + " "
163                    + click.style(audit.name, bold=True)
164                    + " "
165                    + click.style("(not detected)", dim=True)
166                )
167                lines.append(audit_line)
168
169        lines.append("")  # Blank line between audits
170
171    # Overall result - white text on colored background
172    # Count audits (excluding disabled ones)
173    active_audits = [audit for audit in scan_result.audits if not audit.disabled]
174    passed_audits = [audit for audit in active_audits if audit.passed]
175
176    if scan_result.passed:
177        overall = click.style(
178            f" ✔ {len(passed_audits)}/{len(active_audits)} audits passed ",
179            fg="white",
180            bg="green",
181            bold=True,
182        )
183    else:
184        overall = click.style(
185            f" ✗ {len(passed_audits)}/{len(active_audits)} audits passed ",
186            fg="white",
187            bg="red",
188            bold=True,
189        )
190    lines.append(overall)
191
192    return "\n".join(lines)
193
194
195def to_markdown(scan_result: ScanResult, verbose: bool = False) -> str:
196    """Convert scan results to markdown format."""
197    lines = []
198
199    # Header
200    lines.append("# Plain Scan Results\n")
201    lines.append(f"**URL:** {scan_result.url}\n")
202
203    # Overall status
204    # Count audits (excluding disabled ones)
205    active_audits = [audit for audit in scan_result.audits if not audit.disabled]
206    passed_audits = [audit for audit in active_audits if audit.passed]
207
208    if scan_result.passed:
209        lines.append(
210            f"✅ **{len(passed_audits)}/{len(active_audits)} audits passed**\n"
211        )
212    else:
213        lines.append(
214            f"❌ **{len(passed_audits)}/{len(active_audits)} audits passed**\n"
215        )
216
217    # Metadata - Response Chain (only in verbose mode)
218    if verbose and scan_result.metadata and scan_result.metadata.responses:
219        lines.append("## Response Chain\n")
220
221        # Display each response
222        for i, response_data in enumerate(scan_result.metadata.responses, 1):
223            is_final = i == len(scan_result.metadata.responses)
224            is_redirect = not is_final
225
226            # Response header
227            if is_redirect:
228                lines.append(
229                    f"### Response {i} (Redirect)\n\n"
230                    f"- **URL:** {response_data.url}\n"
231                    f"- **Status:** {response_data.status_code}\n"
232                )
233            else:
234                lines.append(
235                    f"### Response {i} (Final)\n\n"
236                    f"- **URL:** {response_data.url}\n"
237                    f"- **Status:** {response_data.status_code}\n"
238                )
239
240            # Headers
241            if response_data.headers:
242                lines.append("\n**Headers:**\n")
243                for header, value in response_data.headers.items():
244                    lines.append(f"- `{header}`: `{value}`")
245
246            # Cookies
247            if response_data.cookies:
248                lines.append("\n**Cookies:**\n")
249                for cookie in response_data.cookies:
250                    attrs = []
251                    if cookie.secure:
252                        attrs.append("Secure")
253                    else:
254                        attrs.append("Not Secure")
255                    if cookie.httponly:
256                        attrs.append("HttpOnly")
257                    if cookie.samesite:
258                        attrs.append(f"SameSite={cookie.samesite}")
259                    lines.append(f"- **{cookie.name}:** {' · '.join(attrs)}")
260
261            lines.append("\n")
262
263    # Audits
264    lines.append("\n## Audits\n")
265    for audit in scan_result.audits:
266        if audit.detected:
267            icon = "✅" if audit.passed else "❌"
268            # Add "required" label only for required audits
269            required_label = " *(required)*" if audit.required else ""
270            lines.append(f"\n### {icon} {audit.name}{required_label}\n")
271
272            # Show description in verbose mode
273            if verbose and audit.description:
274                lines.append(f"*{audit.description}*\n")
275
276            for check in audit.checks:
277                check_icon = "✓" if check.passed else "✗"
278                lines.append(f"- {check_icon} **{check.name}:** {check.message}")
279        else:
280            # Security feature not detected - check if user disabled or just not found
281            if audit.disabled:
282                lines.append(f"\n### ⚪ {audit.name}\n")
283                lines.append("*Disabled*")
284            elif audit.required:
285                lines.append(f"\n### ❌ {audit.name}\n")
286                lines.append("*Required, not detected*")
287            else:
288                lines.append(f"\n### ⚪ {audit.name}\n")
289                lines.append("*Not detected*")
290
291    return "\n".join(lines)