Files
wehub-resource-sync 0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

796 lines
24 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchWithCache } from '../../../src/cache';
import { VERSION } from '../../../src/constants';
import logger from '../../../src/logger';
import {
ADDITIONAL_PLUGINS,
ALL_PLUGINS,
BASE_PLUGINS,
CANARY_BREAKING_STRATEGY_IDS,
HARM_PLUGINS,
PII_PLUGINS,
REDTEAM_PROVIDER_HARM_PLUGINS,
UNALIGNED_PROVIDER_HARM_PLUGINS,
} from '../../../src/redteam/constants';
import { Plugins } from '../../../src/redteam/plugins/index';
import { neverGenerateRemote, shouldGenerateRemote } from '../../../src/redteam/remoteGeneration';
import { getShortPluginId } from '../../../src/redteam/util';
import {
createMockProvider,
createProviderResponse,
type MockApiProvider,
} from '../../factories/provider';
import type { FetchWithCacheResult } from '../../../src/cache';
import type { TestCase } from '../../../src/types/index';
vi.mock('../../../src/cache');
vi.mock('../../../src/cliState', () => ({
__esModule: true,
default: { remote: false },
}));
vi.mock('../../../src/redteam/remoteGeneration', async (importOriginal) => {
return {
...(await importOriginal()),
getRemoteGenerationUrl: vi.fn().mockReturnValue('http://test-url'),
getRemoteHealthUrl: vi.fn().mockReturnValue('http://test-health-url'),
neverGenerateRemote: vi.fn().mockReturnValue(false),
shouldGenerateRemote: vi.fn().mockReturnValue(false),
};
});
vi.mock('../../../src/util/apiHealth', async (importOriginal) => {
return {
...(await importOriginal()),
checkRemoteHealth: vi.fn().mockResolvedValue({
status: 'OK',
message: 'API is healthy',
}),
};
});
// Helper function to create mock fetch responses
function mockFetchResponse(result: any[]): FetchWithCacheResult<unknown> {
return {
data: { result },
cached: false,
status: 200,
statusText: 'OK',
};
}
describe('Plugins', () => {
let mockProvider: MockApiProvider;
beforeEach(() => {
mockProvider = createMockProvider({
response: createProviderResponse({
output: 'Sample output',
error: null as any,
}),
});
// Reset all mocks
vi.clearAllMocks();
vi.mocked(fetchWithCache).mockReset();
});
describe('plugin registration', () => {
it('should register all base plugins', () => {
const basePluginKeys = [
'contracts',
'cross-session-leak',
'debug-access',
'excessive-agency',
'hallucination',
'imitation',
'intent',
'overreliance',
'politics',
'policy',
'prompt-extraction',
'rbac',
'shell-injection',
'sql-injection',
];
basePluginKeys.forEach((key) => {
const plugin = Plugins.find((p) => p.key === key);
expect(plugin).toBeDefined();
});
});
it('should register all aligned harm plugins', () => {
Object.keys(REDTEAM_PROVIDER_HARM_PLUGINS).forEach((key) => {
const plugin = Plugins.find((p) => p.key === key);
expect(plugin).toBeDefined();
});
});
it('should register all unaligned harm plugins', () => {
Object.keys(UNALIGNED_PROVIDER_HARM_PLUGINS).forEach((key) => {
const plugin = Plugins.find((p) => p.key === key);
expect(plugin).toBeDefined();
});
});
it('should register all PII plugins', () => {
PII_PLUGINS.forEach((key) => {
const plugin = Plugins.find((p) => p.key === key);
expect(plugin).toBeDefined();
});
});
it('should register all remote plugins', () => {
const remotePluginKeys = [
'ascii-smuggling',
'bfla',
'bola',
'competitors',
'hijacking',
'religion',
'ssrf',
'indirect-prompt-injection',
'rag-poisoning',
];
remotePluginKeys.forEach((key) => {
const plugin = Plugins.find((p) => p.key === key);
expect(plugin).toBeDefined();
});
});
});
describe('plugin validation', () => {
it('should validate intent plugin config', async () => {
const intentPlugin = Plugins.find((p) => p.key === 'intent');
expect(() => intentPlugin?.validate?.({})).toThrow(
'Intent plugin requires `config.intent` to be set',
);
});
it('should validate policy plugin config', async () => {
const policyPlugin = Plugins.find((p) => p.key === 'policy');
expect(() => policyPlugin?.validate?.({})).toThrow(
'Invariant failed: One of the policy plugins is invalid. The `config` property of a policy plugin must be `{ "policy": { "id": "<policy_id>", "text": "<policy_text>" } }` or `{ "policy": "<policy_text>" }`. Received: {}',
);
});
it('should validate indirect prompt injection plugin config', async () => {
const indirectPlugin = Plugins.find((p) => p.key === 'indirect-prompt-injection');
expect(() => indirectPlugin?.validate?.({})).toThrow(
'Indirect prompt injection plugin requires `config.indirectInjectionVar` to be set',
);
});
it('should validate rag-poisoning plugin config', async () => {
const ragPlugin = Plugins.find((p) => p.key === 'rag-poisoning');
expect(() => ragPlugin?.validate?.({})).toThrow('config.intendedResults');
expect(() => ragPlugin?.validate?.({ intendedResults: [] })).toThrow(
'config.intendedResults',
);
});
});
describe('max chars retries', () => {
it('should retry oversized local PII generations', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return false;
});
vi.spyOn(mockProvider, 'callApi')
.mockResolvedValueOnce({
output: 'Prompt: this prompt is too long\nPrompt: tiny',
error: undefined,
})
.mockResolvedValueOnce({
output: 'Prompt: short',
error: undefined,
});
const plugin = Plugins.find((p) => p.key === 'pii:direct');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 2,
config: { maxCharsPerMessage: 10 },
delayMs: 0,
});
expect(mockProvider.callApi).toHaveBeenCalledTimes(2);
expect(mockProvider.callApi).toHaveBeenNthCalledWith(
2,
expect.stringContaining('Generate replacement prompts only'),
);
expect(result?.map((testCase) => testCase.vars?.testVar).sort()).toEqual(['short', 'tiny']);
});
it('should retry oversized remote generations and strip retry modifiers from metadata', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
vi.mocked(fetchWithCache)
.mockResolvedValueOnce(
mockFetchResponse([
{
vars: { testVar: 'this prompt is too long' },
},
]),
)
.mockResolvedValueOnce(
mockFetchResponse([
{
vars: { testVar: 'short' },
},
]),
);
const plugin = Plugins.find((p) => p.key === 'ssrf');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: {
modifiers: {
maxCharsPerMessage: 'Each generated user message must be 10 characters or fewer.',
},
},
delayMs: 0,
});
expect(fetchWithCache).toHaveBeenCalledTimes(2);
const retryRequestBody = JSON.parse((vi.mocked(fetchWithCache).mock.calls[1][1] as any).body);
expect(retryRequestBody.config.modifiers.__maxCharsPerMessageRetry).toContain(
'Generate replacement prompts only',
);
expect(result).toEqual([
{
vars: { testVar: 'short' },
metadata: {
pluginId: 'ssrf',
pluginConfig: {
modifiers: {
maxCharsPerMessage: 'Each generated user message must be 10 characters or fewer.',
},
},
},
},
]);
});
});
describe('remote generation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should call remote generation with correct parameters', async () => {
// Mock both functions for this test
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
const mockResponse = {
data: { result: [{ test: 'case' }] },
cached: false,
status: 200,
statusText: 'OK',
};
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
const plugin = Plugins.find((p) => p.key === 'ssrf');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: {},
delayMs: 0,
targetId: 'cloud-target-123',
});
expect(fetchWithCache).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: {},
injectVar: 'testVar',
n: 1,
purpose: 'test',
task: 'ssrf',
targetId: 'cloud-target-123',
version: VERSION,
email: null,
}),
}),
expect.any(Number),
);
expect(result).toEqual([
{ test: 'case', metadata: { pluginId: 'ssrf', pluginConfig: { modifiers: {} } } },
]);
});
it('should strip graderExamples from remote generation request but preserve in metadata', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
const mockResponse = {
data: { result: [{ vars: { testVar: 'test content' } }] },
cached: false,
status: 200,
statusText: 'OK',
};
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
const graderExamples = [
{ output: 'safe response', pass: true, score: 1, reason: 'Correctly refused' },
{ output: 'unsafe response', pass: false, score: 0, reason: 'Should have refused' },
];
const plugin = Plugins.find((p) => p.key === 'ssrf');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: {
graderExamples,
language: 'en',
},
delayMs: 0,
});
// Verify graderExamples are NOT in the request body sent to server
const callArgs = vi.mocked(fetchWithCache).mock.calls[0];
const requestBody = JSON.parse((callArgs[1] as any).body);
expect(requestBody.config).not.toHaveProperty('graderExamples');
expect(requestBody.config).toHaveProperty('language', 'en');
// Verify graderExamples ARE preserved in the returned test case metadata
expect(result).toHaveLength(1);
expect(result![0].metadata?.pluginConfig).toHaveProperty('graderExamples', graderExamples);
expect(result![0].metadata?.pluginConfig).toHaveProperty('language', 'en');
});
it('should accept server-materialized multi-input remote generation results', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
vi.mocked(fetchWithCache).mockResolvedValue({
data: {
materializationHandled: true,
result: [
{
metadata: {
inputMaterialization: {
document: {
injectionPlacement: 'comment',
wrapperSummary: 'Internal planning memo with reviewer note.',
},
},
},
vars: {
document:
'data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,Zm9v',
testVar: '{"document":"Summarize the reviewer notes."}',
},
},
],
},
cached: false,
status: 200,
statusText: 'OK',
});
const plugin = Plugins.find((p) => p.key === 'ssrf');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: {
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
},
},
delayMs: 0,
});
expect(result).toEqual([
{
metadata: {
inputMaterialization: {
document: {
injectionPlacement: 'comment',
wrapperSummary: 'Internal planning memo with reviewer note.',
},
},
pluginConfig: {
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
},
modifiers: expect.objectContaining({
__outputFormat: expect.stringContaining('<Prompt>'),
}),
},
pluginId: 'ssrf',
},
vars: {
document:
'data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,Zm9v',
testVar: '{"document":"Summarize the reviewer notes."}',
},
},
]);
});
it('should fail fast when remote multi-input generation hits an older server', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
vi.mocked(fetchWithCache).mockResolvedValue({
data: {
result: [
{
vars: {
testVar: '{"document":"Summarize the reviewer notes."}',
},
},
],
},
cached: false,
status: 200,
statusText: 'OK',
});
const plugin = Plugins.find((p) => p.key === 'ssrf');
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {});
await expect(
plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: {
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
},
},
delayMs: 0,
}),
).resolves.toEqual([]);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Remote plugin generation for ssrf requires remote multi-input materialization support from a newer Promptfoo server.',
),
);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('http://test-url'));
});
it('should preserve coding-agent canary-breaking strategy exclusions in metadata', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
const mockResponse = mockFetchResponse([{ vars: { testVar: 'test content' } }]);
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
const plugin = Plugins.find((p) => p.key === 'coding-agent:secret-env-read');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: { excludeStrategies: ['custom-strategy'] },
delayMs: 0,
});
const callArgs = vi.mocked(fetchWithCache).mock.calls[0];
const requestBody = JSON.parse((callArgs[1] as any).body);
expect(requestBody.config.excludeStrategies).toEqual([
...CANARY_BREAKING_STRATEGY_IDS,
'custom-strategy',
]);
expect(result?.[0].metadata?.pluginConfig?.excludeStrategies).toEqual([
...CANARY_BREAKING_STRATEGY_IDS,
'custom-strategy',
]);
});
it.each([
'coding-agent:core',
'coding-agent:all',
])('should preserve %s canary-breaking strategy exclusions in metadata', async (pluginId) => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
const mockResponse = mockFetchResponse([{ vars: { testVar: 'test content' } }]);
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
const plugin = Plugins.find((p) => p.key === pluginId);
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: { excludeStrategies: ['custom-strategy'] },
delayMs: 0,
});
const callArgs = vi.mocked(fetchWithCache).mock.calls[0];
const requestBody = JSON.parse((callArgs[1] as any).body);
expect(requestBody.config.excludeStrategies).toEqual([
...CANARY_BREAKING_STRATEGY_IDS,
'custom-strategy',
]);
expect(result?.[0].metadata?.pluginConfig?.excludeStrategies).toEqual([
...CANARY_BREAKING_STRATEGY_IDS,
'custom-strategy',
]);
});
it('should handle remote generation errors', async () => {
// Mock shouldGenerateRemote to return true for this test
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(fetchWithCache).mockRejectedValue(new Error('Network error'));
const plugin = Plugins.find((p) => p.key === 'contracts');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: {},
delayMs: 0,
});
expect(result).toEqual([]);
});
it('should add harmful assertions for harmful remote plugins', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
const mockResponse: FetchWithCacheResult<unknown> = {
data: {
result: [
{
vars: { testVar: 'test content' },
metadata: { harmCategory: 'Misinformation/Disinformation' },
},
],
},
cached: false,
status: 200,
statusText: 'OK',
};
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
const plugin = Plugins.find((p) => p.key === 'harmful:misinformation-disinformation');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: {},
delayMs: 0,
});
expect(result).toHaveLength(1);
expect(result![0].assert).toBeDefined();
expect(result![0].assert).toEqual([
{
metric: 'Misinformation & Disinformation - Harmful lies and propaganda',
type: 'promptfoo:redteam:harmful:misinformation-disinformation',
},
]);
});
it('should not modify assertions for non-harmful remote plugins', async () => {
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return false;
});
const originalTestCase = {
assert: [
{
type: 'test',
metric: 'Original',
},
],
vars: {
testVar: 'test content',
},
metadata: {
pluginId: 'ssrf',
},
};
const mockResponse = mockFetchResponse([originalTestCase]);
vi.mocked(fetchWithCache).mockResolvedValue(mockResponse);
const plugin = Plugins.find((p) => p.key === 'ssrf');
const result = await plugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
config: {},
delayMs: 0,
});
expect(result).toHaveLength(1);
expect(result?.[0]).toEqual({
...originalTestCase,
metadata: { ...originalTestCase.metadata, pluginConfig: { modifiers: {} } },
});
});
});
describe('unaligned harm plugins', () => {
it('should require remote generation', async () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return false;
});
vi.mocked(neverGenerateRemote).mockImplementation(function () {
return true;
});
const unalignedPlugin = Plugins.find(
(p) => p.key === Object.keys(UNALIGNED_PROVIDER_HARM_PLUGINS)[0],
);
const result = await unalignedPlugin?.action({
provider: mockProvider,
purpose: 'test',
injectVar: 'testVar',
n: 1,
delayMs: 0,
});
expect(result).toEqual([]);
});
});
describe('plugin metadata', () => {
let remoteTestCases: TestCase[];
beforeEach(() => {
// Setup mock response for remote tests
remoteTestCases = [
{
vars: { testVar: 'test content' },
metadata: { pluginId: 'remote-test-plugin' },
},
];
vi.mocked(fetchWithCache).mockResolvedValue(mockFetchResponse(remoteTestCases));
// Mock callApi to return a test response
vi.spyOn(mockProvider, 'callApi').mockResolvedValue({
output: 'Test response for plugin test',
error: undefined,
});
});
it('should correctly format pluginId using getShortPluginId', () => {
// Test with different types of plugin IDs
const testCases = [
// Simple plugin IDs
{ input: 'contracts', expected: 'contracts' },
{ input: 'excessive-agency', expected: 'excessive-agency' },
{ input: 'hallucination', expected: 'hallucination' },
// IDs with colon
{ input: 'harmful:privacy', expected: 'harmful:privacy' },
{ input: 'harmful:hate', expected: 'harmful:hate' },
{ input: 'pii:direct', expected: 'pii:direct' },
// IDs with prefixes
{ input: 'promptfoo:redteam:contracts', expected: 'contracts' },
{ input: 'promptfoo:redteam:harmful:privacy', expected: 'harmful:privacy' },
{ input: 'promptfoo:redteam:pii:direct', expected: 'pii:direct' },
];
// Test each case
testCases.forEach(({ input, expected }) => {
const result = getShortPluginId(input);
expect(result).toBe(expected);
});
});
// Simplified test just to verify plugins output
it('should verify plugins exist', () => {
// Check for common plugins
const plugins = ['contracts', 'excessive-agency', 'prompt-extraction', 'pii:direct'];
plugins.forEach((key) => {
const plugin = Plugins.find((p) => p.key === key);
expect(plugin).toBeDefined();
});
});
});
describe('plugin registry completeness', () => {
it('should have all plugins from constants registered', () => {
// Get all the plugin keys that should be registered
const expectedPlugins = [
...BASE_PLUGINS,
...Object.keys(HARM_PLUGINS),
...PII_PLUGINS,
...ADDITIONAL_PLUGINS,
];
// Verify all expected plugin keys are present in the registry
// Note: We don't expect exact equality because some plugins like collections may not be in the expected list
const registeredPluginKeys = Plugins.map((p) => p.key);
expect(registeredPluginKeys).toEqual(expect.arrayContaining(expectedPlugins));
});
it('should have unique plugin keys', () => {
// Check that there are no duplicate plugin keys
const pluginKeys = Plugins.map((p) => p.key);
const uniqueKeys = new Set(pluginKeys);
expect(pluginKeys).toHaveLength(uniqueKeys.size);
// Cross-check with ALL_PLUGINS
ALL_PLUGINS.forEach((pluginKey) => {
const matchingPlugins = Plugins.filter((p) => p.key === pluginKey);
// Each key should appear at most once (some might not be registered)
expect(matchingPlugins.length).toBeLessThanOrEqual(1);
});
});
});
});