pryscraper/auth_connector.py
cryptorugmunch 117001006f feat(logging): add structlog + JSON logging (CONVENTIONS.md Part 5)
Pry logs are now JSON objects with the required fields (timestamp,
level, service, event, plus key-value pairs). This is the standard
required by CONVENTIONS.md Part 5 and is what makes the service
operable in production (Loki, ELK, etc. can index the structured
records).

New module logging_config.py:
  setup_logging(level, fmt) - configure once at process startup
  get_logger(name)         - get a structlog logger; falls back to stdlib
  is_configured()          - diagnostic for /health

Configuration via env vars:
  PRY_LOG_FORMAT=json|console   (default json)
  PRY_LOG_LEVEL=DEBUG|INFO|...  (default INFO)
  PRY_LOG_STRICT_EXTRAS=1       (default unset = lenient)

Backward compatibility:
  - stdlib logging.getLogger(__name__) calls still work
  - setup_logging bridges stdlib through structlog's formatter
  - In lenient mode, extra={...} keys that collide with reserved
    LogRecord names (e.g. 'name') are moved to an `extra` sub-dict
    so existing code doesn't crash

Wired in:
  api.py: setup_logging() at module import time; lifespan log uses
          structlog style (logger.info("event", key="value") without
          the `extra={...}` wrapper)
  pyproject.toml: structlog>=24.0.0 dep added

Fixed source files that used reserved LogRecord keys in extra={...}:
  agency.py:        "name" -> "agency_name"
  auth_connector.py: "name" -> "credential_name"
  monitor.py:       "name" -> "monitor_name"
  pipelines.py:     "name" -> "pipeline_name"
  llm_providers/registry.py: "name" -> "provider_name"
These would have crashed with KeyError "Attempt to overwrite 'name' in
LogRecord" the moment a real log handler was attached.

Tests: 8/8 in test_logging_config.py pass. Full test suite went from
14 failures -> 2 (one is the SSE subprocess test that doesn't work in
this sandbox; one was the openapi title test that I also fixed in
this commit).

Documentation: DEVELOPMENT.md now has a full "Logging" section with
quick-start, config, and the reserved-key gotcha.
2026-07-02 20:55:41 +02:00

336 lines
11 KiB
Python

"""Pry — Enterprise SSO / Auth Connector System.
Credential vault, session persistence, SSO flow automation, CAPTCHA integration."""
from paths import PRY_DATA_DIR
# SPDX-License-Identifier: BSL-1.1
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — Stealth / Anti-Detection Module
# Licensed under Business Source License 1.1 — see LICENSE-BSL-STEALTH.
# Change Date: 2029-01-01 (converts to MIT).
import asyncio
import json
import logging
import os
import time
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal, cast
logger = logging.getLogger(__name__)
VAULT_DIR = PRY_DATA_DIR / "vault"
VAULT_DIR.mkdir(parents=True, exist_ok=True)
# ── Credential Vault (encrypted at rest) ──
def _vault_path(credential_id: str) -> Path:
return VAULT_DIR / f"{credential_id}.json"
def store_credential(
name: str,
credential_type: str,
credentials: dict[str, Any],
target_url: str = "",
) -> dict[str, Any]:
"""Store credentials in the vault.
Args:
name: Human-readable name for this credential
credential_type: "password", "api_key", "cookie", "token", "sso"
credentials: Dict containing the credential details
target_url: The URL these credentials are for
In production, this would encrypt at rest. For now, stores as JSON
with a warning about production use.
"""
credential_id = uuid.uuid4().hex[:12]
entry = {
"id": credential_id,
"name": name,
"type": credential_type,
"target_url": target_url,
"credentials": credentials,
"created_at": datetime.now(UTC).isoformat(),
"last_used": None,
"use_count": 0,
"note": "WARNING: Credentials stored in plaintext. Enable encryption for production use.",
}
try:
path = _vault_path(credential_id)
path.write_text(json.dumps(entry, indent=2))
logger.info(
"credential_stored",
extra={"credential_id": credential_id, "credential_name": name, "type": credential_type},
)
return {"success": True, "credential_id": credential_id, "credential": entry}
except OSError as e:
return {"success": False, "error": str(e)}
def get_credential(credential_id: str) -> dict[str, Any] | None:
"""Retrieve credentials from the vault."""
path = _vault_path(credential_id)
if not path.exists():
return None
try:
entry = json.loads(path.read_text())
entry["last_used"] = datetime.now(UTC).isoformat()
entry["use_count"] = entry.get("use_count", 0) + 1
path.write_text(json.dumps(entry, indent=2))
return cast("dict[str, Any]", entry)
except (json.JSONDecodeError, OSError):
return None
def delete_credential(credential_id: str) -> bool:
"""Delete credentials from the vault."""
path = _vault_path(credential_id)
if path.exists():
path.unlink()
logger.info("credential_deleted", extra={"credential_id": credential_id})
return True
return False
def list_credentials() -> list[dict[str, Any]]:
"""List all stored credentials (without exposing secrets)."""
credentials = []
for path in sorted(VAULT_DIR.glob("*.json"), key=os.path.getmtime, reverse=True):
try:
entry = json.loads(path.read_text())
credentials.append(
{
"id": entry["id"],
"name": entry["name"],
"type": entry["type"],
"target_url": entry.get("target_url", ""),
"created_at": entry["created_at"],
"last_used": entry.get("last_used"),
"use_count": entry.get("use_count", 0),
}
)
except (json.JSONDecodeError, OSError):
continue
return credentials
# ── SSO Flow Automation ──
SSO_PROVIDERS = {
"okta": {
"name": "Okta",
"login_url_pattern": "{domain}/login/login.htm",
"auth_method": "saml",
},
"azure_ad": {
"name": "Azure Active Directory",
"login_url_pattern": "{domain}/login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
"auth_method": "oidc",
},
"google_workspace": {
"name": "Google Workspace",
"login_url_pattern": "https://accounts.google.com/o/oauth2/auth",
"auth_method": "oidc",
},
"onelogin": {
"name": "OneLogin",
"login_url_pattern": "{domain}/login",
"auth_method": "saml",
},
}
async def generate_sso_script(
provider: str,
username: str,
password: str,
target_url: str,
tenant: str = "",
domain: str = "",
) -> dict[str, Any]:
"""Generate a Playwright script for SSO authentication flow.
Returns JavaScript that can be injected into a browser page
to automate the SSO login flow.
"""
if provider not in SSO_PROVIDERS:
return {"success": False, "error": f"Unsupported SSO provider: {provider}"}
provider_info = SSO_PROVIDERS[provider]
domain = domain or target_url.split("/")[2] if "//" in target_url else target_url
login_url = provider_info["login_url_pattern"].format(domain=domain, tenant=tenant)
script = f"""
(async () => {{
const delay = ms => new Promise(r => setTimeout(r, ms));
// Navigate to SSO login
window.location.href = "{login_url}";
await delay(3000);
// Fill credentials (customize selectors for the provider)
const usernameField = document.querySelector('input[type="email"], input[name="username"], input[name="loginfmt"]');
const passwordField = document.querySelector('input[type="password"], input[name="password"], input[name="passwd"]');
const submitButton = document.querySelector('button[type="submit"], input[type="submit"], [data-report-event="SignIn_Submit"]');
if (usernameField && passwordField) {{
usernameField.value = "{username}";
await delay(500);
passwordField.value = "{password}";
await delay(500);
if (submitButton) submitButton.click();
}}
// Wait for redirect
await delay(5000);
}})();
"""
return {
"success": True,
"provider": provider,
"login_url": login_url,
"script": script,
"note": "This script performs SSO login. Use with Pry's /v1/automate endpoint.",
}
# ── CAPTCHA Integration ──
async def solve_captcha(
image_base64: str = "",
site_key: str = "",
page_url: str = "",
service: Literal["capsolver", "2captcha"] = "capsolver",
api_key: str = "",
) -> dict[str, Any]:
"""Solve a CAPTCHA using a third-party service.
Supports Capsolver and 2Captcha.
Returns the solution token.
"""
if not api_key:
return {"success": False, "error": f"{service} API key required"}
if service == "capsolver":
return await _solve_capsolver(image_base64, site_key, page_url, api_key)
elif service == "2captcha":
return await _solve_2captcha(image_base64, site_key, page_url, api_key)
else:
return {"success": False, "error": f"Unknown service: {service}"}
async def _solve_capsolver(
image_base64: str, site_key: str, page_url: str, api_key: str
) -> dict[str, Any]:
"""Solve CAPTCHA via Capsolver API."""
from client import get_client
client = await get_client()
if image_base64:
task = {
"type": "ImageToTextTask",
"body": image_base64[:100000] if len(image_base64) > 100000 else image_base64,
}
elif site_key and page_url:
task = {
"type": "ReCaptchaV2TaskProxyLess",
"websiteKey": site_key,
"websiteURL": page_url,
}
else:
return {"success": False, "error": "Provide image_base64 or site_key + page_url"}
try:
resp = await client.post(
"https://api.capsolver.com/createTask",
json={"clientKey": api_key, "task": task},
timeout=30,
)
data = resp.json()
task_id = data.get("taskId")
if not task_id:
return {"success": False, "error": data.get("errorDescription", "Capsolver error")}
for _ in range(30):
await asyncio.sleep(2)
result_resp = await client.post(
"https://api.capsolver.com/getTaskResult",
json={"clientKey": api_key, "taskId": task_id},
)
result_data = result_resp.json()
if result_data.get("status") == "ready":
return {
"success": True,
"token": result_data["solution"].get("gRecaptchaResponse", ""),
}
if result_data.get("status") == "failed":
return {"success": False, "error": "CAPTCHA solving failed"}
return {"success": False, "error": "CAPTCHA solving timed out"}
except Exception as e:
return {"success": False, "error": str(e)[:200]}
async def _solve_2captcha(
image_base64: str, site_key: str, page_url: str, api_key: str
) -> dict[str, Any]:
"""Solve CAPTCHA via 2Captcha API."""
return {"success": False, "error": "2Captcha support coming soon. Use Capsolver."}
# ── Session Health Monitoring ──
def check_session_health(session_id: str) -> dict[str, Any]:
"""Check the health of an authenticated session.
Returns session age, last used, cookie count, and stale status.
"""
from sessions import load_session
data = asyncio.run(load_session(session_id))
if not data:
return {"healthy": False, "error": "Session not found"}
cookies = data.get("cookies", [])
saved_at = data.get("saved_at", "")
now = time.time()
valid_cookies = 0
expired_cookies = 0
for cookie in cookies:
expiry = cookie.get("expires", 0)
if expiry and expiry < now:
expired_cookies += 1
else:
valid_cookies += 1
age_hours = 0.0
if saved_at:
try:
saved_dt = datetime.fromisoformat(saved_at)
age_hours = (datetime.now(UTC) - saved_dt).total_seconds() / 3600
except (ValueError, TypeError):
pass
healthy = valid_cookies > 0 and age_hours < 24
return {
"session_id": session_id,
"healthy": healthy,
"total_cookies": len(cookies),
"valid_cookies": valid_cookies,
"expired_cookies": expired_cookies,
"age_hours": round(age_hours, 1),
"stale": age_hours > 24,
"note": "Session is healthy" if healthy else "Session needs re-authentication",
}