1from __future__ import annotations
2
3from dataclasses import dataclass, field
4from typing import TYPE_CHECKING
5
6if TYPE_CHECKING:
7 from .metadata import ScanMetadata
8
9__all__ = ["CheckResult", "AuditResult", "ScanResult"]
10
11
12@dataclass
13class CheckResult:
14 """Result of a single security check."""
15
16 name: str
17 passed: bool
18 message: str
19
20 @classmethod
21 def from_dict(cls, data: dict) -> CheckResult:
22 """Reconstruct CheckResult from dictionary."""
23 return cls(
24 name=data["name"],
25 passed=data["passed"],
26 message=data["message"],
27 )
28
29 def to_dict(self) -> dict:
30 """Convert to dictionary for JSON serialization."""
31 return {
32 "name": self.name,
33 "passed": self.passed,
34 "message": self.message,
35 }
36
37
38@dataclass
39class AuditResult:
40 """Result of an audit of security checks."""
41
42 name: str
43 detected: bool
44 checks: list[CheckResult] = field(default_factory=list)
45 required: bool = True # Whether this audit is required for all sites
46 disabled: bool = False # True if user disabled this audit via --disable
47 description: str | None = None # Optional description of what this audit checks
48
49 @property
50 def passed(self) -> bool:
51 """Audit passes if detected and all checks pass, or if optional and not detected."""
52 # Disabled audits are excluded from pass/fail logic
53 if self.disabled:
54 return True
55 if not self.detected:
56 # Optional audits pass even when not detected
57 return not self.required
58 return all(check.passed for check in self.checks)
59
60 @classmethod
61 def from_dict(cls, data: dict) -> AuditResult:
62 """Reconstruct AuditResult from dictionary."""
63 checks = [CheckResult.from_dict(c) for c in data.get("checks", [])]
64 return cls(
65 name=data["name"],
66 detected=data["detected"],
67 checks=checks,
68 required=data.get("required", True),
69 disabled=data.get("disabled", False),
70 description=data.get("description"),
71 )
72
73 def to_dict(self) -> dict:
74 """Convert to dictionary for JSON serialization."""
75 result = {
76 "name": self.name,
77 "detected": self.detected,
78 "required": self.required,
79 "passed": self.passed,
80 "checks": [check.to_dict() for check in self.checks],
81 }
82 if self.disabled:
83 result["disabled"] = self.disabled
84 if self.description:
85 result["description"] = self.description
86 return result
87
88
89@dataclass
90class ScanResult:
91 """Complete scan results for a URL."""
92
93 url: str
94 audits: list[AuditResult] = field(default_factory=list)
95 metadata: ScanMetadata | None = None
96
97 @property
98 def passed(self) -> bool:
99 """Scan passes if all audits pass (including required audits being detected)."""
100 if not self.audits:
101 return False
102 return all(audit.passed for audit in self.audits)
103
104 @property
105 def passed_count(self) -> int:
106 """Count of audits that passed (excluding disabled audits)."""
107 return sum(1 for audit in self.audits if audit.passed and not audit.disabled)
108
109 @property
110 def failed_count(self) -> int:
111 """Count of audits that failed (excluding disabled audits)."""
112 return sum(
113 1 for audit in self.audits if not audit.passed and not audit.disabled
114 )
115
116 @property
117 def total_count(self) -> int:
118 """Total count of audits (excluding disabled audits)."""
119 return sum(1 for audit in self.audits if not audit.disabled)
120
121 @classmethod
122 def from_dict(cls, data: dict) -> ScanResult:
123 """Reconstruct ScanResult from dictionary."""
124 from .metadata import ScanMetadata
125
126 audits = [AuditResult.from_dict(a) for a in data.get("audits", [])]
127 metadata = None
128 if "metadata" in data:
129 metadata = ScanMetadata.from_dict(data["metadata"])
130 return cls(
131 url=data["url"],
132 audits=audits,
133 metadata=metadata,
134 )
135
136 def to_dict(self) -> dict:
137 """Convert to dictionary for JSON serialization."""
138 result: dict[str, str | bool | list[dict] | dict] = {
139 "url": self.url,
140 "passed": self.passed,
141 "audits": [audit.to_dict() for audit in self.audits],
142 }
143 if self.metadata:
144 result["metadata"] = self.metadata.to_dict()
145 return result