1from dataclasses import dataclass, field
2from datetime import UTC, datetime
3from typing import TYPE_CHECKING
4
5if TYPE_CHECKING:
6 import httpx
7
8
9@dataclass
10class CookieMetadata:
11 """Metadata for a single cookie."""
12
13 name: str
14 value: str | None
15 domain: str | None
16 path: str
17 secure: bool
18 httponly: bool
19 samesite: str | None
20 expires: int | None = None
21
22 @classmethod
23 def from_dict(cls, data: dict) -> CookieMetadata:
24 """Reconstruct CookieMetadata from dictionary."""
25 return cls(
26 name=data["name"],
27 value=data["value"],
28 domain=data["domain"],
29 path=data["path"],
30 secure=data["secure"],
31 httponly=data["httponly"],
32 samesite=data["samesite"],
33 expires=data.get("expires"),
34 )
35
36 def to_dict(self) -> dict:
37 """Convert to dictionary for JSON serialization."""
38 result: dict[str, str | None | bool | int] = {
39 "name": self.name,
40 "value": self.value,
41 "domain": self.domain,
42 "path": self.path,
43 "secure": self.secure,
44 "httponly": self.httponly,
45 "samesite": self.samesite,
46 }
47 if self.expires is not None:
48 result["expires"] = self.expires
49 return result
50
51
52@dataclass
53class ResponseMetadata:
54 """Metadata for a single HTTP response."""
55
56 url: str
57 status_code: int
58 headers: dict[str, str]
59 cookies: list[CookieMetadata] = field(default_factory=list)
60
61 @classmethod
62 def from_response(cls, response: httpx.Response) -> ResponseMetadata:
63 """Build ResponseMetadata from an httpx.Response object."""
64
65 cookies = []
66
67 # httpx exposes parsed cookies as an httpx.Cookies wrapper; the underlying
68 # `.jar` yields http.cookiejar.Cookie objects with the attributes we need.
69 for cookie in response.cookies.jar:
70 # Non-standard attributes (SameSite, HttpOnly) live in the private
71 # `_rest` dict; SameSite casing varies by server, so match case-insensitively.
72 rest: dict[str, str | None] = getattr(cookie, "_rest", {})
73 samesite = None
74 for key, rest_value in rest.items():
75 if key.lower() == "samesite":
76 samesite = rest_value
77 break
78
79 cookies.append(
80 CookieMetadata(
81 name=cookie.name,
82 value=cookie.value,
83 domain=cookie.domain,
84 path=cookie.path,
85 secure=cookie.secure,
86 httponly="HttpOnly" in rest,
87 samesite=samesite,
88 expires=cookie.expires if cookie.expires else None,
89 )
90 )
91
92 return cls(
93 url=str(response.url),
94 status_code=response.status_code,
95 headers=dict(response.headers),
96 cookies=cookies,
97 )
98
99 @classmethod
100 def from_dict(cls, data: dict) -> ResponseMetadata:
101 """Reconstruct ResponseMetadata from dictionary."""
102 cookies = [CookieMetadata.from_dict(c) for c in data.get("cookies", [])]
103 return cls(
104 url=data["url"],
105 status_code=data["status_code"],
106 headers=data["headers"],
107 cookies=cookies,
108 )
109
110 def to_dict(self) -> dict:
111 """Convert to dictionary for JSON serialization."""
112 result: dict[str, str | int | dict[str, str] | list[dict]] = {
113 "url": self.url,
114 "status_code": self.status_code,
115 "headers": self.headers,
116 }
117 if self.cookies:
118 result["cookies"] = [cookie.to_dict() for cookie in self.cookies]
119 return result
120
121
122@dataclass
123class ScanMetadata:
124 """Metadata for the complete scan, including all responses in the chain."""
125
126 timestamp: str # ISO 8601 format timestamp of when scan was run
127 responses: list[ResponseMetadata] = field(default_factory=list)
128
129 @classmethod
130 def from_response(cls, response: httpx.Response | None) -> ScanMetadata:
131 """Build ScanMetadata from an httpx.Response object (including redirects)."""
132
133 timestamp = datetime.now(UTC).isoformat()
134
135 if response is None:
136 return cls(responses=[], timestamp=timestamp)
137
138 # Build responses array with all responses in the chain
139 responses = []
140
141 # Add all redirect responses from history
142 for redirect_response in response.history:
143 responses.append(ResponseMetadata.from_response(redirect_response))
144
145 # Add the final response
146 responses.append(ResponseMetadata.from_response(response))
147
148 return cls(timestamp=timestamp, responses=responses)
149
150 @classmethod
151 def from_dict(cls, data: dict) -> ScanMetadata:
152 """Reconstruct ScanMetadata from dictionary."""
153 responses = [ResponseMetadata.from_dict(r) for r in data.get("responses", [])]
154 return cls(
155 timestamp=data["timestamp"],
156 responses=responses,
157 )
158
159 def to_dict(self) -> dict:
160 """Convert to dictionary for JSON serialization."""
161 return {
162 "timestamp": self.timestamp,
163 "responses": [response.to_dict() for response in self.responses],
164 }