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)