From ca74936a2b8f317d75e5f4a3b2e06c66c149a001 Mon Sep 17 00:00:00 2001 From: Sebastian Zumbado <59905760+S3B4SZ17@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:08:34 -0600 Subject: [PATCH 1/6] Updating the filter expression description for the tools (#5) # Updating the filter expression description for the tools ## Changes * Updating the filter expression description for the tools that need it. Each API endpoint will define the supported fields in the description and examples * The common query language expressions and operators are defined now in a separate resource * Adding a default non-root user to the container definition --------- Signed-off-by: S3B4SZ17 --- .github/workflows/publish.yaml | 13 +-- .github/workflows/test.yaml | 12 +-- Dockerfile | 2 + README.md | 8 ++ charts/sysdig-mcp/values.yaml | 13 ++- main.py | 31 ++++++- pyproject.toml | 2 +- tests/conftest.py | 12 ++- tools/events_feed/tool.py | 7 +- tools/inventory/tool.py | 83 +++++++++++++++++-- tools/sysdig_sage/tool.py | 9 ++- tools/vulnerability_management/tool.py | 107 +++++++++++++++---------- utils/mcp_server.py | 91 ++++++++++++++++++--- utils/middleware/auth.py | 2 +- utils/reports/inventory_report.py | 39 ++++++--- uv.lock | 2 +- 16 files changed, 329 insertions(+), 104 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 9d18d6e..63c6184 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,6 +5,7 @@ on: push: branches: - main + - beta paths: - pyproject.toml - Dockerfile @@ -84,20 +85,22 @@ jobs: runs-on: ubuntu-latest needs: push_to_registry steps: - - name: Check out the repo + - name: Check out repository uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} # required for better experience using pre-releases + fetch-depth: '0' # Required due to the way Git works, without it this action won't be able to find any or the correct tags - name: Get tag version id: semantic_release - uses: anothrNick/github-tag-action@1.73.0 + uses: anothrNick/github-tag-action@1.71.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEFAULT_BUMP: "patch" - TAG_CONTEXT: ${{ (github.base_ref != 'main') && 'branch' || 'repo' }} + TAG_CONTEXT: 'repo' + WITH_V: true PRERELEASE_SUFFIX: "beta" PRERELEASE: ${{ (github.base_ref != 'main') && 'true' || 'false' }} - DRY_RUN: false - INITIAL_VERSION: ${{ needs.push_to_registry.outputs.tag }} - name: Summary run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index abde233..fa9da10 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -66,11 +66,11 @@ jobs: permissions: contents: write # required for creating a tag steps: - - name: Check out the repo + - name: Check out repository uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} # checkout the correct branch name - fetch-depth: 0 + ref: ${{ github.sha }} # required for better experience using pre-releases + fetch-depth: '0' # Required due to the way Git works, without it this action won't be able to find any or the correct tags - name: Extract current version id: pyproject_version @@ -80,15 +80,15 @@ jobs: - name: Get tag version id: semantic_release - uses: anothrNick/github-tag-action@1.73.0 + uses: anothrNick/github-tag-action@1.71.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEFAULT_BUMP: "patch" - TAG_CONTEXT: ${{ (github.base_ref != 'main') && 'branch' || 'repo' }} + TAG_CONTEXT: 'repo' + WITH_V: true PRERELEASE_SUFFIX: "beta" PRERELEASE: ${{ (github.base_ref != 'main') && 'true' || 'false' }} DRY_RUN: true - INITIAL_VERSION: ${{ steps.pyproject_version.outputs.TAG }} - name: Compare versions run: | diff --git a/Dockerfile b/Dockerfile index 97a20c5..472a84f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,4 +33,6 @@ COPY --from=builder --chown=app:app /app/app_config.yaml /app RUN pip install /app/sysdig_mcp_server.tar.gz +USER 1001:1001 + ENTRYPOINT ["sysdig-mcp-server"] diff --git a/README.md b/README.md index aa049d0..2060249 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - [Description](#description) - [Quickstart Guide](#quickstart-guide) - [Available Tools](#available-tools) + - [Available Resources](#available-resources) - [Requirements](#requirements) - [UV Setup](#uv-setup) - [Configuration](#configuration) @@ -124,6 +125,13 @@ Get up and running with the Sysdig MCP Server quickly using our pre-built Docker +### Available Resources + +- Sysdig Secure Vulnerability Management Overview: + - VM documentation based on the following [url](https://docs.sysdig.com/en/sysdig-secure/vulnerability-management/) +- Sysdig Filter Query Language Instructions: + - Sysdig Filter Query Language for different API endpoint filters + ## Requirements ### UV Setup diff --git a/charts/sysdig-mcp/values.yaml b/charts/sysdig-mcp/values.yaml index 4993f5d..de6bd1a 100644 --- a/charts/sysdig-mcp/values.yaml +++ b/charts/sysdig-mcp/values.yaml @@ -46,13 +46,12 @@ podLabels: {} podSecurityContext: {} # fsGroup: 2000 -securityContext: {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 +securityContext: + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1001 + runAsGroup: 1001 + fsGroup: 1001 service: type: ClusterIP diff --git a/main.py b/main.py index 0ff9ee6..1abc108 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,9 @@ """ import os -import asyncio +import signal +import sys +import logging from dotenv import load_dotenv # Application config loader @@ -12,16 +14,34 @@ # Register all tools so they attach to the MCP server from utils.mcp_server import run_stdio, run_http +# Set up logging +logging.basicConfig( + format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", + level=os.environ.get("LOGLEVEL", "ERROR"), +) +log = logging.getLogger(__name__) + # Load environment variables from .env load_dotenv() app_config = get_app_config() +def handle_signals(): + def signal_handler(sig, frame): + log.info(f"Received signal {sig}, shutting down...") + os._exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGHUP, signal_handler) + + def main(): # Choose transport: "stdio" or "sse" (HTTP/SSE) + handle_signals() transport = os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower() - print(""" + log.info(""" ▄▖ ▌▘ ▖ ▖▄▖▄▖ ▄▖ ▚ ▌▌▛▘▛▌▌▛▌ ▛▖▞▌▌ ▙▌ ▚ █▌▛▘▌▌█▌▛▘ ▄▌▙▌▄▌▙▌▌▙▌ ▌▝ ▌▙▖▌ ▄▌▙▖▌ ▚▘▙▖▌ @@ -29,11 +49,14 @@ def main(): """) if transport == "stdio": # Run MCP server over STDIO (local) - asyncio.run(run_stdio()) + run_stdio() else: # Run MCP server over streamable HTTP by default run_http() if __name__ == "__main__": - main() + try: + sys.exit(main()) + except KeyboardInterrupt: + os._exit(0) diff --git a/pyproject.toml b/pyproject.toml index d22daf5..a4ba989 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sysdig-mcp-server" -version = "0.1.1" +version = "0.1.2-beta.0" description = "Sysdig MCP Server" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/conftest.py b/tests/conftest.py index aa73121..29f8dac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,14 @@ from fastmcp import FastMCP +class MockMCP(FastMCP): + """ + Mock class for FastMCP + """ + + pass + + def util_load_json(path): """ Utility function to load a JSON file from the given path. @@ -42,8 +50,8 @@ def mock_ctx(): Returns: Context: A mocked Context object with 'fastmcp' tags. """ - fastmcp: FastMCP = FastMCP( - name="Test", + + fastmcp: MockMCP = MockMCP( tags=["sysdig", "mcp", "stdio"], ) ctx = Context(fastmcp=fastmcp) diff --git a/tools/events_feed/tool.py b/tools/events_feed/tool.py index 9a2b8ad..18ada11 100644 --- a/tools/events_feed/tool.py +++ b/tools/events_feed/tool.py @@ -14,6 +14,7 @@ from fastmcp import Context from sysdig_client import ApiException from fastmcp.prompts.prompt import PromptMessage, TextContent +from fastmcp.exceptions import ToolError from starlette.requests import Request from sysdig_client.api import SecureEventsApi from utils.sysdig.old_sysdig_api import OldSysdigApi @@ -99,7 +100,7 @@ def tool_get_event_info(self, event_id: str, ctx: Context) -> dict: response = create_standard_response(results=raw, execution_time_ms=execution_time) return response - except ApiException as e: + except ToolError as e: logging.error("Exception when calling SecureEventsApi->get_event_v1: %s\n" % e) raise e @@ -181,7 +182,7 @@ def tool_list_runtime_events( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling SecureEventsApi->get_events_v1: {e}\n") raise e @@ -225,7 +226,7 @@ def tool_get_event_process_tree(self, ctx: Context, event_id: str) -> Dict[str, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling Sysdig Sage API to get process tree: {e}") raise e diff --git a/tools/inventory/tool.py b/tools/inventory/tool.py index 4037721..f426732 100644 --- a/tools/inventory/tool.py +++ b/tools/inventory/tool.py @@ -9,6 +9,7 @@ from pydantic import Field from fastmcp.server.dependencies import get_http_request from fastmcp import Context +from fastmcp.exceptions import ToolError from starlette.requests import Request from sysdig_client import ApiException from sysdig_client.api import InventoryApi @@ -18,8 +19,8 @@ from utils.query_helpers import create_standard_response # Configure logging -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) # Load app config (expects keys: mcp.host, mcp.port, mcp.transport) app_config = get_app_config() @@ -69,12 +70,78 @@ def tool_list_resources( Field( description=( """ - Sysdig Secure filter expression for inventory resources, - base filter: platform in ("GCP", "AWS", "Azure", "Kubernetes"), - Examples: - not isExposed exists; category in ("IAM") and isExposed exists; category in ("IAM","Audit & Monitoring") + Sysdig Secure query filter expression to filter inventory resources. + + Use the resource://filter-query-language to get the expected filter expression format. + + List of supported fields: + - accountName + - accountId + - cluster + - externalDNS + - distribution + - integrationName + - labels + - location + - name + - namespace + - nodeType + - osName + - osImage + - organization + - platform + - control.accepted + - policy + - control.severity + - control.failed + - policy.failed + - policy.passed + - projectName + - projectId + - region + - repository + - resourceOrigin + - type + - subscriptionName + - subscriptionId + - sourceType + - version + - zone + - category + - isExposed + - validatedExposure + - arn + - resourceId + - container.name + - architecture + - baseOS + - digest + - imageId + - os + - container.imageName + - image.registry + - image.tag + - package.inUse + - package.info + - package.path + - package.type + - vuln.cvssScore + - vuln.hasExploit + - vuln.hasFix + - vuln.name + - vuln.severity + - machineImage """ - ) + ), + examples=[ + 'zone in ("zone1") and machineImage = "ami-0b22b359fdfabe1b5"', + '(projectId = "1235495521" or projectId = "987654321") and vuln.severity in ("Critical")', + 'vuln.name in ("CVE-2023-0049")', + 'vuln.cvssScore >= "3"', + 'container.name in ("sysdig-container") and not labels exists', + 'imageId in ("sha256:3768ff6176e29a35ce1354622977a1e5c013045cbc4f30754ef3459218be8ac")', + 'platform in ("GCP", "AWS", "Azure", "Kubernetes") and isExposed exists', + ], ), ] = 'platform in ("GCP", "AWS", "Azure", "Kubernetes")', page_number: Annotated[int, Field(ge=1, description="Page number for pagination (1-based index)")] = 1, @@ -112,7 +179,7 @@ def tool_list_resources( response = create_standard_response(results=api_response, execution_time_ms=execution_time) return response - except ApiException as e: + except ToolError as e: logging.error("Exception when calling InventoryApi->get_resources: %s\n" % e) raise e @@ -141,6 +208,6 @@ def tool_get_resource( response = create_standard_response(results=api_response, execution_time_ms=execution_time) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling InventoryApi->get_resource: {e}") raise e diff --git a/tools/sysdig_sage/tool.py b/tools/sysdig_sage/tool.py index fc33153..516d926 100644 --- a/tools/sysdig_sage/tool.py +++ b/tools/sysdig_sage/tool.py @@ -9,6 +9,7 @@ import time from typing import Any, Dict from fastmcp import Context +from fastmcp.exceptions import ToolError from utils.sysdig.old_sysdig_api import OldSysdigApi from starlette.requests import Request from fastmcp.server.dependencies import get_http_request @@ -74,7 +75,7 @@ async def tool_sysdig_sage(self, ctx: Context, question: str) -> Dict[str, Any]: Dict: JSON-decoded response of the executed SysQL query, or an error object. Raises: - Exception: If the SysQL query generation or execution fails. + ToolError: If the SysQL query generation or execution fails. Examples: # tool_sysdig_sage(question="Match Cloud Resource affected by Critical Vulnerability") @@ -87,8 +88,8 @@ async def tool_sysdig_sage(self, ctx: Context, question: str) -> Dict[str, Any]: old_sysdig_api = self.init_client(config_tags=ctx.fastmcp.tags) sysql_response = await old_sysdig_api.generate_sysql_query(question) if sysql_response.status > 299: - raise Exception(f"Sysdig Sage returned an error: {sysql_response.status} - {sysql_response.data}") - except Exception as e: + raise ToolError(f"Sysdig Sage returned an error: {sysql_response.status} - {sysql_response.data}") + except ToolError as e: log.error(f"Failed to generate SysQL query: {e}") raise e json_resp = sysql_response.json() if sysql_response.data else {} @@ -107,6 +108,6 @@ async def tool_sysdig_sage(self, ctx: Context, question: str) -> Dict[str, Any]: ) return response - except Exception as e: + except ToolError as e: log.error(f"Failed to execute SysQL query: {e}") raise e diff --git a/tools/vulnerability_management/tool.py b/tools/vulnerability_management/tool.py index b70f6c4..7a0bed6 100644 --- a/tools/vulnerability_management/tool.py +++ b/tools/vulnerability_management/tool.py @@ -12,6 +12,7 @@ from sysdig_client.models.scan_result_response import ScanResultResponse from sysdig_client.models.get_policy_response import GetPolicyResponse from fastmcp.prompts.prompt import PromptMessage, TextContent +from fastmcp.exceptions import ToolError from starlette.requests import Request from sysdig_client.api import VulnerabilityManagementApi from fastmcp.server.dependencies import get_http_request @@ -73,14 +74,36 @@ def tool_list_runtime_vulnerabilities( Field( description=( """ - Logical filter expression to select runtime vulnerabilities. - Supports operators: =, !=, in, exists, contains, startsWith. Combine with and/or/not. - Key fields include: asset.type, aws.account.id, aws.host.name, aws.region, - cloudProvider, cloudProvider.account.id, cloudProvider.region, - gcp.instance.id, gcp.instance.zone, gcp.project.id, gcp.project.numericId, - host.hostName, kubernetes.cluster.name, kubernetes.namespace.name, kubernetes.node.name, - kubernetes.pod.container.name, kubernetes.workload.name, kubernetes.workload.type, - workload.name, workload.orchestrator + Sysdig Secure query filter expression to filter runtime vulnerability scan results. + + Use the resource://filter-query-language to get the expected filter expression format. + + Key fields include: + - asset.type + - aws.account.id + - aws.host.name + - aws.region + - cloudProvider + - cloudProvider.account.id + - cloudProvider.region + - gcp.instance.id + - gcp.instance.zone + - gcp.project.id + - gcp.project.numericId + - host.hostName + - kubernetes.cluster.name + - kubernetes.namespace.name + - kubernetes.node.name + - kubernetes.pod.container.name + - kubernetes.workload.name + - kubernetes.workload.type + - workload.name + - workload.orchestrator + + The supported fields are all the fields of the Scope above, plus:: + - freeText + - hasRunningVulns + - hasRunningVulns. """ ), examples=[ @@ -138,9 +161,9 @@ def tool_list_runtime_vulnerabilities( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->scanner_api_service_list_runtime_results: {e}") - return {"error": str(e), "cursor": None} + raise e def tool_list_accepted_risks( self, @@ -179,7 +202,7 @@ def tool_list_accepted_risks( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->get_accepted_risks_v1: {e}") raise e @@ -197,7 +220,7 @@ def tool_get_accepted_risk(self, ctx: Context, accepted_risk_id: str) -> dict: vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) response = vulnerability_api.get_accepted_risk_v1(accepted_risk_id) return response.model_dump_json() if hasattr(response, "dict") else response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->get_accepted_risk_v1: {e}") raise e @@ -208,20 +231,20 @@ def tool_list_registry_scan_results( Optional[str], Field( description=( - "Logical filter expression to select registry scan results. " - "Supports operators: =, !=, in, exists, contains, startsWith. " - "Combine with and/or/not. " - "Key selectors include: " - '- policyStatus (values "noPolicy", "failed", "passed", "accepted"), ' - "- registry.vendor, registry.name, freeText" + """ + Sysdig Secure query filter expression to filter vulnerability scan results on registries. + + Use the resource://filter-query-language to get the expected filter expression format. + + The supported fields are: + - freeText + - vendor + """ ), examples=[ - 'policyStatus in ("noPolicy") and registry.vendor = "harbor"', - 'registry.vendor = "dockerv2" and registry.name = "index.docker.io"', - 'registry.vendor = "harbor" and freeText in ("redis")', - 'policyStatus in ("failed") and registry.vendor = "harbor"' - 'policyStatus in ("passed", "accepted") and registry.vendor = "harbor"', - 'registry.vendor = "dockerv2" and registry.name = "registry.access.redhat.com"', + 'freeText = "alpine:latest" and vendor = "docker"', + 'vendor = "ecr"', + 'vendor = "harbor" and freeText in ("redis")', ], ), ] = None, @@ -235,13 +258,10 @@ def tool_list_registry_scan_results( filter (Optional[str]): Logical filter expression to select registry scan results. Supports operators: =, !=, in, exists, contains, startsWith. Combine with and/or/not. - Key selectors include: - - policyStatus (values "noPolicy", "failed", "passed", "accepted"), - - registry.vendor, registry.name, freeText + Key selectors include: freeText (string), vendor (e.g., "docker", "ecr", "harbor"). Examples: - - policyStatus in ("noPolicy") and registry.vendor = "harbor" - - registry.vendor = "dockerv2" and registry.name = "index.docker.io" - - registry.vendor = "harbor" and freeText in ("redis") + - freeText = "alpine:latest" and vendor = "docker" + - vendor = "ecr" limit (int): Maximum number of results to return. cursor (Optional[str]): Pagination cursor. If None, returns the first page. @@ -262,7 +282,7 @@ def tool_list_registry_scan_results( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->scanner_api_service_list_registry_results: {e}") raise e @@ -283,7 +303,7 @@ def tool_get_vulnerability_policy( vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) response: GetPolicyResponse = vulnerability_api.secure_vulnerability_v1_policies_policy_id_get(policy_id) return response.model_dump_json() if hasattr(response, "dict") else response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_policies_policy_id_get: {e}") raise e @@ -323,7 +343,7 @@ def tool_list_vulnerability_policies( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_policies_get: {e}") raise e @@ -335,17 +355,18 @@ def tool_list_pipeline_scan_results( Optional[str], Field( description=( - "Logical filter expression to select pipeline scan results. " - "Supports operators: =, !=, in, exists, contains, startsWith. " - "Combine with and/or/not. " - "Key selectors include: " - "- policyEvaluationsPassed (true/false), " - "- freeText (string)." + """ + Sysdig Secure query filter expression to filter vulnerability scan results on pipelines. + + Use the resource://filter-query-language to get the expected filter expression format. + + The supported fields are: + - freeText + """ ), examples=[ - "policyEvaluationsPassed = true", + 'freeText in ("nginx")', 'freeText in ("ubuntu")', - 'policyEvaluationsPassed = false and freeText in ("ubuntu")', ], ), ] = None, @@ -387,7 +408,7 @@ def tool_list_pipeline_scan_results( execution_time_ms=duration_ms, ) return response - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_policies_get: {e}") raise e @@ -405,7 +426,7 @@ def tool_get_scan_result(self, ctx: Context, scan_id: str) -> dict: vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) resp: ScanResultResponse = vulnerability_api.secure_vulnerability_v1_results_result_id_get(scan_id) return resp.model_dump_json() if hasattr(resp, "dict") else resp - except ApiException as e: + except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_results_result_id_get: {e}") raise e diff --git a/utils/mcp_server.py b/utils/mcp_server.py index e951e91..0b49e21 100644 --- a/utils/mcp_server.py +++ b/utils/mcp_server.py @@ -5,12 +5,14 @@ import logging import os +import asyncio from typing import Optional import uvicorn from starlette.requests import Request from starlette.responses import JSONResponse, Response from fastapi import FastAPI from fastmcp import FastMCP +from fastmcp.resources import HttpResource, TextResource from utils.middleware.auth import CustomAuthMiddleware from starlette.middleware import Middleware from tools.events_feed.tool import EventsFeedTools @@ -22,11 +24,11 @@ from utils.app_config import get_app_config # Set up logging -log = logging.getLogger(__name__) logging.basicConfig( format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR"), ) +log = logging.getLogger(__name__) # Load app config (expects keys: mcp.host, mcp.port, mcp.transport) app_config = get_app_config() @@ -66,14 +68,23 @@ def get_mcp() -> FastMCP: return _mcp_instance -async def run_stdio(): +def run_stdio(): """ Run the MCP server using STDIO transport. """ mcp = get_mcp() # Add tools to the MCP server add_tools(mcp) - await mcp.run_stdio_async() + # Add resources to the MCP server + add_resources(mcp) + try: + asyncio.run(mcp.run_stdio_async()) + except KeyboardInterrupt: + log.info("Keyboard interrupt received, forcing immediate exit") + os._exit(0) + except Exception as e: + log.error(f"Exception received, forcing immediate exit: {str(e)}") + os._exit(1) def run_http(): @@ -81,6 +92,8 @@ def run_http(): mcp = get_mcp() # Add tools to the MCP server add_tools(mcp) + # Add resources to the MCP server + add_resources(mcp) # Mount the MCP HTTP/SSE app at '/sysdig-mcp-server' mcp_app = mcp.http_app( path="/mcp", transport=os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower(), middleware=middlewares @@ -100,13 +113,27 @@ async def health_check(request: Request) -> Response: """ return JSONResponse({"status": "ok"}) - print(f"Starting {mcp.name} at http://{app_config['app']['host']}:{app_config['app']['port']}/sysdig-mcp-server/mcp") - uvicorn.run( + log.info(f"Starting {mcp.name} at http://{app_config['app']['host']}:{app_config['app']['port']}/sysdig-mcp-server/mcp") + # Use Uvicorn's Config and Server classes for more control + config = uvicorn.Config( app, - port=app_config["app"]["port"], host=app_config["app"]["host"], + port=app_config["app"]["port"], + timeout_graceful_shutdown=1, log_level=os.environ.get("LOGLEVEL", app_config["app"]["log_level"]).lower(), ) + server = uvicorn.Server(config) + + # Override the default behavior + server.force_exit = True # This makes Ctrl+C force exit + try: + asyncio.run(server.serve()) + except KeyboardInterrupt: + log.info("Keyboard interrupt received, forcing immediate exit") + os._exit(0) + except Exception as e: + log.error(f"Exception received, forcing immediate exit: {str(e)}") + os._exit(1) def add_tools(mcp: FastMCP) -> None: @@ -152,10 +179,6 @@ def add_tools(mcp: FastMCP) -> None: description=( """ List inventory resources based on a Sysdig Secure query filter expression with optional pagination.' - Example filters: not isExposed exists; category in ("IAM") and isExposed exists; - category in ("IAM","Audit & Monitoring"); - vuln.hasFix exists and vuln.hasExploit exists and isExposed exists and package.inUse exists and - validatedExposure exists and control.failed in ("Contains AI Package"); """ ), ) @@ -233,3 +256,51 @@ def add_tools(mcp: FastMCP) -> None: """ ), ) + + +def add_resources(mcp: FastMCP) -> None: + """ + Add resources to the MCP server. + Args: + mcp (FastMCP): The FastMCP server instance. + """ + vm_docs = HttpResource( + name="Sysdig Secure Vulnerability Management Overview", + description="Sysdig Secure Vulnerability Management documentation.", + uri="resource://sysdig-secure-vulnerability-management", + url="https://docs.sysdig.com/en/sysdig-secure/vulnerability-management/", + tags=["documentation"], + ) + filter_query_language = TextResource( + name="Sysdig Filter Query Language", + description=( + "Sysdig Filter Query Language documentation. " + "Learn how to filter resources in Sysdig using the Filter Query Language for the API calls." + ), + uri="resource://filter-query-language", + text=( + """ + Query language expressions for filtering results. + The query language allows you to filter resources based on their attributes. + You can use the following operators and functions to build your queries: + + Operators: + - `and` and `not` logical operators + - `=`, `!=` + - `in` + - `contains` and `startsWith` to check partial values of attributes + - `exists` to check if a field exists and not empty + + Note: + The supported fields are going to depend on the API endpoint you are querying. + Check the description of each tool for the supported fields. + + Examples: + - in ("example") and = "example2" + - >= "3" + """ + ), + tags=["query-language", "documentation"], + ) + mcp.add_resource(vm_docs) + mcp.add_resource(filter_query_language) diff --git a/utils/middleware/auth.py b/utils/middleware/auth.py index bc6697d..67ae8b6 100644 --- a/utils/middleware/auth.py +++ b/utils/middleware/auth.py @@ -14,8 +14,8 @@ from utils.app_config import get_app_config # Set up logging -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) # Load app config (expects keys: mcp.host, mcp.port, mcp.transport) app_config = get_app_config() diff --git a/utils/reports/inventory_report.py b/utils/reports/inventory_report.py index d0e17bb..ab6a3fa 100644 --- a/utils/reports/inventory_report.py +++ b/utils/reports/inventory_report.py @@ -6,11 +6,30 @@ import os import dask.dataframe as dd import pandas as pd -from tools.inventory.tool import tool_list_resources +from tools.inventory.tool import InventoryTools +from fastmcp import Context, FastMCP # Configure logging -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) + + +inventory = InventoryTools() + + +class MockMCP(FastMCP): + """ + Mock class for FastMCP + """ + + pass + + +# Mocking MCP context for the inventory tool +fastmcp: MockMCP = MockMCP( + tags=["sysdig", "mcp", "stdio"], +) +ctx = Context(fastmcp=fastmcp) def list_all_resources(filter_exp: str = 'platform in ("GCP")') -> dd.DataFrame: @@ -26,14 +45,16 @@ def list_all_resources(filter_exp: str = 'platform in ("GCP")') -> dd.DataFrame: df: dd.DataFrame = None logging.debug(f"Listing all resources with filter: {filter_exp}") try: - resources = tool_list_resources(filter_exp=filter_exp, page_number=1, page_size=1000) - df = pd.DataFrame.from_records([r.to_dict() for r in resources.data]) - while resources.page.next: + resources = inventory.tool_list_resources(ctx=ctx, filter_exp=filter_exp, page_number=1, page_size=1000) + df = pd.DataFrame.from_records([r for r in resources.get("results", {}).get("data", [])]) + while resources.get("results", {}).get("page", {}).get("next"): # Get the next page of resources - logging.debug(f"Fetching next page: {resources.page.next}") - next_page = resources.page.next - resources = tool_list_resources(filter_exp=filter_exp, page_number=next_page, page_size=1000) - df = dd.concat([df, pd.DataFrame.from_records([r.to_dict() for r in resources.data])], ignore_index=True) + next_page = resources.get("results", {}).get("page", {}).get("next") + logging.debug(f"Fetching next page: {next_page}") + resources = inventory.tool_list_resources(ctx=ctx, filter_exp=filter_exp, page_number=next_page, page_size=1000) + df = dd.concat( + [df, pd.DataFrame.from_records([r for r in resources.get("results", {}).get("data", [])])], ignore_index=True + ) dd.from_pandas return df except Exception as e: diff --git a/uv.lock b/uv.lock index 0b85cab..8dd7000 100644 --- a/uv.lock +++ b/uv.lock @@ -732,7 +732,7 @@ wheels = [ [[package]] name = "sysdig-mcp-server" -version = "0.1.1" +version = "0.1.2b0" source = { editable = "." } dependencies = [ { name = "dask" }, From a46dd9ed1bf205bf9dfce10fae1f8e79647d4464 Mon Sep 17 00:00:00 2001 From: S3B4SZ17 Date: Thu, 3 Jul 2025 19:12:43 -0600 Subject: [PATCH 2/6] fix: Updating GH concurrency error and updating helm chart Signed-off-by: S3B4SZ17 --- .github/workflows/helm_test.yaml | 14 +++++--------- .github/workflows/publish.yaml | 13 +++++++++++-- .github/workflows/test.yaml | 12 +++++++----- charts/sysdig-mcp/Chart.yaml | 4 ++-- charts/sysdig-mcp/values.yaml | 3 +-- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/.github/workflows/helm_test.yaml b/.github/workflows/helm_test.yaml index 4427b67..5c7eb0b 100644 --- a/.github/workflows/helm_test.yaml +++ b/.github/workflows/helm_test.yaml @@ -5,24 +5,20 @@ on: pull_request: branches: - main - - develop - - feature/** - - release/** - - hotfix/** + - beta paths: - 'charts/**' push: branches: - main - - develop - - feature/** - - release/** - - hotfix/** + - beta paths: - 'charts/**' + workflow_call: + workflow_dispatch: concurrency: - group: '${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + group: 'helm-test-${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 63c6184..12e5d90 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -16,7 +16,7 @@ on: workflow_dispatch: concurrency: - group: '${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + group: 'publish-${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: @@ -100,10 +100,19 @@ jobs: TAG_CONTEXT: 'repo' WITH_V: true PRERELEASE_SUFFIX: "beta" - PRERELEASE: ${{ (github.base_ref != 'main') && 'true' || 'false' }} + PRERELEASE: ${{ (github.base_ref == 'beta') && 'true' || (github.base_ref == 'main') && 'false' || (github.base_ref == 'integration') && 'false' || 'true' }} - name: Summary run: | echo "## Release Summary - Tag: ${{ steps.semantic_release.outputs.tag }} - Docker Image: ghcr.io/sysdiglabs/sysdig-mcp-server:v${{ needs.push_to_registry.outputs.version }}" >> $GITHUB_STEP_SUMMARY + + test_helm_chart: + name: Test Helm Chart + needs: push_to_registry + permissions: + contents: read # required for actions/checkout + pull-requests: write # required for creating a PR with the chart changes + uses: ./.github/workflows/helm_test.yaml + secrets: inherit diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fa9da10..5b830ff 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,7 +5,8 @@ on: push: branches: - main - - develop + - beta + - integration - feature/** - release/** - hotfix/** @@ -25,9 +26,10 @@ on: - tools/** - utils/** workflow_call: + workflow_dispatch: concurrency: - group: '${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + group: 'tests-${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: @@ -59,8 +61,8 @@ jobs: - name: Run Unit Tests run: make test - pre_release: - name: Tag Release + check_version: + name: Check Version runs-on: ubuntu-latest needs: test permissions: @@ -87,7 +89,7 @@ jobs: TAG_CONTEXT: 'repo' WITH_V: true PRERELEASE_SUFFIX: "beta" - PRERELEASE: ${{ (github.base_ref != 'main') && 'true' || 'false' }} + PRERELEASE: ${{ (github.base_ref == 'beta') && 'true' || (github.base_ref == 'main') && 'false' || (github.base_ref == 'integration') && 'false' || 'true' }} DRY_RUN: true - name: Compare versions diff --git a/charts/sysdig-mcp/Chart.yaml b/charts/sysdig-mcp/Chart.yaml index 4fe758e..4dd3d2b 100644 --- a/charts/sysdig-mcp/Chart.yaml +++ b/charts/sysdig-mcp/Chart.yaml @@ -20,10 +20,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.1 +version: 0.1.2 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.1.1" +appVersion: "v0.1.2" diff --git a/charts/sysdig-mcp/values.yaml b/charts/sysdig-mcp/values.yaml index de6bd1a..9ea3e33 100644 --- a/charts/sysdig-mcp/values.yaml +++ b/charts/sysdig-mcp/values.yaml @@ -8,7 +8,7 @@ image: repository: ghcr.io/sysdiglabs/sysdig-mcp-server pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. - tag: "v0.1.1-e789d6e" + tag: "v0.1.2-beta.0-3d30346" imagePullSecrets: [] nameOverride: "" @@ -51,7 +51,6 @@ securityContext: runAsNonRoot: true runAsUser: 1001 runAsGroup: 1001 - fsGroup: 1001 service: type: ClusterIP From e981076cd697a38f7efcacd84e3dc2ba06b2beeb Mon Sep 17 00:00:00 2001 From: Sebastian Zumbado <59905760+S3B4SZ17@users.noreply.github.com> Date: Wed, 9 Jul 2025 01:23:13 -0600 Subject: [PATCH 3/6] Add Sysdig CLI scanner tool (#7) # Add Sysdig CLI scanner tool ## Changes * Adding the Sysdig CLI scanner tool * The tool will help you run vuln scans against a particular image or use the IaC mode for infrastructure scans * You need to have the `sysdig-cli-scanner` binary installed * Overall format adjustments --------- Signed-off-by: S3B4SZ17 --- .github/workflows/helm_test.yaml | 41 +-- .github/workflows/publish.yaml | 6 +- .github/workflows/test.yaml | 24 +- README.md | 38 +++ app_config.yaml | 6 + charts/sysdig-mcp/Chart.yaml | 4 +- charts/sysdig-mcp/templates/configmap.yaml | 1 - charts/sysdig-mcp/templates/secrets.yaml | 1 - charts/sysdig-mcp/values.schema.json | 141 +++++++++++ charts/sysdig-mcp/values.yaml | 8 +- pyproject.toml | 4 +- tests/conftest.py | 29 +-- tests/events_feed_test.py | 8 +- tools/cli_scanner/__init__.py | 1 + tools/cli_scanner/tool.py | 187 ++++++++++++++ tools/events_feed/tool.py | 35 +-- tools/inventory/tool.py | 51 ++-- tools/sysdig_sage/tool.py | 32 +-- tools/vulnerability_management/tool.py | 71 +++--- utils/app_config.py | 6 +- utils/mcp_server.py | 275 ++++++++++++--------- utils/query_helpers.py | 1 + utils/reports/inventory_report.py | 20 +- utils/sysdig/api.py | 2 +- utils/sysdig/client_config.py | 34 ++- utils/sysdig/old_sysdig_api.py | 2 +- uv.lock | 6 +- 27 files changed, 691 insertions(+), 343 deletions(-) create mode 100644 charts/sysdig-mcp/values.schema.json create mode 100644 tools/cli_scanner/__init__.py create mode 100644 tools/cli_scanner/tool.py diff --git a/.github/workflows/helm_test.yaml b/.github/workflows/helm_test.yaml index 5c7eb0b..c0d65d8 100644 --- a/.github/workflows/helm_test.yaml +++ b/.github/workflows/helm_test.yaml @@ -4,8 +4,8 @@ name: Lint & Test helm chart on: pull_request: branches: - - main - beta + - main paths: - 'charts/**' push: @@ -22,35 +22,9 @@ concurrency: cancel-in-progress: true jobs: - set-charts: - # Required permissions - permissions: - contents: read - pull-requests: read - outputs: - charts: ${{ steps.charts.outputs.changes }} - name: "Set Charts" - runs-on: [ubuntu-latest] - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v2 - id: charts - with: - base: ${{ github.ref_name }} - filters: | - sysdig-mcp: - - 'charts/sysdig-mcp/**' lint-charts: - needs: set-charts name: Lint new helm charts runs-on: [ubuntu-latest] - strategy: - matrix: - chart: ${{ fromJSON(needs.set-charts.outputs.charts) }} - # When set to true, GitHub cancels all in-progress jobs if any matrix job fails. - fail-fast: false - # The maximum number of jobs that can run simultaneously - max-parallel: 3 steps: - uses: actions/checkout@v4 @@ -60,7 +34,7 @@ jobs: - name: Set up Helm uses: azure/setup-helm@v4 with: - version: v3.5.0 + version: v3.13.3 - uses: actions/setup-python@v4 with: @@ -68,7 +42,9 @@ jobs: check-latest: true - name: Set up chart-testing - uses: helm/chart-testing-action@v2.6.1 + uses: helm/chart-testing-action@v2.7.0 + with: + version: v3.13.0 - name: Run chart-testing (list-changed) id: list-changed @@ -83,9 +59,10 @@ jobs: run: ct lint --target-branch ${{ github.event.repository.default_branch }} --chart-dirs charts - name: Create kind cluster - if: steps.list-changed.outputs.changed == 'true' + if: github.ref_name == 'beta' || github.ref_name == 'main' uses: helm/kind-action@v1.12.0 - name: Run chart-testing (install) - if: steps.list-changed.outputs.changed == 'true' - run: ct install --target-branch ${{ github.event.repository.default_branch }} --chart-dirs charts + if: github.ref_name == 'beta' || github.ref_name == 'main' + run: | + ct install --target-branch ${{ github.event.repository.default_branch }} --chart-dirs charts diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 12e5d90..e77a209 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -7,13 +7,13 @@ on: - main - beta paths: + - '.github/workflows/**' - pyproject.toml - Dockerfile - '*.py' - tests/** - tools/** - utils/** - workflow_dispatch: concurrency: group: 'publish-${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' @@ -45,7 +45,7 @@ jobs: - name: Extract version id: extract_version run: | - VERSION=$(grep 'version =' pyproject.toml | sed -e 's/version = "\(.*\)"/\1/')-$(echo $GITHUB_SHA | cut -c1-7) + VERSION=$(grep 'version =' pyproject.toml | sed -e 's/version = "\(.*\)"/\1/') echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" TAG=v$(grep 'version =' pyproject.toml | sed -e 's/version = "\(.*\)"/\1/') echo "TAG=$TAG" >> "$GITHUB_OUTPUT" @@ -100,7 +100,7 @@ jobs: TAG_CONTEXT: 'repo' WITH_V: true PRERELEASE_SUFFIX: "beta" - PRERELEASE: ${{ (github.base_ref == 'beta') && 'true' || (github.base_ref == 'main') && 'false' || (github.base_ref == 'integration') && 'false' || 'true' }} + PRERELEASE: ${{ (github.base_ref || github.ref_name == 'beta') && 'true' || ((github.base_ref || github.ref_name == 'main') && 'false' || 'true') }} - name: Summary run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 5b830ff..2ab183b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -2,21 +2,6 @@ name: Test on: - push: - branches: - - main - - beta - - integration - - feature/** - - release/** - - hotfix/** - paths: - - pyproject.toml - - Dockerfile - - '*.py' - - tests/** - - tools/** - - utils/** pull_request: paths: - pyproject.toml @@ -80,6 +65,13 @@ jobs: TAG=v$(grep 'version =' pyproject.toml | sed -e 's/version = "\(.*\)"/\1/') echo "TAG=$TAG" >> "$GITHUB_OUTPUT" + - name: Get branch ref name + id: branch_ref + run: | + BRANCH_NAME=${{ github.base_ref || github.ref_name }} + echo "$BRANCH_NAME" + echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_OUTPUT" + - name: Get tag version id: semantic_release uses: anothrNick/github-tag-action@1.71.0 @@ -89,7 +81,7 @@ jobs: TAG_CONTEXT: 'repo' WITH_V: true PRERELEASE_SUFFIX: "beta" - PRERELEASE: ${{ (github.base_ref == 'beta') && 'true' || (github.base_ref == 'main') && 'false' || (github.base_ref == 'integration') && 'false' || 'true' }} + PRERELEASE: ${{ (github.base_ref || github.ref_name == 'beta') && 'true' || (((github.base_ref || github.ref_name == 'main') && 'false' || (github.base_ref || github.ref_name == 'integration') && 'false') || 'true') }} DRY_RUN: true - name: Compare versions diff --git a/README.md b/README.md index 2060249..e0e77d1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # MCP Server +| App Test | Helm Test | +|------|---------| +| [![App Test](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/publish.yaml/badge.svg?branch=main)](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/publish.yaml) | [![Helm Test](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/helm_test.yaml/badge.svg?branch=main)](https://github.com/sysdiglabs/sysdig-mcp-server/actions/workflows/helm_test.yaml) | + +--- + ## Table of contents - [MCP Server](#mcp-server) @@ -79,6 +85,21 @@ Get up and running with the Sysdig MCP Server quickly using our pre-built Docker ## Available Tools +You can select what group of tools to add when running the server by adding/removing them from the `mcp.allowed_tools` list in the app_config.yaml file + +```yaml +... +mcp: + transport: stdio + ... + allowed_tools: + - "events-feed" + - "inventory" + - "vulnerability-management" + - "sysdig-sage" + - "sysdig-cli-scanner" # Only available in stdio local transport mode +``` +
Events Feed @@ -125,6 +146,15 @@ Get up and running with the Sysdig MCP Server quickly using our pre-built Docker
+
+Sysdig CLI scanner + +| Tool Name | Description | Sample Prompt | +|-----------|-------------|----------------| +| `run_sysdig_cli_scanner` | Run the Sysdig CLI Scanner to analyze a container image or IaC files for vulnerabilities and posture and misconfigurations. | "Scan this image ubuntu:latest for vulnerabilities" | + +
+ ### Available Resources - Sysdig Secure Vulnerability Management Overview: @@ -165,6 +195,8 @@ This file contains the main configuration for the application, including: - **sysdig**: The Sysdig Secure host to connect to. - **mcp**: Transport protocol (stdio, sse, streamable-http), URL, host, and port for the MCP server. +> You can set the path for the app_config.yaml using the `APP_CONFIG_FILE=/path/to/app_config.yaml` env var. By default the app will search the file in the root of the app. + ### Environment Variables The following environment variables are required for configuring the Sysdig SDK: @@ -244,6 +276,12 @@ configMap: transport: streamable-http host: "0.0.0.0" port: 8080 + allowed_tools: + - "events-feed" + - "inventory" + - "vulnerability-management" + - "sysdig-sage" + - "sysdig-cli-scanner" # You need the sysdig-cli-scanner binary installed in your server to use this tool ``` Install the chart diff --git a/app_config.yaml b/app_config.yaml index b279824..762d9a8 100644 --- a/app_config.yaml +++ b/app_config.yaml @@ -11,3 +11,9 @@ mcp: transport: stdio host: "localhost" port: 8080 + allowed_tools: + - "events-feed" + - "sysdig-cli-scanner" # Only available in stdio local transport mode + - "vulnerability-management" + - "inventory" + - "sysdig-sage" diff --git a/charts/sysdig-mcp/Chart.yaml b/charts/sysdig-mcp/Chart.yaml index 4dd3d2b..09eecaa 100644 --- a/charts/sysdig-mcp/Chart.yaml +++ b/charts/sysdig-mcp/Chart.yaml @@ -20,10 +20,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.2 +version: 0.1.3 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.1.2" +appVersion: "v0.1.3-beta.0" diff --git a/charts/sysdig-mcp/templates/configmap.yaml b/charts/sysdig-mcp/templates/configmap.yaml index cb149d5..b75f033 100644 --- a/charts/sysdig-mcp/templates/configmap.yaml +++ b/charts/sysdig-mcp/templates/configmap.yaml @@ -1,4 +1,3 @@ ---- {{- if .Values.configMap.enabled -}} apiVersion: v1 kind: ConfigMap diff --git a/charts/sysdig-mcp/templates/secrets.yaml b/charts/sysdig-mcp/templates/secrets.yaml index a8e9450..0976e63 100644 --- a/charts/sysdig-mcp/templates/secrets.yaml +++ b/charts/sysdig-mcp/templates/secrets.yaml @@ -1,4 +1,3 @@ ---- {{- if .Values.sysdig.secrets.create -}} apiVersion: v1 kind: Secret diff --git a/charts/sysdig-mcp/values.schema.json b/charts/sysdig-mcp/values.schema.json new file mode 100644 index 0000000..33cb7bf --- /dev/null +++ b/charts/sysdig-mcp/values.schema.json @@ -0,0 +1,141 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "Values", + "type": "object", + "properties": { + "sysdig": { + "$ref": "#/$defs/SysdigConfig" + }, + "oauth": { + "$ref": "#/$defs/OauthConfig" + } + }, + "required": [ + "configMap", + "sysdig" + ], + "$defs": { + "SysdigConfig": { + "type": "object", + "properties": { + "host": { + "type": [ "string", "null" ], + "description": "Sysdig Tenant Host", + "examples": [ + "https://us2.app.sysdig.com", + "https://eu1.app.sysdig.com" + ] + }, + "mcp": { + "type": "object", + "properties": { + "transport": { + "type": "string", + "enum": [ + "streamable-http", + "sse", + "stdio" + ], + "description": "The transport protocol for the Sysdig MCP" + } + }, + "required": [ + "transport" + ] + }, + "secrets": { + "type": "object", + "properties": { + "create": { + "type": "boolean", + "description": "Whether to create the secret" + }, + "secureAPIToken": { + "type": [ + "string", + "null" + ], + "description": "The API Token to access Sysdig Secure", + "examples": [ + "12345678-1234-1234-1234-123456789012" + ] + } + }, + "required": [ + "create", + "secureAPIToken" + ] + } + }, + "required": [ + "host", + "mcp", + "secrets" + ], + "additionalProperties": false + }, + "OauthConfig": { + "type": "object", + "properties": { + "secrets": { + "type": "object", + "properties": { + "create": { + "type": "boolean", + "description": "Whether to create the secret" + }, + "clientId": { + "type": [ + "string", + "null" + ], + "description": "The Client ID for the OAuth application", + "examples": [ + "my-client-id" + ] + }, + "clientSecret": { + "type": [ + "string", + "null" + ], + "description": "The Client Secret for the OAuth application", + "examples": [ + "my-client-secret" + ] + } + }, + "required": [ + "create", + "clientId", + "clientSecret" + ] + } + }, + "required": [ + "secrets" + ], + "additionalProperties": false + }, + "AppConfig": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether to create the application configuration" + }, + "app_config": { + "type": [ + "string", + "null" + ], + "description": "The application configuration in YAML format" + } + }, + "required": [ + "secrets" + ], + "additionalProperties": false + } + } +} diff --git a/charts/sysdig-mcp/values.yaml b/charts/sysdig-mcp/values.yaml index 9ea3e33..765c3e4 100644 --- a/charts/sysdig-mcp/values.yaml +++ b/charts/sysdig-mcp/values.yaml @@ -8,7 +8,7 @@ image: repository: ghcr.io/sysdiglabs/sysdig-mcp-server pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. - tag: "v0.1.2-beta.0-3d30346" + tag: "v0.1.3-beta.0" imagePullSecrets: [] nameOverride: "" @@ -126,3 +126,9 @@ configMap: transport: streamable-http host: "0.0.0.0" port: 8080 + allowed_tools: + - "events-feed" + - "sysdig-cli-scanner" # You need the sysdig-cli-scanner binary installed in your server to use this tool + - "vulnerability-management" + - "inventory" + - "sysdig-sage" diff --git a/pyproject.toml b/pyproject.toml index a4ba989..1760fb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sysdig-mcp-server" -version = "0.1.2-beta.0" +version = "0.1.3-beta.0" description = "Sysdig MCP Server" readme = "README.md" requires-python = ">=3.12" @@ -10,7 +10,7 @@ dependencies = [ "pyyaml==6.0.2", "sqlalchemy==2.0.36", "sqlmodel==0.0.22", - "sysdig-sdk @ git+https://github.com/sysdiglabs/sysdig-sdk-python@ccdf3effe27a339deaa04a7248b319443d20e5aa", + "sysdig-sdk @ git+https://github.com/sysdiglabs/sysdig-sdk-python@e9b0d336c2f617f3bbd752416860f84eed160c41", "dask==2025.4.1", "oauthlib==3.2.2", "fastapi==0.115.12", diff --git a/tests/conftest.py b/tests/conftest.py index 29f8dac..35d8025 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,11 @@ +""" +Module for general utilities and fixtures used in tests. +""" + import json import pytest import os from unittest.mock import patch -from fastmcp import Context -from fastmcp import FastMCP - - -class MockMCP(FastMCP): - """ - Mock class for FastMCP - """ - - pass def util_load_json(path): @@ -43,21 +37,6 @@ def mock_success_response(): patch.stopall() -@pytest.fixture -def mock_ctx(): - """ - Fixture to create a mock Context object with 'fastmcp' tags. - Returns: - Context: A mocked Context object with 'fastmcp' tags. - """ - - fastmcp: MockMCP = MockMCP( - tags=["sysdig", "mcp", "stdio"], - ) - ctx = Context(fastmcp=fastmcp) - return ctx - - @pytest.fixture def mock_creds(): """ diff --git a/tests/events_feed_test.py b/tests/events_feed_test.py index 016f977..8c7cc7b 100644 --- a/tests/events_feed_test.py +++ b/tests/events_feed_test.py @@ -8,8 +8,6 @@ from unittest.mock import MagicMock, AsyncMock import os -from fastmcp import Context - # Get the absolute path of the current module file module_path = os.path.abspath(__file__) @@ -19,11 +17,11 @@ EVENT_INFO_RESPONSE = util_load_json(f"{module_directory}/test_data/events_feed/event_info_response.json") -def test_get_event_info(mock_success_response: MagicMock | AsyncMock, mock_ctx: Context, mock_creds) -> None: +def test_get_event_info(mock_success_response: MagicMock | AsyncMock, mock_creds) -> None: """Test the get_event_info tool method. Args: mock_success_response (MagicMock | AsyncMock): Mocked response object. - mock_ctx (Context): Mocked Context object. + mock_creds: Mocked credentials. """ # Successful response @@ -32,7 +30,7 @@ def test_get_event_info(mock_success_response: MagicMock | AsyncMock, mock_ctx: tools_client = EventsFeedTools() # Pass the mocked Context object - result: dict = tools_client.tool_get_event_info("12345", mock_ctx) + result: dict = tools_client.tool_get_event_info("12345") results: dict = result["results"] assert result.get("status_code") == HTTPStatus.OK diff --git a/tools/cli_scanner/__init__.py b/tools/cli_scanner/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tools/cli_scanner/__init__.py @@ -0,0 +1 @@ + diff --git a/tools/cli_scanner/tool.py b/tools/cli_scanner/tool.py new file mode 100644 index 0000000..25ec306 --- /dev/null +++ b/tools/cli_scanner/tool.py @@ -0,0 +1,187 @@ +""" +CLI Scanner Tool for Sysdig + +This tool helps you use the Sysdig CLI Scanner to analyze your development files and directories. +""" + +import logging +import os +import subprocess +from typing import Literal, Optional + +from utils.app_config import get_app_config + +logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) + +log = logging.getLogger(__name__) + +# Load app config (expects keys: mcp.host, mcp.port, mcp.transport) +app_config = get_app_config() + + +class CLIScannerTool: + """ + A class to encapsulate the tools for interacting with the Sysdig CLI Scanner. + """ + + cmd: str = "sysdig-cli-scanner" + default_args: list = [ + "--loglevel=err", + "--apiurl=" + app_config["sysdig"]["host"], + ] + iac_default_args: list = [ + "--iac", + "--group-by=violation", + "--recursive", + ] + + exit_code_explained: str = """ + 0: Scan evaluation "pass" + 1: Scan evaluation "fail" + 2: Invalid parameters + 3: Internal error + """ + + def check_sysdig_cli_installed(self) -> None: + """ + Checks if the Sysdig CLI Scanner is installed by verifying the existence of the 'sysdig-cli-scanner' command. + """ + try: + # Attempt to run 'sysdig-cli-scanner --version' to check if it's installed + result = subprocess.run([self.cmd, "--version"], capture_output=True, text=True, check=True) + log.info(f"Sysdig CLI Scanner is installed: {result.stdout.strip()}") + except subprocess.CalledProcessError as e: + error: dict = { + "error": "Sysdig CLI Scanner is not installed or not in the $PATH. Check the docs to install it here: https://docs.sysdig.com/en/sysdig-secure/install-vulnerability-cli-scanner/#deployment" + } + e.output = error + raise e + + def check_env_credentials(self) -> None: + """ + Checks if the necessary environment variables for Sysdig Secure are set. + Raises: + EnvironmentError: If the SYSDIG_SECURE_TOKEN or SYSDIG_HOST environment variables are not set. + """ + sysdig_secure_token = os.environ.get("SYSDIG_SECURE_TOKEN") + sysdig_host = os.environ.get("SYSDIG_HOST", app_config["sysdig"]["host"]) + if not sysdig_secure_token: + log.error("SYSDIG_SECURE_TOKEN environment variable is not set.") + raise EnvironmentError("SYSDIG_SECURE_TOKEN environment variable is not set.") + else: + os.environ["SECURE_API_TOKEN"] = sysdig_secure_token # Ensure the token is set in the environment + if not sysdig_host: + log.error("SYSDIG_HOST environment variable is not set.") + raise EnvironmentError("SYSDIG_HOST environment variable is not set.") + + def run_sysdig_cli_scanner( + self, + image: Optional[str] = None, + mode: Literal["vulnerability", "iac"] = "vulnerability", + standalone: Optional[bool] = False, + offline_analyser: Optional[bool] = False, + full_vulnerability_table: Optional[bool] = False, + separate_by_layer: Optional[bool] = False, + separate_by_image: Optional[bool] = False, + detailed_policies_evaluation: Optional[bool] = False, + path_to_scan: Optional[str] = None, + iac_group_by: Optional[Literal["policy", "resource", "violation"]] = "policy", + iac_recursive: Optional[bool] = True, + iac_severity_threshold: Optional[Literal["never", "high", "medium", "low"]] = "high", + iac_list_unsupported_resources: Optional[bool] = False, + ) -> dict: + """ + Analyzes a Container image for vulnerabilities using the Sysdig CLI Scanner. + Args: + image (str): The name of the container image to analyze. + mode ["vulnerability", "iac"]: The mode of analysis, either "vulnerability" or "iac". + Defaults to "vulnerability". + standalone (bool): In vulnerability mode, run the scan in standalone mode. + Not dependent on Sysdig backend. + offline_analyser (bool): In vulnerability mode, does not perform calls to the Sysdig backend. + full_vulnerability_table (bool): In vulnerability mode, generates a table with all the vulnerabilities, + not just the most important ones. + separate_by_layer (bool): In vulnerability mode, separates vulnerabilities by layer. + separate_by_image (bool): In vulnerability mode, separates vulnerabilities by image. + detailed_policies_evaluation (bool): In vulnerability mode, evaluates policies in detail. + path_to_scan (str): The path to the directory/file to scan in IaC mode. + iac_group_by (str): In IaC mode, groups the results by the specified field. + Options are "policy", "resource", or "violation". Defaults to "policy". + iac_recursive (bool): In IaC mode, scans the directory recursively. Defaults to True. + iac_severity_threshold (str): In IaC mode, sets the severity threshold for vulnerabilities. + Options are "never", "high", "medium", or "low". Defaults to "high". + iac_list_unsupported_resources (bool): In IaC mode, lists unsupported resources. + Defaults to False. + + Returns: + dict: A dictionary containing the output of the analysis of vulnerabilities. + Raises: + Exception: If the Sysdig CLI Scanner encounters an error. + """ + # Check if Sysdig CLI Scanner is installed and environment credentials are set + self.check_sysdig_cli_installed() + self.check_env_credentials() + + # Prepare the command based on the mode + if mode == "iac": + log.info("Running Sysdig CLI Scanner in IaC mode.") + extra_iac_args = [ + f"--group-by={iac_group_by}", + f"--severity-threshold={iac_severity_threshold}", + "--recursive" if iac_recursive else "", + "--list-unsupported-resources" if iac_list_unsupported_resources else "", + ] + # Remove empty strings from the list + extra_iac_args = [arg for arg in extra_iac_args if arg] + cmd = [self.cmd] + self.default_args + self.iac_default_args + extra_iac_args + [path_to_scan] + else: + log.info("Running Sysdig CLI Scanner in vulnerability mode.") + # Default to vulnerability mode + extra_args = [ + "--standalone" if standalone else "", + "--offline-analyzer" if offline_analyser and standalone else "", + "--full-vulns-table" if full_vulnerability_table else "", + "--separate-by-layer" if separate_by_layer else "", + "--separate-by-image" if separate_by_image else "", + "--detailed-policies-eval" if detailed_policies_evaluation else "", + ] + extra_args = [arg for arg in extra_args if arg] # Remove empty strings from the list + cmd = [self.cmd] + self.default_args + extra_args + [image] + + try: + # Run the command + with open("sysdig_cli_scanner_output.json", "w") as output_file: + result = subprocess.run(cmd, text=True, check=True, stdout=output_file, stderr=subprocess.PIPE) + output_result = output_file.read() + output_file.close() + return { + "exit_code": result.returncode, + "output": output_result + result.stderr.strip(), + "exit_codes_explained": self.exit_code_explained, + } + # Handle non-zero exit codes speically exit code 1 + except subprocess.CalledProcessError as e: + log.warning(f"Sysdig CLI Scanner returned non-zero exit code: {e.returncode}") + if e.returncode in [2, 3]: + log.error(f"Sysdig CLI Scanner encountered an error: {e.stderr.strip()}") + result: dict = { + "error": "Error running Sysdig CLI Scanner", + "exit_code": e.returncode, + "output": e.stderr.strip(), + "exit_codes_explained": self.exit_code_explained, + } + raise Exception(result) + else: + with open("sysdig_cli_scanner_output.json", "r") as output_file: + output_result = output_file.read() + result: dict = { + "exit_code": e.returncode, + "stdout": e.stdout, + "output": output_result, + "exit_codes_explained": self.exit_code_explained, + } + os.remove("sysdig_cli_scanner_output.json") + return result + # Handle any other exceptions that may occur and exit codes 2 and 3 + except Exception as e: + raise e diff --git a/tools/events_feed/tool.py b/tools/events_feed/tool.py index 18ada11..43a6e1d 100644 --- a/tools/events_feed/tool.py +++ b/tools/events_feed/tool.py @@ -11,7 +11,6 @@ from datetime import datetime from typing import Optional, Annotated, Any, Dict from pydantic import Field -from fastmcp import Context from sysdig_client import ApiException from fastmcp.prompts.prompt import PromptMessage, TextContent from fastmcp.exceptions import ToolError @@ -25,7 +24,6 @@ from utils.sysdig.api import initialize_api_client logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) - log = logging.getLogger(__name__) # Load app config (expects keys: mcp.host, mcp.port, mcp.transport) @@ -38,21 +36,20 @@ class EventsFeedTools: This class provides methods to retrieve event information and list runtime events. """ - def init_client(self, config_tags: set[str], old_api: bool = False) -> SecureEventsApi | OldSysdigApi: + def init_client(self, old_api: bool = False) -> SecureEventsApi | OldSysdigApi: """ Initializes the SecureEventsApi client from the request state. If the request does not have the API client initialized, it will create a new instance using the Sysdig Secure token and host from the environment variables. Args: - config_tags (set[str]): The tags associated with the MCP server configuration, used to determine the transport mode. + old_api (bool): If True, initializes the OldSysdigApi client instead of SecureEventsApi. Returns: SecureEventsApi | OldSysdigApi: An instance of the SecureEventsApi or OldSysdigApi client. - Raises: - ValueError: If the SYSDIG_SECURE_TOKEN environment variable is not set. """ secure_events_api: SecureEventsApi = None old_sysdig_api: OldSysdigApi = None - if "streamable-http" in config_tags: + transport = os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower() + if transport in ["streamable-http", "sse"]: # Try to get the HTTP request log.debug("Attempting to get the HTTP request to initialize the Sysdig API client.") request: Request = get_http_request() @@ -61,15 +58,11 @@ def init_client(self, config_tags: set[str], old_api: bool = False) -> SecureEve else: # If running in STDIO mode, we need to initialize the API client from environment variables log.debug("Running in STDIO mode, initializing the Sysdig API client from environment variables.") - SYSDIG_SECURE_TOKEN = os.environ.get("SYSDIG_SECURE_TOKEN", "") - if not SYSDIG_SECURE_TOKEN: - raise ValueError("Can not initialize client, SYSDIG_SECURE_TOKEN environment variable is not set.") - SYSDIG_HOST = os.environ.get("SYSDIG_HOST", app_config["sysdig"]["host"]) - cfg = get_configuration(SYSDIG_SECURE_TOKEN, SYSDIG_HOST) + cfg = get_configuration() api_client = initialize_api_client(cfg) secure_events_api = SecureEventsApi(api_client) # Initialize the old Sysdig API client for process tree requests - old_cfg = get_configuration(SYSDIG_SECURE_TOKEN, SYSDIG_HOST, old_api=True) + old_cfg = get_configuration(old_api=True) old_sysdig_api = initialize_api_client(old_cfg) old_sysdig_api = OldSysdigApi(old_sysdig_api) @@ -77,7 +70,7 @@ def init_client(self, config_tags: set[str], old_api: bool = False) -> SecureEve return old_sysdig_api return secure_events_api - def tool_get_event_info(self, event_id: str, ctx: Context) -> dict: + def tool_get_event_info(self, event_id: str) -> dict: """ Retrieves detailed information for a specific security event. @@ -88,7 +81,7 @@ def tool_get_event_info(self, event_id: str, ctx: Context) -> dict: Event: The Event object containing detailed information about the specified event. """ # Init of the sysdig client - secure_events_api = self.init_client(config_tags=ctx.fastmcp.tags) + secure_events_api = self.init_client() try: # Get the HTTP request start_time = time.time() @@ -106,7 +99,6 @@ def tool_get_event_info(self, event_id: str, ctx: Context) -> dict: def tool_list_runtime_events( self, - ctx: Context, cursor: Optional[str] = None, scope_hours: int = 1, limit: int = 50, @@ -148,15 +140,15 @@ def tool_list_runtime_events( cursor (Optional[str]): Cursor for pagination. scope_hours (int): Number of hours back from now to include events. Defaults to 1. severity_level (Optional[str]): One of "info", "low", "medium", "high". If provided, filters by that severity. - If None, includes all severities. + If None, includes all severities. cluster_name (Optional[str]): Name of the Kubernetes cluster to filter events. If None, includes all clusters. limit (int): Maximum number of events to return. Defaults to 50. filter_expr (Optional[str]): An optional filter expression to further narrow down events. Returns: - List[Event]: A list of Event objects matching the criteria. + dict: A dictionary containing the results of the runtime events query, including pagination information. """ - secure_events_api = self.init_client(config_tags=ctx.fastmcp.tags) + secure_events_api = self.init_client() start_time = time.time() # Compute time window now_ns = time.time_ns() @@ -188,13 +180,12 @@ def tool_list_runtime_events( # A tool to retrieve all the process-tree information for a specific event.Add commentMore actions - def tool_get_event_process_tree(self, ctx: Context, event_id: str) -> Dict[str, Any]: + def tool_get_event_process_tree(self, event_id: str) -> dict: """ Retrieves the process tree for a specific security event. Not every event has a process tree, so this may return an empty tree. Args: - ctx (Context): The context object containing request-specific information. event_id (str): The unique identifier of the security event. Returns: @@ -203,7 +194,7 @@ def tool_get_event_process_tree(self, ctx: Context, event_id: str) -> Dict[str, try: start_time = time.time() # Get process tree branches - old_api_client = self.init_client(config_tags=ctx.fastmcp.tags, old_api=True) + old_api_client = self.init_client(old_api=True) branches = old_api_client.request_process_tree_branches(event_id) # Get process tree tree = old_api_client.request_process_tree_trees(event_id) diff --git a/tools/inventory/tool.py b/tools/inventory/tool.py index f426732..c057150 100644 --- a/tools/inventory/tool.py +++ b/tools/inventory/tool.py @@ -8,7 +8,6 @@ from typing import Annotated from pydantic import Field from fastmcp.server.dependencies import get_http_request -from fastmcp import Context from fastmcp.exceptions import ToolError from starlette.requests import Request from sysdig_client import ApiException @@ -32,39 +31,31 @@ class InventoryTools: This class provides methods to list resources and retrieve a single resource by its hash. """ - def init_client(self, config_tags: set[str]) -> InventoryApi: + def init_client(self) -> InventoryApi: """ Initializes the InventoryApi client from the request state. If the request does not have the API client initialized, it will create a new instance using the Sysdig Secure token and host from the environment variables. - Args: - config_tags (set[str]): The tags associated with the MCP server configuration, used to determine the transport mode. Returns: InventoryApi: An instance of the InventoryApi client. - Raises: - ValueError: If the SYSDIG_SECURE_TOKEN environment variable is not set. """ - secure_events_api: InventoryApi = None - if "streamable-http" in config_tags: + inventory_api: InventoryApi = None + transport = os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower() + if transport in ["streamable-http", "sse"]: # Try to get the HTTP request log.debug("Attempting to get the HTTP request to initialize the Sysdig API client.") request: Request = get_http_request() - secure_events_api = request.state.api_instances["inventory"] + inventory_api = request.state.api_instances["inventory"] else: # If running in STDIO mode, we need to initialize the API client from environment variables log.debug("Running in STDIO mode, initializing the Sysdig API client from environment variables.") - SYSDIG_SECURE_TOKEN = os.environ.get("SYSDIG_SECURE_TOKEN", "") - if not SYSDIG_SECURE_TOKEN: - raise ValueError("Can not initialize client, SYSDIG_SECURE_TOKEN environment variable is not set.") - SYSDIG_HOST = os.environ.get("SYSDIG_HOST", app_config["sysdig"]["host"]) - cfg = get_configuration(SYSDIG_SECURE_TOKEN, SYSDIG_HOST) + cfg = get_configuration() api_client = initialize_api_client(cfg) - secure_events_api = InventoryApi(api_client) - return secure_events_api + inventory_api = InventoryApi(api_client) + return inventory_api def tool_list_resources( self, - ctx: Context, filter_exp: Annotated[ str, Field( @@ -154,20 +145,28 @@ def tool_list_resources( List inventory items based on a filter expression, with optional pagination. Args: - filter_exp (str): Sysdig Secure query filter expression. + filter_exp (str): Sysdig query filter expression to filter inventory resources. + Use the resource://filter-query-language to get the expected filter expression format. + Supports operators: =, !=, in, exists, contains, startsWith. + Combine with and/or/not. Examples: - - not isExposed exists - - category in ("IAM") and isExposed exists - - category in ("IAM","Audit & Monitoring") + - zone in ("zone1") and machineImage = "ami-0b22b359fdfabe1b5" + - (projectId = "1235495521" or projectId = "987654321") and vuln.severity in ("Critical") + - vuln.name in ("CVE-2023-0049") + - vuln.cvssScore >= "3" + - container.name in ("sysdig-container") and not labels exists + - imageId in ("sha256:3768ff6176e29a35ce1354622977a1e5c013045cbc4f30754ef3459218be8ac") + - platform in ("GCP", "AWS", "Azure", "Kubernetes") and isExposed exists page_number (int): Page number for pagination (1-based). page_size (int): Number of items per page. with_enrich_containers (bool): Include enriched container information. Returns: - InventoryResourceResponse: The API response containing inventory items. + dict: A dictionary containing the results of the inventory query, including pagination information. + Or a dict containing an error message if the call fails. """ try: - inventory_api = self.init_client(config_tags=ctx.fastmcp.tags) + inventory_api = self.init_client() start_time = time.time() api_response = inventory_api.get_resources_without_preload_content( @@ -185,7 +184,6 @@ def tool_list_resources( def tool_get_resource( self, - ctx: Context, resource_hash: Annotated[str, Field(description="The unique hash of the inventory resource to retrieve.")], ) -> dict: """ @@ -195,11 +193,10 @@ def tool_get_resource( resource_hash (str): The hash identifier of the resource. Returns: - InventoryResourceExtended: The detailed resource object. - Or a dict containing an error message if the call fails. + dict: A dictionary containing the details of the requested inventory resource. """ try: - inventory_api = self.init_client(config_tags=ctx.fastmcp.tags) + inventory_api = self.init_client() start_time = time.time() api_response = inventory_api.get_resource_without_preload_content(hash=resource_hash) diff --git a/tools/sysdig_sage/tool.py b/tools/sysdig_sage/tool.py index 516d926..186d8f0 100644 --- a/tools/sysdig_sage/tool.py +++ b/tools/sysdig_sage/tool.py @@ -8,7 +8,6 @@ import os import time from typing import Any, Dict -from fastmcp import Context from fastmcp.exceptions import ToolError from utils.sysdig.old_sysdig_api import OldSysdigApi from starlette.requests import Request @@ -18,8 +17,8 @@ from utils.sysdig.api import initialize_api_client from utils.query_helpers import create_standard_response -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) app_config = get_app_config() @@ -31,21 +30,17 @@ class SageTools: language questions and execute them against the Sysdig API. """ - def init_client(self, config_tags: set[str]) -> OldSysdigApi: + def init_client(self) -> OldSysdigApi: """ Initializes the OldSysdigApi client from the request state. If the request does not have the API client initialized, it will create a new instance using the Sysdig Secure token and host from the environment variables. - Args: - config_tags (set[str]): The tags associated with the MCP server configuration, used to determine the transport mode. Returns: OldSysdigApi: An instance of the OldSysdigApi client. - - Raises: - ValueError: If the SYSDIG_SECURE_TOKEN environment variable is not set. """ old_sysdig_api: OldSysdigApi = None - if "streamable-http" in config_tags: + transport = os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower() + if transport in ["streamable-http", "sse"]: # Try to get the HTTP request log.debug("Attempting to get the HTTP request to initialize the Sysdig API client.") request: Request = get_http_request() @@ -53,17 +48,12 @@ def init_client(self, config_tags: set[str]) -> OldSysdigApi: else: # If running in STDIO mode, we need to initialize the API client from environment variables log.debug("Running in STDIO mode, initializing the Sysdig API client from environment variables.") - SYSDIG_SECURE_TOKEN = os.environ.get("SYSDIG_SECURE_TOKEN", "") - if not SYSDIG_SECURE_TOKEN: - raise ValueError("Can not initialize client, SYSDIG_SECURE_TOKEN environment variable is not set.") - - SYSDIG_HOST = os.environ.get("SYSDIG_HOST", app_config["sysdig"]["host"]) - cfg = get_configuration(SYSDIG_SECURE_TOKEN, SYSDIG_HOST, old_api=True) + cfg = get_configuration(old_api=True) api_client = initialize_api_client(cfg) old_sysdig_api = OldSysdigApi(api_client) return old_sysdig_api - async def tool_sysdig_sage(self, ctx: Context, question: str) -> Dict[str, Any]: + async def tool_sage_to_sysql(self, question: str) -> dict: """ Queries Sysdig Sage with a natural language question, retrieves a SysQL query, executes it against the Sysdig API, and returns the results. @@ -72,20 +62,20 @@ async def tool_sysdig_sage(self, ctx: Context, question: str) -> Dict[str, Any]: question (str): A natural language question to send to Sage. Returns: - Dict: JSON-decoded response of the executed SysQL query, or an error object. + dict: A dictionary containing the results of the SysQL query execution and the query text. Raises: ToolError: If the SysQL query generation or execution fails. Examples: - # tool_sysdig_sage(question="Match Cloud Resource affected by Critical Vulnerability") - # tool_sysdig_sage(question="Match Kubernetes Workload affected by Critical Vulnerability") - # tool_sysdig_sage(question="Match AWS EC2 Instance that violates control 'EC2 - Instances should use IMDSv2'") + # tool_sage_to_sysql(question="Match Cloud Resource affected by Critical Vulnerability") + # tool_sage_to_sysql(question="Match Kubernetes Workload affected by Critical Vulnerability") + # tool_sage_to_sysql(question="Match AWS EC2 Instance that violates control 'EC2 - Instances should use IMDSv2'") """ # 1) Generate SysQL query try: start_time = time.time() - old_sysdig_api = self.init_client(config_tags=ctx.fastmcp.tags) + old_sysdig_api = self.init_client() sysql_response = await old_sysdig_api.generate_sysql_query(question) if sysql_response.status > 299: raise ToolError(f"Sysdig Sage returned an error: {sysql_response.status} - {sysql_response.data}") diff --git a/tools/vulnerability_management/tool.py b/tools/vulnerability_management/tool.py index 7a0bed6..c9f93dd 100644 --- a/tools/vulnerability_management/tool.py +++ b/tools/vulnerability_management/tool.py @@ -7,8 +7,6 @@ import time from typing import List, Optional, Literal, Annotated from pydantic import Field -from sysdig_client import ApiException -from fastmcp import Context from sysdig_client.models.scan_result_response import ScanResultResponse from sysdig_client.models.get_policy_response import GetPolicyResponse from fastmcp.prompts.prompt import PromptMessage, TextContent @@ -35,20 +33,17 @@ class VulnerabilityManagementTools: and vulnerability policies. """ - def init_client(self, config_tags: set[str]) -> VulnerabilityManagementApi: + def init_client(self) -> VulnerabilityManagementApi: """ Initializes the VulnerabilityManagementApi client from the request state. If the request does not have the API client initialized, it will create a new instance using the Sysdig Secure token and host from the environment variables. - Args: - config_tags (set[str]): The tags associated with the MCP server configuration, used to determine the transport mode. Returns: VulnerabilityManagementApi: An instance of the VulnerabilityManagementApi client. - Raises: - ValueError: If the SYSDIG_SECURE_TOKEN environment variable is not set. """ vulnerability_management_api: VulnerabilityManagementApi = None - if "streamable-http" in config_tags: + transport = os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower() + if transport in ["streamable-http", "sse"]: # Try to get the HTTP request log.debug("Attempting to get the HTTP request to initialize the Sysdig API client.") request: Request = get_http_request() @@ -56,18 +51,13 @@ def init_client(self, config_tags: set[str]) -> VulnerabilityManagementApi: else: # If running in STDIO mode, we need to initialize the API client from environment variables log.debug("Running in STDIO mode, initializing the Sysdig API client from environment variables.") - SYSDIG_SECURE_TOKEN = os.environ.get("SYSDIG_SECURE_TOKEN", "") - if not SYSDIG_SECURE_TOKEN: - raise ValueError("Can not initialize client, SYSDIG_SECURE_TOKEN environment variable is not set.") - SYSDIG_HOST = os.environ.get("SYSDIG_HOST", app_config["sysdig"]["host"]) - cfg = get_configuration(SYSDIG_SECURE_TOKEN, SYSDIG_HOST) + cfg = get_configuration() api_client = initialize_api_client(cfg) vulnerability_management_api = VulnerabilityManagementApi(api_client) return vulnerability_management_api def tool_list_runtime_vulnerabilities( self, - ctx: Context, cursor: Annotated[Optional[str], Field(description="Cursor for pagination. If None, returns the first page.")] = None, filter: Annotated[ Optional[str], @@ -134,7 +124,16 @@ def tool_list_runtime_vulnerabilities( Args: cursor (Optional[str]): Cursor for pagination. If None, returns the first page. - filter (Optional[str]): Query expression to filter the results. + filter (Optional[str]): Sysdig Secure query filter expression to filter runtime vulnerability scan results. + Use the resource://filter-query-language to get the expected filter expression format. + Supports operators: =, !=, in, exists, contains, startsWith. + Combine with and/or/not. + Examples: + - asset.type = "host" + - aws.region = "us-west-2" + - kubernetes.cluster.name = "cluster1" + - cloudProvider = "gcp" and gcp.project.id = "my-project" + - host.hostName startsWith "web-" sort (Optional[str]): Field name to sort by. order (Optional[str]): Sort order, either 'asc' or 'desc'. limit (int): Maximum number of results to return. @@ -146,7 +145,7 @@ def tool_list_runtime_vulnerabilities( - execution_time_ms (float): Execution duration in milliseconds. """ try: - vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) + vulnerability_api = self.init_client() # Record start time for execution duration start_time = time.time() api_response = vulnerability_api.scanner_api_service_list_runtime_results_without_preload_content( @@ -167,7 +166,6 @@ def tool_list_runtime_vulnerabilities( def tool_list_accepted_risks( self, - ctx: Context, filter: Optional[str] = None, limit: int = 50, cursor: Optional[str] = None, @@ -188,7 +186,7 @@ def tool_list_accepted_risks( dict: The API response as a dictionary, or an error dict on failure. """ try: - vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) + vulnerability_api = self.init_client() start_time = time.time() api_response = vulnerability_api.get_accepted_risks_v1_without_preload_content( filter=filter, limit=limit, cursor=cursor, sort=sort, order=order @@ -206,7 +204,7 @@ def tool_list_accepted_risks( log.error(f"Exception when calling VulnerabilityManagementApi->get_accepted_risks_v1: {e}") raise e - def tool_get_accepted_risk(self, ctx: Context, accepted_risk_id: str) -> dict: + def tool_get_accepted_risk(self, accepted_risk_id: str) -> dict: """ Retrieve details of a specific accepted risk by its ID. @@ -217,7 +215,7 @@ def tool_get_accepted_risk(self, ctx: Context, accepted_risk_id: str) -> dict: dict: The accepted risk details as a dictionary, or an error dict on failure. """ try: - vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) + vulnerability_api = self.init_client() response = vulnerability_api.get_accepted_risk_v1(accepted_risk_id) return response.model_dump_json() if hasattr(response, "dict") else response except ToolError as e: @@ -226,7 +224,6 @@ def tool_get_accepted_risk(self, ctx: Context, accepted_risk_id: str) -> dict: def tool_list_registry_scan_results( self, - ctx: Context, filter: Annotated[ Optional[str], Field( @@ -255,13 +252,15 @@ def tool_list_registry_scan_results( Retrieve a paginated list of vulnerability scan results on registries. Args: - filter (Optional[str]): Logical filter expression to select registry scan results. + filter (Optional[str]): Sysdig Secure query filter expression to filter runtime vulnerability scan results. + Use the resource://filter-query-language to get the expected filter expression format. Supports operators: =, !=, in, exists, contains, startsWith. Combine with and/or/not. Key selectors include: freeText (string), vendor (e.g., "docker", "ecr", "harbor"). Examples: - freeText = "alpine:latest" and vendor = "docker" - vendor = "ecr" + - vendor = "harbor" and freeText in ("redis") limit (int): Maximum number of results to return. cursor (Optional[str]): Pagination cursor. If None, returns the first page. @@ -269,7 +268,7 @@ def tool_list_registry_scan_results( dict: The registry scan results as a dictionary, or an error dict on failure. """ try: - vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) + vulnerability_api = self.init_client() start_time = time.time() api_response = vulnerability_api.scanner_api_service_list_registry_results_without_preload_content( filter=filter, limit=limit, cursor=cursor @@ -287,7 +286,7 @@ def tool_list_registry_scan_results( raise e def tool_get_vulnerability_policy( - self, ctx: Context, policy_id: Annotated[int, Field(description="The unique ID of the vulnerability policy to retrieve.")] + self, policy_id: Annotated[int, Field(description="The unique ID of the vulnerability policy to retrieve.")] ) -> GetPolicyResponse | dict: """ Retrieve a specific vulnerability policy by its ID. @@ -300,7 +299,7 @@ def tool_get_vulnerability_policy( dict: An error dict on failure. """ try: - vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) + vulnerability_api = self.init_client() response: GetPolicyResponse = vulnerability_api.secure_vulnerability_v1_policies_policy_id_get(policy_id) return response.model_dump_json() if hasattr(response, "dict") else response except ToolError as e: @@ -309,7 +308,6 @@ def tool_get_vulnerability_policy( def tool_list_vulnerability_policies( self, - ctx: Context, cursor: Optional[str] = None, limit: int = 50, name: Optional[str] = None, @@ -329,7 +327,7 @@ def tool_list_vulnerability_policies( """ start_time = time.time() try: - vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) + vulnerability_api = self.init_client() api_response = vulnerability_api.secure_vulnerability_v1_policies_get_without_preload_content( cursor=cursor, limit=limit, name=name, stages=stages ) @@ -349,7 +347,6 @@ def tool_list_vulnerability_policies( def tool_list_pipeline_scan_results( self, - ctx: Context, cursor: Annotated[Optional[str], Field(description="Cursor for pagination. If None, returns the first page.")] = None, filter: Annotated[ Optional[str], @@ -377,14 +374,16 @@ def tool_list_pipeline_scan_results( Args: cursor (Optional[str]): Cursor for pagination. If None, returns the first page. - filter (Optional[str]): Logical filter expression to select pipeline scan results. + filter (Optional[str]): Sysdig Secure query filter expression to filter vulnerability. + Use the resource://filter-query-language to get the expected filter expression format. + scan results on pipelines. Supports operators: =, !=, in, exists, contains, startsWith. Combine with and/or/not. - Key selectors include: policyEvaluationsPassed (true/false), freeText (string). + Key selectors include: + - freeText (string). Examples: - - policyEvaluationsPassed = true + - freeText in ("nginx") - freeText in ("ubuntu") - - policyEvaluationsPassed = false and freeText in ("ubuntu") limit (int): Maximum number of results to return. Returns: @@ -396,7 +395,7 @@ def tool_list_pipeline_scan_results( """ start_time = time.time() try: - vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) + vulnerability_api = self.init_client() api_response = vulnerability_api.secure_vulnerability_v1_pipeline_results_get_without_preload_content( cursor=cursor, filter=filter, limit=limit ) @@ -412,7 +411,7 @@ def tool_list_pipeline_scan_results( log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_policies_get: {e}") raise e - def tool_get_scan_result(self, ctx: Context, scan_id: str) -> dict: + def tool_get_scan_result(self, scan_id: str) -> dict: """ Retrieve the result of a specific scan. @@ -423,14 +422,14 @@ def tool_get_scan_result(self, ctx: Context, scan_id: str) -> dict: dict: ScanResultResponse as dict, or {"error": ...}. """ try: - vulnerability_api = self.init_client(config_tags=ctx.fastmcp.tags) + vulnerability_api = self.init_client() resp: ScanResultResponse = vulnerability_api.secure_vulnerability_v1_results_result_id_get(scan_id) return resp.model_dump_json() if hasattr(resp, "dict") else resp except ToolError as e: log.error(f"Exception when calling VulnerabilityManagementApi->secure_vulnerability_v1_results_result_id_get: {e}") raise e - def explore_vulnerabilities_prompt(self, ctx: Context, filters: str) -> PromptMessage: + def explore_vulnerabilities_prompt(self, filters: str) -> PromptMessage: """ Generates a prompt message for exploring vulnerabilities based on provided filters. diff --git a/utils/app_config.py b/utils/app_config.py index bb1360b..2550598 100644 --- a/utils/app_config.py +++ b/utils/app_config.py @@ -9,8 +9,8 @@ from typing import Optional # Set up logging -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) # app_config singleton _app_config: Optional[dict] = None @@ -61,9 +61,11 @@ def load_app_config() -> dict: def get_app_config() -> dict: """ Get the the overall app config + This function uses a singleton pattern to ensure the config is loaded only once. + If the config is already loaded, it returns the existing config. Returns: - dict: The app config loaded from the YAML file, or an empty dict if the file + dict: The app config loaded from the YAML file, or an empty dict if the file does not exist or is invalid. """ global _app_config if _app_config is None: diff --git a/utils/mcp_server.py b/utils/mcp_server.py index 0b49e21..de0ee84 100644 --- a/utils/mcp_server.py +++ b/utils/mcp_server.py @@ -10,6 +10,7 @@ import uvicorn from starlette.requests import Request from starlette.responses import JSONResponse, Response +from typing_extensions import Literal from fastapi import FastAPI from fastmcp import FastMCP from fastmcp.resources import HttpResource, TextResource @@ -19,6 +20,7 @@ from tools.inventory.tool import InventoryTools from tools.vulnerability_management.tool import VulnerabilityManagementTools from tools.sysdig_sage.tool import SageTools +from tools.cli_scanner.tool import CLIScannerTool # Application config loader from utils.app_config import get_app_config @@ -37,6 +39,8 @@ middlewares = [Middleware(CustomAuthMiddleware)] +MCP_MOUNT_PATH = "/sysdig-mcp-server" + def create_simple_mcp_server() -> FastMCP: """ @@ -50,7 +54,6 @@ def create_simple_mcp_server() -> FastMCP: instructions="Provides Sysdig Secure tools and resources.", host=app_config["mcp"]["host"], port=app_config["mcp"]["port"], - debug=True, tags=["sysdig", "mcp", os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower()], ) @@ -74,7 +77,7 @@ def run_stdio(): """ mcp = get_mcp() # Add tools to the MCP server - add_tools(mcp) + add_tools(mcp=mcp, allowed_tools=app_config["mcp"]["allowed_tools"], transport_type=app_config["mcp"]["transport"]) # Add resources to the MCP server add_resources(mcp) try: @@ -91,15 +94,15 @@ def run_http(): """Run the MCP server over HTTP/SSE transport via Uvicorn.""" mcp = get_mcp() # Add tools to the MCP server - add_tools(mcp) + add_tools(mcp=mcp, allowed_tools=app_config["mcp"]["allowed_tools"], transport_type=app_config["mcp"]["transport"]) # Add resources to the MCP server add_resources(mcp) - # Mount the MCP HTTP/SSE app at '/sysdig-mcp-server' - mcp_app = mcp.http_app( - path="/mcp", transport=os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower(), middleware=middlewares - ) + # Mount the MCP HTTP/SSE app at 'MCP_MOUNT_PATH' + transport = os.environ.get("MCP_TRANSPORT", app_config["mcp"]["transport"]).lower() + mcp_app = mcp.http_app(transport=transport, middleware=middlewares) + suffix_path = mcp.settings.streamable_http_path if transport == "streamable-http" else mcp.settings.sse_path app = FastAPI(lifespan=mcp_app.lifespan) - app.mount("/sysdig-mcp-server", mcp_app) + app.mount(MCP_MOUNT_PATH, mcp_app) @app.get("/healthz", response_class=Response) async def health_check(request: Request) -> Response: @@ -113,7 +116,9 @@ async def health_check(request: Request) -> Response: """ return JSONResponse({"status": "ok"}) - log.info(f"Starting {mcp.name} at http://{app_config['app']['host']}:{app_config['app']['port']}/sysdig-mcp-server/mcp") + log.info( + f"Starting {mcp.name} at http://{app_config['app']['host']}:{app_config['app']['port']}{MCP_MOUNT_PATH}{suffix_path}" + ) # Use Uvicorn's Config and Server classes for more control config = uvicorn.Config( app, @@ -136,126 +141,152 @@ async def health_check(request: Request) -> Response: os._exit(1) -def add_tools(mcp: FastMCP) -> None: - """Add tools to the MCP server.""" +def add_tools(mcp: FastMCP, allowed_tools: list, transport_type: Literal["stdio", "streamable-http"] = "stdio") -> None: + """ + Add tools to the MCP server based on the allowed tools and transport type. + Args: + mcp (FastMCP): The FastMCP server instance. + allowed_tools (list): List of tools to register. + transport_type (Literal["stdio", "streamable-http"]): The transport type for the MCP server. + """ - # Register the events feed tools - events_feed_tools = EventsFeedTools() - log.info("Adding Events Feed Tools...") - mcp.add_tool( - events_feed_tools.tool_get_event_info, - name="get_event_info", - description="Retrieve detailed information for a specific security event by its ID", - ) - mcp.add_tool( - events_feed_tools.tool_list_runtime_events, - name="list_runtime_events", - description="List runtime security events from the last given hours, optionally filtered by severity level.", - ) + if "events-feed" in allowed_tools: + # Register the events feed tools + events_feed_tools = EventsFeedTools() + log.info("Adding Events Feed Tools...") + mcp.add_tool( + events_feed_tools.tool_get_event_info, + name="get_event_info", + description="Retrieve detailed information for a specific security event by its ID", + ) + mcp.add_tool( + events_feed_tools.tool_list_runtime_events, + name="list_runtime_events", + description="List runtime security events from the last given hours, optionally filtered by severity level.", + ) - mcp.add_prompt( - events_feed_tools.investigate_event_prompt, - name="investigate_event", - description="Prompt to investigate a security event based on its severity and time range.", - tags={"analysis", "secure_feeds"}, - ) - mcp.add_tool( - events_feed_tools.tool_get_event_process_tree, - name="get_event_process_tree", - description=( + mcp.add_prompt( + events_feed_tools.investigate_event_prompt, + name="investigate_event", + description="Prompt to investigate a security event based on its severity and time range.", + tags={"analysis", "secure_feeds"}, + ) + mcp.add_tool( + events_feed_tools.tool_get_event_process_tree, + name="get_event_process_tree", + description=( + """ + Retrieve the process tree for a specific security event by its ID. Not every event has a process tree, + so this may return an empty tree. """ - Retrieve the process tree for a specific security event by its ID. Not every event has a process tree, - so this may return an empty tree. - """ - ), - ) + ), + ) # Register the Sysdig Inventory tools - log.info("Adding Sysdig Inventory Tools...") - inventory_tools = InventoryTools() - mcp.add_tool( - inventory_tools.tool_list_resources, - name="list_resources", - description=( - """ - List inventory resources based on a Sysdig Secure query filter expression with optional pagination.' - """ - ), - ) - mcp.add_tool( - inventory_tools.tool_get_resource, - name="get_resource", - description="Retrieve a single inventory resource by its unique hash identifier.", - ) + if "inventory" in allowed_tools: + # Register the Sysdig Inventory tools + log.info("Adding Sysdig Inventory Tools...") + inventory_tools = InventoryTools() + mcp.add_tool( + inventory_tools.tool_list_resources, + name="list_resources", + description=( + """ + List inventory resources based on Sysdig Filter Query Language expression with optional pagination.' + """ + ), + ) + mcp.add_tool( + inventory_tools.tool_get_resource, + name="get_resource", + description="Retrieve a single inventory resource by its unique hash identifier.", + ) - # Register the Sysdig Vulnerability Management tools - log.info("Adding Sysdig Vulnerability Management Tools...") - vulnerability_tools = VulnerabilityManagementTools() - mcp.add_tool( - vulnerability_tools.tool_list_runtime_vulnerabilities, - name="list_runtime_vulnerabilities", - description=( - """ - List runtime vulnerability assets scan results from Sysdig Vulnerability Management API - (Supports pagination using cursor). - """ - ), - ) - mcp.add_tool( - vulnerability_tools.tool_list_accepted_risks, - name="list_accepted_risks", - description="List all accepted risks. Supports filtering and pagination.", - ) - mcp.add_tool( - vulnerability_tools.tool_get_accepted_risk, - name="get_accepted_risk", - description="Retrieve a specific accepted risk by its ID.", - ) - mcp.add_tool( - vulnerability_tools.tool_list_registry_scan_results, - name="list_registry_scan_results", - description="List registry scan results. Supports filtering and pagination.", - ) - mcp.add_tool( - vulnerability_tools.tool_get_vulnerability_policy, - name="get_vulnerability_policy_by_id", - description="Retrieve a specific vulnerability policy by its ID.", - ) - mcp.add_tool( - vulnerability_tools.tool_list_vulnerability_policies, - name="list_vulnerability_policies", - description="List all vulnerability policies. Supports filtering, pagination, and sorting.", - ) - mcp.add_tool( - vulnerability_tools.tool_list_pipeline_scan_results, - name="list_pipeline_scan_results", - description="List pipeline scan results (e.g., built images). Supports pagination and filtering.", - ) - mcp.add_tool( - vulnerability_tools.tool_get_scan_result, - name="get_scan_result", - description="Retrieve a specific scan result (registry/runtime/pipeline).", - ) - mcp.add_prompt( - vulnerability_tools.explore_vulnerabilities_prompt, - name="explore_vulnerabilities", - description="Prompt to explore vulnerabilities based on filters", - tags={"vulnerability", "exploration"}, - ) + if "vulnerability-management" in allowed_tools: + # Register the Sysdig Vulnerability Management tools + log.info("Adding Sysdig Vulnerability Management Tools...") + vulnerability_tools = VulnerabilityManagementTools() + mcp.add_tool( + vulnerability_tools.tool_list_runtime_vulnerabilities, + name="list_runtime_vulnerabilities", + description=( + """ + List runtime vulnerability assets scan results from Sysdig Vulnerability Management API + (Supports pagination using cursor). + """ + ), + ) + mcp.add_tool( + vulnerability_tools.tool_list_accepted_risks, + name="list_accepted_risks", + description="List all accepted risks. Supports filtering and pagination.", + ) + mcp.add_tool( + vulnerability_tools.tool_get_accepted_risk, + name="get_accepted_risk", + description="Retrieve a specific accepted risk by its ID.", + ) + mcp.add_tool( + vulnerability_tools.tool_list_registry_scan_results, + name="list_registry_scan_results", + description="List registry scan results. Supports filtering and pagination.", + ) + mcp.add_tool( + vulnerability_tools.tool_get_vulnerability_policy, + name="get_vulnerability_policy_by_id", + description="Retrieve a specific vulnerability policy by its ID.", + ) + mcp.add_tool( + vulnerability_tools.tool_list_vulnerability_policies, + name="list_vulnerability_policies", + description="List all vulnerability policies. Supports filtering, pagination, and sorting.", + ) + mcp.add_tool( + vulnerability_tools.tool_list_pipeline_scan_results, + name="list_pipeline_scan_results", + description="List pipeline scan results (e.g., built images). Supports pagination and filtering.", + ) + mcp.add_tool( + vulnerability_tools.tool_get_scan_result, + name="get_scan_result", + description="Retrieve a specific scan result (registry/runtime/pipeline).", + ) + mcp.add_prompt( + vulnerability_tools.explore_vulnerabilities_prompt, + name="explore_vulnerabilities", + description="Prompt to explore vulnerabilities based on filters", + tags={"vulnerability", "exploration"}, + ) - # Register the Sysdig Sage tools - log.info("Adding Sysdig Sage Tools...") - sysdig_sage_tools = SageTools() - mcp.add_tool( - sysdig_sage_tools.tool_sysdig_sage, - name="sysdig_sysql_sage_query", - description=( - """ - Query Sysdig Sage to generate a SysQL query based on a natural language question, - execute it against the Sysdig API, and return the results. - """ - ), - ) + if "sysdig-sage" in allowed_tools: + # Register the Sysdig Sage tools + log.info("Adding Sysdig Sage Tools...") + sysdig_sage_tools = SageTools() + mcp.add_tool( + sysdig_sage_tools.tool_sage_to_sysql, + name="sysdig_sysql_sage_query", + description=( + """ + Query Sysdig Sage to generate a SysQL query based on a natural language question, + execute it against the Sysdig API, and return the results. + """ + ), + ) + + if "sysdig-cli-scanner" in allowed_tools: + # Register the tools for STDIO transport + cli_scanner_tool = CLIScannerTool() + log.info("Adding Sysdig CLI Scanner Tool...") + mcp.add_tool( + cli_scanner_tool.run_sysdig_cli_scanner, + name="run_sysdig_cli_scanner", + description=( + """ + Run the Sysdig CLI Scanner to analyze a container image or IaC files for vulnerabilities + and posture and misconfigurations. + """ + ), + ) def add_resources(mcp: FastMCP) -> None: diff --git a/utils/query_helpers.py b/utils/query_helpers.py index 5dd802b..1ab58d0 100644 --- a/utils/query_helpers.py +++ b/utils/query_helpers.py @@ -25,6 +25,7 @@ def create_standard_response(results: RESTResponseType, execution_time_ms: str, raise ApiException( status=results.status, reason=results.reason, + data=results.data, ) else: response = results.json() if results.data else {} diff --git a/utils/reports/inventory_report.py b/utils/reports/inventory_report.py index ab6a3fa..fc0cc83 100644 --- a/utils/reports/inventory_report.py +++ b/utils/reports/inventory_report.py @@ -7,7 +7,6 @@ import dask.dataframe as dd import pandas as pd from tools.inventory.tool import InventoryTools -from fastmcp import Context, FastMCP # Configure logging logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) @@ -17,21 +16,6 @@ inventory = InventoryTools() -class MockMCP(FastMCP): - """ - Mock class for FastMCP - """ - - pass - - -# Mocking MCP context for the inventory tool -fastmcp: MockMCP = MockMCP( - tags=["sysdig", "mcp", "stdio"], -) -ctx = Context(fastmcp=fastmcp) - - def list_all_resources(filter_exp: str = 'platform in ("GCP")') -> dd.DataFrame: """ List all resources in the Sysdig inventory. @@ -45,13 +29,13 @@ def list_all_resources(filter_exp: str = 'platform in ("GCP")') -> dd.DataFrame: df: dd.DataFrame = None logging.debug(f"Listing all resources with filter: {filter_exp}") try: - resources = inventory.tool_list_resources(ctx=ctx, filter_exp=filter_exp, page_number=1, page_size=1000) + resources = inventory.tool_list_resources(filter_exp=filter_exp, page_number=1, page_size=1000) df = pd.DataFrame.from_records([r for r in resources.get("results", {}).get("data", [])]) while resources.get("results", {}).get("page", {}).get("next"): # Get the next page of resources next_page = resources.get("results", {}).get("page", {}).get("next") logging.debug(f"Fetching next page: {next_page}") - resources = inventory.tool_list_resources(ctx=ctx, filter_exp=filter_exp, page_number=next_page, page_size=1000) + resources = inventory.tool_list_resources(filter_exp=filter_exp, page_number=next_page, page_size=1000) df = dd.concat( [df, pd.DataFrame.from_records([r for r in resources.get("results", {}).get("data", [])])], ignore_index=True ) diff --git a/utils/sysdig/api.py b/utils/sysdig/api.py index c69c83f..299796c 100644 --- a/utils/sysdig/api.py +++ b/utils/sysdig/api.py @@ -20,7 +20,7 @@ def get_api_client(config: Configuration) -> ApiClient: return api_client_instance -def initialize_api_client(config: Configuration) -> ApiClient: +def initialize_api_client(config: Configuration = None) -> ApiClient: """ Initializes the Sysdig API client with the provided token and host. This function creates a new ApiClient instance and returns a dictionary of API instances diff --git a/utils/sysdig/client_config.py b/utils/sysdig/client_config.py index f120828..ac244da 100644 --- a/utils/sysdig/client_config.py +++ b/utils/sysdig/client_config.py @@ -6,14 +6,17 @@ import os import logging import re +from typing import Optional # Set up logging -log = logging.getLogger(__name__) logging.basicConfig(format="%(asctime)s-%(process)d-%(levelname)s- %(message)s", level=os.environ.get("LOGLEVEL", "ERROR")) +log = logging.getLogger(__name__) # Lazy-load the Sysdig client configuration -def get_configuration(token: str, sysdig_host_url: str, old_api: bool = False) -> sysdig_client.Configuration: +def get_configuration( + token: Optional[str] = None, sysdig_host_url: Optional[str] = None, old_api: bool = False +) -> sysdig_client.Configuration: """ Returns a configured Sysdig client using environment variables. @@ -24,6 +27,11 @@ def get_configuration(token: str, sysdig_host_url: str, old_api: bool = False) - Returns: sysdig_client.Configuration: A configured Sysdig client instance. """ + # Check if the token and sysdig_host_url are provided, otherwise fetch from environment variables + if not token and not sysdig_host_url: + env_vars = get_api_env_vars() + token = env_vars["SYSDIG_SECURE_TOKEN"] + sysdig_host_url = env_vars["SYSDIG_HOST"] if not old_api: sysdig_host_url = _get_public_api_url(sysdig_host_url) log.info(f"Using public API URL: {sysdig_host_url}") @@ -35,6 +43,28 @@ def get_configuration(token: str, sysdig_host_url: str, old_api: bool = False) - return configuration +def get_api_env_vars() -> dict: + """ + Get the necessary environment variables for the Sysdig API client. + + Returns: + dict: A dictionary containing the required environment variables. + Raises: + ValueError: If any of the required environment variables are not set. + """ + required_vars = ["SYSDIG_SECURE_TOKEN", "SYSDIG_HOST"] + env_vars = {} + for var in required_vars: + value = os.environ.get(var) + if not value: + log.error(f"Missing required environment variable: {var}") + raise ValueError(f"Environment variable {var} is not set. Please set it before running the application.") + env_vars[var] = value + log.info("All required environment variables are set.") + + return env_vars + + def _get_public_api_url(base_url: str) -> str: """ Get the public API URL from the base URL. diff --git a/utils/sysdig/old_sysdig_api.py b/utils/sysdig/old_sysdig_api.py index 07b96a5..eedf21c 100644 --- a/utils/sysdig/old_sysdig_api.py +++ b/utils/sysdig/old_sysdig_api.py @@ -56,7 +56,7 @@ def request_process_tree_branches(self, process_id: str) -> RESTResponseType: process_id (str): The ID of the process to retrieve branches for. Returns: - Dict[str, Any]: The JSON-decoded response containing the process tree branches. + dict: The JSON-decoded response containing the process tree branches. """ url = f"{self.base}/process-tree/v1/process-branches/{process_id}" resp = self.api_client.call_api("GET", url, header_params=self.headers) diff --git a/uv.lock b/uv.lock index 8dd7000..246433f 100644 --- a/uv.lock +++ b/uv.lock @@ -732,7 +732,7 @@ wheels = [ [[package]] name = "sysdig-mcp-server" -version = "0.1.2b0" +version = "0.1.3b0" source = { editable = "." } dependencies = [ { name = "dask" }, @@ -767,7 +767,7 @@ requires-dist = [ { name = "requests" }, { name = "sqlalchemy", specifier = "==2.0.36" }, { name = "sqlmodel", specifier = "==0.0.22" }, - { name = "sysdig-sdk", git = "https://github.com/sysdiglabs/sysdig-sdk-python?rev=ccdf3effe27a339deaa04a7248b319443d20e5aa" }, + { name = "sysdig-sdk", git = "https://github.com/sysdiglabs/sysdig-sdk-python?rev=e9b0d336c2f617f3bbd752416860f84eed160c41" }, ] [package.metadata.requires-dev] @@ -780,7 +780,7 @@ dev = [ [[package]] name = "sysdig-sdk" version = "1.0.0" -source = { git = "https://github.com/sysdiglabs/sysdig-sdk-python?rev=ccdf3effe27a339deaa04a7248b319443d20e5aa#ccdf3effe27a339deaa04a7248b319443d20e5aa" } +source = { git = "https://github.com/sysdiglabs/sysdig-sdk-python?rev=e9b0d336c2f617f3bbd752416860f84eed160c41#e9b0d336c2f617f3bbd752416860f84eed160c41" } dependencies = [ { name = "pydantic" }, { name = "python-dateutil" }, From cd1834e8c209d354cf0a4ad58ddd0a309f07cec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Magall=C3=B3n?= <59057379+alecron@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:50:26 +0200 Subject: [PATCH 4/6] Support app.region format URLS (#8) # Support app.region format URLS ## Changes Support the https://app.region.sysdig.com URLs --- charts/sysdig-mcp/Chart.yaml | 2 +- charts/sysdig-mcp/values.yaml | 2 +- pyproject.toml | 2 +- utils/sysdig/client_config.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/charts/sysdig-mcp/Chart.yaml b/charts/sysdig-mcp/Chart.yaml index 09eecaa..e751341 100644 --- a/charts/sysdig-mcp/Chart.yaml +++ b/charts/sysdig-mcp/Chart.yaml @@ -26,4 +26,4 @@ version: 0.1.3 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.1.3-beta.0" +appVersion: "v0.1.3-beta.1" diff --git a/charts/sysdig-mcp/values.yaml b/charts/sysdig-mcp/values.yaml index 765c3e4..4cb43f8 100644 --- a/charts/sysdig-mcp/values.yaml +++ b/charts/sysdig-mcp/values.yaml @@ -8,7 +8,7 @@ image: repository: ghcr.io/sysdiglabs/sysdig-mcp-server pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. - tag: "v0.1.3-beta.0" + tag: "v0.1.3-beta.1" imagePullSecrets: [] nameOverride: "" diff --git a/pyproject.toml b/pyproject.toml index 1760fb7..b1d7526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sysdig-mcp-server" -version = "0.1.3-beta.0" +version = "0.1.3-beta.1" description = "Sysdig MCP Server" readme = "README.md" requires-python = ">=3.12" diff --git a/utils/sysdig/client_config.py b/utils/sysdig/client_config.py index ac244da..c4103b1 100644 --- a/utils/sysdig/client_config.py +++ b/utils/sysdig/client_config.py @@ -79,7 +79,7 @@ def _get_public_api_url(base_url: str) -> str: # This assumes the region is a subdomain that starts with 2 lowercase letters and ends with a digit pattern = re.search(r"https://(?:(?P[a-z]{2}\d)\.app|app\.(?P[a-z]{2}\d))\.sysdig\.com", base_url) if pattern: - region = pattern.group(1) # Extract the region + region = pattern.group("region1") or pattern.group("region2") # Extract the region return f"https://api.{region}.sysdig.com" else: # Edge case for the secure API URL that is us1 From 83fcc1383269ab000132d11be8c917fe247f239b Mon Sep 17 00:00:00 2001 From: S3B4SZ17 Date: Wed, 9 Jul 2025 08:48:41 -0600 Subject: [PATCH 5/6] Updating project version and workflows Signed-off-by: S3B4SZ17 --- .github/workflows/helm_test.yaml | 2 -- .github/workflows/publish.yaml | 3 --- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/helm_test.yaml b/.github/workflows/helm_test.yaml index c0d65d8..e910462 100644 --- a/.github/workflows/helm_test.yaml +++ b/.github/workflows/helm_test.yaml @@ -4,14 +4,12 @@ name: Lint & Test helm chart on: pull_request: branches: - - beta - main paths: - 'charts/**' push: branches: - main - - beta paths: - 'charts/**' workflow_call: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index e77a209..3a1fb35 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,7 +5,6 @@ on: push: branches: - main - - beta paths: - '.github/workflows/**' - pyproject.toml @@ -99,8 +98,6 @@ jobs: DEFAULT_BUMP: "patch" TAG_CONTEXT: 'repo' WITH_V: true - PRERELEASE_SUFFIX: "beta" - PRERELEASE: ${{ (github.base_ref || github.ref_name == 'beta') && 'true' || ((github.base_ref || github.ref_name == 'main') && 'false' || 'true') }} - name: Summary run: | diff --git a/pyproject.toml b/pyproject.toml index b1d7526..dde7f8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sysdig-mcp-server" -version = "0.1.3-beta.1" +version = "0.1.3" description = "Sysdig MCP Server" readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 246433f..c4f4b78 100644 --- a/uv.lock +++ b/uv.lock @@ -732,7 +732,7 @@ wheels = [ [[package]] name = "sysdig-mcp-server" -version = "0.1.3b0" +version = "0.1.3" source = { editable = "." } dependencies = [ { name = "dask" }, From cd7cc63f19366695efeaac6b129643f09e7790c3 Mon Sep 17 00:00:00 2001 From: S3B4SZ17 Date: Wed, 9 Jul 2025 09:31:16 -0600 Subject: [PATCH 6/6] Update helm chart version of app Signed-off-by: S3B4SZ17 --- .github/workflows/helm_test.yaml | 4 ++-- charts/sysdig-mcp/Chart.yaml | 2 +- charts/sysdig-mcp/values.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/helm_test.yaml b/.github/workflows/helm_test.yaml index e910462..b0a4d23 100644 --- a/.github/workflows/helm_test.yaml +++ b/.github/workflows/helm_test.yaml @@ -57,10 +57,10 @@ jobs: run: ct lint --target-branch ${{ github.event.repository.default_branch }} --chart-dirs charts - name: Create kind cluster - if: github.ref_name == 'beta' || github.ref_name == 'main' + if: steps.list-changed.outputs.changed == 'true' uses: helm/kind-action@v1.12.0 - name: Run chart-testing (install) - if: github.ref_name == 'beta' || github.ref_name == 'main' + if: steps.list-changed.outputs.changed == 'true' run: | ct install --target-branch ${{ github.event.repository.default_branch }} --chart-dirs charts diff --git a/charts/sysdig-mcp/Chart.yaml b/charts/sysdig-mcp/Chart.yaml index e751341..224d8b0 100644 --- a/charts/sysdig-mcp/Chart.yaml +++ b/charts/sysdig-mcp/Chart.yaml @@ -26,4 +26,4 @@ version: 0.1.3 # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.1.3-beta.1" +appVersion: "v0.1.3" diff --git a/charts/sysdig-mcp/values.yaml b/charts/sysdig-mcp/values.yaml index 4cb43f8..e84f93a 100644 --- a/charts/sysdig-mcp/values.yaml +++ b/charts/sysdig-mcp/values.yaml @@ -8,7 +8,7 @@ image: repository: ghcr.io/sysdiglabs/sysdig-mcp-server pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. - tag: "v0.1.3-beta.1" + tag: "v0.1.3" imagePullSecrets: [] nameOverride: ""