Plain is headed towards 1.0! Subscribe for development updates →

  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        return cls(
 93            url=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 = {
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: requests.Response | None) -> ScanMetadata:
131        """Build ScanMetadata from a requests.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        }