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        }