From 97675a81b2db885989e5c5f79dcf8ef9da311889 Mon Sep 17 00:00:00 2001 From: Jack Batzner Date: Mon, 6 Apr 2026 10:57:19 -0500 Subject: [PATCH] feat(mcp-proxy): add OWASP mitigates field to policy rules and audit - Add mitigates?: string[] to PolicyRule interface for OWASP risk IDs - Add mitigatedRisks?: string[] to PolicyDecision for downstream consumers - Thread mitigatedRisks through proxy.ts policy evaluation to audit.ts - Add mitigates field to AuditEvent and CloudEvent data payload - Annotate standard.yaml, strict.yaml, enterprise.yaml with ASI risk IDs - Add policy evaluation tests for mitigates propagation Relates to Discussion #814 (Agentic Standards Landscape - MCP governance) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../mcp-proxy/policies/enterprise.yaml | 11 +++ .../packages/mcp-proxy/policies/standard.yaml | 8 ++ .../packages/mcp-proxy/policies/strict.yaml | 7 ++ .../packages/mcp-proxy/src/audit.ts | 2 + .../packages/mcp-proxy/src/policy.ts | 19 ++-- .../packages/mcp-proxy/src/proxy.ts | 1 + .../mcp-proxy/tests/policy-audit.test.ts | 94 +++++++++++++++++++ 7 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 packages/agent-mesh/packages/mcp-proxy/tests/policy-audit.test.ts diff --git a/packages/agent-mesh/packages/mcp-proxy/policies/enterprise.yaml b/packages/agent-mesh/packages/mcp-proxy/policies/enterprise.yaml index 9357a458f..fbed9dec5 100644 --- a/packages/agent-mesh/packages/mcp-proxy/policies/enterprise.yaml +++ b/packages/agent-mesh/packages/mcp-proxy/policies/enterprise.yaml @@ -12,23 +12,28 @@ rules: - tool: "run_shell" action: deny reason: "Shell execution prohibited per security policy SOC2-CC6.1" + mitigates: ["ASI02", "ASI05"] - tool: "execute_command" action: deny reason: "Command execution prohibited" + mitigates: ["ASI02", "ASI05"] - tool: "eval" action: deny reason: "Dynamic code evaluation prohibited" + mitigates: ["ASI05"] - tool: "spawn_process" action: deny reason: "Process spawning prohibited" + mitigates: ["ASI02", "ASI05", "ASI10"] # === SENSITIVE DATA PROTECTION === - tool: "read_file" action: allow + mitigates: ["ASI02", "ASI07"] conditions: # Block access to sensitive file types - path_not_contains: @@ -50,6 +55,7 @@ rules: - tool: "write_file" action: allow + mitigates: ["ASI02"] conditions: - path_not_contains: - ".env" @@ -66,11 +72,13 @@ rules: - tool: "delete_file" action: deny reason: "File deletion requires manual approval" + mitigates: ["ASI02"] # === DATABASE OPERATIONS === - tool: "query_database" action: allow + mitigates: ["ASI02", "ASI05"] conditions: # Block destructive queries - argument_not_matches: @@ -83,6 +91,7 @@ rules: - tool: "http_request" action: allow + mitigates: ["ASI08", "ASI09"] rate_limit: requests: 20 per: minute @@ -114,9 +123,11 @@ rate_limits: global: requests: 200 per: minute + mitigates: ["ASI08"] # Audit settings audit: + mitigates: ["ASI09", "ASI10"] # Log all tool calls (not just violations) log_all: true # Include argument values (may contain sensitive data) diff --git a/packages/agent-mesh/packages/mcp-proxy/policies/standard.yaml b/packages/agent-mesh/packages/mcp-proxy/policies/standard.yaml index 6d8c22c23..6c4e98bfc 100644 --- a/packages/agent-mesh/packages/mcp-proxy/policies/standard.yaml +++ b/packages/agent-mesh/packages/mcp-proxy/policies/standard.yaml @@ -12,28 +12,34 @@ rules: - tool: "run_shell" action: deny reason: "Shell execution not permitted" + mitigates: ["ASI02", "ASI05"] - tool: "execute_command" action: deny reason: "Direct command execution not permitted" + mitigates: ["ASI02", "ASI05"] - tool: "eval" action: deny reason: "Code evaluation not permitted" + mitigates: ["ASI05"] - tool: "spawn_process" action: deny reason: "Process spawning not permitted" + mitigates: ["ASI02", "ASI05", "ASI10"] # === SENSITIVE FILE PROTECTION === - tool: "read_file" action: allow + mitigates: ["ASI02", "ASI07"] conditions: - path_not_contains: [".env", ".secret", "id_rsa", ".pem", "credentials"] - tool: "write_file" action: allow + mitigates: ["ASI02"] conditions: - path_not_contains: [".env", ".secret", "id_rsa", ".pem", "/etc/", "/bin/"] @@ -41,6 +47,7 @@ rules: - tool: "http_request" action: allow + mitigates: ["ASI08", "ASI09"] rate_limit: requests: 30 per: minute @@ -61,3 +68,4 @@ rate_limits: global: requests: 100 per: minute + mitigates: ["ASI08"] diff --git a/packages/agent-mesh/packages/mcp-proxy/policies/strict.yaml b/packages/agent-mesh/packages/mcp-proxy/policies/strict.yaml index 93d28c0b7..822ab84d2 100644 --- a/packages/agent-mesh/packages/mcp-proxy/policies/strict.yaml +++ b/packages/agent-mesh/packages/mcp-proxy/policies/strict.yaml @@ -14,6 +14,7 @@ rules: - tool: "read_file" action: allow + mitigates: ["ASI02", "ASI07"] conditions: - path_not_contains: [".env", ".secret", "credentials", "password", ".key"] reason: "Read-only file access permitted" @@ -35,22 +36,27 @@ rules: - tool: "write_file" action: deny reason: "Write operations not permitted in strict mode" + mitigates: ["ASI02"] - tool: "delete_file" action: deny reason: "Delete operations not permitted" + mitigates: ["ASI02"] - tool: "run_shell" action: deny reason: "Shell execution blocked" + mitigates: ["ASI02", "ASI05"] - tool: "execute_command" action: deny reason: "Command execution blocked" + mitigates: ["ASI02", "ASI05"] - tool: "eval" action: deny reason: "Code evaluation blocked" + mitigates: ["ASI05"] # === CATCH-ALL === @@ -63,6 +69,7 @@ rate_limits: global: requests: 50 per: minute + mitigates: ["ASI08"] per_tool: read_file: requests: 20 diff --git a/packages/agent-mesh/packages/mcp-proxy/src/audit.ts b/packages/agent-mesh/packages/mcp-proxy/src/audit.ts index 3b3870281..892dd36bb 100644 --- a/packages/agent-mesh/packages/mcp-proxy/src/audit.ts +++ b/packages/agent-mesh/packages/mcp-proxy/src/audit.ts @@ -21,6 +21,7 @@ export interface AuditEvent { decision: 'allow' | 'deny'; reason?: string; rule?: string; + mitigates?: string[]; latency_ms?: number; } @@ -89,6 +90,7 @@ export class AuditLogger { decision: event.decision, reason: event.reason, matched_rule: event.rule, + mitigates: event.mitigates, latency_ms: event.latency_ms, }, // Extension attributes for AgentMesh diff --git a/packages/agent-mesh/packages/mcp-proxy/src/policy.ts b/packages/agent-mesh/packages/mcp-proxy/src/policy.ts index b02f1b98c..87981bebc 100644 --- a/packages/agent-mesh/packages/mcp-proxy/src/policy.ts +++ b/packages/agent-mesh/packages/mcp-proxy/src/policy.ts @@ -16,7 +16,8 @@ export interface PolicyRule { action: 'allow' | 'deny'; reason?: string; conditions?: PolicyCondition[]; - rate_limit?: { requests: number; per: string }; + rate_limit?: { requests: number; per: string }; + mitigates?: string[]; } export interface PolicyCondition { @@ -39,7 +40,8 @@ export interface Policy { export interface PolicyDecision { allowed: boolean; reason?: string; - matchedRule?: string; + matchedRule?: string; + mitigatedRisks?: string[]; } // Built-in policies @@ -62,9 +64,9 @@ const BUILTIN_POLICY_DEFS: Record = { version: '1.0', mode: 'enforce', rules: [ - { tool: 'run_shell', action: 'deny', reason: 'Shell execution blocked' }, - { tool: 'execute_command', action: 'deny', reason: 'Command execution blocked' }, - { tool: 'eval', action: 'deny', reason: 'Eval blocked' }, + { tool: 'run_shell', action: 'deny', reason: 'Shell execution blocked', mitigates: ['ASI02', 'ASI05'] }, + { tool: 'execute_command', action: 'deny', reason: 'Command execution blocked', mitigates: ['ASI02', 'ASI05'] }, + { tool: 'eval', action: 'deny', reason: 'Eval blocked', mitigates: ['ASI05'] }, { tool: '*', action: 'allow' }, ], }, @@ -82,8 +84,8 @@ const BUILTIN_POLICY_DEFS: Record = { version: '1.0', mode: 'enforce', rules: [ - { tool: 'run_shell', action: 'deny', reason: 'Shell execution blocked' }, - { tool: 'execute_command', action: 'deny', reason: 'Command execution blocked' }, + { tool: 'run_shell', action: 'deny', reason: 'Shell execution blocked', mitigates: ['ASI02', 'ASI05'] }, + { tool: 'execute_command', action: 'deny', reason: 'Command execution blocked', mitigates: ['ASI02', 'ASI05'] }, { tool: 'write_file', action: 'allow', rate_limit: { requests: 10, per: 'minute' } }, { tool: '*', action: 'allow' }, ], @@ -150,7 +152,8 @@ export function evaluatePolicy( return { allowed: rule.action === 'allow', reason: rule.reason, - matchedRule: rule.tool, + matchedRule: rule.tool, + mitigatedRisks: rule.mitigates ? [...rule.mitigates] : undefined, }; } } diff --git a/packages/agent-mesh/packages/mcp-proxy/src/proxy.ts b/packages/agent-mesh/packages/mcp-proxy/src/proxy.ts index 9a9f2ce0f..cd15de221 100644 --- a/packages/agent-mesh/packages/mcp-proxy/src/proxy.ts +++ b/packages/agent-mesh/packages/mcp-proxy/src/proxy.ts @@ -190,6 +190,7 @@ export class MCPProxy { decision: decision.allowed ? 'allow' : 'deny', reason: decision.reason, rule: decision.matchedRule, + mitigates: decision.mitigatedRisks, }); if (!decision.allowed && this.options.mode === 'enforce') { diff --git a/packages/agent-mesh/packages/mcp-proxy/tests/policy-audit.test.ts b/packages/agent-mesh/packages/mcp-proxy/tests/policy-audit.test.ts new file mode 100644 index 000000000..74b0d6494 --- /dev/null +++ b/packages/agent-mesh/packages/mcp-proxy/tests/policy-audit.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, describe, expect, it } from 'vitest'; +import { once } from 'events'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { AuditLogger } from '../src/audit.js'; +import { evaluatePolicy, Policy } from '../src/policy.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('evaluatePolicy', () => { + it('copies mitigates from the matched rule into the decision', () => { + const policy: Policy = { + version: '1.0', + mode: 'enforce', + rules: [ + { + tool: 'run_shell', + action: 'deny', + reason: 'blocked', + mitigates: ['ASI02', 'ASI05'], + }, + { tool: '*', action: 'allow' }, + ], + }; + + const decision = evaluatePolicy(policy, 'run_shell', {}); + + expect(decision).toMatchObject({ + allowed: false, + matchedRule: 'run_shell', + mitigatedRisks: ['ASI02', 'ASI05'], + }); + }); + + it('leaves mitigatedRisks unset when the matched rule has no annotations', () => { + const policy: Policy = { + version: '1.0', + mode: 'enforce', + rules: [{ tool: '*', action: 'allow' }], + }; + + const decision = evaluatePolicy(policy, 'read_file', { path: 'README.md' }); + + expect(decision.allowed).toBe(true); + expect(decision.mitigatedRisks).toBeUndefined(); + }); +}); + +describe('AuditLogger', () => { + it('includes mitigates in CloudEvents data only when present', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'mcp-proxy-audit-')); + tempDirs.push(tempDir); + + const logPath = join(tempDir, 'audit.log'); + const logger = new AuditLogger({ path: logPath }); + + logger.log({ + type: 'ai.agentmesh.policy.violation', + tool: 'run_shell', + decision: 'deny', + mitigates: ['ASI02', 'ASI05'], + }); + logger.log({ + type: 'ai.agentmesh.tool.invoked', + tool: 'read_file', + decision: 'allow', + }); + + logger.close(); + + const stream = Reflect.get(logger, 'stream'); + if (stream) { + await once(stream, 'finish'); + } + + const [deniedEntry, allowedEntry] = readFileSync(logPath, 'utf-8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { data: Record }); + + expect(deniedEntry.data.mitigates).toEqual(['ASI02', 'ASI05']); + expect(allowedEntry.data).not.toHaveProperty('mitigates'); + }); +});