v0.165.0
  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        }