1from __future__ import annotations
2
3from dataclasses import dataclass, field
4from datetime import UTC, datetime
5from typing import TYPE_CHECKING
6
7if TYPE_CHECKING:
8 import requests
9
10
11@dataclass
12class CookieMetadata:
13 """Metadata for a single cookie."""
14
15 name: str
16 value: str
17 domain: str | None
18 path: str
19 secure: bool
20 httponly: bool
21 samesite: str | None
22 expires: int | None = None
23
24 @classmethod
25 def from_dict(cls, data: dict) -> CookieMetadata:
26 """Reconstruct CookieMetadata from dictionary."""
27 return cls(
28 name=data["name"],
29 value=data["value"],
30 domain=data["domain"],
31 path=data["path"],
32 secure=data["secure"],
33 httponly=data["httponly"],
34 samesite=data["samesite"],
35 expires=data.get("expires"),
36 )
37
38 def to_dict(self) -> dict:
39 """Convert to dictionary for JSON serialization."""
40 result = {
41 "name": self.name,
42 "value": self.value,
43 "domain": self.domain,
44 "path": self.path,
45 "secure": self.secure,
46 "httponly": self.httponly,
47 "samesite": self.samesite,
48 }
49 if self.expires is not None:
50 result["expires"] = self.expires
51 return result
52
53
54@dataclass
55class ResponseMetadata:
56 """Metadata for a single HTTP response."""
57
58 url: str
59 status_code: int
60 headers: dict[str, str]
61 cookies: list[CookieMetadata] = field(default_factory=list)
62
63 @classmethod
64 def from_response(cls, response: requests.Response) -> ResponseMetadata:
65 """Build ResponseMetadata from a requests.Response object."""
66
67 cookies = []
68
69 # Build cookie metadata if present
70 if response.cookies:
71 for cookie in response.cookies:
72 # Extract SameSite attribute (can be in _rest as "SameSite" or "samesite")
73 samesite = None
74 if hasattr(cookie, "_rest") and cookie._rest:
75 for key in cookie._rest:
76 if key.lower() == "samesite":
77 samesite = cookie._rest[key]
78 break
79
80 cookie_metadata = CookieMetadata(
81 name=cookie.name,
82 value=cookie.value,
83 domain=cookie.domain,
84 path=cookie.path,
85 secure=cookie.secure,
86 httponly=hasattr(cookie, "_rest") and "HttpOnly" in cookie._rest,
87 samesite=samesite,
88 expires=cookie.expires if cookie.expires else None,
89 )
90 cookies.append(cookie_metadata)
91
92 # response.url and status_code are always set after a successful request
93 # but types-requests stubs don't guarantee this
94 url = response.url
95 status_code = response.status_code
96 if url is None or status_code is None:
97 raise ValueError("Response missing url or status_code")
98
99 return cls(
100 url=url,
101 status_code=status_code,
102 headers=dict(response.headers),
103 cookies=cookies,
104 )
105
106 @classmethod
107 def from_dict(cls, data: dict) -> ResponseMetadata:
108 """Reconstruct ResponseMetadata from dictionary."""
109 cookies = [CookieMetadata.from_dict(c) for c in data.get("cookies", [])]
110 return cls(
111 url=data["url"],
112 status_code=data["status_code"],
113 headers=data["headers"],
114 cookies=cookies,
115 )
116
117 def to_dict(self) -> dict:
118 """Convert to dictionary for JSON serialization."""
119 result = {
120 "url": self.url,
121 "status_code": self.status_code,
122 "headers": self.headers,
123 }
124 if self.cookies:
125 result["cookies"] = [cookie.to_dict() for cookie in self.cookies]
126 return result
127
128
129@dataclass
130class ScanMetadata:
131 """Metadata for the complete scan, including all responses in the chain."""
132
133 timestamp: str # ISO 8601 format timestamp of when scan was run
134 responses: list[ResponseMetadata] = field(default_factory=list)
135
136 @classmethod
137 def from_response(cls, response: requests.Response | None) -> ScanMetadata:
138 """Build ScanMetadata from a requests.Response object (including redirects)."""
139
140 timestamp = datetime.now(UTC).isoformat()
141
142 if response is None:
143 return cls(responses=[], timestamp=timestamp)
144
145 # Build responses array with all responses in the chain
146 responses = []
147
148 # Add all redirect responses from history
149 for redirect_response in response.history:
150 responses.append(ResponseMetadata.from_response(redirect_response))
151
152 # Add the final response
153 responses.append(ResponseMetadata.from_response(response))
154
155 return cls(timestamp=timestamp, responses=responses)
156
157 @classmethod
158 def from_dict(cls, data: dict) -> ScanMetadata:
159 """Reconstruct ScanMetadata from dictionary."""
160 responses = [ResponseMetadata.from_dict(r) for r in data.get("responses", [])]
161 return cls(
162 timestamp=data["timestamp"],
163 responses=responses,
164 )
165
166 def to_dict(self) -> dict:
167 """Convert to dictionary for JSON serialization."""
168 return {
169 "timestamp": self.timestamp,
170 "responses": [response.to_dict() for response in self.responses],
171 }