From ddd9a1993990805baef54ebcbc250217c1d099f8 Mon Sep 17 00:00:00 2001 From: S3B4SZ17 Date: Wed, 2 Jul 2025 16:53:27 -0600 Subject: [PATCH 1/2] fix: Changing the description of some tools for the filter query language Signed-off-by: S3B4SZ17 --- .github/workflows/publish.yaml | 12 ++- .github/workflows/test.yaml | 12 +-- Dockerfile | 2 + README.md | 8 ++ charts/sysdig-mcp/values.yaml | 13 ++- pyproject.toml | 2 +- tools/inventory/tool.py | 143 +++++++++++++++++++++++-- tools/vulnerability_management/tool.py | 82 ++++++++------ utils/mcp_server.py | 60 ++++++++++- uv.lock | 2 +- 10 files changed, 272 insertions(+), 64 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 9d18d6e..bed9581 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -84,20 +84,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/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/tools/inventory/tool.py b/tools/inventory/tool.py index 4037721..5807a3c 100644 --- a/tools/inventory/tool.py +++ b/tools/inventory/tool.py @@ -18,8 +18,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 +69,143 @@ 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") + Use the filter-query-language to filter the results. + + List of supported fields: + - field: accountName + Description: The account name that will be included in the results. + - field: accountId + Description: The account id that will be included in the results. + - field: cluster + Description: The kubernetes cluster that will be included in the results. + - field: externalDNS + Description: The external DNS that will be included in the results. + - field: distribution + Description: The kubernetes distribution that will be included in the results. + - field: integrationName + Description: The name of the integration an IaC resource belongs to. + - field: labels + Description: The resource labels that will be included in the results. + - field: location + Description: The web address of an IaC Manifest. + - field: name + Description: The names that will be included in the results. + - field: namespace + Description: The namespace that will be included in the results. + - field: nodeType + Description: The nodeType that will be included in the results. + - field: osName + Description: The operating system that will be included in the results. + - field: osImage + Description: The operating system image that will be included in the results. + - field: organization + Description: The organization that will be included in the results. + - field: platform + Description: The platform that will be included in the results. + - field: control.accepted + Description: Include (or Exclude) only resources with accepted results. + Supported operators: exists and not exists. + - field: policy + Description: Include resources that applied the selected policies. + Supported operators: in, not in, exists, not exists. + - field: control.severity + Description: Include resources that have violated risks in the selected severities. + Supported operators: in, not in. + - field: control.failed + Description: Include resources that have violated the selected risks. + Supported operators: in, not in, exists, not exists. + - field: policy.failed + Description: Include resources that failed the selected policies. + Supported operators: in, not in, exists, not exists. + - field: policy.passed + Description: Include resources that passed the selected policies. + Supported operators: in, not in, exists, not exists. + - field: projectName + Description: The project name that will be included in the results. + - field: projectId + Description: The project id that will be included in the results. + - field: region + Description: The regions that will be included in the results. + - field: repository + Description: The Repository an IaC resource belongs to. + - field: resourceOrigin + Description: Origin of the resource. Supported values: Code, Deployed. + - field: type + Description: The resource types that will be included in the results. + - field: subscriptionName + Description: The Azure subscription name that will be included in the results. + - field: subscriptionId + Description: The Azure subscription id that will be included in the results. + - field: sourceType + Description: The source type of an IaC resource. + Supported values: YAML, Kustomize, Terraform, Helm. + - field: version + Description: OCP Cluster versions that will be included in the results. + - field: zone + Description: The zones that will be included in the results. + - field: category + Description: The category that will be included in the results. + Supported operators: in, not in. + - field: isExposed + Description: Specifies whether the resource to return is exposed to the internet. + Supported operators: exists and not exists. + - field: validatedExposure + Description: Specifies whether the resource to return is exposed to the internet and could be reach + by our network exposure validator. Supported operators: exists and not exists. + - field: arn + Description: The AWS ARN of the resource. + - field: resourceId + Description: The Azure or GCP Resource Identifier of the resource. + - field: container.name + Description: Filters the resource by a container. + - field: architecture + Description: Image architecture. + - field: baseOS + Description: Image Base OS. + - field: digest + Description: Image Digest. + - field: imageId + Description: Image Id. + - field: os + Description: Image OS. + - field: container.imageName + Description: Image Pullstring. + - field: image.registry + Description: Image Registry. + - field: image.tag + Description: Image tag. + - field: package.inUse + Description: Package in use filter. Supported operators: exists and not exists. + - field: package.info + Description: Filters by a package using the format [packge name] - field: [version]. + - field: package.path + Description: Filters by package path. + - field: package.type + Description: Package type. + - field: vuln.cvssScore + Description: Filter by vulnerability CVSS. Supported operators: = and >=. + - field: vuln.hasExploit + Description: Filters resources by the existence of vulnerabilities with exploits. + Supported operators: exists and not exists. + - field: vuln.hasFix + Description: Filters resources by the existence of vulnerabilities with fixes. + Supported operators: exists and not exists. + - field: vuln.name + Description: Filter by vulnerability name. + - field: vuln.severity + Description: Filter by vulnerability severity. Supported operators: in, not in, exists and not exists. + - field: machineImage + Description: Filter by host machine image. """ - ) + ), + 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")', page_number: Annotated[int, Field(ge=1, description="Page number for pagination (1-based index)")] = 1, diff --git a/tools/vulnerability_management/tool.py b/tools/vulnerability_management/tool.py index b70f6c4..687c699 100644 --- a/tools/vulnerability_management/tool.py +++ b/tools/vulnerability_management/tool.py @@ -73,14 +73,34 @@ 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 + Use the filter-query-language to filter the results. + + 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=[ @@ -208,20 +228,18 @@ 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" + """ + Use the filter-query-language to filter the results. + + 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 +253,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. @@ -335,17 +350,16 @@ 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)." + """ + Use the filter-query-language to filter the results. + + The supported fields are: + - freeText + """ ), examples=[ - "policyEvaluationsPassed = true", + 'freeText in ("nginx")', 'freeText in ("ubuntu")', - 'policyEvaluationsPassed = false and freeText in ("ubuntu")', ], ), ] = None, diff --git a/utils/mcp_server.py b/utils/mcp_server.py index e951e91..fe0dcc2 100644 --- a/utils/mcp_server.py +++ b/utils/mcp_server.py @@ -11,6 +11,7 @@ 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 @@ -73,6 +74,8 @@ async def run_stdio(): mcp = get_mcp() # Add tools to the MCP server add_tools(mcp) + # Add resources to the MCP server + add_resources(mcp) await mcp.run_stdio_async() @@ -81,6 +84,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 @@ -152,10 +157,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 +234,54 @@ 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 + + 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" + - asset.type = "host" + - cloudProvider = "gcp" and gcp.project.id = "my-project" + Note: + The supported fields are going to depend on the API endpoint you are querying. + Chek the description of each tool for the supported fields. + """ + ), + tags=["query-language", "documentation"], + ) + mcp.add_resource(vm_docs) + mcp.add_resource(filter_query_language) 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 29233358d502cd2e8caf47cd377cfcec6cb51ec4 Mon Sep 17 00:00:00 2001 From: S3B4SZ17 Date: Thu, 3 Jul 2025 19:03:55 -0600 Subject: [PATCH 2/2] Updating the init and conventions config of the mcp server Signed-off-by: S3B4SZ17 --- .github/workflows/publish.yaml | 1 + main.py | 31 +++- tests/conftest.py | 12 +- tools/events_feed/tool.py | 7 +- tools/inventory/tool.py | 192 +++++++++---------------- tools/sysdig_sage/tool.py | 9 +- tools/vulnerability_management/tool.py | 31 ++-- utils/mcp_server.py | 47 ++++-- utils/middleware/auth.py | 2 +- utils/reports/inventory_report.py | 39 +++-- 10 files changed, 194 insertions(+), 177 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index bed9581..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 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/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 5807a3c..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 @@ -69,133 +70,67 @@ def tool_list_resources( Field( description=( """ - Use the filter-query-language to filter the results. - + 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: - - field: accountName - Description: The account name that will be included in the results. - - field: accountId - Description: The account id that will be included in the results. - - field: cluster - Description: The kubernetes cluster that will be included in the results. - - field: externalDNS - Description: The external DNS that will be included in the results. - - field: distribution - Description: The kubernetes distribution that will be included in the results. - - field: integrationName - Description: The name of the integration an IaC resource belongs to. - - field: labels - Description: The resource labels that will be included in the results. - - field: location - Description: The web address of an IaC Manifest. - - field: name - Description: The names that will be included in the results. - - field: namespace - Description: The namespace that will be included in the results. - - field: nodeType - Description: The nodeType that will be included in the results. - - field: osName - Description: The operating system that will be included in the results. - - field: osImage - Description: The operating system image that will be included in the results. - - field: organization - Description: The organization that will be included in the results. - - field: platform - Description: The platform that will be included in the results. - - field: control.accepted - Description: Include (or Exclude) only resources with accepted results. - Supported operators: exists and not exists. - - field: policy - Description: Include resources that applied the selected policies. - Supported operators: in, not in, exists, not exists. - - field: control.severity - Description: Include resources that have violated risks in the selected severities. - Supported operators: in, not in. - - field: control.failed - Description: Include resources that have violated the selected risks. - Supported operators: in, not in, exists, not exists. - - field: policy.failed - Description: Include resources that failed the selected policies. - Supported operators: in, not in, exists, not exists. - - field: policy.passed - Description: Include resources that passed the selected policies. - Supported operators: in, not in, exists, not exists. - - field: projectName - Description: The project name that will be included in the results. - - field: projectId - Description: The project id that will be included in the results. - - field: region - Description: The regions that will be included in the results. - - field: repository - Description: The Repository an IaC resource belongs to. - - field: resourceOrigin - Description: Origin of the resource. Supported values: Code, Deployed. - - field: type - Description: The resource types that will be included in the results. - - field: subscriptionName - Description: The Azure subscription name that will be included in the results. - - field: subscriptionId - Description: The Azure subscription id that will be included in the results. - - field: sourceType - Description: The source type of an IaC resource. - Supported values: YAML, Kustomize, Terraform, Helm. - - field: version - Description: OCP Cluster versions that will be included in the results. - - field: zone - Description: The zones that will be included in the results. - - field: category - Description: The category that will be included in the results. - Supported operators: in, not in. - - field: isExposed - Description: Specifies whether the resource to return is exposed to the internet. - Supported operators: exists and not exists. - - field: validatedExposure - Description: Specifies whether the resource to return is exposed to the internet and could be reach - by our network exposure validator. Supported operators: exists and not exists. - - field: arn - Description: The AWS ARN of the resource. - - field: resourceId - Description: The Azure or GCP Resource Identifier of the resource. - - field: container.name - Description: Filters the resource by a container. - - field: architecture - Description: Image architecture. - - field: baseOS - Description: Image Base OS. - - field: digest - Description: Image Digest. - - field: imageId - Description: Image Id. - - field: os - Description: Image OS. - - field: container.imageName - Description: Image Pullstring. - - field: image.registry - Description: Image Registry. - - field: image.tag - Description: Image tag. - - field: package.inUse - Description: Package in use filter. Supported operators: exists and not exists. - - field: package.info - Description: Filters by a package using the format [packge name] - field: [version]. - - field: package.path - Description: Filters by package path. - - field: package.type - Description: Package type. - - field: vuln.cvssScore - Description: Filter by vulnerability CVSS. Supported operators: = and >=. - - field: vuln.hasExploit - Description: Filters resources by the existence of vulnerabilities with exploits. - Supported operators: exists and not exists. - - field: vuln.hasFix - Description: Filters resources by the existence of vulnerabilities with fixes. - Supported operators: exists and not exists. - - field: vuln.name - Description: Filter by vulnerability name. - - field: vuln.severity - Description: Filter by vulnerability severity. Supported operators: in, not in, exists and not exists. - - field: machineImage - Description: Filter by host machine image. + - 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=[ @@ -205,6 +140,7 @@ def tool_list_resources( '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")', @@ -243,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 @@ -272,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 687c699..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,7 +74,9 @@ def tool_list_runtime_vulnerabilities( Field( description=( """ - Use the filter-query-language to filter the results. + 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 @@ -158,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, @@ -199,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 @@ -217,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 @@ -229,7 +232,9 @@ def tool_list_registry_scan_results( Field( description=( """ - Use the filter-query-language to filter the results. + 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 @@ -277,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 @@ -298,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 @@ -338,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 @@ -351,7 +356,9 @@ def tool_list_pipeline_scan_results( Field( description=( """ - Use the filter-query-language to filter the results. + 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 @@ -401,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 @@ -419,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 fe0dcc2..0b49e21 100644 --- a/utils/mcp_server.py +++ b/utils/mcp_server.py @@ -5,6 +5,7 @@ import logging import os +import asyncio from typing import Optional import uvicorn from starlette.requests import Request @@ -23,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() @@ -67,7 +68,7 @@ def get_mcp() -> FastMCP: return _mcp_instance -async def run_stdio(): +def run_stdio(): """ Run the MCP server using STDIO transport. """ @@ -76,7 +77,14 @@ async def run_stdio(): add_tools(mcp) # Add resources to the MCP server add_resources(mcp) - await mcp.run_stdio_async() + 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(): @@ -105,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: @@ -269,16 +291,13 @@ def add_resources(mcp: FastMCP) -> None: - `contains` and `startsWith` to check partial values of attributes - `exists` to check if a field exists and not empty - 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" - - asset.type = "host" - - cloudProvider = "gcp" and gcp.project.id = "my-project" Note: The supported fields are going to depend on the API endpoint you are querying. - Chek the description of each tool for the supported fields. + Check the description of each tool for the supported fields. + + Examples: + - in ("example") and = "example2" + - >= "3" """ ), tags=["query-language", "documentation"], 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: