diff --git a/plugins/panw-prisma-airs/intercept.ts b/plugins/panw-prisma-airs/intercept.ts index 34e72a9fd..7b666b193 100644 --- a/plugins/panw-prisma-airs/intercept.ts +++ b/plugins/panw-prisma-airs/intercept.ts @@ -9,11 +9,11 @@ import { getText, post } from '../utils'; const AIRS_URL = 'https://service.api.aisecurity.paloaltonetworks.com/v1/scan/sync/request'; -const fetchAIRS = async (payload: any, apiKey: string, timeout?: number) => { +const fetchAIRS = async (payload: any, apiKey: string) => { const opts = { headers: { 'x-pan-token': apiKey }, }; - return post(AIRS_URL, payload, opts, timeout); + return post(AIRS_URL, payload, opts); }; export const handler: PluginHandler = async ( @@ -26,6 +26,16 @@ export const handler: PluginHandler = async ( process.env.AIRS_API_KEY || ''; + // Return verdict=true with error for missing credentials to allow traffic flow + if (!apiKey || apiKey.trim() === '') { + return { + verdict: true, + error: + 'AIRS_API_KEY is required but not configured. Please add your API key in the Portkey dashboard.', + data: null, + }; + } + let verdict = true; let data: any = null; let error: any = null; @@ -33,24 +43,37 @@ export const handler: PluginHandler = async ( try { const text = getText(ctx, hook); // prompt or response - const payload = { - tr_id: - typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' - ? crypto.randomUUID() - : Math.random().toString(36).substring(2) + Date.now().toString(36), - ai_profile: { - profile_name: params.profile_name ?? 'dev-block-all-profile', - }, + // Extract Portkey's trace ID from request headers to use as AIRS tr_id (AI Session ID) + const traceId = + ctx?.request?.headers?.['x-portkey-trace-id'] || + (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : Math.random().toString(36).substring(2) + Date.now().toString(36)); + + const payload: any = { + tr_id: traceId, // Use Portkey's trace ID as AIRS AI Session ID metadata: { ai_model: params.ai_model ?? 'unknown-model', app_user: params.app_user ?? 'portkey-gateway', + app_name: params.app_name ? `Portkey-${params.app_name}` : 'Portkey', }, contents: [ { [hook === 'beforeRequestHook' ? 'prompt' : 'response']: text }, ], }; - const res: any = await fetchAIRS(payload, apiKey, params.timeout); + // Only include ai_profile if profile_name or profile_id is provided + if (params.profile_name || params.profile_id) { + payload.ai_profile = {}; + if (params.profile_name) { + payload.ai_profile.profile_name = params.profile_name; + } + if (params.profile_id) { + payload.ai_profile.profile_id = params.profile_id; + } + } + + const res: any = await fetchAIRS(payload, apiKey); if (!res || typeof res.action !== 'string') { throw new Error('Malformed AIRS response'); diff --git a/plugins/panw-prisma-airs/manifest.json b/plugins/panw-prisma-airs/manifest.json index cd2426dc3..31105c334 100644 --- a/plugins/panw-prisma-airs/manifest.json +++ b/plugins/panw-prisma-airs/manifest.json @@ -1,14 +1,14 @@ { "id": "panwPrismaAirs", "name": "PANW Prisma AIRS Guardrail", - "description": "Blocks prompt/response when Palo Alto Networks Prisma AI Runtime Security returns action=block.", + "description": "Palo Alto Networks Prisma AI Runtime Security provides real-time scanning for prompt injections, malicious content, PII leakage, and policy violations. Blocks requests or responses when action=block is returned.", "credentials": { "type": "object", "properties": { "AIRS_API_KEY": { "type": "string", "label": "AIRS API Key", - "description": "The API key for Palo Alto Networks Prisma AI Runtime Security", + "description": "API key for Palo Alto Networks Prisma AI Runtime Security. Find your API key in Strata Cloud Manager.", "encrypted": true } }, @@ -20,14 +20,69 @@ "name": "PANW Prisma AIRS Guardrail", "type": "guardrail", "supportedHooks": ["beforeRequestHook", "afterRequestHook"], + "description": [ + { + "type": "subHeading", + "text": "Scan prompts and responses for security threats using Prisma AIRS profiles linked to your API key." + } + ], "parameters": { "type": "object", "properties": { - "profile_name": { "type": "string" }, - "ai_model": { "type": "string" }, - "app_user": { "type": "string" } + "profile_name": { + "type": "string", + "label": "Profile Name", + "description": [ + { + "type": "subHeading", + "text": "AI security profile name from Prisma AIRS. Leave empty to use the profile linked to your API key in Strata Cloud Manager." + } + ] + }, + "profile_id": { + "type": "string", + "label": "Profile ID", + "description": [ + { + "type": "subHeading", + "text": "AI security profile ID. Can be used instead of or in addition to profile_name." + } + ] + }, + "ai_model": { + "type": "string", + "label": "AI Model", + "description": [ + { + "type": "subHeading", + "text": "The AI model being used (e.g., gpt-4, claude-3-5-sonnet). Used for tracking and reporting." + } + ], + "default": "unknown-model" + }, + "app_user": { + "type": "string", + "label": "Application User", + "description": [ + { + "type": "subHeading", + "text": "User identifier for tracking purposes. Useful for audit logs and user-level analytics." + } + ], + "default": "portkey-gateway" + }, + "app_name": { + "type": "string", + "label": "Application Name", + "description": [ + { + "type": "subHeading", + "text": "Custom application name for tracking. Will be prefixed with 'Portkey-' (e.g., 'Portkey-chatbot')." + } + ] + } }, - "required": ["profile_name"] + "required": [] } } ] diff --git a/plugins/panw-prisma-airs/panw.airs.test.ts b/plugins/panw-prisma-airs/panw.airs.test.ts index ac078bfaa..1b9e64180 100644 --- a/plugins/panw-prisma-airs/panw.airs.test.ts +++ b/plugins/panw-prisma-airs/panw.airs.test.ts @@ -1,5 +1,14 @@ import { handler as panwPrismaAirsHandler } from './intercept'; +// Mock the utils module +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + post: jest.fn(), +})); + +import * as utils from '../utils'; +const mockPost = utils.post as jest.MockedFunction; + describe('PANW Prisma AIRS Guardrail', () => { const mockContext = { request: { text: 'This is a test prompt.' }, @@ -11,10 +20,15 @@ describe('PANW Prisma AIRS Guardrail', () => { profile_name: 'test-profile', ai_model: 'gpt-unit-test', app_user: 'unit-tester', - timeout: 3000, }; + beforeEach(() => { + mockPost.mockClear(); + }); + it('should return a result object with verdict, data, and error', async () => { + mockPost.mockResolvedValue({ action: 'allow' }); + const result = await panwPrismaAirsHandler( mockContext, params, @@ -24,4 +38,213 @@ describe('PANW Prisma AIRS Guardrail', () => { expect(result).toHaveProperty('data'); expect(result).toHaveProperty('error'); }); + + it('should work without profile_name (profile linked to API Key)', async () => { + mockPost.mockResolvedValue({ action: 'allow' }); + + const paramsWithoutProfile = { + credentials: { AIRS_API_KEY: 'dummy-key' }, + ai_model: 'gpt-unit-test', + app_user: 'unit-tester', + }; + const result = await panwPrismaAirsHandler( + mockContext, + paramsWithoutProfile, + 'beforeRequestHook' + ); + expect(result).toHaveProperty('verdict'); + expect(result).toHaveProperty('data'); + expect(result).toHaveProperty('error'); + }); + + it('should support profile_id parameter', async () => { + mockPost.mockResolvedValue({ action: 'allow' }); + + const paramsWithProfileId = { + credentials: { AIRS_API_KEY: 'dummy-key' }, + profile_id: 'test-profile-id', + ai_model: 'gpt-unit-test', + app_user: 'unit-tester', + }; + const result = await panwPrismaAirsHandler( + mockContext, + paramsWithProfileId, + 'beforeRequestHook' + ); + expect(result).toHaveProperty('verdict'); + expect(result).toHaveProperty('data'); + expect(result).toHaveProperty('error'); + }); + + it('should support app_name parameter', async () => { + mockPost.mockResolvedValue({ action: 'allow' }); + + const paramsWithAppName = { + credentials: { AIRS_API_KEY: 'dummy-key' }, + profile_name: 'test-profile', + app_name: 'testapp', + ai_model: 'gpt-unit-test', + app_user: 'unit-tester', + }; + const result = await panwPrismaAirsHandler( + mockContext, + paramsWithAppName, + 'beforeRequestHook' + ); + expect(result).toHaveProperty('verdict'); + expect(result).toHaveProperty('data'); + expect(result).toHaveProperty('error'); + }); + + // New behavioral tests + it('should block when AIRS returns action=block', async () => { + mockPost.mockResolvedValue({ action: 'block' }); + + const result = await panwPrismaAirsHandler( + mockContext, + params, + 'beforeRequestHook' + ); + + expect(result.verdict).toBe(false); + expect(result.data).toEqual({ action: 'block' }); + expect(result.error).toBeNull(); + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + it('should allow when AIRS returns action=allow', async () => { + mockPost.mockResolvedValue({ action: 'allow' }); + + const result = await panwPrismaAirsHandler( + mockContext, + params, + 'beforeRequestHook' + ); + + expect(result.verdict).toBe(true); + expect(result.data).toEqual({ action: 'allow' }); + expect(result.error).toBeNull(); + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + it('should allow traffic when API key is missing (no HTTP call)', async () => { + // Temporarily clear the environment variable + const originalEnvKey = process.env.AIRS_API_KEY; + delete process.env.AIRS_API_KEY; + + const paramsWithoutKey = { + ...params, + credentials: {}, + }; + + const result = await panwPrismaAirsHandler( + mockContext, + paramsWithoutKey, + 'beforeRequestHook' + ); + + // Restore the environment variable to its exact original state + if (originalEnvKey !== undefined) { + process.env.AIRS_API_KEY = originalEnvKey; + } else { + delete process.env.AIRS_API_KEY; + } + + expect(result.verdict).toBe(true); + expect(result.error).toContain( + 'AIRS_API_KEY is required but not configured' + ); + expect(result.data).toBeNull(); + expect(mockPost).not.toHaveBeenCalled(); // No HTTP call made + }); + + it('should allow traffic when API key is empty string (no HTTP call)', async () => { + // Temporarily clear the environment variable + const originalEnvKey = process.env.AIRS_API_KEY; + delete process.env.AIRS_API_KEY; + + const paramsWithEmptyKey = { + ...params, + credentials: { AIRS_API_KEY: ' ' }, // whitespace only + }; + + const result = await panwPrismaAirsHandler( + mockContext, + paramsWithEmptyKey, + 'beforeRequestHook' + ); + + // Restore the environment variable to its exact original state + if (originalEnvKey !== undefined) { + process.env.AIRS_API_KEY = originalEnvKey; + } else { + delete process.env.AIRS_API_KEY; + } + + expect(result.verdict).toBe(true); + expect(result.error).toContain( + 'AIRS_API_KEY is required but not configured' + ); + expect(result.data).toBeNull(); + expect(mockPost).not.toHaveBeenCalled(); // No HTTP call made + }); + + it('should handle malformed AIRS response', async () => { + mockPost.mockResolvedValue({ invalid: 'response' }); // Missing 'action' field + + const result = await panwPrismaAirsHandler( + mockContext, + params, + 'beforeRequestHook' + ); + + expect(result.verdict).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error.message).toContain('Malformed AIRS response'); + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + it('should handle network errors', async () => { + const networkError = new Error('Network timeout'); + mockPost.mockRejectedValue(networkError); + + const result = await panwPrismaAirsHandler( + mockContext, + params, + 'beforeRequestHook' + ); + + expect(result.verdict).toBe(false); + expect(result.error).toBe(networkError); + expect(result.data).toBeNull(); + expect(mockPost).toHaveBeenCalledTimes(1); + }); + + it('should use x-portkey-trace-id as tr_id when available', async () => { + const traceId = '38d838c3-2151-4f40-9729-9607f34ea446'; + const mockContextWithTraceId = { + request: { + text: 'This is a test prompt.', + headers: { 'x-portkey-trace-id': traceId }, + }, + response: { text: 'This is a test response.' }, + }; + + mockPost.mockResolvedValue({ action: 'allow' }); + + await panwPrismaAirsHandler( + mockContextWithTraceId, + params, + 'beforeRequestHook' + ); + + // Verify the post call was made with the correct tr_id + expect(mockPost).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + tr_id: traceId, + }), + expect.any(Object) + ); + }); });