"""URLScan.io API client for URL analysis.""" import os import time from typing import Dict, Any, Optional from dotenv import load_dotenv import requests # Load environment variables from .env file load_dotenv() class URLScanError(Exception): """Custom exception for URLScan API errors.""" pass class URLScanClient: """Client for interacting with URLScan.io API.""" BASE_URL = "https://urlscan.io/api/v1" def __init__(self, api_key: Optional[str] = None): """ Initialize URLScan client. Args: api_key: URLScan.io API key. If not provided, will try to get from URLSCAN_API_KEY environment variable. Raises: URLScanError: If no API key is provided or found in environment. """ self.api_key = api_key or os.getenv("URLSCAN_API_KEY") if not self.api_key: raise URLScanError( "No API key provided. Set URLSCAN_API_KEY environment variable " "or pass api_key parameter." ) self.session = requests.Session() self.session.headers.update({ "API-Key": self.api_key, "Content-Type": "application/json" }) def submit_url( self, url: str, visibility: str = "public", tags: Optional[list] = None ) -> Dict[str, Any]: """ Submit a URL for scanning. Args: url: The URL to scan visibility: Visibility of the scan ('public', 'unlisted', or 'private') tags: Optional list of tags for categorization Returns: Dictionary containing scan submission response with 'uuid' and 'api' fields Raises: URLScanError: If submission fails """ endpoint = f"{self.BASE_URL}/scan/" payload = { "url": url, "visibility": visibility } if tags: payload["tags"] = tags try: response = self.session.post(endpoint, json=payload) response.raise_for_status() return response.json() except requests.exceptions.HTTPError as e: if response.status_code == 429: raise URLScanError("Rate limit exceeded. Please wait before retrying.") elif response.status_code == 400: raise URLScanError(f"Bad request: {response.text}") else: raise URLScanError(f"HTTP error occurred: {e}") except requests.exceptions.RequestException as e: raise URLScanError(f"Request failed: {e}") def get_result(self, uuid: str) -> Dict[str, Any]: """ Get scan results by UUID. Args: uuid: The scan UUID returned from submit_url Returns: Dictionary containing scan results Raises: URLScanError: If retrieval fails """ endpoint = f"{self.BASE_URL}/result/{uuid}/" try: response = self.session.get(endpoint) response.raise_for_status() return response.json() except requests.exceptions.HTTPError as e: if response.status_code == 404: raise URLScanError( f"Scan not found or not ready yet. UUID: {uuid}" ) else: raise URLScanError(f"HTTP error occurred: {e}") except requests.exceptions.RequestException as e: raise URLScanError(f"Request failed: {e}") def submit_and_wait( self, url: str, visibility: str = "public", tags: Optional[list] = None, max_wait: int = 60, poll_interval: int = 5 ) -> Dict[str, Any]: """ Submit a URL and wait for results. Args: url: The URL to scan visibility: Visibility of the scan tags: Optional list of tags max_wait: Maximum time to wait for results (seconds) poll_interval: Time between polling attempts (seconds) Returns: Dictionary containing scan results Raises: URLScanError: If submission or retrieval fails, or timeout occurs """ # Submit URL submission = self.submit_url(url, visibility, tags) uuid = submission.get("uuid") if not uuid: raise URLScanError("No UUID returned from submission") # Wait for results elapsed = 0 while elapsed < max_wait: try: time.sleep(poll_interval) elapsed += poll_interval result = self.get_result(uuid) return result except URLScanError as e: if "not found or not ready" in str(e): # Scan not ready yet, continue waiting continue else: # Other error, raise it raise raise URLScanError( f"Timeout waiting for scan results. UUID: {uuid}. " f"You can retrieve results later using get_result('{uuid}')" ) def search(self, query: str, size: int = 100) -> Dict[str, Any]: """ Search URLScan.io database. Args: query: Search query (e.g., 'domain:example.com') size: Number of results to return (max 10000) Returns: Dictionary containing search results Raises: URLScanError: If search fails """ endpoint = f"{self.BASE_URL}/search/" params = { "q": query, "size": min(size, 10000) } try: response = self.session.get(endpoint, params=params) response.raise_for_status() return response.json() except requests.exceptions.HTTPError as e: raise URLScanError(f"HTTP error occurred: {e}") except requests.exceptions.RequestException as e: raise URLScanError(f"Request failed: {e}") def get_verdict(self, uuid: str) -> Optional[str]: """ Get the verdict (malicious/safe) for a scan. Args: uuid: The scan UUID Returns: Verdict string ('malicious', 'safe', or None if not available) Raises: URLScanError: If retrieval fails """ result = self.get_result(uuid) # Extract verdict from results verdicts = result.get("verdicts", {}) overall = verdicts.get("overall", {}) if overall.get("malicious", False): return "malicious" elif overall.get("score", 0) == 0: return "safe" else: return None