diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1f329e8..128a9bc 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -36,19 +36,19 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Build binary (Linux in Ubuntu 25.10 container) + - name: Build binary (Linux in Ubuntu 26.04 container) if: matrix.name == 'linux' shell: bash run: | - # Docker container with Ubuntu 25.10 and build the binary inside it + # Docker container with Ubuntu 26.04 and build the binary inside it rm -rf dist/* docker run --rm \ -v "$PWD:/workspace" \ -w /workspace \ - ubuntu:25.10 \ + ubuntu:26.04 \ bash -c " set -e - echo 'Setting up Ubuntu 25.10 build environment...' + echo 'Setting up Ubuntu 26.04 build environment...' apt-get update -y apt-get install -y --fix-missing curl python3 python3-pip python3-venv binutils diff --git a/.github/workflows/mend-scan.yaml b/.github/workflows/mend-scan.yaml index 41d8ead..35c3cb7 100644 --- a/.github/workflows/mend-scan.yaml +++ b/.github/workflows/mend-scan.yaml @@ -1,6 +1,9 @@ name: Mend CLI Scan on: + workflow_run: + workflows: ["Tests"] + types: [completed] workflow_dispatch: push: @@ -8,6 +11,7 @@ on: jobs: mend-scan: runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} steps: - name: Checkout code uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index a5a2697..0880d2e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,11 @@ wheels/ # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 .idea/ -version_info.txt \ No newline at end of file +version_info.txt + +# Mend/WhiteSource artifacts +whitesource/ +.whitesource.lock +mend +mend-cli +*.mend.json diff --git a/.whitesource b/.whitesource new file mode 100644 index 0000000..6b2345a --- /dev/null +++ b/.whitesource @@ -0,0 +1,47 @@ +{ + "scanSettings": { + "configMode": "AUTO", + "configExternalURL": "", + "projectToken": "", + "baseBranches": [] + }, + "scanSettingsSAST": { + "enableScan": true, + "scanPullRequests": true, + "incrementalScan": true, + "findingSuppressions": "enabled", + "enableSecretsScan": true, + "baseBranches": [], + "snippetSize": 10 + }, + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure", + "displayMode": "diff", + "useMendCheckNames": true + }, + "checkRunSettingsSAST": { + "checkRunConclusionLevel": "failure", + "severityThreshold": "high" + }, + "issueSettings": { + "minSeverityLevel": "LOW", + "issueType": "DEPENDENCY" + }, + "issueSettingsSAST": { + "minSeverityLevel": "high", + "issueType": "repo" + }, + "remediateSettings": { + "workflowRules": { + "enabled": true + } + }, + "imageSettings":{ + "imageTracing":{ + "enableImageTracingPR": false, + "addRepositoryCoordinate": false, + "addDockerfilePath": false, + "addMendIdentifier": false + } + } +} diff --git a/Dockerfile b/Dockerfile index db66dd8..94313c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM ubuntu:25.10 +FROM ubuntu:26.04 WORKDIR /app diff --git a/README.md b/README.md index 5279aed..e5dff28 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,74 @@ When using custom CA certificate bundles, you must configure both: --- +## OpenTelemetry +Perfecto MCP reports traces and metrics for MCP tool calls using [OpenTelemetry](https://opentelemetry.io/). This gives you visibility into which tools are used, how long they take, and when errors occur. + +Telemetry is enabled by default. You do not need to configure anything unless you want to change where data is sent or turn it off. + +### Default behavior + +By default, telemetry is exported over gRPC to: + +`https://grpc.public.prd.shared.perforce.com` + +The service is identified as `perfecto-mcp` with the current release version. If telemetry fails to start or export, the MCP server continues to work as usual. + +### Turn off telemetry + +Add `OTEL_SDK_DISABLED=true` to your MCP client environment: + +```json +"env": { + "OTEL_SDK_DISABLED": "true" +} +``` + +For Docker, pass it with `-e`: + +```json +"-e", +"OTEL_SDK_DISABLED=true" +``` + +### Send data to your own collector + +Use these environment variables to point at a different OpenTelemetry collector (for example, a local one during development): + +| Variable | Description | Default | +|----------|-------------|---------| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL | `https://grpc.public.prd.shared.perforce.com` | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc`, `http/protobuf`, or `http/json` | `grpc` | + +gRPC example (typical local collector on port 4317): + +```json +"env": { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc" +} +``` + +HTTP example (typical local collector on port 4318): + +```json +"env": { + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318", + "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf" +} +``` + +### What gets reported + +Each MCP tool call creates: + +- **Traces** — one span per call with the tool name, action, MCP client name and version, session ID, and error type when applicable. +- **Metrics** — `mcp.tool.calls` (count) and `mcp.tool.duration` (seconds), broken down by tool and action. + +Security tokens and other credentials are not included in telemetry data. + +If your MCP client sends `traceparent` or `tracestate` in the request metadata, Perfecto MCP links its spans to that parent trace. --- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..14fbafb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security + +## Reporting a vulnerability + +If you believe you've found a security vulnerability, please report it responsibly: + +- **Preferred:** [Report via GitHub Security Advisories](https://github.com/PerfectoCode/perfecto-mcp/security/advisories) (private report). +- **Alternative:** Open a [GitHub Issue](https://github.com/PerfectoCode/perfecto-mcp/issues) and use a discrete description; we will move sensitive details to a private channel. + +Please do not disclose security issues in public issues or discussions before we've had a chance to address them. + +Thank you for helping keep Perfecto MCP and its users safe. diff --git a/config/perfecto.py b/config/perfecto.py index 1cbde76..1001cef 100644 --- a/config/perfecto.py +++ b/config/perfecto.py @@ -1,7 +1,8 @@ TOOLS_PREFIX: str = "perfecto" -WEBSITE: str = "https://github.com/PerfectoCore/perfecto-mcp/" -GITHUB: str = "https://github.com/PerfectoCore/perfecto-mcp" -SUPPORT_MESSAGE: str = "If you think this is a bug, please contact Perfecto support or report issue at https://github.com/PerfectoCore/perfecto-mcp/issues" +WEBSITE: str = "https://github.com/PerfectoCode/perfecto-mcp/" +GITHUB: str = "https://github.com/PerfectoCode/perfecto-mcp" +GITHUB_API_LATEST_RELEASE: str = "https://api.github.com/repos/PerfectoCode/perfecto-mcp/releases/latest" +SUPPORT_MESSAGE: str = "If you think this is a bug, please contact Perfecto support or report issue at https://github.com/PerfectoCode/perfecto-mcp/issues" SECURITY_TOKEN_FILE_ENV_NAME: str = "PERFECTO_SECURITY_TOKEN_FILE" SECURITY_TOKEN_ENV_NAME: str = "PERFECTO_SECURITY_TOKEN" diff --git a/config/security.py b/config/security.py new file mode 100644 index 0000000..cd6734b --- /dev/null +++ b/config/security.py @@ -0,0 +1,140 @@ +"""Security helpers for HTTP endpoint allowlisting and sensitive path detection.""" + +import re +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + + +SENSITIVE_PATH_PREFIXES = ( + "/etc/", + "/proc/", + "/sys/", + "/dev/", + "/boot/", + "/root/", + "/run/secrets/", + "/var/run/", + "/var/db/", + "/var/root/", + "/var/log/", + "/var/spool/", + "/private/etc/", + # macOS: /var symlinks to /private/var. Block sensitive subdirs; allow /private/var/folders/ + "/private/var/run/", + "/private/var/db/", + "/private/var/root/", + "/private/var/log/", + "/private/var/spool/", + "/system/", + "/library/keychains/", + "/windows/", + "/program files/", + "/program files (x86)/", + "/programdata/", +) + +SENSITIVE_PATH_CONTAINS = ( + "/.ssh/", + "/.aws/", + "/.azure/", + "/.gnupg/", + "/.kube/", + "/.docker/", + "/.terraform/", + "/.pulumi/", + "/.config/gcloud/", + "/appdata/roaming/microsoft/credentials/", + "/appdata/roaming/gnupg/", + "/appdata/roaming/aws/", +) + +SENSITIVE_FILE_NAMES = { + ".env", + ".netrc", + ".git-credentials", + "kubeconfig", + ".npmrc", + ".pypirc", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519", + "known_hosts", + "authorized_keys", + "credentials", + "credentials.db", + "terraform.tfstate", + "terraform.tfstate.backup", +} + +SENSITIVE_FILE_EXTENSIONS = { + ".pem", + ".key", + ".p12", + ".pfx", + ".kdbx", + ".tfstate", + ".tfvars", + ".ovpn", +} + +# Domains allowed for unauthenticated http_request (help documentation fetches). +ALLOWED_HTTP_REQUEST_DOMAINS = ( + "help.perfecto.io", +) + + +def normalize_path_for_security(file_path: str) -> str: + return file_path.replace("\\", "/").strip().lower() + + +def normalize_windows_drive_prefix(file_path: str) -> str: + if re.match(r"^[a-z]:/", file_path): + return file_path[2:] + return file_path + + +def detect_sensitive_upload_path_reason(file_path: str) -> Optional[str]: + # Denylist of sensitive local paths/files. Available for future upload flows; + # http_request allowlisting is the active control in this project today. + normalized_path = normalize_path_for_security(file_path) + normalized_without_drive = normalize_windows_drive_prefix(normalized_path) + base_name = Path(normalized_path).name + + for prefix in SENSITIVE_PATH_PREFIXES: + if normalized_path.startswith(prefix) or normalized_without_drive.startswith(prefix): + return f"sensitive system path prefix '{prefix}'" + + for sensitive_fragment in SENSITIVE_PATH_CONTAINS: + if sensitive_fragment in normalized_path: + return f"sensitive path segment '{sensitive_fragment}'" + + if base_name in SENSITIVE_FILE_NAMES: + return f"sensitive file name '{base_name}'" + + for sensitive_extension in SENSITIVE_FILE_EXTENSIONS: + if base_name.endswith(sensitive_extension): + return f"sensitive file extension '{sensitive_extension}'" + + if base_name.startswith(".env."): + return "sensitive environment file pattern '.env.*'" + + return None + + +def validate_http_request_endpoint(endpoint: str) -> Optional[str]: + parsed_url = urlparse(endpoint) + + if parsed_url.scheme != "https": + return "Invalid endpoint scheme. Only https URLs are allowed." + + if not parsed_url.hostname: + return "Invalid endpoint URL. Absolute URL with hostname is required." + + host = parsed_url.hostname.lower() + if host not in ALLOWED_HTTP_REQUEST_DOMAINS: + allowed = ", ".join(ALLOWED_HTTP_REQUEST_DOMAINS) + return f"Endpoint host '{host}' is not allowed. Allowed hosts: {allowed}" + + return None diff --git a/config/token.py b/config/token.py index dc55521..624bc10 100644 --- a/config/token.py +++ b/config/token.py @@ -26,6 +26,11 @@ class PerfectoToken: __slots__ = ("token", "cloud_name") def __init__(self, token: str, cloud_name: str): + if not token or not isinstance(token, str): + raise PerfectoTokenError("Invalid security token format: expected non-empty string") + if cloud_name is not None and (not isinstance(cloud_name, str) or not cloud_name): + raise PerfectoTokenError("Invalid cloud name format: expected non-empty string") + self.token = token self.cloud_name = cloud_name @@ -34,20 +39,18 @@ def __init__(self, token: str, cloud_name: str): def from_file(cls, path: Union[str, Path], cloud_name: str) -> "PerfectoToken": p = Path(path) if not p.exists() or not p.is_file(): - raise PerfectoTokenError(f"directory or file does not exist: {p!r}") + raise PerfectoTokenError("Token file does not exist or is not a file") try: raw = p.read_text(encoding="utf-8") except Exception as e: - raise PerfectoTokenError(f"Error reading/parsing file at {p!r}: {e}") from e + raise PerfectoTokenError(f"Error reading token file: {type(e).__name__}") from e - try: - token_val = raw - cloud_name_val = cloud_name - except KeyError as e: - raise PerfectoTokenError(f"missing field {e.args[0]!r} at {p!r}") from e + token_val = raw.strip() + if not token_val: + raise PerfectoTokenError("Token file is empty") - return cls(token=token_val, cloud_name=cloud_name_val) + return cls(token=token_val, cloud_name=cloud_name) def __repr__(self): - return f"" + return "" diff --git a/mend.config b/mend.config new file mode 100644 index 0000000..32ea553 --- /dev/null +++ b/mend.config @@ -0,0 +1,25 @@ +# Mend CLI Configuration +# Documentation: https://docs.mend.io/bundle/integrations/page/mend_cli.html + +# Scan configuration +scope: "Perfecto//perfecto-mcp" + +# Policy enforcement (matching previous Unified Agent behavior) +# Set to true if you want builds to fail on policy violations +failPolicy: false + +# Update project inventory +update: true + +# Additional scanning options +scanSettings: + # Include dev dependencies + includeDevDependencies: false + + # Scan comments to add metadata + scanComment: "${GITHUB_REF_NAME}" + +# Report settings +reportSettings: + # Generate detailed reports + verbose: true diff --git a/pyproject.toml b/pyproject.toml index e27befd..ae771f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,18 @@ [project] name = "perfecto-mcp" -version = "1.1.1" +version = "1.1.2" description = "MCP server for Perfecto Cloud Platform" readme = "README.md" requires-python = ">=3.11" dependencies = [ "httpx[http2]>=0.28.1", - "mcp[cli]>=1.17.0", + "mcp[cli]>=1.27.0,<2.0.0", "opentelemetry-api>=1.20.0", "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp>=1.20.0", - "pyinstaller>=6.0.0", + "pyjwt>=2.12.0", + "starlette>=0.47.3", + "pyinstaller>=6.17.0", "pydantic>=2.11.7", "pydantic-core>=2.33.2", "pydantic-settings>=2.10.1", diff --git a/server.py b/server.py index c85535a..1600788 100644 --- a/server.py +++ b/server.py @@ -5,6 +5,7 @@ from tools.device_manager import register as register_device_manager from tools.execution_manager import register as register_execution_manager from tools.help_manager import register as register_help_manager +from tools.tools_manager import register as register_tools_manager from tools.user_manager import register as register_user_manager @@ -21,3 +22,4 @@ def register_tools(mcp, token: Optional[PerfectoToken]): register_execution_manager(mcp, token) register_help_manager(mcp, token) register_ai_scriptless_manager(mcp, token) + register_tools_manager(mcp, token) diff --git a/tests/test_datetime_utils.py b/tests/test_datetime_utils.py new file mode 100644 index 0000000..600756b --- /dev/null +++ b/tests/test_datetime_utils.py @@ -0,0 +1,14 @@ +"""Datetime utility timezone tests.""" + +from tools.utils import get_date_time_iso + + +class TestDateTimeIsoTimezone: + def test_returns_none_for_none_timestamp(self): + assert get_date_time_iso(None) is None + + def test_returns_utc_timezone_for_unix_epoch(self): + assert get_date_time_iso(0) == "1970-01-01T00:00:00+00:00" + + def test_returns_utc_timezone_for_known_timestamp(self): + assert get_date_time_iso(1710000000) == "2024-03-09T16:00:00+00:00" diff --git a/tests/test_help_utils_href_interpolation.py b/tests/test_help_utils_href_interpolation.py new file mode 100644 index 0000000..994afee --- /dev/null +++ b/tests/test_help_utils_href_interpolation.py @@ -0,0 +1,104 @@ +"""Help utils href interpolation and HTML sanitization tests.""" + +import lxml.html + +from tools.help_utils import process_inline_elements, table_to_markdown, html_to_markdown + + +class TestHelpUtilsHrefInterpolation: + def test_process_inline_elements_interpolates_href_in_html_mode(self): + element = lxml.html.fromstring("

Go here

") + + rendered = process_inline_elements( + element, + base_url="https://help.perfecto.io", + as_html=True, + ) + + assert "here" in rendered + assert "{href}" not in rendered + + def test_table_to_markdown_interpolates_href_inside_html_table_cells(self): + table = lxml.html.fromstring( + "" + "" + "" + "
Doc
Guide
" + ) + + rendered = table_to_markdown( + table, + base_url="https://help.perfecto.io", + as_html=True, + ) + + assert "Guide" in rendered + assert "{href}" not in rendered + + def test_html_to_markdown_outputs_markdown_links_without_literal_template(self): + html = ( + "
" + "

Read Start

" + "
" + ) + rendered = html_to_markdown(html, base_url="https://help.perfecto.io") + + assert "[Start](https://help.perfecto.io/docs/start.html)" in rendered + assert "{href}" not in rendered + + +class TestHelpUtilsHtmlSanitization: + def test_javascript_href_case_insensitive_blocked(self): + element = lxml.html.fromstring("

click

") + + rendered = process_inline_elements(element, as_html=True) + + assert "JAVASCRIPT:" not in rendered + assert "alert" not in rendered + + def test_javascript_href_mixed_case_blocked(self): + element = lxml.html.fromstring("

click

") + + rendered = process_inline_elements(element, as_html=True) + + assert "JavaScript:" not in rendered + + def test_html_special_chars_escaped_in_link_text(self): + element = lxml.html.fromstring("

A & B

") + + rendered = process_inline_elements(element, as_html=True) + + assert "A & B" in rendered + assert "