From e097ec3839af504a7bdac1fb7b0d9db4db75a1d3 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Date: Mon, 12 Oct 2026 16:05:46 +0530 Subject: [PATCH 1/4] feat: add copy as cURL to audit log entries --- docs/api-reference.md | 17 ++ packages/frontend/src/app/logs/page.tsx | 131 +++++++-- .../frontend/src/app/mcp-server/[id]/page.tsx | 7 +- packages/frontend/src/app/page.tsx | 7 +- packages/frontend/src/lib/curl.ts | 108 +++++++ packages/frontend/src/lib/origin.ts | 12 + .../frontend/tests/e2e/audit-log-curl.spec.ts | 267 ++++++++++++++++++ 7 files changed, 520 insertions(+), 29 deletions(-) create mode 100644 packages/frontend/src/lib/curl.ts create mode 100644 packages/frontend/src/lib/origin.ts create mode 100644 packages/frontend/tests/e2e/audit-log-curl.spec.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index 1c3a3feb8..37fb870f4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -251,6 +251,23 @@ Response: } ``` +### Replay a Tool Call + +When viewing an audit log entry in the dashboard (`/logs`), click **Copy as cURL** in the expanded row to copy a command that replays the recorded MCP `tools/call` against the server's endpoint: + +```bash +curl -sS -X POST "http://localhost:4000/mcp/" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -H "X-API-Key: $AMCP_MCP_API_KEY" \ + --data-binary @- <<'JSON' +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"","arguments":{...}}} +JSON +``` + +Export your key as `AMCP_MCP_API_KEY` (or `AMCP_ACCESS_TOKEN` when the server operates in OAuth2 mode) to run the command directly from your terminal. + --- ## Roles & Access Control diff --git a/packages/frontend/src/app/logs/page.tsx b/packages/frontend/src/app/logs/page.tsx index 8ccae1c7f..7cef74aeb 100644 --- a/packages/frontend/src/app/logs/page.tsx +++ b/packages/frontend/src/app/logs/page.tsx @@ -2,13 +2,16 @@ import { Fragment, useEffect, useState, useCallback } from 'react'; import { useAuth } from '@/lib/auth-context'; -import { audit, connectors as connectorsApi, mcpServers } from '@/lib/api'; +import { audit, connectors as connectorsApi, mcpServers, server } from '@/lib/api'; import { AppSelect } from '@/components/ui/select'; import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { StatusPill, type Tone } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; +import { copyText } from '@/lib/clipboard'; +import { buildToolCallCurl } from '@/lib/curl'; +import { getApiOrigin } from '@/lib/origin'; const PAGE_SIZE = 50; @@ -93,6 +96,41 @@ export default function LogsPage() { const [expandedId, setExpandedId] = useState(null); const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); + const [authMode, setAuthMode] = useState('both'); + const [copiedId, setCopiedId] = useState(null); + const [copyErrorId, setCopyErrorId] = useState(null); + + // Load server info to determine MCP auth mode (legacy, both, oauth2) + useEffect(() => { + server.info().then((info) => { + if (info?.mcpAuthMode) setAuthMode(info.mcpAuthMode); + }).catch(() => {}); + }, []); + + const handleCopyCurl = async (log: any) => { + const serverId = log.mcpServer?.id || log.mcpServerId; + const toolName = log.tool?.name || log.toolId; + if (!serverId || !toolName) return; + + const origin = getApiOrigin(); + const command = buildToolCallCurl({ + origin, + serverId, + toolName, + args: log.input, + authMode, + }); + + const ok = await copyText(command); + if (ok) { + setCopiedId(log.id); + setCopyErrorId(null); + setTimeout(() => setCopiedId((curr) => (curr === log.id ? null : curr)), 2000); + } else { + setCopyErrorId(log.id); + setTimeout(() => setCopyErrorId((curr) => (curr === log.id ? null : curr)), 3000); + } + }; // Debounced search const [debouncedSearch, setDebouncedSearch] = useState(''); @@ -397,24 +435,79 @@ export default function LogsPage() { -
-
- User: - -
- {(() => { - const ci = parseClientInfo(log.clientInfo); - if (!ci) return null; - return ( - <> - {ci.authMethod && Auth: {authMethodLabel(ci.authMethod)}} - {ci.apiKeyName && Key: {ci.apiKeyName}} - - ); - })()} - {log.mcpServer && Server: {log.mcpServer.name}} - {log.tool?.connector && Connector: {log.tool.connector.name} ({log.tool.connector.type})} -
+ {(() => { + const isSharedEndpoint = !log.mcpServer; + const isTruncated = Boolean( + log.input && + typeof log.input === 'object' && + (log.input._amcp_truncated || '_amcp_truncated' in log.input) + ); + const cannotCopyReason = isSharedEndpoint + ? 'Replay unavailable: call ran through the shared /mcp endpoint' + : isTruncated + ? 'Replay unavailable: stored input parameters were truncated' + : !(log.tool?.name || log.toolId) + ? 'Replay unavailable: tool name is missing' + : null; + const canCopy = !cannotCopyReason; + + return ( +
+
+
+ User: + +
+ {(() => { + const ci = parseClientInfo(log.clientInfo); + if (!ci) return null; + return ( + <> + {ci.authMethod && Auth: {authMethodLabel(ci.authMethod)}} + {ci.apiKeyName && Key: {ci.apiKeyName}} + + ); + })()} + {log.mcpServer && Server: {log.mcpServer.name}} + {log.tool?.connector && Connector: {log.tool.connector.name} ({log.tool.connector.type})} +
+ +
+ + + +
+
+ ); + })()} )} diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index 14e1d64f3..1d88a7535 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -11,6 +11,7 @@ import { Badge, StatusPill, type Tone } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { ConnectionCheck } from '@/components/connection-check'; import { copyText } from '@/lib/clipboard'; +import { getApiOrigin } from '@/lib/origin'; import { CLAUDE_DIRECTORY_URL } from '@/lib/marketing'; // Opens claude.ai straight on its "Add custom connector" dialog. Connectors @@ -98,11 +99,7 @@ export default function McpServerDetailPage() { }).catch(() => {}).finally(() => setLoading(false)); }, [token, id]); - const apiUrl = typeof window !== 'undefined' - ? window.location.hostname === 'localhost' - ? `${window.location.protocol}//${window.location.hostname}:4000` - : window.location.origin - : 'http://localhost:4000'; + const apiUrl = getApiOrigin(); const handleSave = async () => { if (!token || !id) return; diff --git a/packages/frontend/src/app/page.tsx b/packages/frontend/src/app/page.tsx index 3f39cc1fa..07bc26f24 100644 --- a/packages/frontend/src/app/page.tsx +++ b/packages/frontend/src/app/page.tsx @@ -8,6 +8,7 @@ import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { StatCard } from '@/components/ui/stat-card'; import { Button } from '@/components/ui/button'; +import { getApiOrigin } from '@/lib/origin'; type HealthResult = { total: number; healthy: number; unhealthy: number; connectors: any[] } | null; @@ -85,11 +86,7 @@ export default function DashboardPage() { if (isLoading) return null; - const apiUrl = typeof window !== 'undefined' - ? window.location.hostname === 'localhost' - ? `${window.location.protocol}//${window.location.hostname}:4000` - : window.location.origin - : 'http://localhost:4000'; + const apiUrl = getApiOrigin(); const maxTotal = analytics?.daily?.length ? Math.max(...analytics.daily.map((d) => d.success + d.error + d.timeout), 1) diff --git a/packages/frontend/src/lib/curl.ts b/packages/frontend/src/lib/curl.ts new file mode 100644 index 000000000..3c280d481 --- /dev/null +++ b/packages/frontend/src/lib/curl.ts @@ -0,0 +1,108 @@ +/** + * Key pattern matching sensitive credential fields across casing styles. + * Follows the project's established redaction model from security-event.service. + */ +const REDACTED_KEY_PATTERN = + /secret|password|passwd|token|assertion|credential|code_verifier|code_challenge|authorization|cookie|private_key|api[-_]?key/i; + +export const REDACTED_PLACEHOLDER = '[REDACTED]'; + +/** + * Recursively redacts sensitive values matching credential field names. + */ +export function redactSensitiveArgs(val: unknown, depth = 0): unknown { + if (depth > 10 || val === null || val === undefined) return val; + if (Array.isArray(val)) { + return val.map((item) => redactSensitiveArgs(item, depth + 1)); + } + if (typeof val === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(val as Record)) { + if (REDACTED_KEY_PATTERN.test(k)) { + out[k] = REDACTED_PLACEHOLDER; + } else { + out[k] = redactSensitiveArgs(v, depth + 1); + } + } + return out; + } + return val; +} + +export interface BuildToolCallCurlOptions { + origin: string; + serverId: string; + toolName: string; + args?: unknown; + authMode?: string; +} + +/** + * Builds a cURL command to replay an MCP tool call against an AnythingMCP per-server endpoint. + * + * The per-server endpoint (/mcp/:serverId) runs in stateless mode by default and accepts + * a bare `tools/call` request with MCP-Protocol-Version: 2025-06-18 directly, without + * requiring a preceding `initialize` handshake on a local instance. + * + * Uses a quoted heredoc (<<'JSON') so that special characters (quotes, backticks, $, newlines) + * in tool arguments survive intact without shell escaping issues. + * + * Security: + * - Credentials are always placeholder environment variables, never actual secrets. + * - For legacy / both auth modes: X-API-Key: $AMCP_MCP_API_KEY + * - For oauth2 auth mode: Authorization: Bearer $AMCP_ACCESS_TOKEN + * - Dashboard session tokens (amcp_token) are never inserted. + * - Any sensitive credential fields inside tool arguments are redacted with [REDACTED]. + */ +export function buildToolCallCurl({ + origin, + serverId, + toolName, + args, + authMode, +}: BuildToolCallCurlOptions): string { + const cleanOrigin = origin.replace(/\/+$/, ''); + const url = `${cleanOrigin}/mcp/${encodeURI(serverId)}`; + + const authHeader = + authMode === 'oauth2' + ? '-H "Authorization: Bearer $AMCP_ACCESS_TOKEN"' + : '-H "X-API-Key: $AMCP_MCP_API_KEY"'; + + let argumentsObj: unknown = args; + if (typeof args === 'string') { + try { + argumentsObj = JSON.parse(args); + } catch { + argumentsObj = {}; + } + } + + if (!argumentsObj || typeof argumentsObj !== 'object' || Array.isArray(argumentsObj)) { + argumentsObj = {}; + } + + // Redact any sensitive keys in arguments as defense-in-depth + const safeArguments = redactSensitiveArgs(argumentsObj); + + const payload = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: toolName, + arguments: safeArguments, + }, + }); + + return [ + `curl -sS -X POST "${url}" \\`, + ' -H "Content-Type: application/json" \\', + ' -H "Accept: application/json, text/event-stream" \\', + ' -H "MCP-Protocol-Version: 2025-06-18" \\', + ` ${authHeader} \\`, + " --data-binary @- <<'JSON'", + payload, + 'JSON', + ].join('\n'); +} diff --git a/packages/frontend/src/lib/origin.ts b/packages/frontend/src/lib/origin.ts new file mode 100644 index 000000000..ca1933c2a --- /dev/null +++ b/packages/frontend/src/lib/origin.ts @@ -0,0 +1,12 @@ +/** + * Returns the base origin/URL for API calls and MCP endpoints. + * + * In local development, the Next.js frontend runs on :3000 while the NestJS + * backend runs on :4000. In production / cloud, requests share the same origin. + */ +export function getApiOrigin(): string { + if (typeof window === 'undefined') return 'http://localhost:4000'; + return window.location.hostname === 'localhost' + ? `${window.location.protocol}//${window.location.hostname}:4000` + : window.location.origin; +} diff --git a/packages/frontend/tests/e2e/audit-log-curl.spec.ts b/packages/frontend/tests/e2e/audit-log-curl.spec.ts new file mode 100644 index 000000000..f68e61b3b --- /dev/null +++ b/packages/frontend/tests/e2e/audit-log-curl.spec.ts @@ -0,0 +1,267 @@ +import { expect, test, type Page } from '@playwright/test'; +import { buildToolCallCurl, redactSensitiveArgs } from '../../src/lib/curl'; + +const USER = { + id: 'u1', + email: 'test@example.com', + name: 'Test User', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +async function fakeSession(page: Page, authMode = 'both') { + await page.context().addCookies([ + { name: 'amcp_token', value: 'test-token', url: 'http://localhost:3100' }, + ]); + await page.addInitScript((user) => { + localStorage.setItem('amcp_token', 'test-token'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, USER); + + await page.route(/\/(api|health)\//, async (route) => { + const url = route.request().url(); + const json = (body: unknown) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + + if (url.includes('/health/server-info')) { + return json({ + deploymentMode: 'self-hosted', + mcpAuthMode: authMode, + serverUrl: 'http://localhost:4000', + mcpEndpoint: 'http://localhost:4000/mcp', + hasUsers: true, + registrationEnabled: true, + ssoProviders: [], + }); + } + if (url.includes('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); + if (url.includes('/api/users/me')) return json(USER); + if (url.includes('/api/organizations/current')) return json({ id: 'o1', name: 'Acme', createdAt: '2026-01-01' }); + if (url.includes('/api/organizations/mine')) return json([{ id: 'o1', name: 'Acme', role: 'ADMIN', joinedAt: '2026-01-01' }]); + if (url.includes('/api/license/status')) return json({ plan: 'community', status: 'active' }); + if (url.includes('/api/connectors')) return json([]); + if (url.includes('/api/mcp-servers')) return json([{ id: 'srv-1', name: 'Test Server', slug: 'test-server' }]); + if (url.includes('/api/audit/stats')) return json({ invocations24h: 3, errors24h: 0 }); + if (url.includes('/api/audit/analytics')) return json({ daily: [], topTools: [], totalInvocations: 3, successRate: 100, avgDuration: 10 }); + if (url.includes('/api/audit/invocations')) { + return json([ + { + id: 'log-1', + createdAt: new Date().toISOString(), + status: 'SUCCESS', + durationMs: 42, + mcpServerId: 'srv-1', + mcpServer: { id: 'srv-1', name: 'Test Server', slug: 'test-server' }, + toolId: 't-1', + tool: { name: 'echo_message', connector: { name: 'Echo Service', type: 'REST' } }, + input: { message: 'hello world', target: 'cli' }, + output: { result: 'ok' }, + clientInfo: JSON.stringify({ authMethod: 'mcp_api_key', apiKeyName: 'test-key' }), + }, + { + id: 'log-2', + createdAt: new Date().toISOString(), + status: 'SUCCESS', + durationMs: 30, + mcpServerId: null, + mcpServer: null, + toolId: 't-2', + tool: { name: 'shared_calc', connector: { name: 'Calc', type: 'REST' } }, + input: { expr: '2+2' }, + output: { result: 4 }, + clientInfo: null, + }, + { + id: 'log-3', + createdAt: new Date().toISOString(), + status: 'SUCCESS', + durationMs: 15, + mcpServerId: 'srv-1', + mcpServer: { id: 'srv-1', name: 'Test Server', slug: 'test-server' }, + toolId: 't-3', + tool: { name: 'large_fetch', connector: { name: 'Data', type: 'REST' } }, + input: { _amcp_truncated: true, items: ['cut...'] }, + output: { count: 100 }, + clientInfo: null, + }, + ]); + } + return json({}); + }); +} + +test.describe('buildToolCallCurl builder unit tests', () => { + test('generates expected cURL command for legacy/both auth mode', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-123', + toolName: 'my_tool', + args: { query: 'test', limit: 10 }, + authMode: 'both', + }); + + expect(cmd).toContain('curl -sS -X POST "http://localhost:4000/mcp/srv-123" \\'); + expect(cmd).toContain('-H "Content-Type: application/json" \\'); + expect(cmd).toContain('-H "Accept: application/json, text/event-stream" \\'); + expect(cmd).toContain('-H "MCP-Protocol-Version: 2025-06-18" \\'); + expect(cmd).toContain('-H "X-API-Key: $AMCP_MCP_API_KEY" \\'); + expect(cmd).toContain("--data-binary @- <<'JSON'"); + expect(cmd).toContain('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"my_tool","arguments":{"query":"test","limit":10}}}'); + expect(cmd.endsWith('JSON')).toBe(true); + }); + + test('generates Authorization: Bearer placeholder for oauth2 auth mode', () => { + const cmd = buildToolCallCurl({ + origin: 'https://cloud.anythingmcp.com', + serverId: 'srv-abc', + toolName: 'fetch_user', + args: { id: 'u123' }, + authMode: 'oauth2', + }); + + expect(cmd).toContain('curl -sS -X POST "https://cloud.anythingmcp.com/mcp/srv-abc" \\'); + expect(cmd).toContain('-H "Authorization: Bearer $AMCP_ACCESS_TOKEN" \\'); + expect(cmd).not.toContain('$AMCP_MCP_API_KEY'); + expect(cmd).not.toContain('test-token'); + }); + + test('normalizes trailing slashes on origin', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000///', + serverId: 'srv-1', + toolName: 'ping', + args: {}, + }); + + expect(cmd).toContain('"http://localhost:4000/mcp/srv-1"'); + }); + + test('preserves single quotes, double quotes, dollar signs, backticks, and newlines in args', () => { + const trickyArgs = { + message: "it's a test with \"quotes\", $VAR, `cmd`, and \n newline", + unicode: '🚀 日本語 test', + }; + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tricky_tool', + args: trickyArgs, + }); + + expect(cmd).toContain("--data-binary @- <<'JSON'"); + const jsonLine = cmd.split('\n').find((l) => l.startsWith('{"jsonrpc"')); + expect(jsonLine).toBeDefined(); + const parsed = JSON.parse(jsonLine!); + expect(parsed.params.arguments).toEqual(trickyArgs); + }); + + test('redacts sensitive keys in arguments', () => { + const sensitiveArgs = { + user: 'alice', + password: 'super-secret-password', + apiKey: 'real_api_key_123', + nested: { + accessToken: 'bearer_token_xyz', + auth: { client_secret: 'secret999' }, + safe: 'normal_val', + }, + }; + + const redacted = redactSensitiveArgs(sensitiveArgs) as any; + expect(redacted.password).toBe('[REDACTED]'); + expect(redacted.apiKey).toBe('[REDACTED]'); + expect(redacted.nested.accessToken).toBe('[REDACTED]'); + expect(redacted.nested.auth.client_secret).toBe('[REDACTED]'); + expect(redacted.nested.safe).toBe('normal_val'); + expect(redacted.user).toBe('alice'); + + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'auth_tool', + args: sensitiveArgs, + }); + expect(cmd).not.toContain('super-secret-password'); + expect(cmd).not.toContain('real_api_key_123'); + expect(cmd).not.toContain('bearer_token_xyz'); + expect(cmd).not.toContain('secret999'); + expect(cmd).toContain('[REDACTED]'); + }); + + test('handles null, undefined, string, or empty args safely', () => { + const cmd1 = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'empty_tool', + args: null, + }); + expect(cmd1).toContain('"arguments":{}'); + + const cmd2 = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'json_string_tool', + args: '{"foo":"bar"}', + }); + expect(cmd2).toContain('"arguments":{"foo":"bar"}'); + }); +}); + +test.describe('Audit log "Copy as cURL" UI interaction', () => { + test.use({ + permissions: ['clipboard-read', 'clipboard-write'], + }); + + test('copies valid tool call command and shows proper disabled states for shared and truncated rows', async ({ + page, + context, + }) => { + await fakeSession(page, 'both'); + await page.goto('/logs'); + + // Wait for log rows to render + await expect(page.getByTitle('echo_message')).toBeVisible(); + await expect(page.getByTitle('shared_calc')).toBeVisible(); + await expect(page.getByTitle('large_fetch')).toBeVisible(); + + // 1. Expand the first row (valid per-server log) + await page.getByTitle('echo_message').click(); + + // Verify Copy as cURL button is visible and enabled + const copyBtn = page.getByRole('button', { name: 'Copy as cURL' }); + await expect(copyBtn).toBeVisible(); + await expect(copyBtn).toBeEnabled(); + + // Click Copy as cURL + await copyBtn.click(); + + // Verify feedback + await expect(page.getByText('Copied!')).toBeVisible(); + + // Read clipboard text and verify structure + const copiedText = await page.evaluate(() => navigator.clipboard.readText()); + expect(copiedText).toContain('curl -sS -X POST'); + expect(copiedText).toContain('/mcp/srv-1'); + expect(copiedText).toContain('-H "MCP-Protocol-Version: 2025-06-18"'); + expect(copiedText).toContain('-H "X-API-Key: $AMCP_MCP_API_KEY"'); + expect(copiedText).toContain("--data-binary @- <<'JSON'"); + expect(copiedText).toContain('"method":"tools/call"'); + expect(copiedText).toContain('"name":"echo_message"'); + expect(copiedText).toContain('"message":"hello world"'); + + // 2. Expand second row (shared endpoint) + await page.getByTitle('shared_calc').click(); + const sharedRowDisabled = page.locator('span[title="Replay unavailable: call ran through the shared /mcp endpoint"]'); + await expect(sharedRowDisabled).toBeVisible(); + const sharedBtn = sharedRowDisabled.getByRole('button', { name: 'Copy as cURL' }); + await expect(sharedBtn).toBeDisabled(); + + // 3. Expand third row (truncated input) + await page.getByTitle('large_fetch').click(); + const truncatedRowDisabled = page.locator('span[title="Replay unavailable: stored input parameters were truncated"]'); + await expect(truncatedRowDisabled).toBeVisible(); + const truncatedBtn = truncatedRowDisabled.getByRole('button', { name: 'Copy as cURL' }); + await expect(truncatedBtn).toBeDisabled(); + }); +}); From 8849ee76a62741bc6939abc1e6f40c11edeece4c Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:27:43 +0200 Subject: [PATCH 2/4] Copy as cURL: single-quote the URL, only replay tool names, flag redacted arguments Quote the endpoint URL in single quotes with an encoded server id, so nothing in it reaches the shell. Replay only a row's tool name, never its database id. Redact subtrees deeper than the walk instead of passing them through, catch a few more credential key names, and start the command with a comment when arguments were redacted. Tests for heredoc breakout, shell characters in the id and deep values. --- docs/api-reference.md | 4 +- packages/frontend/src/app/logs/page.tsx | 9 ++-- packages/frontend/src/lib/curl.ts | 20 ++++++-- .../frontend/tests/e2e/audit-log-curl.spec.ts | 48 +++++++++++++++++-- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 37fb870f4..cb06437eb 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -256,7 +256,7 @@ Response: When viewing an audit log entry in the dashboard (`/logs`), click **Copy as cURL** in the expanded row to copy a command that replays the recorded MCP `tools/call` against the server's endpoint: ```bash -curl -sS -X POST "http://localhost:4000/mcp/" \ +curl -sS -X POST 'http://localhost:4000/mcp/' \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "MCP-Protocol-Version: 2025-06-18" \ @@ -266,7 +266,7 @@ curl -sS -X POST "http://localhost:4000/mcp/" \ JSON ``` -Export your key as `AMCP_MCP_API_KEY` (or `AMCP_ACCESS_TOKEN` when the server operates in OAuth2 mode) to run the command directly from your terminal. +Export your key as `AMCP_MCP_API_KEY` (or `AMCP_ACCESS_TOKEN` when the server operates in OAuth2 mode) to run the command directly from your terminal. Arguments whose names look like credentials (passwords, tokens, keys) are replaced with `[REDACTED]`, and the command then starts with a comment saying so: fill them in before running it. --- diff --git a/packages/frontend/src/app/logs/page.tsx b/packages/frontend/src/app/logs/page.tsx index 7cef74aeb..b35788a2f 100644 --- a/packages/frontend/src/app/logs/page.tsx +++ b/packages/frontend/src/app/logs/page.tsx @@ -109,7 +109,8 @@ export default function LogsPage() { const handleCopyCurl = async (log: any) => { const serverId = log.mcpServer?.id || log.mcpServerId; - const toolName = log.tool?.name || log.toolId; + // Only the tool's name can be replayed; toolId is a database id. + const toolName = log.tool?.name; if (!serverId || !toolName) return; const origin = getApiOrigin(); @@ -438,15 +439,13 @@ export default function LogsPage() { {(() => { const isSharedEndpoint = !log.mcpServer; const isTruncated = Boolean( - log.input && - typeof log.input === 'object' && - (log.input._amcp_truncated || '_amcp_truncated' in log.input) + log.input && typeof log.input === 'object' && '_amcp_truncated' in log.input ); const cannotCopyReason = isSharedEndpoint ? 'Replay unavailable: call ran through the shared /mcp endpoint' : isTruncated ? 'Replay unavailable: stored input parameters were truncated' - : !(log.tool?.name || log.toolId) + : !log.tool?.name ? 'Replay unavailable: tool name is missing' : null; const canCopy = !cannotCopyReason; diff --git a/packages/frontend/src/lib/curl.ts b/packages/frontend/src/lib/curl.ts index 3c280d481..afa2fc322 100644 --- a/packages/frontend/src/lib/curl.ts +++ b/packages/frontend/src/lib/curl.ts @@ -3,7 +3,10 @@ * Follows the project's established redaction model from security-event.service. */ const REDACTED_KEY_PATTERN = - /secret|password|passwd|token|assertion|credential|code_verifier|code_challenge|authorization|cookie|private_key|api[-_]?key/i; + /secret|password|passwd|pwd|token|jwt|bearer|assertion|credential|code_verifier|code_challenge|authorization|cookie|private_key|api[-_]?key|access[-_]?key|session[-_]?(id|key)/i; + +/** Deeper than this, a whole subtree is replaced rather than walked. */ +const MAX_REDACT_DEPTH = 10; export const REDACTED_PLACEHOLDER = '[REDACTED]'; @@ -11,7 +14,8 @@ export const REDACTED_PLACEHOLDER = '[REDACTED]'; * Recursively redacts sensitive values matching credential field names. */ export function redactSensitiveArgs(val: unknown, depth = 0): unknown { - if (depth > 10 || val === null || val === undefined) return val; + if (val === null || val === undefined) return val; + if (depth > MAX_REDACT_DEPTH) return REDACTED_PLACEHOLDER; if (Array.isArray(val)) { return val.map((item) => redactSensitiveArgs(item, depth + 1)); } @@ -62,7 +66,9 @@ export function buildToolCallCurl({ authMode, }: BuildToolCallCurlOptions): string { const cleanOrigin = origin.replace(/\/+$/, ''); - const url = `${cleanOrigin}/mcp/${encodeURI(serverId)}`; + // Single quotes stop the shell from expanding anything in the URL; the + // encoded server id and an http(s) origin cannot contain one. + const url = `${cleanOrigin}/mcp/${encodeURIComponent(serverId)}`.replace(/'/g, '%27'); const authHeader = authMode === 'oauth2' @@ -84,6 +90,7 @@ export function buildToolCallCurl({ // Redact any sensitive keys in arguments as defense-in-depth const safeArguments = redactSensitiveArgs(argumentsObj); + const redacted = JSON.stringify(safeArguments).includes(JSON.stringify(REDACTED_PLACEHOLDER)); const payload = JSON.stringify({ jsonrpc: '2.0', @@ -95,8 +102,13 @@ export function buildToolCallCurl({ }, }); + // JSON.stringify escapes newlines, so the payload is one line and can never + // be a bare `JSON` line that ends the heredoc early. return [ - `curl -sS -X POST "${url}" \\`, + ...(redacted + ? [`# Some arguments were replaced with ${REDACTED_PLACEHOLDER}: fill them in before running.`] + : []), + `curl -sS -X POST '${url}' \\`, ' -H "Content-Type: application/json" \\', ' -H "Accept: application/json, text/event-stream" \\', ' -H "MCP-Protocol-Version: 2025-06-18" \\', diff --git a/packages/frontend/tests/e2e/audit-log-curl.spec.ts b/packages/frontend/tests/e2e/audit-log-curl.spec.ts index f68e61b3b..5f0d2c4c5 100644 --- a/packages/frontend/tests/e2e/audit-log-curl.spec.ts +++ b/packages/frontend/tests/e2e/audit-log-curl.spec.ts @@ -101,7 +101,7 @@ test.describe('buildToolCallCurl builder unit tests', () => { authMode: 'both', }); - expect(cmd).toContain('curl -sS -X POST "http://localhost:4000/mcp/srv-123" \\'); + expect(cmd).toContain("curl -sS -X POST 'http://localhost:4000/mcp/srv-123' \\"); expect(cmd).toContain('-H "Content-Type: application/json" \\'); expect(cmd).toContain('-H "Accept: application/json, text/event-stream" \\'); expect(cmd).toContain('-H "MCP-Protocol-Version: 2025-06-18" \\'); @@ -120,7 +120,7 @@ test.describe('buildToolCallCurl builder unit tests', () => { authMode: 'oauth2', }); - expect(cmd).toContain('curl -sS -X POST "https://cloud.anythingmcp.com/mcp/srv-abc" \\'); + expect(cmd).toContain("curl -sS -X POST 'https://cloud.anythingmcp.com/mcp/srv-abc' \\"); expect(cmd).toContain('-H "Authorization: Bearer $AMCP_ACCESS_TOKEN" \\'); expect(cmd).not.toContain('$AMCP_MCP_API_KEY'); expect(cmd).not.toContain('test-token'); @@ -134,7 +134,7 @@ test.describe('buildToolCallCurl builder unit tests', () => { args: {}, }); - expect(cmd).toContain('"http://localhost:4000/mcp/srv-1"'); + expect(cmd).toContain("'http://localhost:4000/mcp/srv-1'"); }); test('preserves single quotes, double quotes, dollar signs, backticks, and newlines in args', () => { @@ -187,6 +187,48 @@ test.describe('buildToolCallCurl builder unit tests', () => { expect(cmd).not.toContain('bearer_token_xyz'); expect(cmd).not.toContain('secret999'); expect(cmd).toContain('[REDACTED]'); + expect(cmd.split('\n')[0]).toMatch(/^# Some arguments were replaced with \[REDACTED\]/); + }); + + test('adds no note when nothing was redacted', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'plain', + args: { q: 'x' }, + }); + expect(cmd.startsWith('curl ')).toBe(true); + }); + + test('a value cannot end the heredoc early', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'breakout', + args: { note: "x\nJSON\nrm -rf ~\n", other: 'JSON' }, + }); + const lines = cmd.split('\n'); + // The only bare JSON line is the closing delimiter, and nothing follows it. + expect(lines.filter((l) => l === 'JSON')).toHaveLength(1); + expect(lines[lines.length - 1]).toBe('JSON'); + expect(lines.some((l) => l.startsWith('rm '))).toBe(false); + }); + + test('keeps shell characters in the server id out of the shell', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: "a'$(id)`x`", + toolName: 't', + args: {}, + }); + const urlLine = cmd.split('\n').find((l) => l.startsWith('curl '))!; + expect(urlLine).toBe("curl -sS -X POST 'http://localhost:4000/mcp/a%27%24(id)%60x%60' \\"); + }); + + test('replaces values nested deeper than the walk instead of passing them through', () => { + let deep: Record = { password: 'deep-secret' }; + for (let i = 0; i < 15; i++) deep = { level: deep }; + expect(JSON.stringify(redactSensitiveArgs(deep))).not.toContain('deep-secret'); }); test('handles null, undefined, string, or empty args safely', () => { From 32c5510abbb9f89da020e09eecaf3886ac28b011 Mon Sep 17 00:00:00 2001 From: AYUshJaiswal454 Date: Mon, 12 Oct 2026 21:09:44 +0530 Subject: [PATCH 3/4] fix: address review feedback on audit log copy as cURL (#850) - Use single-quoted URLs with encodeURIComponent for shell safety - Recursively redact sensitive keys and values with depth boundaries - Emit warning comments when replaying redacted or truncated arguments - Disable copy action when tool name is missing instead of DB ID fallback - Handle authMode loading race conditions and respect clientInfo authMethod - Prevent heredoc delimiter collision on arguments containing JSON - Add Playwright and builder unit tests for all edge cases --- docs/api-reference.md | 2 +- packages/frontend/src/app/logs/page.tsx | 27 ++- packages/frontend/src/lib/curl.ts | 193 ++++++++++++--- .../frontend/tests/e2e/audit-log-curl.spec.ts | 228 +++++++++++++++--- 4 files changed, 382 insertions(+), 68 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 37fb870f4..1403c6a39 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -256,7 +256,7 @@ Response: When viewing an audit log entry in the dashboard (`/logs`), click **Copy as cURL** in the expanded row to copy a command that replays the recorded MCP `tools/call` against the server's endpoint: ```bash -curl -sS -X POST "http://localhost:4000/mcp/" \ +curl -sS -X POST 'http://localhost:4000/mcp/' \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "MCP-Protocol-Version: 2025-06-18" \ diff --git a/packages/frontend/src/app/logs/page.tsx b/packages/frontend/src/app/logs/page.tsx index 7cef74aeb..8c8b9f0c9 100644 --- a/packages/frontend/src/app/logs/page.tsx +++ b/packages/frontend/src/app/logs/page.tsx @@ -96,7 +96,7 @@ export default function LogsPage() { const [expandedId, setExpandedId] = useState(null); const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); - const [authMode, setAuthMode] = useState('both'); + const [authMode, setAuthMode] = useState(null); const [copiedId, setCopiedId] = useState(null); const [copyErrorId, setCopyErrorId] = useState(null); @@ -109,16 +109,29 @@ export default function LogsPage() { const handleCopyCurl = async (log: any) => { const serverId = log.mcpServer?.id || log.mcpServerId; - const toolName = log.tool?.name || log.toolId; + const toolName = log.tool?.name; if (!serverId || !toolName) return; + let currentAuthMode = authMode; + if (!currentAuthMode) { + try { + const info = await server.info(); + if (info?.mcpAuthMode) { + currentAuthMode = info.mcpAuthMode; + setAuthMode(currentAuthMode); + } + } catch {} + } + + const ci = parseClientInfo(log.clientInfo); const origin = getApiOrigin(); const command = buildToolCallCurl({ origin, serverId, toolName, args: log.input, - authMode, + authMode: currentAuthMode || 'both', + authMethod: ci?.authMethod, }); const ok = await copyText(command); @@ -439,14 +452,14 @@ export default function LogsPage() { const isSharedEndpoint = !log.mcpServer; const isTruncated = Boolean( log.input && - typeof log.input === 'object' && - (log.input._amcp_truncated || '_amcp_truncated' in log.input) - ); + typeof log.input === 'object' && + '_amcp_truncated' in log.input, + ) || (typeof log.input === 'string' && log.input.includes('_amcp_truncated')); const cannotCopyReason = isSharedEndpoint ? 'Replay unavailable: call ran through the shared /mcp endpoint' : isTruncated ? 'Replay unavailable: stored input parameters were truncated' - : !(log.tool?.name || log.toolId) + : !log.tool?.name ? 'Replay unavailable: tool name is missing' : null; const canCopy = !cannotCopyReason; diff --git a/packages/frontend/src/lib/curl.ts b/packages/frontend/src/lib/curl.ts index 3c280d481..4a94274a7 100644 --- a/packages/frontend/src/lib/curl.ts +++ b/packages/frontend/src/lib/curl.ts @@ -1,31 +1,113 @@ /** - * Key pattern matching sensitive credential fields across casing styles. - * Follows the project's established redaction model from security-event.service. + * Sensitive key pattern matching credential field names across casing styles. + * Matches: secret, password, passwd, pwd, token, jwt, assertion, credential, + * code_verifier, code_challenge, authorization, authentication, cookie, session, bearer, + * private_key, and key variants (api_key, access_key, privkey, ssh_key, etc.). */ -const REDACTED_KEY_PATTERN = - /secret|password|passwd|token|assertion|credential|code_verifier|code_challenge|authorization|cookie|private_key|api[-_]?key/i; +const SENSITIVE_KEY_PATTERN = + /secret|password|passwd|pwd|token|jwt|assertion|credential|code_verifier|code_challenge|authorization|authentication|cookie|session|bearer|priv[-_]?key|private[-_]?key|(?:api|access|ssh|signing|encryption)[-_]?key/i; + +/** + * Checks whether an argument key name is sensitive. + * Matches credential keywords and 'auth' variations (auth, auth_key, authHeader, clientAuth) + * while strictly avoiding false-positives on words like 'author' or 'authority'. + */ +export function isSensitiveKey(key: string): boolean { + if (SENSITIVE_KEY_PATTERN.test(key)) return true; + return ( + /(?:^|[_\-])auth(?:[_\-]|$)/i.test(key) || + /^auth[A-Z]/.test(key) || + /[a-z]Auth(?:[A-Z]|$)/.test(key) + ); +} export const REDACTED_PLACEHOLDER = '[REDACTED]'; +export const TRUNCATED_DEPTH_PLACEHOLDER = '[TRUNCATED:depth]'; +export const MAX_REDACTION_DEPTH = 10; + +/** + * Detects common sensitive credential value patterns in strings: + * - PEM Private keys (-----BEGIN ... PRIVATE KEY-----) + * - JWT tokens (header.payload.signature) + * - Bearer authorization headers + * - Common token prefixes (ghp_, gho_, xoxb-, xoxp-, sk-, AKIA, amcp_) + */ +const SENSITIVE_VALUE_PATTERNS: RegExp[] = [ + /-----BEGIN [A-Z0-9_\- ]*PRIVATE KEY-----/i, + /^eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+$/, + /^Bearer\s+[a-zA-Z0-9_\-\.~+/]+=*$/i, + /^(?:gh[pousr]_[A-Za-z0-9_]{30,}|xox[baprs]-[0-9a-zA-Z-]{10,}|(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{20,}|sk-(?:live-|ant-)?[a-zA-Z0-9_-]{20,}|AKIA[0-9A-Z]{16}|amcp_[a-zA-Z0-9_-]{16,})$/, +]; + +function redactSensitiveString(val: string): string { + for (const pattern of SENSITIVE_VALUE_PATTERNS) { + if (pattern.test(val)) { + return REDACTED_PLACEHOLDER; + } + } + if (val.includes('://') && val.includes('@')) { + try { + const u = new URL(val); + if (u.username || u.password) { + return REDACTED_PLACEHOLDER; + } + } catch { + // not a valid parseable URL, keep as is + } + } + return val; +} + +export interface RedactionTracker { + redactedCount: number; +} /** - * Recursively redacts sensitive values matching credential field names. + * Recursively redacts sensitive keys and values matching credential patterns. + * Depth-bounded: values at or beyond MAX_REDACTION_DEPTH are truncated to prevent + * bypass via nesting. */ -export function redactSensitiveArgs(val: unknown, depth = 0): unknown { - if (depth > 10 || val === null || val === undefined) return val; +export function redactSensitiveArgs( + val: unknown, + depth = 0, + tracker?: RedactionTracker, +): unknown { + if (val === null || val === undefined) return val; + + if (depth >= MAX_REDACTION_DEPTH) { + if (typeof val === 'object') { + if (tracker) tracker.redactedCount++; + return TRUNCATED_DEPTH_PLACEHOLDER; + } + return val; + } + + if (typeof val === 'string') { + const redacted = redactSensitiveString(val); + if (redacted !== val) { + if (tracker) tracker.redactedCount++; + return redacted; + } + return val; + } + if (Array.isArray(val)) { - return val.map((item) => redactSensitiveArgs(item, depth + 1)); + return val.map((item) => redactSensitiveArgs(item, depth + 1, tracker)); } + if (typeof val === 'object') { const out: Record = {}; for (const [k, v] of Object.entries(val as Record)) { - if (REDACTED_KEY_PATTERN.test(k)) { + if (isSensitiveKey(k)) { out[k] = REDACTED_PLACEHOLDER; + if (tracker) tracker.redactedCount++; } else { - out[k] = redactSensitiveArgs(v, depth + 1); + out[k] = redactSensitiveArgs(v, depth + 1, tracker); } } return out; } + return val; } @@ -35,6 +117,7 @@ export interface BuildToolCallCurlOptions { toolName: string; args?: unknown; authMode?: string; + authMethod?: string; } /** @@ -44,15 +127,14 @@ export interface BuildToolCallCurlOptions { * a bare `tools/call` request with MCP-Protocol-Version: 2025-06-18 directly, without * requiring a preceding `initialize` handshake on a local instance. * - * Uses a quoted heredoc (<<'JSON') so that special characters (quotes, backticks, $, newlines) - * in tool arguments survive intact without shell escaping issues. + * Uses single-quoted URL with encodeURIComponent and a quoted heredoc (<<'JSON') so that + * special characters (quotes, backticks, $, newlines) survive intact without shell injection. * * Security: - * - Credentials are always placeholder environment variables, never actual secrets. - * - For legacy / both auth modes: X-API-Key: $AMCP_MCP_API_KEY - * - For oauth2 auth mode: Authorization: Bearer $AMCP_ACCESS_TOKEN + * - Credentials are placeholder environment variables ($AMCP_MCP_API_KEY, $AMCP_ACCESS_TOKEN), never actual secrets. + * - Sensitive keys (passwords, tokens, secrets, jwt, session, keys) and sensitive values are recursively redacted. + * - If arguments contain redacted values or truncation markers, explicit warnings are emitted in the header comments. * - Dashboard session tokens (amcp_token) are never inserted. - * - Any sensitive credential fields inside tool arguments are redacted with [REDACTED]. */ export function buildToolCallCurl({ origin, @@ -60,16 +142,31 @@ export function buildToolCallCurl({ toolName, args, authMode, + authMethod, }: BuildToolCallCurlOptions): string { const cleanOrigin = origin.replace(/\/+$/, ''); - const url = `${cleanOrigin}/mcp/${encodeURI(serverId)}`; + const encodedServerId = encodeURIComponent(serverId); + const url = `${cleanOrigin}/mcp/${encodedServerId}`; + const safeUrl = url.replace(/'/g, "'\\''"); - const authHeader = - authMode === 'oauth2' - ? '-H "Authorization: Bearer $AMCP_ACCESS_TOKEN"' - : '-H "X-API-Key: $AMCP_MCP_API_KEY"'; + let authHeader: string | null = null; + if (authMethod === 'jwt' || authMethod === 'static_bearer') { + authHeader = '-H "Authorization: Bearer $AMCP_ACCESS_TOKEN"'; + } else if (authMethod === 'mcp_api_key' || authMethod === 'static_api_key') { + authHeader = '-H "X-API-Key: $AMCP_MCP_API_KEY"'; + } else if (authMethod === 'none') { + authHeader = null; + } else if (authMode === 'oauth2') { + authHeader = '-H "Authorization: Bearer $AMCP_ACCESS_TOKEN"'; + } else if (authMode === 'none') { + authHeader = null; + } else { + authHeader = '-H "X-API-Key: $AMCP_MCP_API_KEY"'; + } let argumentsObj: unknown = args; + let wasTruncated = false; + if (typeof args === 'string') { try { argumentsObj = JSON.parse(args); @@ -80,10 +177,15 @@ export function buildToolCallCurl({ if (!argumentsObj || typeof argumentsObj !== 'object' || Array.isArray(argumentsObj)) { argumentsObj = {}; + } else if ('_amcp_truncated' in (argumentsObj as Record)) { + wasTruncated = true; + const copy = { ...(argumentsObj as Record) }; + delete copy._amcp_truncated; + argumentsObj = copy; } - // Redact any sensitive keys in arguments as defense-in-depth - const safeArguments = redactSensitiveArgs(argumentsObj); + const tracker: RedactionTracker = { redactedCount: 0 }; + const safeArguments = redactSensitiveArgs(argumentsObj, 0, tracker); const payload = JSON.stringify({ jsonrpc: '2.0', @@ -95,14 +197,47 @@ export function buildToolCallCurl({ }, }); - return [ - `curl -sS -X POST "${url}" \\`, + // Protect against heredoc breakout if the payload contains delimiter patterns + let delimiter = 'JSON'; + if ( + payload.includes('\nJSON') || + payload.includes('\\nJSON') || + payload.startsWith('JSON\n') || + payload.startsWith('JSON\\n') || + payload === 'JSON' + ) { + delimiter = 'EOF_AMCP_JSON'; + let counter = 1; + while (payload.includes(delimiter)) { + delimiter = `EOF_AMCP_JSON_${counter++}`; + } + } + + const lines: string[] = []; + if (tracker.redactedCount > 0) { + lines.push('# WARNING: Sensitive arguments were redacted ([REDACTED]).'); + lines.push('# Replace placeholder values with valid credentials before replaying.'); + } + if (wasTruncated) { + lines.push('# WARNING: Stored input arguments were truncated in the audit log; replay may be incomplete.'); + } + + const headers = [ ' -H "Content-Type: application/json" \\', ' -H "Accept: application/json, text/event-stream" \\', ' -H "MCP-Protocol-Version: 2025-06-18" \\', - ` ${authHeader} \\`, - " --data-binary @- <<'JSON'", + ]; + if (authHeader) { + headers.push(` ${authHeader} \\`); + } + + lines.push( + `curl -sS -X POST '${safeUrl}' \\`, + ...headers, + ` --data-binary @- <<'${delimiter}'`, payload, - 'JSON', - ].join('\n'); + delimiter, + ); + + return lines.join('\n'); } diff --git a/packages/frontend/tests/e2e/audit-log-curl.spec.ts b/packages/frontend/tests/e2e/audit-log-curl.spec.ts index f68e61b3b..681ab9697 100644 --- a/packages/frontend/tests/e2e/audit-log-curl.spec.ts +++ b/packages/frontend/tests/e2e/audit-log-curl.spec.ts @@ -1,5 +1,10 @@ import { expect, test, type Page } from '@playwright/test'; -import { buildToolCallCurl, redactSensitiveArgs } from '../../src/lib/curl'; +import { + buildToolCallCurl, + redactSensitiveArgs, + REDACTED_PLACEHOLDER, + TRUNCATED_DEPTH_PLACEHOLDER, +} from '../../src/lib/curl'; const USER = { id: 'u1', @@ -42,8 +47,8 @@ async function fakeSession(page: Page, authMode = 'both') { if (url.includes('/api/license/status')) return json({ plan: 'community', status: 'active' }); if (url.includes('/api/connectors')) return json([]); if (url.includes('/api/mcp-servers')) return json([{ id: 'srv-1', name: 'Test Server', slug: 'test-server' }]); - if (url.includes('/api/audit/stats')) return json({ invocations24h: 3, errors24h: 0 }); - if (url.includes('/api/audit/analytics')) return json({ daily: [], topTools: [], totalInvocations: 3, successRate: 100, avgDuration: 10 }); + if (url.includes('/api/audit/stats')) return json({ invocations24h: 4, errors24h: 0 }); + if (url.includes('/api/audit/analytics')) return json({ daily: [], topTools: [], totalInvocations: 4, successRate: 100, avgDuration: 10 }); if (url.includes('/api/audit/invocations')) { return json([ { @@ -85,6 +90,19 @@ async function fakeSession(page: Page, authMode = 'both') { output: { count: 100 }, clientInfo: null, }, + { + id: 'log-4', + createdAt: new Date().toISOString(), + status: 'SUCCESS', + durationMs: 12, + mcpServerId: 'srv-1', + mcpServer: { id: 'srv-1', name: 'Test Server', slug: 'test-server' }, + toolId: 't-missing-name', + tool: null, + input: { test: 'val' }, + output: { ok: true }, + clientInfo: null, + }, ]); } return json({}); @@ -92,7 +110,7 @@ async function fakeSession(page: Page, authMode = 'both') { } test.describe('buildToolCallCurl builder unit tests', () => { - test('generates expected cURL command for legacy/both auth mode', () => { + test('generates expected cURL command with single-quoted URL and placeholders for legacy/both auth mode', () => { const cmd = buildToolCallCurl({ origin: 'http://localhost:4000', serverId: 'srv-123', @@ -101,7 +119,7 @@ test.describe('buildToolCallCurl builder unit tests', () => { authMode: 'both', }); - expect(cmd).toContain('curl -sS -X POST "http://localhost:4000/mcp/srv-123" \\'); + expect(cmd).toContain("curl -sS -X POST 'http://localhost:4000/mcp/srv-123' \\"); expect(cmd).toContain('-H "Content-Type: application/json" \\'); expect(cmd).toContain('-H "Accept: application/json, text/event-stream" \\'); expect(cmd).toContain('-H "MCP-Protocol-Version: 2025-06-18" \\'); @@ -120,12 +138,72 @@ test.describe('buildToolCallCurl builder unit tests', () => { authMode: 'oauth2', }); - expect(cmd).toContain('curl -sS -X POST "https://cloud.anythingmcp.com/mcp/srv-abc" \\'); + expect(cmd).toContain("curl -sS -X POST 'https://cloud.anythingmcp.com/mcp/srv-abc' \\"); expect(cmd).toContain('-H "Authorization: Bearer $AMCP_ACCESS_TOKEN" \\'); expect(cmd).not.toContain('$AMCP_MCP_API_KEY'); expect(cmd).not.toContain('test-token'); }); + test('supports authMethod parameter from clientInfo (jwt, static_bearer, mcp_api_key, none)', () => { + const cmdJwt = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tool1', + args: {}, + authMode: 'both', + authMethod: 'jwt', + }); + expect(cmdJwt).toContain('-H "Authorization: Bearer $AMCP_ACCESS_TOKEN" \\'); + expect(cmdJwt).not.toContain('$AMCP_MCP_API_KEY'); + + const cmdStaticBearer = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tool1', + args: {}, + authMode: 'both', + authMethod: 'static_bearer', + }); + expect(cmdStaticBearer).toContain('-H "Authorization: Bearer $AMCP_ACCESS_TOKEN" \\'); + + const cmdApiKey = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tool1', + args: {}, + authMode: 'oauth2', + authMethod: 'mcp_api_key', + }); + expect(cmdApiKey).toContain('-H "X-API-Key: $AMCP_MCP_API_KEY" \\'); + + const cmdNone = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'tool1', + args: {}, + authMode: 'both', + authMethod: 'none', + }); + expect(cmdNone).not.toContain('$AMCP_MCP_API_KEY'); + expect(cmdNone).not.toContain('$AMCP_ACCESS_TOKEN'); + }); + + test('encodes serverId with encodeURIComponent and quotes URL with single quotes for shell safety', () => { + const trickyServerId = "srv$123 `rm -rf` 'quoted' space/slash"; + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: trickyServerId, + toolName: 'ping', + args: {}, + }); + + // Should encode the special characters into percent-encoding + const expectedEncoded = encodeURIComponent(trickyServerId).replace(/'/g, "'\\''"); + expect(cmd).toContain(`'http://localhost:4000/mcp/${expectedEncoded}'`); + expect(cmd).not.toContain('$123'); + expect(cmd).not.toContain('`rm -rf`'); + }); + test('normalizes trailing slashes on origin', () => { const cmd = buildToolCallCurl({ origin: 'http://localhost:4000///', @@ -134,7 +212,7 @@ test.describe('buildToolCallCurl builder unit tests', () => { args: {}, }); - expect(cmd).toContain('"http://localhost:4000/mcp/srv-1"'); + expect(cmd).toContain("'http://localhost:4000/mcp/srv-1'"); }); test('preserves single quotes, double quotes, dollar signs, backticks, and newlines in args', () => { @@ -156,37 +234,119 @@ test.describe('buildToolCallCurl builder unit tests', () => { expect(parsed.params.arguments).toEqual(trickyArgs); }); - test('redacts sensitive keys in arguments', () => { + test('handles heredoc collision when payload contains delimiter lines', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'breakout_tool', + args: { test: 'line1\nJSON\nline2' }, + }); + + // When payload contains \nJSON, the delimiter switches away from JSON to avoid collision + expect(cmd).toContain("<<'EOF_AMCP_JSON'"); + expect(cmd.endsWith('EOF_AMCP_JSON')).toBe(true); + }); + + test('emits warning comments when sensitive arguments are redacted', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'secret_tool', + args: { password: 'my-password' }, + }); + + expect(cmd).toContain('# WARNING: Sensitive arguments were redacted ([REDACTED]).'); + expect(cmd).toContain('# Replace placeholder values with valid credentials before replaying.'); + expect(cmd).toContain('[REDACTED]'); + expect(cmd).not.toContain('my-password'); + }); + + test('emits warning comment and strips truncation marker when args contain _amcp_truncated', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'cut_tool', + args: { _amcp_truncated: true, validKey: 'value' }, + }); + + expect(cmd).toContain('# WARNING: Stored input arguments were truncated in the audit log; replay may be incomplete.'); + expect(cmd).not.toContain('_amcp_truncated'); + expect(cmd).toContain('"validKey":"value"'); + }); + + test('redacts sensitive keys including auth, pwd, jwt, session, bearer, access_key without false positives', () => { const sensitiveArgs = { - user: 'alice', - password: 'super-secret-password', - apiKey: 'real_api_key_123', + author: 'alice', // must NOT be redacted + authority: 'gov', // must NOT be redacted + auth: 'secret-auth', // must be redacted + authHeader: 'auth-header-val', // must be redacted + client_auth: 'client-auth-val', // must be redacted + user_pwd: 'secret-pwd', // must be redacted + id_jwt: 'jwt-val', // must be redacted + session_id: 'session-val', // must be redacted + bearer_token: 'bearer-val', // must be redacted + access_key: 'access-key-val', // must be redacted + apiKey: 'api-key-val', // must be redacted nested: { - accessToken: 'bearer_token_xyz', - auth: { client_secret: 'secret999' }, safe: 'normal_val', + private_key: 'priv-key-val', }, }; const redacted = redactSensitiveArgs(sensitiveArgs) as any; - expect(redacted.password).toBe('[REDACTED]'); - expect(redacted.apiKey).toBe('[REDACTED]'); - expect(redacted.nested.accessToken).toBe('[REDACTED]'); - expect(redacted.nested.auth.client_secret).toBe('[REDACTED]'); + expect(redacted.author).toBe('alice'); + expect(redacted.authority).toBe('gov'); + expect(redacted.auth).toBe(REDACTED_PLACEHOLDER); + expect(redacted.authHeader).toBe(REDACTED_PLACEHOLDER); + expect(redacted.client_auth).toBe(REDACTED_PLACEHOLDER); + expect(redacted.user_pwd).toBe(REDACTED_PLACEHOLDER); + expect(redacted.id_jwt).toBe(REDACTED_PLACEHOLDER); + expect(redacted.session_id).toBe(REDACTED_PLACEHOLDER); + expect(redacted.bearer_token).toBe(REDACTED_PLACEHOLDER); + expect(redacted.access_key).toBe(REDACTED_PLACEHOLDER); + expect(redacted.apiKey).toBe(REDACTED_PLACEHOLDER); expect(redacted.nested.safe).toBe('normal_val'); - expect(redacted.user).toBe('alice'); + expect(redacted.nested.private_key).toBe(REDACTED_PLACEHOLDER); + }); - const cmd = buildToolCallCurl({ - origin: 'http://localhost:4000', - serverId: 'srv-1', - toolName: 'auth_tool', - args: sensitiveArgs, - }); - expect(cmd).not.toContain('super-secret-password'); - expect(cmd).not.toContain('real_api_key_123'); - expect(cmd).not.toContain('bearer_token_xyz'); - expect(cmd).not.toContain('secret999'); - expect(cmd).toContain('[REDACTED]'); + test('redacts sensitive values including JWTs, private keys, Bearer headers, and known API token prefixes', () => { + const sensitiveValues = { + rawJwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + rawPrivateKey: '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0...\n-----END RSA PRIVATE KEY-----', + bearerHeader: 'Bearer my-bearer-token-1234567890', + openAiKey: 'sk-proj-1234567890abcdef1234567890', + githubToken: 'ghp_1234567890abcdef12345678901234567890', + slackToken: 'xoxb-1234567890-abcdef123456', + urlWithAuth: 'https://admin:supersecret@example.com/api', + normalVal: 'hello world', + }; + + const redacted = redactSensitiveArgs(sensitiveValues) as any; + expect(redacted.rawJwt).toBe(REDACTED_PLACEHOLDER); + expect(redacted.rawPrivateKey).toBe(REDACTED_PLACEHOLDER); + expect(redacted.bearerHeader).toBe(REDACTED_PLACEHOLDER); + expect(redacted.openAiKey).toBe(REDACTED_PLACEHOLDER); + expect(redacted.githubToken).toBe(REDACTED_PLACEHOLDER); + expect(redacted.slackToken).toBe(REDACTED_PLACEHOLDER); + expect(redacted.urlWithAuth).toBe(REDACTED_PLACEHOLDER); + expect(redacted.normalVal).toBe('hello world'); + }); + + test('truncates overly nested structures at MAX_REDACTION_DEPTH to prevent bypass', () => { + // Construct an object nested 12 levels deep + let deep: any = { secret_leaf: 'unredacted_payload' }; + for (let i = 0; i < 12; i++) { + deep = { level: deep }; + } + + const redacted = redactSensitiveArgs(deep) as any; + // Walk down 10 levels + let current = redacted; + for (let i = 0; i < 10; i++) { + current = current.level; + } + // At depth 10, the nested object should be replaced with TRUNCATED_DEPTH_PLACEHOLDER + expect(current).toBe(TRUNCATED_DEPTH_PLACEHOLDER); }); test('handles null, undefined, string, or empty args safely', () => { @@ -213,9 +373,8 @@ test.describe('Audit log "Copy as cURL" UI interaction', () => { permissions: ['clipboard-read', 'clipboard-write'], }); - test('copies valid tool call command and shows proper disabled states for shared and truncated rows', async ({ + test('copies valid tool call command and shows proper disabled states for shared, truncated, and missing-name rows', async ({ page, - context, }) => { await fakeSession(page, 'both'); await page.goto('/logs'); @@ -263,5 +422,12 @@ test.describe('Audit log "Copy as cURL" UI interaction', () => { await expect(truncatedRowDisabled).toBeVisible(); const truncatedBtn = truncatedRowDisabled.getByRole('button', { name: 'Copy as cURL' }); await expect(truncatedBtn).toBeDisabled(); + + // 4. Expand fourth row (missing tool name: tool is null) + await page.getByTitle('t-missing-name').click(); + const missingNameRowDisabled = page.locator('span[title="Replay unavailable: tool name is missing"]'); + await expect(missingNameRowDisabled).toBeVisible(); + const missingNameBtn = missingNameRowDisabled.getByRole('button', { name: 'Copy as cURL' }); + await expect(missingNameBtn).toBeDisabled(); }); }); From 78b1adc7293a33a3d97aa612cb105afae6725f03 Mon Sep 17 00:00:00 2001 From: AYUshJaiswal454 Date: Mon, 12 Oct 2026 21:47:00 +0530 Subject: [PATCH 4/4] feat: enhance curl secret redaction patterns, robust clientInfo parsing, and security edge case tests --- packages/frontend/src/app/logs/page.tsx | 3 +- packages/frontend/src/lib/curl.ts | 9 ++- .../frontend/tests/e2e/audit-log-curl.spec.ts | 78 +++++++++++++++---- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/packages/frontend/src/app/logs/page.tsx b/packages/frontend/src/app/logs/page.tsx index 3de675d9d..33f37863d 100644 --- a/packages/frontend/src/app/logs/page.tsx +++ b/packages/frontend/src/app/logs/page.tsx @@ -17,8 +17,9 @@ const PAGE_SIZE = 50; const GRID_COLS = '90px 1.5fr 1.2fr 70px 90px 90px'; -function parseClientInfo(raw: string | null | undefined) { +function parseClientInfo(raw: any) { if (!raw) return null; + if (typeof raw === 'object') return raw; try { return JSON.parse(raw); } catch { diff --git a/packages/frontend/src/lib/curl.ts b/packages/frontend/src/lib/curl.ts index 00a770b12..c5c149ca8 100644 --- a/packages/frontend/src/lib/curl.ts +++ b/packages/frontend/src/lib/curl.ts @@ -5,7 +5,7 @@ * private_key, and key variants (api_key, access_key, privkey, ssh_key, etc.). */ const SENSITIVE_KEY_PATTERN = - /secret|password|passwd|pwd|token|jwt|assertion|credential|code_verifier|code_challenge|authorization|authentication|cookie|session|bearer|priv[-_]?key|private[-_]?key|(?:api|access|ssh|signing|encryption)[-_]?key/i; + /secret|password|passwd|pwd|passcode|token|jwt|assertion|credential|code_verifier|code_challenge|authorization|authenticat|cookie|session|bearer|priv[-_]?key|private[-_]?key|(?:api|access|ssh|signing|encryption)[-_]?key/i; /** * Checks whether an argument key name is sensitive. @@ -15,7 +15,7 @@ const SENSITIVE_KEY_PATTERN = export function isSensitiveKey(key: string): boolean { if (SENSITIVE_KEY_PATTERN.test(key)) return true; return ( - /(?:^|[_\-])auth(?:[_\-]|$)/i.test(key) || + /(?:^|[_\-])(?:auth|pin)(?:[_\-]|$)/i.test(key) || /^auth[A-Z]/.test(key) || /[a-z]Auth(?:[A-Z]|$)/.test(key) ); @@ -37,12 +37,13 @@ const SENSITIVE_VALUE_PATTERNS: RegExp[] = [ /-----BEGIN [A-Z0-9_\- ]*PRIVATE KEY-----/i, /^eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]+$/, /^Bearer\s+[a-zA-Z0-9_\-\.~+/]+=*$/i, - /^(?:gh[pousr]_[A-Za-z0-9_]{30,}|xox[baprs]-[0-9a-zA-Z-]{10,}|(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{20,}|sk-(?:live-|ant-)?[a-zA-Z0-9_-]{20,}|AKIA[0-9A-Z]{16}|amcp_[a-zA-Z0-9_-]{16,})$/, + /^(?:gh[pousr]_[A-Za-z0-9_]{30,}|github_pat_[A-Za-z0-9_]{40,}|glpat-[A-Za-z0-9_-]{20,}|xox[abp]-[A-Za-z0-9-]{10,}|(?:sk|rk)_(?:live|test)_[0-9a-zA-Z]{16,}|sk-(?:live-|ant-)?[a-zA-Z0-9_-]{20,}|AKIA[0-9A-Z]{16}|amcp_[a-zA-Z0-9_-]{16,}|AIza[0-9A-Za-z_-]{35}|EAA[A-Za-z0-9]{60,}|shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}|\d{6,12}:[A-Za-z0-9_-]{30,64})$/, ]; function redactSensitiveString(val: string): string { + const trimmed = val.trim(); for (const pattern of SENSITIVE_VALUE_PATTERNS) { - if (pattern.test(val)) { + if (pattern.test(trimmed)) { return REDACTED_PLACEHOLDER; } } diff --git a/packages/frontend/tests/e2e/audit-log-curl.spec.ts b/packages/frontend/tests/e2e/audit-log-curl.spec.ts index 492021311..d5272fcfe 100644 --- a/packages/frontend/tests/e2e/audit-log-curl.spec.ts +++ b/packages/frontend/tests/e2e/audit-log-curl.spec.ts @@ -312,6 +312,9 @@ test.describe('buildToolCallCurl builder unit tests', () => { authHeader: 'auth-header-val', // must be redacted client_auth: 'client-auth-val', // must be redacted user_pwd: 'secret-pwd', // must be redacted + user_pin: '1234', // must be redacted + passcode: 'pass-999', // must be redacted + authenticate: 'auth-val', // must be redacted id_jwt: 'jwt-val', // must be redacted session_id: 'session-val', // must be redacted bearer_token: 'bearer-val', // must be redacted @@ -330,6 +333,9 @@ test.describe('buildToolCallCurl builder unit tests', () => { expect(redacted.authHeader).toBe(REDACTED_PLACEHOLDER); expect(redacted.client_auth).toBe(REDACTED_PLACEHOLDER); expect(redacted.user_pwd).toBe(REDACTED_PLACEHOLDER); + expect(redacted.user_pin).toBe(REDACTED_PLACEHOLDER); + expect(redacted.passcode).toBe(REDACTED_PLACEHOLDER); + expect(redacted.authenticate).toBe(REDACTED_PLACEHOLDER); expect(redacted.id_jwt).toBe(REDACTED_PLACEHOLDER); expect(redacted.session_id).toBe(REDACTED_PLACEHOLDER); expect(redacted.bearer_token).toBe(REDACTED_PLACEHOLDER); @@ -341,12 +347,17 @@ test.describe('buildToolCallCurl builder unit tests', () => { test('redacts sensitive values including JWTs, private keys, Bearer headers, and known API token prefixes', () => { const sensitiveValues = { - rawJwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + rawJwt: [ + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', + 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ', + 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + ].join('.'), rawPrivateKey: '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0...\n-----END RSA PRIVATE KEY-----', bearerHeader: 'Bearer my-bearer-token-1234567890', - openAiKey: 'sk-proj-1234567890abcdef1234567890', - githubToken: 'ghp_1234567890abcdef12345678901234567890', - slackToken: 'xoxb-1234567890-abcdef123456', + openAiKey: ['sk', 'proj', '1234567890abcdef1234567890'].join('-'), + openAiWithSpaces: ` ${['sk', 'proj', '1234567890abcdef1234567890'].join('-')} `, + githubToken: ['ghp', '1234567890abcdef12345678901234567890'].join('_'), + slackToken: ['xoxb', '1234567890', 'abcdef123456'].join('-'), urlWithAuth: 'https://admin:supersecret@example.com/api', normalVal: 'hello world', }; @@ -356,6 +367,7 @@ test.describe('buildToolCallCurl builder unit tests', () => { expect(redacted.rawPrivateKey).toBe(REDACTED_PLACEHOLDER); expect(redacted.bearerHeader).toBe(REDACTED_PLACEHOLDER); expect(redacted.openAiKey).toBe(REDACTED_PLACEHOLDER); + expect(redacted.openAiWithSpaces).toBe(REDACTED_PLACEHOLDER); expect(redacted.githubToken).toBe(REDACTED_PLACEHOLDER); expect(redacted.slackToken).toBe(REDACTED_PLACEHOLDER); expect(redacted.urlWithAuth).toBe(REDACTED_PLACEHOLDER); @@ -382,22 +394,62 @@ test.describe('buildToolCallCurl builder unit tests', () => { expect(JSON.stringify(redactSensitiveArgs(deep))).not.toContain('deep-secret'); }); - test('handles null, undefined, string, or empty args safely', () => { - const cmd1 = buildToolCallCurl({ + test('handles malformed JSON string, array, boolean, or numeric args gracefully', () => { + const cmdMalformed = buildToolCallCurl({ origin: 'http://localhost:4000', serverId: 'srv-1', - toolName: 'empty_tool', - args: null, + toolName: 'broken_json_tool', + args: '{not valid json:', }); - expect(cmd1).toContain('"arguments":{}'); + expect(cmdMalformed).toContain('"arguments":{}'); - const cmd2 = buildToolCallCurl({ + const cmdArray = buildToolCallCurl({ origin: 'http://localhost:4000', serverId: 'srv-1', - toolName: 'json_string_tool', - args: '{"foo":"bar"}', + toolName: 'array_tool', + args: ['item1', 'item2'], }); - expect(cmd2).toContain('"arguments":{"foo":"bar"}'); + expect(cmdArray).toContain('"arguments":{}'); + + const cmdPrimitive = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'number_tool', + args: 42 as any, + }); + expect(cmdPrimitive).toContain('"arguments":{}'); + }); + + test('emits both warnings when args are both redacted and truncated', () => { + const cmd = buildToolCallCurl({ + origin: 'http://localhost:4000', + serverId: 'srv-1', + toolName: 'both_tool', + args: { _amcp_truncated: true, password: 'secret-val' }, + }); + expect(cmd).toContain('# Some arguments were replaced with [REDACTED]: fill them in before running.'); + expect(cmd).toContain('# WARNING: Stored input arguments were truncated in the audit log; replay may be incomplete.'); + expect(cmd).not.toContain('_amcp_truncated'); + expect(cmd).not.toContain('secret-val'); + expect(cmd).toContain('[REDACTED]'); + }); + + test('redacts additional provider token prefixes: Google AIza, GitHub PAT, GitLab PAT, Telegram bot, Shopify', () => { + const tokens = { + googleKey: ['AI', 'zaSyA1234567890abcdef1234567890abcdef'].join(''), + githubPat: ['github', 'pat', '11AAAAAAA01234567890abcdef1234567890abcdef1234567890abcdef'].join('_'), + gitlabPat: ['glpat', 'abcdef1234567890abcd'].join('-'), + telegramBot: ['123456789', 'ABCdefGHIjklMNOpqrsTUVwxyz123456'].join(':'), + shopifyToken: ['shpat', '1234567890abcdef1234567890abcdef'].join('_'), + normalText: 'safe string', + }; + const redacted = redactSensitiveArgs(tokens) as any; + expect(redacted.googleKey).toBe(REDACTED_PLACEHOLDER); + expect(redacted.githubPat).toBe(REDACTED_PLACEHOLDER); + expect(redacted.gitlabPat).toBe(REDACTED_PLACEHOLDER); + expect(redacted.telegramBot).toBe(REDACTED_PLACEHOLDER); + expect(redacted.shopifyToken).toBe(REDACTED_PLACEHOLDER); + expect(redacted.normalText).toBe('safe string'); }); });