diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index ecd0629..8b3a86f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ security add-generic-password -a your-user -s openstack -w "your-password" | **Nova** (Compute) | `nova_list_servers`, `nova_get_server`, `nova_list_flavors`, `nova_server_action`* | Servers, flavors, actions | | **Neutron** (Networking) | `neutron_list_networks`, `neutron_list_subnets`, `neutron_list_ports`, `neutron_list_security_groups` | Networks, subnets, ports, security groups | | **Cinder** (Block Storage) | `cinder_list_volumes`, `cinder_get_volume` | Volumes | -| **Keystone** (Identity) | `keystone_list_projects`, `keystone_token_info`, `keystone_list_app_credentials`, `keystone_create_app_credential`*, `keystone_delete_app_credential`* | Projects, auth info, app credentials | +| **Keystone** (Identity) | `keystone_list_projects`, `keystone_token_info`, `keystone_list_application_credentials`, `keystone_create_application_credential`*, `keystone_delete_application_credential`* | Projects, auth info, app credentials | | **Designate** (DNS) | `designate_list_zones`, `designate_get_zone`, `designate_list_recordsets` | DNS zones and records | | **Barbican** (Key Manager) | `barbican_list_secrets`, `barbican_get_secret` | Secrets metadata (no payloads) | | **Swift** (Object Storage) | `swift_list_containers`, `swift_list_objects`, `swift_get_object_metadata` | Containers and objects | @@ -45,7 +45,7 @@ security add-generic-password -a your-user -s openstack -w "your-password" | Service | Tools | Description | |---------|-------|-------------| | **Hermes** (Audit) | `hermes_list_events`, `hermes_get_event`, `hermes_list_attributes` | CADF audit events | -| **Limes** (Quota/Usage) | `limes_get_project`, `limes_get_domain`, `limes_get_cluster` | Quota and usage reports | +| **Limes** (Quota/Usage) | `limes_get_project_quota`, `limes_get_domain_quota`, `limes_get_cluster_quota` | Quota and usage reports | | **Keppel** (Container Registry) | `keppel_list_accounts`, `keppel_list_repositories`, `keppel_list_manifests` | Container image registry | | **Archer** (Endpoint Service) | `archer_list_services`, `archer_get_service`, `archer_list_endpoints`, `archer_get_endpoint` | Private endpoint connectivity | | **Maia** (Prometheus) | `maia_query`, `maia_query_range`, `maia_label_values`, `maia_metric_names` | PromQL instant and range queries, metrics | @@ -160,6 +160,10 @@ Try these after setup: - "What load balancers exist and what pools do they have?" - "Show me pending Castellum autoscaling operations" +## Companion: Agent Toolkit + +For enhanced AI workflows, pair this MCP server with the [OpenStack Agent Toolkit](https://github.com/notque/openstack-agent-toolkit) — a Claude Code plugin providing domain knowledge, operational skills, and safety hooks for SAP Converged Cloud infrastructure. + ## Development ```bash diff --git a/REUSE.toml b/REUSE.toml index 6181994..50421ff 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -11,6 +11,7 @@ path = [ ".gitignore", ".license-scan-overrides.jsonl", ".license-scan-rules.json", + "LICENSE", ] SPDX-FileCopyrightText = "SAP SE or an SAP affiliate company" SPDX-License-Identifier = "Apache-2.0" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..be3ee4e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,44 @@ + + +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in this project, please report it responsibly. + +**Do NOT open a public GitHub issue for security vulnerabilities.** + +Instead, please report vulnerabilities via one of these channels: + +1. **GitHub Security Advisories**: Use the [Report a vulnerability](https://github.com/notque/openstack-mcp-server/security/advisories/new) feature +2. **Email**: Contact the maintainers directly (see CODEOWNERS) + +### What to include + +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +### Response timeline + +- **Acknowledgment**: Within 48 hours +- **Assessment**: Within 7 days +- **Fix**: Dependent on severity (critical: ASAP, high: 14 days, medium: 30 days) + +## Security Architecture + +This project implements a three-layer security model: + +1. **Read-Only Mode** (default): Mutating tools are not registered +2. **Tool Annotations**: MCP clients prompt users before destructive actions +3. **Credential Isolation**: Auth tokens and passwords never reach the LLM + +See the [README](README.md#security) for full architecture details. + +## Supported Versions + +Only the latest release is supported with security updates. diff --git a/internal/auth/provider.go b/internal/auth/provider.go index 53dd841..d8c160c 100644 --- a/internal/auth/provider.go +++ b/internal/auth/provider.go @@ -424,8 +424,9 @@ func (p *Provider) CronusClient() (*gophercloud.ServiceClient, error) { // Token returns the current auth token for internal API calls. // SECURITY: This value must NEVER be included in MCP tool responses. // It is used only for server-side OpenStack API authentication. +// Uses the mutex-protected accessor to avoid races during reauth (SSE mode). func (p *Provider) Token() string { - return p.providerClient.TokenID + return p.providerClient.Token() } // UserID returns the authenticated user's ID (from the auth token response). diff --git a/internal/server/server.go b/internal/server/server.go index fb60d48..43aea52 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -63,7 +63,9 @@ func (s *Server) Run() error { case "stdio": return mcpserver.ServeStdio(s.mcp) case "sse": - addr := fmt.Sprintf(":%d", s.cfg.Port) + // SECURITY: Bind to localhost only by default. SSE has no authentication; + // binding to 0.0.0.0 would allow any network process to invoke tools. + addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port) sseServer := mcpserver.NewSSEServer(s.mcp) return sseServer.Start(addr) default: diff --git a/internal/tools/archer/archer.go b/internal/tools/archer/archer.go index e106d87..ed5c792 100644 --- a/internal/tools/archer/archer.go +++ b/internal/tools/archer/archer.go @@ -60,9 +60,9 @@ func listServicesHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { } url := client.Endpoint + "service" - if status := shared.StringParam(request, "status"); status != "" { - url += "?status=" + status - } + url += shared.SafeQueryParams(map[string]string{ + "status": shared.StringParam(request, "status"), + }) var body any //nolint:bodyclose @@ -89,14 +89,10 @@ func listEndpointsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { } url := client.Endpoint + "endpoint" - sep := "?" - if svcID := shared.StringParam(request, "service_id"); svcID != "" { - url += sep + "service_id=" + svcID - sep = "&" - } - if status := shared.StringParam(request, "status"); status != "" { - url += sep + "status=" + status - } + url += shared.SafeQueryParams(map[string]string{ + "service_id": shared.StringParam(request, "service_id"), + "status": shared.StringParam(request, "status"), + }) var body any //nolint:bodyclose @@ -126,6 +122,9 @@ func getServiceHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { if serviceID == "" { return shared.ToolError("service_id is required"), nil } + if errResult := shared.ValidateUUID(serviceID, "service_id"); errResult != nil { + return errResult, nil + } url := client.Endpoint + "service/" + serviceID @@ -157,6 +156,9 @@ func getEndpointHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { if endpointID == "" { return shared.ToolError("endpoint_id is required"), nil } + if errResult := shared.ValidateUUID(endpointID, "endpoint_id"); errResult != nil { + return errResult, nil + } url := client.Endpoint + "endpoint/" + endpointID diff --git a/internal/tools/keppel/keppel.go b/internal/tools/keppel/keppel.go index b779f71..908017f 100644 --- a/internal/tools/keppel/keppel.go +++ b/internal/tools/keppel/keppel.go @@ -81,6 +81,9 @@ func listReposHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { if account == "" { return shared.ToolError("account is required"), nil } + if errResult := shared.ValidatePathSegment(account, "account"); errResult != nil { + return errResult, nil + } url := client.Endpoint + "keppel/v1/accounts/" + account + "/repositories" @@ -113,11 +116,17 @@ func listManifestsHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { if account == "" || repo == "" { return shared.ToolError("account and repository are required"), nil } + if errResult := shared.ValidatePathSegment(account, "account"); errResult != nil { + return errResult, nil + } + if errResult := shared.ValidatePathSegment(repo, "repository"); errResult != nil { + return errResult, nil + } url := client.Endpoint + "keppel/v1/accounts/" + account + "/repositories/" + repo + "/_manifests" - if v := shared.StringParam(request, "vulnerability_status"); v != "" { - url += "?vulnerability_status=" + v - } + url += shared.SafeQueryParams(map[string]string{ + "vulnerability_status": shared.StringParam(request, "vulnerability_status"), + }) var body any //nolint:bodyclose diff --git a/internal/tools/limes/limes.go b/internal/tools/limes/limes.go index 26f4d70..9218800 100644 --- a/internal/tools/limes/limes.go +++ b/internal/tools/limes/limes.go @@ -64,21 +64,20 @@ func getProjectQuotaHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { if domainID == "" || projectID == "" { return shared.ToolError("domain_id and project_id are required"), nil } - - url := client.Endpoint + "domains/" + domainID + "/projects/" + projectID - sep := "?" - if svc := shared.StringParam(request, "service"); svc != "" { - url += sep + "service=" + svc - sep = "&" + if errResult := shared.ValidateUUID(domainID, "domain_id"); errResult != nil { + return errResult, nil } - if res := shared.StringParam(request, "resource"); res != "" { - url += sep + "resource=" + res - sep = "&" - } - if area := shared.StringParam(request, "area"); area != "" { - url += sep + "area=" + area + if errResult := shared.ValidateUUID(projectID, "project_id"); errResult != nil { + return errResult, nil } + url := client.Endpoint + "domains/" + domainID + "/projects/" + projectID + url += shared.SafeQueryParams(map[string]string{ + "service": shared.StringParam(request, "service"), + "resource": shared.StringParam(request, "resource"), + "area": shared.StringParam(request, "area"), + }) + var body any //nolint:bodyclose _, err = client.Get(ctx, url, &body, &gophercloud.RequestOpts{ @@ -107,20 +106,16 @@ func getDomainQuotaHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { if domainID == "" { return shared.ToolError("domain_id is required"), nil } + if errResult := shared.ValidateUUID(domainID, "domain_id"); errResult != nil { + return errResult, nil + } url := client.Endpoint + "domains/" + domainID - sep := "?" - if svc := shared.StringParam(request, "service"); svc != "" { - url += sep + "service=" + svc - sep = "&" - } - if res := shared.StringParam(request, "resource"); res != "" { - url += sep + "resource=" + res - sep = "&" - } - if area := shared.StringParam(request, "area"); area != "" { - url += sep + "area=" + area - } + url += shared.SafeQueryParams(map[string]string{ + "service": shared.StringParam(request, "service"), + "resource": shared.StringParam(request, "resource"), + "area": shared.StringParam(request, "area"), + }) var body any //nolint:bodyclose @@ -147,18 +142,11 @@ func getClusterQuotaHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { } url := client.Endpoint + "clusters/current" - sep := "?" - if svc := shared.StringParam(request, "service"); svc != "" { - url += sep + "service=" + svc - sep = "&" - } - if res := shared.StringParam(request, "resource"); res != "" { - url += sep + "resource=" + res - sep = "&" - } - if area := shared.StringParam(request, "area"); area != "" { - url += sep + "area=" + area - } + url += shared.SafeQueryParams(map[string]string{ + "service": shared.StringParam(request, "service"), + "resource": shared.StringParam(request, "resource"), + "area": shared.StringParam(request, "area"), + }) var body any //nolint:bodyclose diff --git a/internal/tools/nova/nova.go b/internal/tools/nova/nova.go index b590682..634f2cb 100644 --- a/internal/tools/nova/nova.go +++ b/internal/tools/nova/nova.go @@ -140,7 +140,31 @@ func getServerHandler(provider *auth.Provider) mcpserver.ToolHandlerFunc { return shared.ToolError("failed to get server %s: %v", serverID, err), nil } - out, err := json.MarshalIndent(srv, "", " ") + // SECURITY: Use allowlist of safe fields. The full Server struct contains + // AdminPass (the admin password set at provisioning) and potentially other + // sensitive fields from extensions. Never marshal the entire struct. + safe := map[string]any{ + "id": srv.ID, + "name": srv.Name, + "status": srv.Status, + "tenant_id": srv.TenantID, + "user_id": srv.UserID, + "addresses": srv.Addresses, + "flavor": srv.Flavor, + "image": srv.Image, + "key_name": srv.KeyName, + "created": srv.Created, + "updated": srv.Updated, + "host_id": srv.HostID, + "availability_zone": srv.AvailabilityZone, + "metadata": srv.Metadata, + "security_groups": srv.SecurityGroups, + "attached_volumes": srv.AttachedVolumes, + "fault": srv.Fault, + "tags": srv.Tags, + } + + out, err := json.MarshalIndent(safe, "", " ") if err != nil { return shared.ToolError("failed to marshal response: %v", err), nil } diff --git a/internal/tools/shared/helpers.go b/internal/tools/shared/helpers.go index 65efe53..650f1b2 100644 --- a/internal/tools/shared/helpers.go +++ b/internal/tools/shared/helpers.go @@ -6,10 +6,55 @@ package shared import ( "fmt" + "net/url" + "regexp" "github.com/mark3labs/mcp-go/mcp" ) +// uuidPattern validates that a string is a proper UUID format. +// Used to prevent path traversal attacks via ID parameters in URL construction. +var uuidPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// safePathSegmentPattern validates that a string is a safe URL path segment. +// Allows alphanumeric, hyphens, underscores, dots, and forward slashes (for repo paths). +// Rejects: .., control chars, query strings, fragments. +var safePathSegmentPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/\-]*$`) + +// ValidateUUID checks if a string is a valid UUID. Returns an error tool result if invalid. +func ValidateUUID(value, paramName string) *mcp.CallToolResult { + if !uuidPattern.MatchString(value) { + return ToolError("%s must be a valid UUID (got: %q)", paramName, value) + } + return nil +} + +// ValidatePathSegment checks if a string is safe for use in a URL path. +// Rejects empty strings, path traversal attempts (..), and control characters. +func ValidatePathSegment(value, paramName string) *mcp.CallToolResult { + if value == "" { + return ToolError("%s is required", paramName) + } + if !safePathSegmentPattern.MatchString(value) { + return ToolError("%s contains invalid characters (got: %q)", paramName, value) + } + return nil +} + +// SafeQueryParams builds a URL query string from key-value pairs, properly encoding values. +func SafeQueryParams(params map[string]string) string { + values := url.Values{} + for k, v := range params { + if v != "" { + values.Set(k, v) + } + } + if encoded := values.Encode(); encoded != "" { + return "?" + encoded + } + return "" +} + // ToolError creates an MCP tool error response. // Error messages are sanitized to prevent accidental credential leakage // (e.g., from gophercloud error strings that may include response bodies). diff --git a/internal/tools/shared/helpers_test.go b/internal/tools/shared/helpers_test.go new file mode 100644 index 0000000..b49c0b0 --- /dev/null +++ b/internal/tools/shared/helpers_test.go @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +// SPDX-License-Identifier: Apache-2.0 + +package shared + +import ( + "testing" +) + +func TestValidateUUID_ValidUUIDs(t *testing.T) { + validUUIDs := []string{ + "550e8400-e29b-41d4-a716-446655440000", + "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "00000000-0000-0000-0000-000000000000", + "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", + } + for _, uuid := range validUUIDs { + if result := ValidateUUID(uuid, "test_id"); result != nil { + t.Errorf("ValidateUUID(%q) should pass, got error", uuid) + } + } +} + +func TestValidateUUID_InvalidUUIDs(t *testing.T) { + invalidUUIDs := []struct { + name string + value string + }{ + {"path traversal", "../../admin/tokens"}, + {"too short", "abc123"}, + {"not hex", "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz"}, + {"sql injection", "'; DROP TABLE servers; --"}, + {"query injection", "abc&admin=true"}, + {"empty", ""}, + {"with spaces", "550e8400 e29b 41d4 a716 446655440000"}, + {"newlines", "550e8400-e29b-41d4-a716\n-446655440000"}, + } + for _, tt := range invalidUUIDs { + t.Run(tt.name, func(t *testing.T) { + result := ValidateUUID(tt.value, "test_id") + if result == nil { + t.Errorf("ValidateUUID(%q) should fail", tt.value) + } + if result != nil && !result.IsError { + t.Errorf("ValidateUUID(%q) should return IsError=true", tt.value) + } + }) + } +} + +func TestValidatePathSegment_Valid(t *testing.T) { + validSegments := []string{ + "my-account", + "repo-name", + "my_image/nested", + "account.name", + "simple123", + } + for _, seg := range validSegments { + if result := ValidatePathSegment(seg, "test"); result != nil { + t.Errorf("ValidatePathSegment(%q) should pass, got error", seg) + } + } +} + +func TestValidatePathSegment_Invalid(t *testing.T) { + invalidSegments := []struct { + name string + value string + }{ + {"path traversal", "../../../etc/passwd"}, + {"double dot prefix", "..secret"}, + {"query string", "account?admin=true"}, + {"fragment", "account#fragment"}, + {"empty", ""}, + {"control chars", "account\x00name"}, + {"space", "my account"}, + {"starts with dot", ".hidden"}, + {"starts with dash", "-invalid"}, + } + for _, tt := range invalidSegments { + t.Run(tt.name, func(t *testing.T) { + result := ValidatePathSegment(tt.value, "test") + if result == nil { + t.Errorf("ValidatePathSegment(%q) should fail", tt.value) + } + }) + } +} + +func TestSafeQueryParams_EncodesValues(t *testing.T) { + tests := []struct { + name string + params map[string]string + want string + }{ + { + name: "single param", + params: map[string]string{"status": "active"}, + want: "?status=active", + }, + { + name: "empty value skipped", + params: map[string]string{"status": "", "name": "test"}, + want: "?name=test", + }, + { + name: "all empty", + params: map[string]string{"status": "", "name": ""}, + want: "", + }, + { + name: "special chars encoded", + params: map[string]string{"query": "foo&bar=baz"}, + want: "?query=foo%26bar%3Dbaz", + }, + { + name: "spaces encoded", + params: map[string]string{"name": "my server"}, + want: "?name=my+server", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SafeQueryParams(tt.params) + if got != tt.want { + t.Errorf("SafeQueryParams() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSanitizeResponse_RedactsAdminPass(t *testing.T) { + input := `{"server": {"id": "abc", "adminPass": "MySecretPassword123!", "name": "test"}}` + result := SanitizeResponse(input) + if result == input { + t.Errorf("SanitizeResponse should redact adminPass field") + } + if !containsAny(result, `"adminPass": "[REDACTED]"`) { + t.Errorf("expected adminPass to be redacted, got: %s", result) + } + // Verify other fields preserved + if !containsAny(result, `"name": "test"`) { + t.Errorf("expected name field to be preserved, got: %s", result) + } +} + +func TestSanitizeResponse_RedactsAdminPassCaseInsensitive(t *testing.T) { + input := `{"AdminPass": "secret123"}` + result := SanitizeResponse(input) + if containsAny(result, "secret123") { + t.Errorf("SanitizeResponse should redact AdminPass (case insensitive), got: %s", result) + } +} + +func containsAny(s, substr string) bool { + return s != "" && substr != "" && contains(s, substr) +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/tools/shared/sanitize.go b/internal/tools/shared/sanitize.go index 9481762..3ccb26d 100644 --- a/internal/tools/shared/sanitize.go +++ b/internal/tools/shared/sanitize.go @@ -14,6 +14,8 @@ import ( // These keys could appear in OpenStack API responses if raw responses are forwarded. var sensitiveKeys = []string{ "password", + "adminPass", + "admin_pass", "secret", "token_id", "x-auth-token",