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