diff --git a/api/agent/__init__.py b/api/agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/agent/agent.py b/api/agent/agent.py new file mode 100644 index 00000000..8c5381e0 --- /dev/null +++ b/api/agent/agent.py @@ -0,0 +1,195 @@ +"""ReAct agent loop for the CSPM AI assistant. + +Takes a natural-language question, iteratively calls CSPM tool functions, +and returns a grounded answer. No Flask imports; this module must be +importable outside a request context. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from api.agent import tools as _tools +from api.services.ai_guard import clean_text, _QUESTION_MAX +from api.services.ai_provider import get_completion + +logger = logging.getLogger(__name__) + +_SYSTEM_PROMPT = """You are a CSPM (Cloud Security Posture Management) security assistant for Azure. + +RULES: +- Answer ONLY from evidence returned by the tools below. + Never invent findings, scores, compliance percentages, or resource names. +- If the tools return no relevant data, say so honestly. + +AVAILABLE TOOLS: +1. scan_subscription(subscription_id: str) -> {"scan_id": str, "status": str} + Queue a new scan for the subscription. + +2. get_findings(subscription_id: str, severity: str|null, state: str|null) -> list + Return findings filtered by optional severity (CRITICAL/HIGH/MEDIUM/LOW) and state. + +3. get_compliance_posture(framework: str, subscription_id: str) -> dict + Return compliance posture for a framework. Valid frameworks: "mcsb", "cis", "iso27001". + +4. get_attack_paths(scan_id: str) -> list + Return attack paths for a completed scan (stub, returns []). + +5. explain_finding(finding_id: int) -> dict|null + Return full detail for a finding including description and remediation. + +6. suggest_remediation(finding_id: int) -> dict|null + Return step-by-step remediation for a finding. + +RESPONSE FORMAT: +Respond with ONLY a JSON object. Two forms are allowed: + +To call a tool: +{"tool": "", "args": {}} + +To give a final answer when you have enough evidence (or no tool can help further): +{"answer": "", "confidence": "HIGH"|"LOW"} + +Use "HIGH" confidence only when the tools returned concrete evidence for your answer. +Use "LOW" confidence when the evidence is sparse or a tool returned nothing. +Do not include any text outside the JSON object. +""" + + +def _build_prompt(messages: list[str]) -> str: + """Concatenate all messages into a single prompt string.""" + return "\n\n".join(messages) + + +def _dispatch_tool( + tool_name: str, + args: dict, + db_conn: Any, + subscription_id: str, + tenant_id: str, +) -> Any: + """Call the named tool. Returns the tool result or an error dict. + + Tenant isolation: subscription_id and tenant_id always come from the + caller, never from the LLM-supplied args. + """ + try: + if tool_name == "scan_subscription": + return _tools.scan_subscription(db_conn, subscription_id, tenant_id) + + if tool_name == "get_findings": + severity = args.get("severity") + state = args.get("state") + return _tools.get_findings(db_conn, subscription_id, tenant_id, severity=severity, state=state) + + if tool_name == "get_compliance_posture": + framework = args.get("framework", "mcsb") + return _tools.get_compliance_posture(db_conn, framework, subscription_id, tenant_id) + + if tool_name == "get_attack_paths": + scan_id = str(args.get("scan_id", "")) + return _tools.get_attack_paths(db_conn, scan_id, tenant_id) + + if tool_name == "explain_finding": + finding_id = args.get("finding_id") + return _tools.explain_finding(db_conn, finding_id, tenant_id) + + if tool_name == "suggest_remediation": + finding_id = args.get("finding_id") + return _tools.suggest_remediation(db_conn, finding_id, tenant_id) + + return {"error": "unknown tool"} + + except Exception as exc: # noqa: BLE001 + logger.warning("Tool %r raised an exception: %s", tool_name, exc) + return {"error": str(exc)} + + +def run_agent( + question: str, + subscription_id: str, + tenant_id: str, + db_conn: Any, + provider: str, + api_key: str, + model: str = None, + max_iterations: int = 8, +) -> dict: + """Run the ReAct agent loop and return a grounded answer. + + Returns: + { + "answer": str, + "tools_used": list[str], + "iterations": int, + "confidence": "HIGH" | "LOW", + } + + Raises: + ValueError: if question is empty after cleaning + """ + cleaned_question = clean_text(question, _QUESTION_MAX) + if not cleaned_question: + raise ValueError("question is required") + + system_message = _SYSTEM_PROMPT.strip() + user_message = f"User question: {cleaned_question}" + + messages: list[str] = [system_message, user_message] + tools_used: list[str] = [] + + for i in range(max_iterations): + logger.debug("Agent iteration %d/%d", i + 1, max_iterations) + + raw = get_completion(provider, api_key, _build_prompt(messages), model) + + # Attempt to parse as JSON + try: + parsed = json.loads(raw.strip()) + except (json.JSONDecodeError, AttributeError): + logger.debug("Iteration %d: unparseable LLM response, treating as final answer", i + 1) + return { + "answer": (raw or "")[:2000], + "tools_used": tools_used, + "iterations": i + 1, + "confidence": "LOW", + } + + if "answer" in parsed: + logger.debug("Iteration %d: received final answer", i + 1) + return { + "answer": parsed["answer"], + "tools_used": tools_used, + "iterations": i + 1, + "confidence": parsed.get("confidence", "LOW"), + } + + if "tool" in parsed: + tool_name = parsed["tool"] + tool_args = parsed.get("args", {}) + logger.debug("Iteration %d: calling tool %r with args %r", i + 1, tool_name, tool_args) + + result = _dispatch_tool(tool_name, tool_args, db_conn, subscription_id, tenant_id) + tools_used.append(tool_name) + messages.append(f"Tool result: {json.dumps(result)}") + continue + + # Parsed JSON but neither "answer" nor "tool" key present + logger.debug("Iteration %d: parsed JSON has no recognised key, treating as final answer", i + 1) + return { + "answer": raw[:2000], + "tools_used": tools_used, + "iterations": i + 1, + "confidence": "LOW", + } + + # Exhausted max iterations without a final answer + logger.warning("Agent hit max_iterations=%d without a final answer", max_iterations) + return { + "answer": "I was unable to gather enough evidence to answer confidently.", + "tools_used": tools_used, + "iterations": max_iterations, + "confidence": "LOW", + } diff --git a/api/agent/tools.py b/api/agent/tools.py new file mode 100644 index 00000000..93bd1ea1 --- /dev/null +++ b/api/agent/tools.py @@ -0,0 +1,232 @@ +"""CSPM agent tool functions. + +Each function takes a psycopg2 connection as its first argument so callers +can inject a real or fake connection. Tenant isolation is enforced through +explicit tenant_id parameters — never trust user input for this. + +No Flask imports: this module must be importable outside a Flask request +context. +""" + +import logging + +logger = logging.getLogger(__name__) + +_VALID_FRAMEWORKS = {"mcsb", "cis", "iso27001"} + + +def scan_subscription(db_conn, subscription_id: str, tenant_id: str) -> dict: + """Queue a scan for the subscription and return {"scan_id": str, "status": str}. + + Inserts a row into the scans table with status='PENDING' and returns the + generated scan_id. The existing worker picks it up; this function only + queues it. + """ + sql = ( + "INSERT INTO scans (subscription_id, tenant_id, status, created_at)" + " VALUES (%s, %s, 'PENDING', NOW()) RETURNING scan_id" + ) + try: + with db_conn.cursor() as cur: + cur.execute(sql, (subscription_id, tenant_id)) + row = cur.fetchone() + db_conn.commit() + if row is None: + raise RuntimeError("INSERT INTO scans did not return a scan_id") + return {"scan_id": str(row[0]), "status": "PENDING"} + except RuntimeError: + raise + except Exception: + try: + db_conn.rollback() + except Exception: + pass + logger.exception( + "scan_subscription failed for subscription_id=%s tenant_id=%s", + subscription_id, + tenant_id, + ) + raise RuntimeError("Failed to queue scan") + + +def get_findings( + db_conn, + subscription_id: str, + tenant_id: str, + severity: str = None, + state: str = None, +) -> list: + """Return findings for the subscription, filtered by severity and/or state. + + Returns a list of dicts with keys: + finding_id, rule_id, resource_id, severity, state + """ + params = [tenant_id, subscription_id] + where_clauses = [ + "fl.tenant_id = %s", + "sro.subscription_id = %s", + ] + if severity is not None: + where_clauses.append("UPPER(sro.severity) = UPPER(%s)") + params.append(severity) + if state is not None: + where_clauses.append("UPPER(fl.state) = UPPER(%s)") + params.append(state) + + sql = ( + "SELECT fl.finding_id, ff.rule_id, ff.resource_id, sro.severity, fl.state" + " FROM finding_lifecycles fl" + " JOIN finding_fingerprints ff ON ff.fingerprint_id = fl.fingerprint_id" + " JOIN scan_rule_outcomes sro ON sro.outcome_id = fl.outcome_id" + " WHERE " + " AND ".join(where_clauses) + " LIMIT 200" # nosec B608 + ) + try: + with db_conn.cursor() as cur: + cur.execute(sql, params) + rows = cur.fetchall() + return [ + { + "finding_id": row[0], + "rule_id": row[1], + "resource_id": row[2], + "severity": row[3], + "state": row[4], + } + for row in rows + ] + except Exception: + try: + db_conn.rollback() + except Exception: + pass + logger.exception( + "get_findings failed for subscription_id=%s tenant_id=%s", + subscription_id, + tenant_id, + ) + return [] + + +def get_compliance_posture(db_conn, framework: str, subscription_id: str, tenant_id: str) -> dict: + """Return compliance posture for a framework. + + Valid frameworks: "mcsb", "cis", "iso27001" + Raises ValueError for unknown framework. + + If no data exists yet returns zeroed-out structure. + """ + if framework.lower() not in _VALID_FRAMEWORKS: + raise ValueError(f"Unknown compliance framework: {framework!r}. Valid options: {sorted(_VALID_FRAMEWORKS)}") + + sql = ( + "SELECT score, pass_count, fail_count, gaps" + " FROM compliance_scores" + " WHERE LOWER(framework) = LOWER(%s)" + " AND subscription_id = %s" + " AND tenant_id = %s" + " LIMIT 1" + ) + empty = { + "framework": framework, + "score": 0.0, + "pass_count": 0, + "fail_count": 0, + "gaps": [], + } + try: + with db_conn.cursor() as cur: + cur.execute(sql, (framework, subscription_id, tenant_id)) + row = cur.fetchone() + if row is None: + return empty + return { + "framework": framework, + "score": float(row[0]), + "pass_count": int(row[1]), + "fail_count": int(row[2]), + "gaps": row[3] if row[3] is not None else [], + } + except Exception: + try: + db_conn.rollback() + except Exception: + pass + logger.exception( + "get_compliance_posture failed for framework=%s subscription_id=%s tenant_id=%s", + framework, + subscription_id, + tenant_id, + ) + return empty + + +def get_attack_paths(db_conn, scan_id: str, tenant_id: str) -> list: + """Return BFS attack paths for a scan. + + STUB: returns empty list until PRs #352-354 merge. + """ + logger.warning( + "get_attack_paths called for scan_id=%s but attack paths are not yet available (pending PRs #352-354)", + scan_id, + ) + return [] + + +def explain_finding(db_conn, finding_id: int, tenant_id: str) -> dict | None: + """Return full detail for a finding including playbook. + + Returns None if the finding does not exist or belongs to a different tenant. + """ + sql = ( + "SELECT fl.finding_id, ff.rule_id, ff.resource_id, sro.severity," + " sro.description, sro.remediation, fl.state" + " FROM finding_lifecycles fl" + " JOIN finding_fingerprints ff ON ff.fingerprint_id = fl.fingerprint_id" + " JOIN scan_rule_outcomes sro ON sro.outcome_id = fl.outcome_id" + " WHERE fl.finding_id = %s" + " AND fl.tenant_id = %s" + " LIMIT 1" + ) + try: + with db_conn.cursor() as cur: + cur.execute(sql, (finding_id, tenant_id)) + row = cur.fetchone() + if row is None: + return None + return { + "finding_id": row[0], + "rule_id": row[1], + "resource_id": row[2], + "severity": row[3], + "description": row[4], + "remediation": row[5], + "state": row[6], + } + except Exception: + try: + db_conn.rollback() + except Exception: + pass + logger.exception( + "explain_finding failed for finding_id=%s tenant_id=%s", + finding_id, + tenant_id, + ) + return None + + +def suggest_remediation(db_conn, finding_id: int, tenant_id: str) -> dict | None: + """Return step-by-step remediation for a finding. + + Reuses explain_finding internally. Returns None if finding not found or + belongs to a different tenant. + """ + detail = explain_finding(db_conn, finding_id, tenant_id) + if detail is None: + return None + return { + "finding_id": detail["finding_id"], + "rule_id": detail["rule_id"], + "steps": detail["remediation"], + "resource_id": detail["resource_id"], + } diff --git a/api/app.py b/api/app.py index 9f8465dd..5a41065d 100644 --- a/api/app.py +++ b/api/app.py @@ -48,6 +48,9 @@ _KNOWN_ROLES = KNOWN_ROLES _WRITE_ROLES = WRITE_ROLES +# POST paths that read-only roles (viewer) may also call +_READ_ACCESSIBLE_POSTS = frozenset({"/api/agent/query"}) + # Generous enough for legitimate manual or automated readiness checks from # one source, but bounded well under the default pool size # (DB_POOL_MAX_CONN=10) so a single caller can never claim more than half @@ -241,7 +244,11 @@ def verify_jwt() -> None: g.user = principal role = principal["role"] - if request.method not in ("GET", "HEAD") and role not in _WRITE_ROLES: + if ( + request.method not in ("GET", "HEAD") + and role not in _WRITE_ROLES + and request.path not in _READ_ACCESSIBLE_POSTS + ): return jsonify( { "error": "This token's role is not authorized for write operations", @@ -254,6 +261,7 @@ def verify_jwt() -> None: # ------------------------------------------------------------------ # # Blueprints # # ------------------------------------------------------------------ # + from api.routes.agent import agent_bp from api.routes.ai import ai_bp from api.routes.assurance import assurance_bp from api.routes.cbom import cbom_bp @@ -265,6 +273,7 @@ def verify_jwt() -> None: from api.routes.scans import scans_bp from api.routes.score import score_bp + app.register_blueprint(agent_bp) app.register_blueprint(ai_bp) app.register_blueprint(assurance_bp) app.register_blueprint(cbom_bp) diff --git a/api/routes/agent.py b/api/routes/agent.py new file mode 100644 index 00000000..546c1668 --- /dev/null +++ b/api/routes/agent.py @@ -0,0 +1,82 @@ +"""Agent route: POST /api/agent/query — natural-language CSPM Q&A.""" + +import logging +import os + +from flask import Blueprint, g, jsonify, request + +from api.agent.agent import run_agent +from api.models.finding import DatabaseManager +from api.rate_limit import rate_limit +from api.routes.scans import _subscription_is_authorized + +agent_bp = Blueprint("agent", __name__) +logger = logging.getLogger(__name__) + + +def _get_db() -> DatabaseManager: + if "db" not in g: + g.db = DatabaseManager(os.environ["DATABASE_URL"]) + g.db.connect() + return g.db + + +@agent_bp.post("/api/agent/query") +@rate_limit(max_requests=20, window_seconds=60) +def agent_query(): + """Answer a natural-language CSPM question using the ReAct agent loop. + + Request body: + question (str, required) -- the user's question + subscription_id (str, optional) -- falls back to AZURE_SUBSCRIPTION_ID env var + framework (str, optional) -- compliance framework context + + Returns 200 with answer, tools_used, iterations, confidence on success. + """ + body = request.get_json(silent=True) or {} + + question = body.get("question", "") + if not isinstance(question, str) or not question.strip(): + return jsonify({"error": "question is required"}), 400 + + provider = os.environ.get("AI_PROVIDER", "anthropic") + api_key = os.environ.get("AI_API_KEY", "") + model = os.environ.get("AI_MODEL") # None means provider uses its default + + if not api_key: + return jsonify({"error": "AI provider not configured"}), 503 + + tenant_id = g.user.get("tenant") or "" + if not tenant_id: + return jsonify({"error": "tenant context not available; configure OIDC authentication"}), 403 + + subscription_id = body.get("subscription_id") or os.environ.get("AZURE_SUBSCRIPTION_ID", "") + if not subscription_id: + return jsonify({"error": "subscription_id is required"}), 400 + + if not _subscription_is_authorized(subscription_id): + return jsonify({"error": "subscription_id is not authorized for this deployment"}), 403 + + try: + db = _get_db() + result = run_agent( + question=question, + subscription_id=subscription_id, + tenant_id=tenant_id, + db_conn=db.conn, + provider=provider, + api_key=api_key, + model=model, + ) + except Exception: + logger.exception("Unexpected error in agent_query") + return jsonify({"error": "internal server error"}), 500 + + return jsonify( + { + "answer": result["answer"], + "tools_used": result["tools_used"], + "iterations": result["iterations"], + "confidence": result["confidence"], + } + ), 200 diff --git a/cli.py b/cli.py new file mode 100644 index 00000000..fee6223d --- /dev/null +++ b/cli.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""OpenShield CLI. + +Usage: + openshield ask "What are my critical findings?" --subscription sub-001 + openshield ask "What is my MCSB compliance score?" -s sub-001 --framework mcsb +""" + +from __future__ import annotations + +import os +import sys +from typing import Optional + +import requests + + +def cmd_ask( + question: str, + subscription_id: str, + token: str, + url: str, + framework: Optional[str] = None, +) -> int: + """Call POST /api/agent/query and print the result.""" + body: dict = {"question": question, "subscription_id": subscription_id} + if framework: + body["framework"] = framework + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + try: + resp = requests.post(f"{url}/api/agent/query", json=body, headers=headers, timeout=120) + except requests.exceptions.RequestException as exc: + print(f"error: cannot connect to {url} ({exc})", file=sys.stderr) + return 1 + + if resp.status_code == 200: + data = resp.json() + print(data.get("answer", "")) + tools = " | ".join(data.get("tools_used") or []) or "-" + confidence = data.get("confidence", "-") + iterations = data.get("iterations", "-") + print(f"\n[tools used: {tools} | confidence: {confidence} | iterations: {iterations}]") + return 0 + + # 4xx / 5xx + try: + error_msg = resp.json().get("error", resp.text) + except Exception: + error_msg = resp.text + print(f"error: {error_msg}", file=sys.stderr) + return 1 + + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser(prog="openshield", description="OpenShield CLI") + subparsers = parser.add_subparsers(dest="command") + + ask_parser = subparsers.add_parser("ask", help="Ask the CSPM AI agent a question") + ask_parser.add_argument("question", help="Question to ask the agent") + ask_parser.add_argument( + "--subscription", + "-s", + dest="subscription", + default=None, + help="Azure subscription ID (or set AZURE_SUBSCRIPTION_ID)", + ) + ask_parser.add_argument( + "--framework", + "-f", + dest="framework", + default=None, + help="Compliance framework (optional)", + ) + ask_parser.add_argument( + "--url", + dest="url", + default=None, + help="API base URL (default: OPENSHIELD_API_URL or http://localhost:5000)", + ) + ask_parser.add_argument( + "--token", + dest="token", + default=None, + help="Bearer token (or set OPENSHIELD_TOKEN)", + ) + + args = parser.parse_args(argv) + + if args.command is None: + parser.print_help() + return 0 + + if args.command == "ask": + subscription_id = args.subscription or os.environ.get("AZURE_SUBSCRIPTION_ID") + if not subscription_id: + print( + "error: --subscription is required (or set AZURE_SUBSCRIPTION_ID)", + file=sys.stderr, + ) + return 1 + + token = args.token or os.environ.get("OPENSHIELD_TOKEN") + if not token: + print( + "error: --token is required (or set OPENSHIELD_TOKEN)", + file=sys.stderr, + ) + return 1 + + url = args.url or os.environ.get("OPENSHIELD_API_URL", "http://localhost:5000") + + return cmd_ask( + question=args.question, + subscription_id=subscription_id, + token=token, + url=url, + framework=args.framework, + ) + + parser.print_help() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 00000000..f67ec509 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,76 @@ +# OpenShield MCP Server + +This server exposes the 6 OpenShield CSPM tools to any MCP-compatible host +(Claude Desktop, GitHub Copilot, or any other MCP client) over stdio. + +## What it does + +The server makes the following tools available to connected LLM hosts: + +| Tool | Description | +|------|-------------| +| `scan_subscription` | Trigger an Azure subscription scan and return the scan_id | +| `get_findings` | Return security findings, optionally filtered by severity or state | +| `get_compliance_posture` | Return compliance score and gaps for mcsb, cis, or iso27001 | +| `get_attack_paths` | Return BFS attack paths for a completed scan | +| `explain_finding` | Return full detail and risk context for a finding | +| `suggest_remediation` | Return step-by-step remediation guidance for a finding | + +Tenant isolation is enforced server-side via `OPENSHIELD_TENANT_ID`. The LLM +caller never supplies or influences the tenant identifier. + +## Prerequisites + +- Python 3.11+ +- `pip install mcp psycopg2-binary` +- A running PostgreSQL database reachable via `DATABASE_URL` + +## Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `DATABASE_URL` | Yes | PostgreSQL connection string, e.g. `postgresql://user:pass@host/db` | +| `OPENSHIELD_TENANT_ID` | Yes | Tenant identifier used for row-level isolation | + +## Adding to Claude Desktop + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json` +(macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): + +```json +{ + "mcpServers": { + "openshield-cspm": { + "command": "python", + "args": ["-m", "mcp.server"], + "cwd": "/path/to/openshield", + "env": { + "DATABASE_URL": "postgresql://user:pass@localhost/openshield", + "OPENSHIELD_TENANT_ID": "your-tenant-id" + } + } + } +} +``` + +Restart Claude Desktop after saving. The six CSPM tools will appear in the +tool picker. + +## Running standalone + +```bash +DATABASE_URL=postgresql://user:pass@localhost/openshield \ +OPENSHIELD_TENANT_ID=my-tenant \ +python -m mcp.server +``` + +The server speaks JSON-RPC over stdin/stdout and is designed to be launched +by an MCP host process, not run interactively. + +## Security notes + +- `OPENSHIELD_TENANT_ID` is always read from the environment. The LLM cannot + override it through tool arguments. +- Each tool call opens and closes its own database connection (stateless per + call). No connection is held between calls. +- No Flask or HTTP server code is present in this module. diff --git a/mcp/__init__.py b/mcp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/server.py b/mcp/server.py new file mode 100644 index 00000000..6d9b95fe --- /dev/null +++ b/mcp/server.py @@ -0,0 +1,236 @@ +"""OpenShield MCP server: exposes 6 CSPM tools to Claude Desktop and Copilot. + +Run as a standalone script: + python -m mcp.server + +The server communicates over stdio using the Model Context Protocol. + +Required environment variables: + DATABASE_URL — PostgreSQL connection string + OPENSHIELD_TENANT_ID — Tenant identifier (never supplied by the LLM caller) +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import sys + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Tool definitions (plain dicts — loaded at import time, no mcp dependency). +# The TOOLS list is intentionally a list of dicts here so that the module can +# be imported for testing without the installed mcp package. The _make_tools() +# helper converts them to mcp.types.Tool objects at runtime. +# --------------------------------------------------------------------------- + +_TOOL_SPECS: list[dict] = [ + { + "name": "scan_subscription", + "description": "Trigger an Azure subscription scan and return the scan_id.", + "inputSchema": { + "type": "object", + "properties": { + "subscription_id": { + "type": "string", + "description": "Azure subscription UUID", + }, + }, + "required": ["subscription_id"], + }, + }, + { + "name": "get_findings", + "description": ("Return security findings for a subscription, optionally filtered by severity or state."), + "inputSchema": { + "type": "object", + "properties": { + "subscription_id": {"type": "string"}, + "severity": { + "type": "string", + "enum": ["CRITICAL", "HIGH", "MEDIUM", "LOW"], + "description": "Optional severity filter", + }, + "state": { + "type": "string", + "enum": ["OPEN", "RESOLVED", "REOPENED"], + "description": "Optional state filter", + }, + }, + "required": ["subscription_id"], + }, + }, + { + "name": "get_compliance_posture", + "description": ("Return compliance score and gaps for a framework (mcsb, cis, iso27001)."), + "inputSchema": { + "type": "object", + "properties": { + "framework": { + "type": "string", + "enum": ["mcsb", "cis", "iso27001"], + }, + "subscription_id": {"type": "string"}, + }, + "required": ["framework", "subscription_id"], + }, + }, + { + "name": "get_attack_paths", + "description": "Return BFS attack paths for a completed scan.", + "inputSchema": { + "type": "object", + "properties": { + "scan_id": { + "type": "string", + "description": "UUID of the completed scan", + }, + }, + "required": ["scan_id"], + }, + }, + { + "name": "explain_finding", + "description": "Return full detail and risk context for a finding.", + "inputSchema": { + "type": "object", + "properties": { + "finding_id": {"type": "integer"}, + }, + "required": ["finding_id"], + }, + }, + { + "name": "suggest_remediation", + "description": "Return step-by-step remediation guidance for a finding.", + "inputSchema": { + "type": "object", + "properties": { + "finding_id": {"type": "integer"}, + }, + "required": ["finding_id"], + }, + }, +] + + +def _import_mcp(): + """Import the installed mcp package, raising a clear error if missing. + + The local mcp/ package shadows the installed one, so we temporarily + remove the project root from sys.path before importing, then restore it. + """ + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + saved = sys.path[:] + # Remove project root so installed mcp is found instead of this package + sys.path = [p for p in sys.path if os.path.normcase(os.path.abspath(p)) != os.path.normcase(project_root)] + # Remove any stale local mcp entries + stale = [k for k in sys.modules if k == "mcp" or k.startswith("mcp.")] + for k in stale: + del sys.modules[k] + try: + from mcp.server import Server as _Server + from mcp.server.stdio import stdio_server as _stdio_server + from mcp.types import Tool as _Tool, TextContent as _TextContent + + return _Server, _stdio_server, _Tool, _TextContent + except ImportError as exc: + raise ImportError( + "The 'mcp' package is required to run the MCP server. Install it with: pip install mcp" + ) from exc + finally: + sys.path = saved + + +def _make_tools(Tool): + """Convert _TOOL_SPECS to mcp.types.Tool objects.""" + return [Tool(**spec) for spec in _TOOL_SPECS] + + +# TOOLS is a list of plain dicts at import time so the module can be imported +# in tests without needing the mcp runtime to be fully loadable. +TOOLS = _TOOL_SPECS + + +# --------------------------------------------------------------------------- +# MCP server (built only when the module is run as __main__) +# --------------------------------------------------------------------------- + + +def _build_app(): + """Construct and return a fully wired MCP Server instance.""" + try: + import psycopg2 + except ImportError as exc: + raise ImportError("psycopg2 is required: pip install psycopg2-binary") from exc + + from api.agent.tools import ( + explain_finding, + get_attack_paths, + get_compliance_posture, + get_findings, + scan_subscription, + suggest_remediation, + ) + + Server, stdio_server, Tool, TextContent = _import_mcp() + tools = _make_tools(Tool) + app = Server("openshield-cspm") + + @app.list_tools() + async def list_tools() -> list: + return tools + + @app.call_tool() + async def call_tool(name: str, arguments: dict): + db_url = os.environ["DATABASE_URL"] + # tenant_id always comes from env — never from LLM tool-call arguments + tenant_id = os.environ["OPENSHIELD_TENANT_ID"] + conn = psycopg2.connect(db_url) + try: + if name == "scan_subscription": + result = scan_subscription(conn, arguments["subscription_id"], tenant_id) + elif name == "get_findings": + result = get_findings( + conn, + arguments["subscription_id"], + tenant_id, + severity=arguments.get("severity"), + state=arguments.get("state"), + ) + elif name == "get_compliance_posture": + result = get_compliance_posture( + conn, + arguments["framework"], + arguments["subscription_id"], + tenant_id, + ) + elif name == "get_attack_paths": + result = get_attack_paths(conn, arguments["scan_id"], tenant_id) + elif name == "explain_finding": + result = explain_finding(conn, arguments["finding_id"], tenant_id) + elif name == "suggest_remediation": + result = suggest_remediation(conn, arguments["finding_id"], tenant_id) + else: + result = {"error": f"unknown tool: {name}"} + except Exception as exc: + logger.exception("Tool %s failed", name) + result = {"error": str(exc)} + finally: + conn.close() + return [TextContent(type="text", text=json.dumps(result))] + + return app, stdio_server + + +async def _main(): + app, stdio_server = _build_app() + async with stdio_server() as streams: + await app.run(*streams, app.create_initialization_options()) + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/requirements-dev.txt b/requirements-dev.txt index 32cdba87..63fb393e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,125 +2,167 @@ aiohappyeyeballs==2.7.1 \ --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 -aiohttp==3.14.3 \ - --hash=sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39 \ - --hash=sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043 \ - --hash=sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b \ - --hash=sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d \ - --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \ - --hash=sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f \ - --hash=sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7 \ - --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \ - --hash=sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559 \ - --hash=sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f \ - --hash=sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929 \ - --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \ - --hash=sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9 \ - --hash=sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8 \ - --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \ - --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \ - --hash=sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8 \ - --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \ - --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \ - --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \ - --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \ - --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \ - --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \ - --hash=sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a \ - --hash=sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c \ - --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \ - --hash=sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5 \ - --hash=sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d \ - --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \ - --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \ - --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \ - --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \ - --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 \ - --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \ - --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \ - --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \ - --hash=sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427 \ - --hash=sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32 \ - --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \ - --hash=sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db \ - --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 \ - --hash=sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a \ - --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \ - --hash=sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06 \ - --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \ - --hash=sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228 \ - --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \ - --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \ - --hash=sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee \ - --hash=sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2 \ - --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \ - --hash=sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43 \ - --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \ - --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \ - --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \ - --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \ - --hash=sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0 \ - --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \ - --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \ - --hash=sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529 \ - --hash=sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c \ - --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \ - --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \ - --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \ - --hash=sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f \ - --hash=sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e \ - --hash=sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa \ - --hash=sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b \ - --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \ - --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \ - --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \ - --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \ - --hash=sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c \ - --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \ - --hash=sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae \ - --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \ - --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c \ - --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \ - --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \ - --hash=sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a \ - --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \ - --hash=sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d \ - --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \ - --hash=sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15 \ - --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \ - --hash=sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d \ - --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \ - --hash=sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19 \ - --hash=sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239 \ - --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \ - --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \ - --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \ - --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \ - --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \ - --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \ - --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \ - --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \ - --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \ - --hash=sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d \ - --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \ - --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \ - --hash=sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3 \ - --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \ - --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \ - --hash=sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787 \ - --hash=sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb \ - --hash=sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b \ - --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \ - --hash=sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910 \ - --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \ - --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \ - --hash=sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48 \ - --hash=sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b \ - --hash=sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce \ - --hash=sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a \ - --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \ - --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 \ - --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5 +aiohttp==3.14.5 \ + --hash=sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1 \ + --hash=sha256:038c2c7e8caa26b6c8423779b5eaf1893904048a512c19b32fe841ffa5592b50 \ + --hash=sha256:0684952aeae1f5dbfe02d46039338513b94009baecd15d8e4098a357c4c4a2a6 \ + --hash=sha256:0790ec66fa4013e83c53b9025a45d454723da1a2fce28b3208c9b32d08af162f \ + --hash=sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d \ + --hash=sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210 \ + --hash=sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a \ + --hash=sha256:0a8ea271867e360ac985ae607f4a23ad9a38414b9aca1d49ec98839ae660e49f \ + --hash=sha256:0e6f16f5e49c4b8267988c05ab07760d7064cea57d077c3d068d04b0fbb992cb \ + --hash=sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e \ + --hash=sha256:1220353657ad49493551f089ce02f1a348fd57ffd585bfec77f2f3c4fe3a7346 \ + --hash=sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1 \ + --hash=sha256:149fb56caf7acb67073126f675d0958d9c4b3125fcd3f6d4877df98aa8a97ce9 \ + --hash=sha256:14f04769cfefe4734016a856a83af36133cd17779cef9ae817f812b8ba9d6d51 \ + --hash=sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155 \ + --hash=sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863 \ + --hash=sha256:16c8abd5bca220a47efe667d26f8460124c81810787e79ee87b242677563d9dd \ + --hash=sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30 \ + --hash=sha256:1aead151c3abbac6b32942e452020cb66d7efc099d253cc6c20f748e926c858b \ + --hash=sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738 \ + --hash=sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec \ + --hash=sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7 \ + --hash=sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382 \ + --hash=sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6 \ + --hash=sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f \ + --hash=sha256:20726f9782d5c2744c1c66255842d1d163bb3edcf768b8de25216bf47f7b6ccf \ + --hash=sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584 \ + --hash=sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20 \ + --hash=sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12 \ + --hash=sha256:248d779ad720b49d4fb355720e60c9e5f444f95887bc16974fea48fc56c41789 \ + --hash=sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658 \ + --hash=sha256:276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c \ + --hash=sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781 \ + --hash=sha256:293d3ae7c6a0ed176a42e59a1b5fde825ead65c835360f734148e96729f928d2 \ + --hash=sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111 \ + --hash=sha256:3093b72c215bda16ce961a6d073f6e71d46e022962a9d5d457c5d4d421c78b57 \ + --hash=sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4 \ + --hash=sha256:330900acd0dc4cb8b27f9c127fbaad770964845338493e7906ae3822e82dbf8d \ + --hash=sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab \ + --hash=sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c \ + --hash=sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748 \ + --hash=sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d \ + --hash=sha256:3e51a27980c3788e6e6b3325d694fdd4898087fa8a86b2763af77b39353da41e \ + --hash=sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0 \ + --hash=sha256:3f2dcc00191fd563e9075181a14ec31d7dd63223ced7582cc70a15a499de0c79 \ + --hash=sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae \ + --hash=sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3 \ + --hash=sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7 \ + --hash=sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421 \ + --hash=sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793 \ + --hash=sha256:4f07fe3ac408d8b3f768be471dc3f56d43843c47d97c66120534467a15ead197 \ + --hash=sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0 \ + --hash=sha256:50983e3be33d8c0942ab88cec3905b10602f64c469b20153c48c5d4e558dd016 \ + --hash=sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045 \ + --hash=sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622 \ + --hash=sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480 \ + --hash=sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f \ + --hash=sha256:5af42135fdfebdadbc2bcd9c0842a48ccf0d62794c36a260b21dc4b94d1e0119 \ + --hash=sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3 \ + --hash=sha256:5c76f1802bab718a68ac3cce447160605c734551f95c67ae90fa1132b215cb29 \ + --hash=sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939 \ + --hash=sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3 \ + --hash=sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c \ + --hash=sha256:602c1e9b718a3275c580149f947e7fac65044c0a20e599553fb12e9700da9eca \ + --hash=sha256:657291433bf4dd3142f3abac495764cd47d0c7c92087751e6666c6447e65fcef \ + --hash=sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d \ + --hash=sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343 \ + --hash=sha256:6e1d8637cf73eebc92eba2e11d4cfff98a3b562f2505bd75bba766d908926e8d \ + --hash=sha256:6e4251c0ba4624a68a2c11471a1ac54c3306876c21f0ae86de085cc9241c8905 \ + --hash=sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc \ + --hash=sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67 \ + --hash=sha256:6fdcd6af7e2e51d1ba1b4bea16e97b074bcb7b5dd0246a9d8201341bb28085a0 \ + --hash=sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794 \ + --hash=sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835 \ + --hash=sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d \ + --hash=sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d \ + --hash=sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c \ + --hash=sha256:7779cd97e61ebe583ec2f1c5616cdd038aa08a4453b1848c67842176d054948e \ + --hash=sha256:788ecaa9c10533b786ce5ba70c4f2df78ad41819fd00a6c99d92b66f9a32e1da \ + --hash=sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9 \ + --hash=sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115 \ + --hash=sha256:823c910f046f23f4c713b8d99a2242dc65f591cb45ee86418fa11762a3c2963c \ + --hash=sha256:82c7583cd3dfdc7dcc927835b4f6c7faae7ecc1ba3ca5879321621ae2e6f8e84 \ + --hash=sha256:8966ecac808dd5f473c9c4cefd10cd3ffda71c18a4d3493b7c7d2ae1803bf2cc \ + --hash=sha256:8df7d481654ac96fe1ba9a02a9f67770fdd367823e0d5ef01b922725c4bd2cfa \ + --hash=sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f \ + --hash=sha256:932ce7e694bbc29b2bf6f64f2343c27d148d4997c771d01bdade4639b6749ff4 \ + --hash=sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5 \ + --hash=sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c \ + --hash=sha256:96a2e584f0b9ed8f1fa33211397dcf67bb7069866402cb405d191c2f0defb9a3 \ + --hash=sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a \ + --hash=sha256:9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14 \ + --hash=sha256:9bab2045550c4fe0f7baf89574db1b455c195750702ba96fef1f16972b146617 \ + --hash=sha256:9bf1d5dcc15204d9ec8b8ea4c18fd66e6b80e5de1f4ecbafb3a2f2740f8039d4 \ + --hash=sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d \ + --hash=sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2 \ + --hash=sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820 \ + --hash=sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed \ + --hash=sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd \ + --hash=sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593 \ + --hash=sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac \ + --hash=sha256:a6d02b4c38de03d9c7617813433e6a0fb6b522797974177d69d9dad431900833 \ + --hash=sha256:a7d470cf7b206e6359fc77b1b860632fde400d5a2ed59cd0181b93a686bc81ee \ + --hash=sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3 \ + --hash=sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600 \ + --hash=sha256:a9d3983bd6ab7aa1cfd573544ae98df9b6cb6912a5185a198263e024a636861d \ + --hash=sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703 \ + --hash=sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764 \ + --hash=sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99 \ + --hash=sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a \ + --hash=sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755 \ + --hash=sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84 \ + --hash=sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5 \ + --hash=sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3 \ + --hash=sha256:b7806e804889231b0e06469fd4a5c06313d1c0a3377322b6d9237fa5e0fe4167 \ + --hash=sha256:bea559ad70218d230663e4210875735076a9bfea5994cef34a55a25faeaf2544 \ + --hash=sha256:bf163cc701f3d4ac43ba7d97771bf5fd955220ef5500ef3ee847bc0ecfbf4ec1 \ + --hash=sha256:c147451b4a58e7050f7f7394e6c467867c84161560001f9ad4fb2d1446743946 \ + --hash=sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e \ + --hash=sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4 \ + --hash=sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02 \ + --hash=sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba \ + --hash=sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02 \ + --hash=sha256:c8859a013ae0de1074660992139a1a440df3e6b219b86cf0d3f11c2692bb4fe3 \ + --hash=sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a \ + --hash=sha256:cb11a971a3aea10f9b8373be628f1df932964fc6c6b174516d318a48c3ac4412 \ + --hash=sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e \ + --hash=sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec \ + --hash=sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319 \ + --hash=sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2 \ + --hash=sha256:d3112585250b199296c26ca6e0131640b6a8d01bab8b232d2eb3763ed469de11 \ + --hash=sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073 \ + --hash=sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f \ + --hash=sha256:d94e44be379e569758fee8a9a58431cfc3c2598c708b92b1cfe96c66b4c94aef \ + --hash=sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603 \ + --hash=sha256:dbf53ae2601b7fd5a93c3944deea3a78d40f495226d582c35ef7a433425ce2b2 \ + --hash=sha256:dca3fa8d8a0a26679862eccb0b1a9151b2b9f1cd2c212e7a6335778faaff5833 \ + --hash=sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450 \ + --hash=sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a \ + --hash=sha256:e29347c142cf6e99e0dff5e2995ead1d50fa3b51bf37a7c726a7ccfe5419745a \ + --hash=sha256:e4f5cf4dc72a71c4cfa9751b4950be22f733626670230d46e7d606592aa22d59 \ + --hash=sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db \ + --hash=sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329 \ + --hash=sha256:e95c8def4b81c5d68d5cf1f54c07acd7c0d2577af244e5b6da802120825737c6 \ + --hash=sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1 \ + --hash=sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8 \ + --hash=sha256:ef692a24087a699c0a4a26af45e746e0c1eae2116f6d8a5ff91d8aae2b867b45 \ + --hash=sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b \ + --hash=sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09 \ + --hash=sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e \ + --hash=sha256:f2ebb54b3f932210503072f09974b4fb574d823e497a944adfdcd140a6a00255 \ + --hash=sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1 \ + --hash=sha256:f375db73a39f5cf83696d500e21a67f418dc9a988955756f254be8f03b7b3651 \ + --hash=sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91 \ + --hash=sha256:f8d40ce41991e9d56fab4f5dc4a51fe59bc3b5c77c27f4b148963064d00232e8 \ + --hash=sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d \ + --hash=sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677 \ + --hash=sha256:fbdc5ec49f9ca3cd24955cf3520b10a4d4c901ba2572094c84274e9e7eb30534 \ + --hash=sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441 \ + --hash=sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550 aiosignal==1.4.0 \ --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e alembic==1.18.5 \ @@ -1483,8 +1525,8 @@ urllib3==2.8.0 \ --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 websocket-client==1.9.2 \ --hash=sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce -werkzeug==3.1.8 \ - --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 +werkzeug==3.1.9 \ + --hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab yarl==1.25.1 \ --hash=sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3 \ --hash=sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de \ diff --git a/requirements.in b/requirements.in index 1c8abe85..3dcd5ce8 100644 --- a/requirements.in +++ b/requirements.in @@ -45,3 +45,6 @@ prometheus-client>=0.19.0 python-json-logger>=2.0.7 sentry-sdk>=1.40.0 certifi==2026.6.17 +# Security pins: keep these at or above the patched versions. +aiohttp>=3.14.4 +werkzeug>=3.1.9 diff --git a/requirements.txt b/requirements.txt index 45eb9a8a..5f253195 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,125 +2,167 @@ aiohappyeyeballs==2.7.1 \ --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 -aiohttp==3.14.3 \ - --hash=sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39 \ - --hash=sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043 \ - --hash=sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b \ - --hash=sha256:0fdea2281997af69da84c77ffa6f5938a0285f21fb3887c249d67419ca865b3d \ - --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \ - --hash=sha256:134ac5ddcf61c6fad984b9a5727d83492ada43d63471db20fb73042c13fca62f \ - --hash=sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7 \ - --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \ - --hash=sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559 \ - --hash=sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f \ - --hash=sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929 \ - --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \ - --hash=sha256:1b59533861b70a2185c8f4f350f791f39d64358ef6944ce71c5240c9ec0982c9 \ - --hash=sha256:1c5281acc88b92396f88c7e1e2748f8466689df22b80170e4f51efa712fb47a8 \ - --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \ - --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \ - --hash=sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8 \ - --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \ - --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \ - --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \ - --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \ - --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \ - --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \ - --hash=sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a \ - --hash=sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c \ - --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \ - --hash=sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5 \ - --hash=sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d \ - --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \ - --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \ - --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \ - --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \ - --hash=sha256:48d67b87db6279c044760787eb01f6413032c2e6f3ba1cafaa492b1c8e578479 \ - --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \ - --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \ - --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \ - --hash=sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427 \ - --hash=sha256:5373dc80ad1aa2fb9ad95c83f24eef418bbda3a61375f128e5b0192e4f3f9b32 \ - --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \ - --hash=sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db \ - --hash=sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42 \ - --hash=sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a \ - --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \ - --hash=sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06 \ - --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \ - --hash=sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228 \ - --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \ - --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \ - --hash=sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee \ - --hash=sha256:6debfa7312ff9d4c124dc71d72e9a0a4b9e0879e48ba6fcb42bef5c3300289e2 \ - --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \ - --hash=sha256:70c987b27534f9ae1a723f47ae921571d616da21d3208282bf4c52af5164ac43 \ - --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \ - --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \ - --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \ - --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \ - --hash=sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0 \ - --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \ - --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \ - --hash=sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529 \ - --hash=sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c \ - --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \ - --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \ - --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \ - --hash=sha256:a3a8296e7ab5c295f53f1041487cb088e1480775aafbf7fe545d93b770a0f96f \ - --hash=sha256:a3e22975f905b89a55a488c2a08f2fdb2186175349e917d48985cc468a3d4c6e \ - --hash=sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa \ - --hash=sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b \ - --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \ - --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \ - --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \ - --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \ - --hash=sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c \ - --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \ - --hash=sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae \ - --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \ - --hash=sha256:bdd0e2834dce1a26c1bbe26464861e16bbe217042cbff619247c11594472518c \ - --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \ - --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \ - --hash=sha256:c3c200cf9757edd785051dc699c7ecbec22110dbfcb3fefc7a9f9695eda8ea7a \ - --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \ - --hash=sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d \ - --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \ - --hash=sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15 \ - --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \ - --hash=sha256:cda5fd5c95ad7a125a2e8464acc78b98b94c475a3780d6aa0aa157c93f470f4d \ - --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \ - --hash=sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19 \ - --hash=sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239 \ - --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \ - --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \ - --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \ - --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \ - --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \ - --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \ - --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \ - --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \ - --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \ - --hash=sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d \ - --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \ - --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \ - --hash=sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3 \ - --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \ - --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \ - --hash=sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787 \ - --hash=sha256:eac645b09bcfdf73df7536331f0678c1086ea250981118ddb5199e17ccef72bb \ - --hash=sha256:eb0495d778817619273c108784292be161a924b9f5ae5cbbc70a2caa6838250b \ - --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \ - --hash=sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910 \ - --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \ - --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \ - --hash=sha256:f4e05329faa0ea1a404b37de4f034fd2c2defcca06a68dc6745e4e56c88e8a48 \ - --hash=sha256:f53bcd52f585e1ac3e590d61434eb61f9a88c38df041b4ea126d97144344a77b \ - --hash=sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce \ - --hash=sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a \ - --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \ - --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 \ - --hash=sha256:fd51ebf9d3a00c074df4ede271023f4d2dba289bcc740b88191872716014e3c5 +aiohttp==3.14.5 \ + --hash=sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1 \ + --hash=sha256:038c2c7e8caa26b6c8423779b5eaf1893904048a512c19b32fe841ffa5592b50 \ + --hash=sha256:0684952aeae1f5dbfe02d46039338513b94009baecd15d8e4098a357c4c4a2a6 \ + --hash=sha256:0790ec66fa4013e83c53b9025a45d454723da1a2fce28b3208c9b32d08af162f \ + --hash=sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d \ + --hash=sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210 \ + --hash=sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a \ + --hash=sha256:0a8ea271867e360ac985ae607f4a23ad9a38414b9aca1d49ec98839ae660e49f \ + --hash=sha256:0e6f16f5e49c4b8267988c05ab07760d7064cea57d077c3d068d04b0fbb992cb \ + --hash=sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e \ + --hash=sha256:1220353657ad49493551f089ce02f1a348fd57ffd585bfec77f2f3c4fe3a7346 \ + --hash=sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1 \ + --hash=sha256:149fb56caf7acb67073126f675d0958d9c4b3125fcd3f6d4877df98aa8a97ce9 \ + --hash=sha256:14f04769cfefe4734016a856a83af36133cd17779cef9ae817f812b8ba9d6d51 \ + --hash=sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155 \ + --hash=sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863 \ + --hash=sha256:16c8abd5bca220a47efe667d26f8460124c81810787e79ee87b242677563d9dd \ + --hash=sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30 \ + --hash=sha256:1aead151c3abbac6b32942e452020cb66d7efc099d253cc6c20f748e926c858b \ + --hash=sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738 \ + --hash=sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec \ + --hash=sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7 \ + --hash=sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382 \ + --hash=sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6 \ + --hash=sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f \ + --hash=sha256:20726f9782d5c2744c1c66255842d1d163bb3edcf768b8de25216bf47f7b6ccf \ + --hash=sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584 \ + --hash=sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20 \ + --hash=sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12 \ + --hash=sha256:248d779ad720b49d4fb355720e60c9e5f444f95887bc16974fea48fc56c41789 \ + --hash=sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658 \ + --hash=sha256:276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c \ + --hash=sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781 \ + --hash=sha256:293d3ae7c6a0ed176a42e59a1b5fde825ead65c835360f734148e96729f928d2 \ + --hash=sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111 \ + --hash=sha256:3093b72c215bda16ce961a6d073f6e71d46e022962a9d5d457c5d4d421c78b57 \ + --hash=sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4 \ + --hash=sha256:330900acd0dc4cb8b27f9c127fbaad770964845338493e7906ae3822e82dbf8d \ + --hash=sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab \ + --hash=sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c \ + --hash=sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748 \ + --hash=sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d \ + --hash=sha256:3e51a27980c3788e6e6b3325d694fdd4898087fa8a86b2763af77b39353da41e \ + --hash=sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0 \ + --hash=sha256:3f2dcc00191fd563e9075181a14ec31d7dd63223ced7582cc70a15a499de0c79 \ + --hash=sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae \ + --hash=sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3 \ + --hash=sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7 \ + --hash=sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421 \ + --hash=sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793 \ + --hash=sha256:4f07fe3ac408d8b3f768be471dc3f56d43843c47d97c66120534467a15ead197 \ + --hash=sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0 \ + --hash=sha256:50983e3be33d8c0942ab88cec3905b10602f64c469b20153c48c5d4e558dd016 \ + --hash=sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045 \ + --hash=sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622 \ + --hash=sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480 \ + --hash=sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f \ + --hash=sha256:5af42135fdfebdadbc2bcd9c0842a48ccf0d62794c36a260b21dc4b94d1e0119 \ + --hash=sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3 \ + --hash=sha256:5c76f1802bab718a68ac3cce447160605c734551f95c67ae90fa1132b215cb29 \ + --hash=sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939 \ + --hash=sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3 \ + --hash=sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c \ + --hash=sha256:602c1e9b718a3275c580149f947e7fac65044c0a20e599553fb12e9700da9eca \ + --hash=sha256:657291433bf4dd3142f3abac495764cd47d0c7c92087751e6666c6447e65fcef \ + --hash=sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d \ + --hash=sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343 \ + --hash=sha256:6e1d8637cf73eebc92eba2e11d4cfff98a3b562f2505bd75bba766d908926e8d \ + --hash=sha256:6e4251c0ba4624a68a2c11471a1ac54c3306876c21f0ae86de085cc9241c8905 \ + --hash=sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc \ + --hash=sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67 \ + --hash=sha256:6fdcd6af7e2e51d1ba1b4bea16e97b074bcb7b5dd0246a9d8201341bb28085a0 \ + --hash=sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794 \ + --hash=sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835 \ + --hash=sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d \ + --hash=sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d \ + --hash=sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c \ + --hash=sha256:7779cd97e61ebe583ec2f1c5616cdd038aa08a4453b1848c67842176d054948e \ + --hash=sha256:788ecaa9c10533b786ce5ba70c4f2df78ad41819fd00a6c99d92b66f9a32e1da \ + --hash=sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9 \ + --hash=sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115 \ + --hash=sha256:823c910f046f23f4c713b8d99a2242dc65f591cb45ee86418fa11762a3c2963c \ + --hash=sha256:82c7583cd3dfdc7dcc927835b4f6c7faae7ecc1ba3ca5879321621ae2e6f8e84 \ + --hash=sha256:8966ecac808dd5f473c9c4cefd10cd3ffda71c18a4d3493b7c7d2ae1803bf2cc \ + --hash=sha256:8df7d481654ac96fe1ba9a02a9f67770fdd367823e0d5ef01b922725c4bd2cfa \ + --hash=sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f \ + --hash=sha256:932ce7e694bbc29b2bf6f64f2343c27d148d4997c771d01bdade4639b6749ff4 \ + --hash=sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5 \ + --hash=sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c \ + --hash=sha256:96a2e584f0b9ed8f1fa33211397dcf67bb7069866402cb405d191c2f0defb9a3 \ + --hash=sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a \ + --hash=sha256:9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14 \ + --hash=sha256:9bab2045550c4fe0f7baf89574db1b455c195750702ba96fef1f16972b146617 \ + --hash=sha256:9bf1d5dcc15204d9ec8b8ea4c18fd66e6b80e5de1f4ecbafb3a2f2740f8039d4 \ + --hash=sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d \ + --hash=sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2 \ + --hash=sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820 \ + --hash=sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed \ + --hash=sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd \ + --hash=sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593 \ + --hash=sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac \ + --hash=sha256:a6d02b4c38de03d9c7617813433e6a0fb6b522797974177d69d9dad431900833 \ + --hash=sha256:a7d470cf7b206e6359fc77b1b860632fde400d5a2ed59cd0181b93a686bc81ee \ + --hash=sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3 \ + --hash=sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600 \ + --hash=sha256:a9d3983bd6ab7aa1cfd573544ae98df9b6cb6912a5185a198263e024a636861d \ + --hash=sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703 \ + --hash=sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764 \ + --hash=sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99 \ + --hash=sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a \ + --hash=sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755 \ + --hash=sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84 \ + --hash=sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5 \ + --hash=sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3 \ + --hash=sha256:b7806e804889231b0e06469fd4a5c06313d1c0a3377322b6d9237fa5e0fe4167 \ + --hash=sha256:bea559ad70218d230663e4210875735076a9bfea5994cef34a55a25faeaf2544 \ + --hash=sha256:bf163cc701f3d4ac43ba7d97771bf5fd955220ef5500ef3ee847bc0ecfbf4ec1 \ + --hash=sha256:c147451b4a58e7050f7f7394e6c467867c84161560001f9ad4fb2d1446743946 \ + --hash=sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e \ + --hash=sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4 \ + --hash=sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02 \ + --hash=sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba \ + --hash=sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02 \ + --hash=sha256:c8859a013ae0de1074660992139a1a440df3e6b219b86cf0d3f11c2692bb4fe3 \ + --hash=sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a \ + --hash=sha256:cb11a971a3aea10f9b8373be628f1df932964fc6c6b174516d318a48c3ac4412 \ + --hash=sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e \ + --hash=sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec \ + --hash=sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319 \ + --hash=sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2 \ + --hash=sha256:d3112585250b199296c26ca6e0131640b6a8d01bab8b232d2eb3763ed469de11 \ + --hash=sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073 \ + --hash=sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f \ + --hash=sha256:d94e44be379e569758fee8a9a58431cfc3c2598c708b92b1cfe96c66b4c94aef \ + --hash=sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603 \ + --hash=sha256:dbf53ae2601b7fd5a93c3944deea3a78d40f495226d582c35ef7a433425ce2b2 \ + --hash=sha256:dca3fa8d8a0a26679862eccb0b1a9151b2b9f1cd2c212e7a6335778faaff5833 \ + --hash=sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450 \ + --hash=sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a \ + --hash=sha256:e29347c142cf6e99e0dff5e2995ead1d50fa3b51bf37a7c726a7ccfe5419745a \ + --hash=sha256:e4f5cf4dc72a71c4cfa9751b4950be22f733626670230d46e7d606592aa22d59 \ + --hash=sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db \ + --hash=sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329 \ + --hash=sha256:e95c8def4b81c5d68d5cf1f54c07acd7c0d2577af244e5b6da802120825737c6 \ + --hash=sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1 \ + --hash=sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8 \ + --hash=sha256:ef692a24087a699c0a4a26af45e746e0c1eae2116f6d8a5ff91d8aae2b867b45 \ + --hash=sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b \ + --hash=sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09 \ + --hash=sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e \ + --hash=sha256:f2ebb54b3f932210503072f09974b4fb574d823e497a944adfdcd140a6a00255 \ + --hash=sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1 \ + --hash=sha256:f375db73a39f5cf83696d500e21a67f418dc9a988955756f254be8f03b7b3651 \ + --hash=sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91 \ + --hash=sha256:f8d40ce41991e9d56fab4f5dc4a51fe59bc3b5c77c27f4b148963064d00232e8 \ + --hash=sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d \ + --hash=sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677 \ + --hash=sha256:fbdc5ec49f9ca3cd24955cf3520b10a4d4c901ba2572094c84274e9e7eb30534 \ + --hash=sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441 \ + --hash=sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550 aiosignal==1.4.0 \ --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e alembic==1.18.5 \ @@ -1324,8 +1366,8 @@ urllib3==2.8.0 \ --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 websocket-client==1.9.2 \ --hash=sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce -werkzeug==3.1.8 \ - --hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 +werkzeug==3.1.9 \ + --hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab yarl==1.25.1 \ --hash=sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3 \ --hash=sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de \ diff --git a/tests/conftest.py b/tests/conftest.py index 02b505b4..bbd14cc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,36 @@ def app(): from api.app import create_app + application = create_app() + application.config["TESTING"] = True + application.config["JWT_SECRET"] = _TEST_JWT_SECRET + + # In tests the verifier runs in shared_secret mode which always sets + # tenant=None. Add a before_request hook that upgrades the principal to + # include a synthetic test tenant so agent-route tests can pass the + # empty-tenant guard without needing real OIDC configuration. + from flask import g as _g + + @application.before_request + def _inject_test_tenant(): + # Only patch if g.user is already set (i.e., auth passed) and tenant is None + try: + if _g.user.get("tenant") is None: + _g.user = {**_g.user, "tenant": "test-tenant-001"} + except AttributeError: + pass + + return application + + +@pytest.fixture +def app_no_tenant_inject(): + """App fixture without the synthetic tenant injection hook. + + Used by tests that specifically validate the empty-tenant 403 guard. + """ + from api.app import create_app + application = create_app() application.config["TESTING"] = True application.config["JWT_SECRET"] = _TEST_JWT_SECRET @@ -34,6 +64,7 @@ def auth_headers(): payload = { "sub": "test-user", "role": "admin", + "tenant": "test-tenant-001", "iat": int(time.time()), "exp": int(time.time()) + 3600, } diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py new file mode 100644 index 00000000..555b9ce1 --- /dev/null +++ b/tests/test_agent_loop.py @@ -0,0 +1,236 @@ +"""Unit tests for api.agent.agent - the ReAct loop. + +All LLM calls are mocked; no live network calls are made. +""" + +import json +import unittest +from unittest.mock import MagicMock, patch + +from api.agent.agent import run_agent + + +_PROVIDER = "anthropic" +_PROVIDER_TOKEN = "test-key" +_SUB_ID = "sub-123" +_TENANT_ID = "tenant-abc" + + +# Minimal fake db_conn so tool dispatch does not raise AttributeError +def _fake_db(): + return MagicMock() + + +def _answer(text: str, confidence: str = "HIGH") -> str: + return json.dumps({"answer": text, "confidence": confidence}) + + +def _tool_call(name: str, args: dict = None) -> str: + return json.dumps({"tool": name, "args": args or {}}) + + +class TestSingleTurnAnswer(unittest.TestCase): + """LLM returns a final answer on the first call.""" + + @patch("api.agent.agent.get_completion") + def test_returns_answer_immediately(self, mock_llm): + mock_llm.return_value = _answer("Everything looks good.", "HIGH") + + result = run_agent( + question="Are there any critical findings?", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + ) + + self.assertEqual(result["answer"], "Everything looks good.") + self.assertEqual(result["confidence"], "HIGH") + self.assertEqual(result["iterations"], 1) + self.assertEqual(result["tools_used"], []) + mock_llm.assert_called_once() + + +class TestToolCallThenAnswer(unittest.TestCase): + """LLM calls a tool first, then gives a final answer.""" + + @patch("api.agent.tools.get_findings", return_value=[{"finding_id": 1, "severity": "HIGH"}]) + @patch("api.agent.agent.get_completion") + def test_tool_then_answer(self, mock_llm, mock_get_findings): + mock_llm.side_effect = [ + _tool_call("get_findings", {"severity": "HIGH"}), + _answer("Found 1 HIGH finding.", "HIGH"), + ] + + result = run_agent( + question="Show me HIGH findings", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + ) + + self.assertEqual(result["answer"], "Found 1 HIGH finding.") + self.assertEqual(result["iterations"], 2) + self.assertIn("get_findings", result["tools_used"]) + self.assertEqual(mock_llm.call_count, 2) + + +class TestMaxIterationsReached(unittest.TestCase): + """LLM keeps calling tools; loop hits max_iterations and returns fallback.""" + + @patch("api.agent.tools.get_findings", return_value=[]) + @patch("api.agent.agent.get_completion") + def test_max_iterations_fallback(self, mock_llm, _mock_findings): + mock_llm.return_value = _tool_call("get_findings") + + result = run_agent( + question="List findings", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + max_iterations=8, + ) + + self.assertIn("unable to gather", result["answer"].lower()) + self.assertEqual(result["confidence"], "LOW") + self.assertEqual(result["iterations"], 8) + self.assertEqual(mock_llm.call_count, 8) + + +class TestEmptyQuestionRaisesValueError(unittest.TestCase): + """Empty question (after cleaning) raises ValueError.""" + + @patch("api.agent.agent.get_completion") + def test_empty_string_raises(self, mock_llm): + with self.assertRaises(ValueError) as ctx: + run_agent( + question=" ", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + ) + self.assertIn("required", str(ctx.exception).lower()) + mock_llm.assert_not_called() + + @patch("api.agent.agent.get_completion") + def test_empty_string_literal_raises(self, mock_llm): + with self.assertRaises(ValueError): + run_agent( + question="", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + ) + mock_llm.assert_not_called() + + +class TestUnknownToolReturnsErrorAndContinues(unittest.TestCase): + """Unknown tool name returns error dict; loop continues to next iteration.""" + + @patch("api.agent.agent.get_completion") + def test_unknown_tool_error_dict(self, mock_llm): + mock_llm.side_effect = [ + _tool_call("nonexistent_tool"), + _answer("Fallback answer after unknown tool.", "LOW"), + ] + + result = run_agent( + question="Do something", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + ) + + # Loop should have continued and returned the second response + self.assertEqual(result["answer"], "Fallback answer after unknown tool.") + self.assertEqual(result["iterations"], 2) + # The bad tool name is still recorded in tools_used + self.assertIn("nonexistent_tool", result["tools_used"]) + + +class TestToolExceptionCaughtAndLoopContinues(unittest.TestCase): + """Tool exception is caught and returned as error dict; loop does not crash.""" + + @patch("api.agent.tools.get_findings", side_effect=RuntimeError("DB unavailable")) + @patch("api.agent.agent.get_completion") + def test_tool_exception_caught(self, mock_llm, _mock_findings): + mock_llm.side_effect = [ + _tool_call("get_findings"), + _answer("Could not retrieve findings.", "LOW"), + ] + + result = run_agent( + question="Get findings", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + ) + + # Loop must not raise; should reach the second LLM response + self.assertEqual(result["answer"], "Could not retrieve findings.") + self.assertEqual(result["iterations"], 2) + + +class TestUnparseableLLMResponse(unittest.TestCase): + """Non-JSON LLM response is treated as a low-confidence final answer.""" + + @patch("api.agent.agent.get_completion") + def test_unparseable_is_low_confidence_answer(self, mock_llm): + mock_llm.return_value = "This is just plain text, not JSON." + + result = run_agent( + question="What is the posture?", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + ) + + self.assertIn("plain text", result["answer"]) + self.assertEqual(result["confidence"], "LOW") + self.assertEqual(result["iterations"], 1) + mock_llm.assert_called_once() + + +class TestToolsUsedOrder(unittest.TestCase): + """tools_used records tool names in the order they were called.""" + + @patch("api.agent.tools.get_findings", return_value=[]) + @patch("api.agent.tools.get_compliance_posture", return_value={"score": 0.9, "framework": "mcsb"}) + @patch("api.agent.agent.get_completion") + def test_tools_used_in_order(self, mock_llm, _mock_posture, _mock_findings): + mock_llm.side_effect = [ + _tool_call("get_findings"), + _tool_call("get_compliance_posture", {"framework": "mcsb"}), + _answer("Both tools called.", "HIGH"), + ] + + result = run_agent( + question="Show findings and posture", + subscription_id=_SUB_ID, + tenant_id=_TENANT_ID, + db_conn=_fake_db(), + provider=_PROVIDER, + api_key=_PROVIDER_TOKEN, + ) + + self.assertEqual(result["tools_used"], ["get_findings", "get_compliance_posture"]) + self.assertEqual(result["iterations"], 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_agent_route.py b/tests/test_agent_route.py new file mode 100644 index 00000000..17a5ce4b --- /dev/null +++ b/tests/test_agent_route.py @@ -0,0 +1,159 @@ +"""Integration tests for POST /api/agent/query.""" + +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_GOOD_RESULT = { + "answer": "You have 3 critical findings.", + "tools_used": ["get_findings"], + "iterations": 2, + "confidence": "HIGH", +} + + +def _mock_db(): + db = MagicMock() + db.conn = MagicMock() + return db + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestAgentQueryRoute: + """Tests for POST /api/agent/query.""" + + def _post(self, client, auth_headers, body, env=None): + env = env or {} + with patch.dict("os.environ", {"AI_API_KEY": "test-key", **env}): + return client.post("/api/agent/query", json=body, headers=auth_headers) + + # 1. Valid request returns 200 with all expected fields + def test_valid_request_returns_200(self, client, auth_headers): + with ( + patch("api.routes.agent.run_agent", return_value=_GOOD_RESULT), + patch("api.routes.agent._get_db", return_value=_mock_db()), + ): + resp = self._post( + client, + auth_headers, + {"question": "What are my critical findings?", "subscription_id": "sub-001"}, + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["answer"] == _GOOD_RESULT["answer"] + assert data["tools_used"] == _GOOD_RESULT["tools_used"] + assert data["iterations"] == _GOOD_RESULT["iterations"] + assert data["confidence"] == _GOOD_RESULT["confidence"] + + # 2. Missing question field returns 400 + def test_missing_question_returns_400(self, client, auth_headers): + with patch.dict("os.environ", {"AI_API_KEY": "test-key"}): + resp = client.post("/api/agent/query", json={"framework": "mcsb"}, headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()["error"] == "question is required" + + # 3. Empty string question returns 400 + def test_empty_question_returns_400(self, client, auth_headers): + with patch.dict("os.environ", {"AI_API_KEY": "test-key"}): + resp = client.post("/api/agent/query", json={"question": " "}, headers=auth_headers) + assert resp.status_code == 400 + assert resp.get_json()["error"] == "question is required" + + # 4. Missing AI_API_KEY returns 503 + def test_missing_api_key_returns_503(self, client, auth_headers): + with patch.dict("os.environ", {"AI_API_KEY": ""}): + resp = client.post( + "/api/agent/query", + json={"question": "What are my findings?", "subscription_id": "sub-001"}, + headers=auth_headers, + ) + assert resp.status_code == 503 + assert resp.get_json()["error"] == "AI provider not configured" + + # 5. run_agent raises unexpected exception -> 500 + def test_run_agent_exception_returns_500(self, client, auth_headers): + with ( + patch("api.routes.agent.run_agent", side_effect=RuntimeError("boom")), + patch("api.routes.agent._get_db", return_value=_mock_db()), + patch.dict("os.environ", {"AI_API_KEY": "test-key"}), + ): + resp = client.post( + "/api/agent/query", + json={"question": "What are my findings?", "subscription_id": "sub-001"}, + headers=auth_headers, + ) + assert resp.status_code == 500 + assert resp.get_json()["error"] == "internal server error" + + # 6. Rate limit: 21st request returns 429 + def test_rate_limit_blocks_excess_requests(self, client, auth_headers): + """21st request within the window must return 429.""" + with ( + patch("api.routes.agent.run_agent", return_value=_GOOD_RESULT), + patch("api.routes.agent._get_db", return_value=_mock_db()), + patch.dict("os.environ", {"AI_API_KEY": "test-key", "DATABASE_URL": "postgresql://test"}), + ): + # Patch _check_and_record directly: first 20 return True, then False + call_count = {"n": 0} + + def fake_check_and_record(key, max_requests, window_seconds): + call_count["n"] += 1 + return call_count["n"] <= max_requests + + with patch("api.rate_limit._check_and_record", side_effect=fake_check_and_record): + # Make app non-testing so rate limiter runs + client.application.config["TESTING"] = False + try: + for _ in range(20): + r = client.post( + "/api/agent/query", + json={"question": "test", "subscription_id": "sub-001"}, + headers=auth_headers, + ) + assert r.status_code == 200 + r21 = client.post( + "/api/agent/query", + json={"question": "test", "subscription_id": "sub-001"}, + headers=auth_headers, + ) + assert r21.status_code == 429 + finally: + client.application.config["TESTING"] = True + + # 7. tenant_id comes from JWT (g.user), not from request body + def test_tenant_from_jwt_not_body(self, app_no_tenant_inject): + """Agent route must reject when tenant is empty (shared_secret mode returns tenant=None).""" + import time + import jwt + from tests.conftest import _TEST_JWT_SECRET + + # Build a token (shared_secret mode always resolves tenant=None in auth.py) + payload = { + "sub": "test-user", + "role": "admin", + "iat": int(time.time()), + "exp": int(time.time()) + 3600, + } + token = jwt.encode(payload, _TEST_JWT_SECRET, algorithm="HS256") + no_tenant_headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + client = app_no_tenant_inject.test_client() + with patch.dict("os.environ", {"AI_API_KEY": "test-key"}): + resp = client.post( + "/api/agent/query", + json={ + "question": "What are my findings?", + "subscription_id": "sub-001", + "tenant_id": "attacker-tenant", + }, + headers=no_tenant_headers, + ) + assert resp.status_code == 403 + assert "tenant" in resp.get_json()["error"] diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py new file mode 100644 index 00000000..32fd24b1 --- /dev/null +++ b/tests/test_agent_tools.py @@ -0,0 +1,246 @@ +"""Unit tests for api.agent.tools using a fake psycopg2 connection.""" + +import pytest + +from api.agent.tools import ( + explain_finding, + get_attack_paths, + get_compliance_posture, + get_findings, + scan_subscription, + suggest_remediation, +) + + +# --------------------------------------------------------------------------- +# Fake psycopg2 connection / cursor +# --------------------------------------------------------------------------- + + +class _FakeCursor: + """Minimal psycopg2 cursor stand-in.""" + + def __init__(self, rows=None): + self._rows = rows or [] + self.executed_sql = None + self.executed_params = None + + def execute(self, sql, params=None): + self.executed_sql = sql + self.executed_params = params + + def fetchone(self): + return self._rows[0] if self._rows else None + + def fetchall(self): + return list(self._rows) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +class _FakeConn: + """Minimal psycopg2 connection stand-in.""" + + def __init__(self, rows=None): + self._cursor = _FakeCursor(rows) + self.committed = False + + def cursor(self): + return self._cursor + + def commit(self): + self.committed = True + + +# --------------------------------------------------------------------------- +# scan_subscription +# --------------------------------------------------------------------------- + + +class TestScanSubscription: + def test_returns_dict_with_scan_id(self): + """scan_subscription should return a dict with scan_id and status keys.""" + conn = _FakeConn(rows=[("abc-123",)]) + result = scan_subscription(conn, subscription_id="sub-1", tenant_id="tenant-1") + assert "scan_id" in result + assert result["scan_id"] == "abc-123" + assert result["status"] == "PENDING" + + def test_raises_runtime_error_on_no_row(self): + """scan_subscription should raise RuntimeError when INSERT returns no row.""" + conn = _FakeConn(rows=[]) + with pytest.raises(RuntimeError): + scan_subscription(conn, subscription_id="sub-1", tenant_id="tenant-1") + + +# --------------------------------------------------------------------------- +# get_findings +# --------------------------------------------------------------------------- + + +class TestGetFindings: + def test_no_filters_returns_list(self): + """get_findings with no optional filters returns a list of dicts.""" + rows = [(1, "AZ-NET-001", "/subs/sub-1/rg/rg1", "HIGH", "OPEN")] + conn = _FakeConn(rows=rows) + result = get_findings(conn, subscription_id="sub-1", tenant_id="tenant-1") + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["finding_id"] == 1 + assert result[0]["rule_id"] == "AZ-NET-001" + + def test_severity_filter_passes_correct_param(self): + """get_findings with severity filter includes severity in the SQL params.""" + conn = _FakeConn(rows=[]) + get_findings( + conn, + subscription_id="sub-1", + tenant_id="tenant-1", + severity="HIGH", + ) + params = conn._cursor.executed_params + assert "HIGH" in params + + def test_state_filter_passes_correct_param(self): + """get_findings with state filter includes state in the SQL params.""" + conn = _FakeConn(rows=[]) + get_findings( + conn, + subscription_id="sub-1", + tenant_id="tenant-1", + state="OPEN", + ) + params = conn._cursor.executed_params + assert "OPEN" in params + + +# --------------------------------------------------------------------------- +# get_compliance_posture +# --------------------------------------------------------------------------- + + +class TestGetCompliancePosture: + def test_valid_framework_returns_expected_keys(self): + """get_compliance_posture with a valid framework returns required keys.""" + row = (85.5, 42, 8, ["gap-1", "gap-2"]) + conn = _FakeConn(rows=[row]) + result = get_compliance_posture( + conn, + framework="mcsb", + subscription_id="sub-1", + tenant_id="tenant-1", + ) + assert result["framework"] == "mcsb" + assert result["score"] == 85.5 + assert result["pass_count"] == 42 + assert result["fail_count"] == 8 + assert result["gaps"] == ["gap-1", "gap-2"] + + def test_raises_value_error_for_unknown_framework(self): + """get_compliance_posture raises ValueError for an unrecognised framework.""" + conn = _FakeConn(rows=[]) + with pytest.raises(ValueError, match="Unknown compliance framework"): + get_compliance_posture( + conn, + framework="nist-made-up", + subscription_id="sub-1", + tenant_id="tenant-1", + ) + + def test_no_data_returns_zeroed_structure(self): + """get_compliance_posture returns zeroed structure when no rows exist.""" + conn = _FakeConn(rows=[]) + result = get_compliance_posture( + conn, + framework="cis", + subscription_id="sub-1", + tenant_id="tenant-1", + ) + assert result["score"] == 0.0 + assert result["pass_count"] == 0 + assert result["fail_count"] == 0 + assert result["gaps"] == [] + + +# --------------------------------------------------------------------------- +# get_attack_paths +# --------------------------------------------------------------------------- + + +class TestGetAttackPaths: + def test_returns_empty_list(self): + """get_attack_paths is a stub and always returns an empty list.""" + conn = _FakeConn(rows=[]) + result = get_attack_paths(conn, scan_id="scan-1", tenant_id="tenant-1") + assert result == [] + + +# --------------------------------------------------------------------------- +# explain_finding +# --------------------------------------------------------------------------- + + +class TestExplainFinding: + def test_returns_none_when_db_returns_no_row(self): + """explain_finding returns None when the finding does not exist.""" + conn = _FakeConn(rows=[]) + result = explain_finding(conn, finding_id=999, tenant_id="tenant-1") + assert result is None + + def test_returns_dict_with_expected_keys_when_row_exists(self): + """explain_finding returns a dict with all required keys when row found.""" + row = ( + 42, + "AZ-STOR-001", + "/subs/sub-1/rg/rg1/stor-1", + "MEDIUM", + "Storage account accessible publicly.", + "Disable public access on storage account.", + "OPEN", + ) + conn = _FakeConn(rows=[row]) + result = explain_finding(conn, finding_id=42, tenant_id="tenant-1") + assert result is not None + assert result["finding_id"] == 42 + assert result["rule_id"] == "AZ-STOR-001" + assert result["severity"] == "MEDIUM" + assert result["description"] == "Storage account accessible publicly." + assert result["remediation"] == "Disable public access on storage account." + assert result["state"] == "OPEN" + + +# --------------------------------------------------------------------------- +# suggest_remediation +# --------------------------------------------------------------------------- + + +class TestSuggestRemediation: + def test_returns_none_when_finding_not_found(self): + """suggest_remediation returns None when the underlying finding is missing.""" + conn = _FakeConn(rows=[]) + result = suggest_remediation(conn, finding_id=999, tenant_id="tenant-1") + assert result is None + + def test_returns_dict_with_steps_key_when_finding_exists(self): + """suggest_remediation returns a dict with a steps key when finding found.""" + row = ( + 7, + "AZ-ID-001", + "/subs/sub-1/rg/rg1/vm-1", + "HIGH", + "Identity misconfigured.", + "Assign a managed identity and remove inline credentials.", + "OPEN", + ) + conn = _FakeConn(rows=[row]) + result = suggest_remediation(conn, finding_id=7, tenant_id="tenant-1") + assert result is not None + assert "steps" in result + assert result["steps"] == "Assign a managed identity and remove inline credentials." + assert result["finding_id"] == 7 + assert result["rule_id"] == "AZ-ID-001" + assert result["resource_id"] == "/subs/sub-1/rg/rg1/vm-1" diff --git a/tests/test_cli_ask.py b/tests/test_cli_ask.py new file mode 100644 index 00000000..72d36c8e --- /dev/null +++ b/tests/test_cli_ask.py @@ -0,0 +1,163 @@ +"""Tests for the `openshield ask` CLI command.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_GOOD_RESPONSE = { + "answer": "You have 3 critical findings.", + "tools_used": ["get_findings", "explain_finding"], + "iterations": 2, + "confidence": "HIGH", +} + + +def _mock_response(status_code: int, json_body: dict) -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_body + resp.text = str(json_body) + return resp + + +def _run_cli(*args: str, env: dict | None = None) -> tuple[int, str, str]: + """Run the CLI with the given argv and return (exit_code, stdout, stderr).""" + import io + from cli import main + + stdout_buf = io.StringIO() + stderr_buf = io.StringIO() + + with patch("sys.stdout", stdout_buf), patch("sys.stderr", stderr_buf): + exit_code = main(list(args)) + + return exit_code, stdout_buf.getvalue(), stderr_buf.getvalue() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestAskCommand: + """Tests for `openshield ask`.""" + + def test_successful_response_prints_answer_and_metadata(self): + """A 200 response prints the answer then a metadata line.""" + mock_resp = _mock_response(200, _GOOD_RESPONSE) + + with ( + patch("requests.post", return_value=mock_resp) as mock_post, + patch.dict("os.environ", {"OPENSHIELD_TOKEN": "test-token"}, clear=False), + ): + exit_code, stdout, stderr = _run_cli( + "ask", + "What are my critical findings?", + "--subscription", + "sub-001", + ) + + assert exit_code == 0 + assert "You have 3 critical findings." in stdout + assert "[tools used:" in stdout + assert "get_findings" in stdout + assert "confidence: HIGH" in stdout + assert "iterations: 2" in stdout + # No live HTTP call — mock was used + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert "/api/agent/query" in call_kwargs[0][0] + body = call_kwargs[1]["json"] + assert body["question"] == "What are my critical findings?" + assert body["subscription_id"] == "sub-001" + + def test_api_error_prints_error_and_exits_1(self): + """A 4xx response prints the error field and exits with code 1.""" + mock_resp = _mock_response(403, {"error": "Subscription not authorized"}) + + with ( + patch("requests.post", return_value=mock_resp), + patch.dict("os.environ", {"OPENSHIELD_TOKEN": "test-token"}, clear=False), + ): + exit_code, stdout, stderr = _run_cli( + "ask", + "What are my critical findings?", + "--subscription", + "sub-001", + ) + + assert exit_code == 1 + assert "Subscription not authorized" in stderr + + def test_missing_subscription_exits_before_http_call(self): + """When subscription is absent (no arg, no env var), exit 1 without HTTP.""" + # Ensure AZURE_SUBSCRIPTION_ID is not set + with patch("requests.post") as mock_post, patch.dict("os.environ", {}, clear=True): + # Also clear OPENSHIELD_TOKEN to isolate; provide token via arg + exit_code, stdout, stderr = _run_cli( + "ask", + "What are my critical findings?", + "--token", + "test-token", + ) + + assert exit_code == 1 + assert "subscription" in stderr.lower() + mock_post.assert_not_called() + + def test_missing_token_exits_before_http_call(self): + """When token is absent (no arg, no env var), exit 1 without HTTP.""" + with patch("requests.post") as mock_post, patch.dict("os.environ", {}, clear=True): + exit_code, stdout, stderr = _run_cli( + "ask", + "What are my critical findings?", + "--subscription", + "sub-001", + ) + + assert exit_code == 1 + assert "token" in stderr.lower() + mock_post.assert_not_called() + + def test_connection_error_exits_1_with_friendly_message(self): + """A connection error prints a friendly message and exits with code 1.""" + import requests as req_lib + + with ( + patch("requests.post", side_effect=req_lib.ConnectionError("refused")), + patch.dict("os.environ", {"OPENSHIELD_TOKEN": "test-token"}, clear=False), + ): + exit_code, stdout, stderr = _run_cli( + "ask", + "What are my critical findings?", + "--subscription", + "sub-001", + ) + + assert exit_code == 1 + assert "connect" in stderr.lower() + + def test_framework_flag_included_in_request_body(self): + """When --framework is supplied, it is sent in the request body.""" + mock_resp = _mock_response(200, {**_GOOD_RESPONSE}) + + with ( + patch("requests.post", return_value=mock_resp) as mock_post, + patch.dict("os.environ", {"OPENSHIELD_TOKEN": "test-token"}, clear=False), + ): + exit_code, stdout, stderr = _run_cli( + "ask", + "What is my MCSB compliance score?", + "--subscription", + "sub-001", + "--framework", + "mcsb", + ) + + assert exit_code == 0 + body = mock_post.call_args[1]["json"] + assert body.get("framework") == "mcsb" diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 00000000..a24e3c73 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,33 @@ +"""Smoke tests for the OpenShield MCP server module.""" + +from mcp.server import TOOLS + + +def test_tools_count(): + """mcp/server.py must expose exactly 6 CSPM tool definitions.""" + assert len(TOOLS) == 6 + + +def test_tool_names(): + """Each tool spec must have a unique non-empty name.""" + names = [t["name"] for t in TOOLS] + assert sorted(names) == sorted( + [ + "scan_subscription", + "get_findings", + "get_compliance_posture", + "get_attack_paths", + "explain_finding", + "suggest_remediation", + ] + ) + + +def test_tool_schemas(): + """Every tool must have an inputSchema with at least one required field.""" + for tool in TOOLS: + schema = tool["inputSchema"] + assert schema.get("type") == "object" + assert "properties" in schema + assert "required" in schema + assert len(schema["required"]) >= 1