feat: add n8n_audit_instance and n8n_manage_credentials tools (v2.47.0) (#702)

Add comprehensive security auditing combining n8n's built-in POST /audit
API with deep workflow scanning using 50+ regex patterns for hardcoded
secrets, unauthenticated webhook detection, error handling gap analysis,
data retention risk assessment, and PII detection.

The audit returns a compact markdown report grouped by workflow with a
Remediation Playbook showing auto-fixable items (with tool chains), items
requiring review, and items requiring user action.

Also adds n8n_manage_credentials tool (list/get/create/update/delete/getSchema)
enabling AI agents to create credentials and assign them to nodes as part
of security remediation.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2026-04-04 11:27:20 +02:00
committed by GitHub
parent 12d7d5bdb6
commit 796c427317
45 changed files with 3537 additions and 77 deletions

View File

@@ -0,0 +1,340 @@
import { describe, it, expect } from 'vitest';
import {
buildAuditReport,
type AuditReportInput,
type UnifiedAuditReport,
} from '@/services/audit-report-builder';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const DEFAULT_PERFORMANCE = {
builtinAuditMs: 100,
workflowFetchMs: 50,
customScanMs: 200,
totalMs: 350,
};
function makeInput(overrides: Partial<AuditReportInput> = {}): AuditReportInput {
return {
builtinAudit: [],
customReport: null,
performance: DEFAULT_PERFORMANCE,
instanceUrl: 'https://n8n.example.com',
...overrides,
};
}
function makeFinding(overrides: Record<string, unknown> = {}) {
return {
id: 'CRED-001',
severity: 'critical' as const,
category: 'hardcoded_secrets',
title: 'Hardcoded openai_key detected',
description: 'Found a hardcoded openai_key in node "HTTP Request".',
recommendation: 'Move this secret into n8n credentials.',
remediationType: 'auto_fixable' as const,
remediation: [
{
tool: 'n8n_manage_credentials',
args: { action: 'create' },
description: 'Create credential',
},
],
location: {
workflowId: 'wf-1',
workflowName: 'Test Workflow',
workflowActive: true,
nodeName: 'HTTP Request',
nodeType: 'n8n-nodes-base.httpRequest',
},
...overrides,
};
}
function makeCustomReport(findings: any[], workflowsScanned = 1) {
const summary = { critical: 0, high: 0, medium: 0, low: 0, total: findings.length };
for (const f of findings) {
summary[f.severity as keyof typeof summary]++;
}
return {
findings,
workflowsScanned,
scanDurationMs: 150,
summary,
};
}
// ===========================================================================
// Tests
// ===========================================================================
describe('audit-report-builder', () => {
describe('empty reports', () => {
it('should produce "No issues found" when built-in audit is empty and no custom findings', () => {
const input = makeInput({ builtinAudit: [], customReport: null });
const result = buildAuditReport(input);
expect(result.markdown).toContain('No issues found');
expect(result.summary.totalFindings).toBe(0);
expect(result.summary.critical).toBe(0);
expect(result.summary.high).toBe(0);
expect(result.summary.medium).toBe(0);
expect(result.summary.low).toBe(0);
});
it('should produce "No issues found" when built-in audit is null-like', () => {
const input = makeInput({ builtinAudit: null });
const result = buildAuditReport(input);
expect(result.markdown).toContain('No issues found');
});
});
describe('built-in audit rendering', () => {
it('should render built-in audit with Nodes Risk Report', () => {
// Real n8n API uses { risk: "nodes", sections: [...] } format
const builtinAudit = {
'Nodes Risk Report': {
risk: 'nodes',
sections: [
{
title: 'Insecure node detected',
description: 'Node X uses deprecated API',
recommendation: 'Update to latest version',
location: [{ id: 'node-1' }, { id: 'node-2' }],
},
],
},
};
const input = makeInput({ builtinAudit });
const result = buildAuditReport(input);
expect(result.markdown).toContain('Nodes Risk Report');
expect(result.markdown).toContain('Insecure node detected');
expect(result.markdown).toContain('deprecated API');
expect(result.markdown).toContain('Affected: 2 items');
// Built-in locations are counted as low severity
expect(result.summary.low).toBe(2);
expect(result.summary.totalFindings).toBe(2);
});
it('should render Instance Risk Report with version and settings info', () => {
const builtinAudit = {
'Instance Risk Report': {
risk: 'instance',
sections: [
{
title: 'Outdated instance',
description: 'Running an old version',
recommendation: 'Update n8n',
nextVersions: [{ name: '1.20.0' }, { name: '1.21.0' }],
settings: {
authenticationMethod: 'none',
publicApiDisabled: false,
},
},
],
},
};
const input = makeInput({ builtinAudit });
const result = buildAuditReport(input);
expect(result.markdown).toContain('Instance Risk Report');
expect(result.markdown).toContain('Available versions: 1.20.0, 1.21.0');
expect(result.markdown).toContain('authenticationMethod');
});
});
describe('grouped by workflow', () => {
it('should group findings by workflow with table format', () => {
const findings = [
makeFinding({ id: 'CRED-001', severity: 'critical', title: 'Critical issue' }),
makeFinding({ id: 'ERR-001', severity: 'medium', title: 'Medium issue', category: 'error_handling' }),
];
const input = makeInput({ customReport: makeCustomReport(findings) });
const result = buildAuditReport(input);
// Should have a workflow heading
expect(result.markdown).toContain('Test Workflow');
// Should have a table with findings
expect(result.markdown).toContain('| ID | Severity | Finding | Node | Fix |');
expect(result.markdown).toContain('CRED-001');
expect(result.markdown).toContain('ERR-001');
});
it('should sort findings within workflow by severity', () => {
const findings = [
makeFinding({ id: 'LOW-001', severity: 'low', title: 'Low issue' }),
makeFinding({ id: 'CRIT-001', severity: 'critical', title: 'Critical issue' }),
];
const input = makeInput({ customReport: makeCustomReport(findings) });
const result = buildAuditReport(input);
const critIdx = result.markdown.indexOf('CRIT-001');
const lowIdx = result.markdown.indexOf('LOW-001');
expect(critIdx).toBeLessThan(lowIdx);
});
it('should sort workflows by worst severity first', () => {
const findings = [
makeFinding({ id: 'LOW-001', severity: 'low', title: 'Low issue', location: { workflowId: 'wf-2', workflowName: 'Safe Workflow', nodeName: 'Set', nodeType: 'n8n-nodes-base.set' } }),
makeFinding({ id: 'CRIT-001', severity: 'critical', title: 'Critical issue', location: { workflowId: 'wf-1', workflowName: 'Danger Workflow', nodeName: 'HTTP', nodeType: 'n8n-nodes-base.httpRequest' } }),
];
const input = makeInput({ customReport: makeCustomReport(findings, 2) });
const result = buildAuditReport(input);
const dangerIdx = result.markdown.indexOf('Danger Workflow');
const safeIdx = result.markdown.indexOf('Safe Workflow');
expect(dangerIdx).toBeLessThan(safeIdx);
});
});
describe('remediation playbook', () => {
it('should show auto-fixable section for secrets and webhooks', () => {
const findings = [
makeFinding({ remediationType: 'auto_fixable', category: 'hardcoded_secrets' }),
makeFinding({ id: 'WEBHOOK-001', severity: 'medium', remediationType: 'auto_fixable', category: 'unauthenticated_webhooks', title: 'Unauthenticated webhook' }),
];
const input = makeInput({ customReport: makeCustomReport(findings) });
const result = buildAuditReport(input);
expect(result.markdown).toContain('Auto-fixable by agent');
expect(result.markdown).toContain('Hardcoded secrets');
expect(result.markdown).toContain('Unauthenticated webhooks');
expect(result.markdown).toContain('n8n_manage_credentials');
});
it('should show review section for error handling and PII', () => {
const findings = [
makeFinding({ id: 'ERR-001', severity: 'medium', remediationType: 'review_recommended', category: 'error_handling', title: 'No error handling' }),
makeFinding({ id: 'PII-001', severity: 'medium', remediationType: 'review_recommended', category: 'hardcoded_secrets', title: 'PII found' }),
];
const input = makeInput({ customReport: makeCustomReport(findings) });
const result = buildAuditReport(input);
expect(result.markdown).toContain('Requires review');
expect(result.markdown).toContain('Error handling gaps');
expect(result.markdown).toContain('PII in parameters');
});
it('should show user action section for data retention', () => {
const findings = [
makeFinding({ id: 'RET-001', severity: 'low', remediationType: 'user_action_needed', category: 'data_retention', title: 'Excessive retention' }),
];
const input = makeInput({ customReport: makeCustomReport(findings) });
const result = buildAuditReport(input);
expect(result.markdown).toContain('Requires your action');
expect(result.markdown).toContain('Data retention');
});
it('should surface built-in audit actionables in playbook', () => {
const builtinAudit = {
'Instance Risk Report': {
risk: 'instance',
sections: [
{ title: 'Outdated instance', description: 'Old version', recommendation: 'Update' },
],
},
'Nodes Risk Report': {
risk: 'nodes',
sections: [
{ title: 'Community nodes', description: 'Unvetted', recommendation: 'Review', location: [{ id: '1' }, { id: '2' }] },
],
},
};
const input = makeInput({ builtinAudit });
const result = buildAuditReport(input);
expect(result.markdown).toContain('Outdated instance');
expect(result.markdown).toContain('Community nodes');
});
});
describe('warnings', () => {
it('should include warnings in the report when provided', () => {
const input = makeInput({
warnings: [
'Could not fetch 2 workflows due to permissions',
'Built-in audit endpoint returned partial results',
],
});
const result = buildAuditReport(input);
expect(result.markdown).toContain('Could not fetch 2 workflows');
expect(result.markdown).toContain('partial results');
});
it('should not include warnings when none are provided', () => {
const input = makeInput({ warnings: undefined });
const result = buildAuditReport(input);
expect(result.markdown).not.toContain('Warning');
});
});
describe('performance timing', () => {
it('should include scan performance metrics in the report', () => {
const input = makeInput({
performance: {
builtinAuditMs: 120,
workflowFetchMs: 80,
customScanMs: 250,
totalMs: 450,
},
});
const result = buildAuditReport(input);
expect(result.markdown).toContain('120ms');
expect(result.markdown).toContain('80ms');
expect(result.markdown).toContain('250ms');
});
});
describe('summary counts', () => {
it('should aggregate counts across both built-in and custom sources', () => {
const builtinAudit = {
'Nodes Risk Report': {
risk: 'nodes',
sections: [
{
title: 'Issue',
description: 'Desc',
location: [{ id: '1' }, { id: '2' }, { id: '3' }],
},
],
},
};
const findings = [
makeFinding({ severity: 'critical' }),
makeFinding({ id: 'CRED-002', severity: 'high' }),
makeFinding({ id: 'CRED-003', severity: 'medium' }),
];
const input = makeInput({
builtinAudit,
customReport: makeCustomReport(findings, 5),
});
const result = buildAuditReport(input);
expect(result.summary.critical).toBe(1);
expect(result.summary.high).toBe(1);
expect(result.summary.medium).toBe(1);
// 3 built-in locations counted as low
expect(result.summary.low).toBe(3);
expect(result.summary.totalFindings).toBe(6);
expect(result.summary.workflowsScanned).toBe(5);
});
});
});

View File

@@ -0,0 +1,558 @@
import { describe, it, expect } from 'vitest';
import {
scanWorkflow,
maskSecret,
SECRET_PATTERNS,
PII_PATTERNS,
type ScanDetection,
} from '@/services/credential-scanner';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Minimal workflow wrapper for single-node tests. */
function makeWorkflow(
nodeParams: Record<string, unknown>,
opts?: {
nodeName?: string;
nodeType?: string;
workflowId?: string;
workflowName?: string;
pinData?: Record<string, unknown>;
staticData?: Record<string, unknown>;
settings?: Record<string, unknown>;
},
) {
return {
id: opts?.workflowId ?? 'wf-1',
name: opts?.workflowName ?? 'Test Workflow',
nodes: [
{
name: opts?.nodeName ?? 'HTTP Request',
type: opts?.nodeType ?? 'n8n-nodes-base.httpRequest',
parameters: nodeParams,
},
],
pinData: opts?.pinData,
staticData: opts?.staticData,
settings: opts?.settings,
};
}
/** Helper that returns the first detection label, or null. */
function firstLabel(detections: ScanDetection[]): string | null {
return detections.length > 0 ? detections[0].label : null;
}
// ===========================================================================
// Pattern matching — true positives
// ===========================================================================
describe('credential-scanner', () => {
describe('pattern matching — true positives', () => {
it('should detect OpenAI key (sk-proj- prefix)', () => {
const wf = makeWorkflow({ apiKey: 'sk-proj-abc123def456ghi789jkl0' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('openai_key');
});
it('should detect OpenAI key (sk- prefix without proj)', () => {
const wf = makeWorkflow({ apiKey: 'sk-abcdefghij1234567890abcdefghij' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('openai_key');
});
it('should detect AWS access key', () => {
const wf = makeWorkflow({ accessKeyId: 'AKIA1234567890ABCDEF' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('aws_key');
});
it('should detect GitHub PAT (ghp_ prefix)', () => {
const wf = makeWorkflow({ token: 'ghp_1234567890abcdefghijklmnopqrstuvwxyz' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('github_pat');
});
it('should detect Stripe secret key', () => {
const wf = makeWorkflow({ stripeKey: 'sk_live_1234567890abcdef12345' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('stripe_key');
});
it('should detect JWT token', () => {
const jwt =
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
const wf = makeWorkflow({ token: jwt });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('jwt_token');
});
it('should detect Slack bot token', () => {
const wf = makeWorkflow({ token: 'xoxb-1234567890-abcdefghij' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('slack_token');
});
it('should detect SendGrid API key', () => {
const key =
'SG.abcdefghijklmnopqrstuv.abcdefghijklmnopqrstuvwxyz0123456789abcdefg';
const wf = makeWorkflow({ apiKey: key });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('sendgrid_key');
});
it('should detect private key header', () => {
const wf = makeWorkflow({
privateKey: '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQ...',
});
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('private_key');
});
it('should detect Bearer token', () => {
const wf = makeWorkflow({
header: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9abcdef',
});
const detections = scanWorkflow(wf);
// Could match bearer_token or jwt_token; at minimum one detection exists
const labels = detections.map((d) => d.label);
expect(labels).toContain('bearer_token');
});
it('should detect URL with embedded credentials', () => {
const wf = makeWorkflow({
connectionString: 'postgres://admin:secret_password@db.example.com:5432/mydb',
});
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('url_with_auth');
});
it('should detect Anthropic key', () => {
const wf = makeWorkflow({ apiKey: 'sk-ant-abcdefghijklmnopqrstuvwxyz1234' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('anthropic_key');
});
it('should detect GitHub OAuth token (gho_ prefix)', () => {
const wf = makeWorkflow({ token: 'gho_1234567890abcdefghijklmnopqrstuvwxyz' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('github_oauth');
});
it('should detect Stripe restricted key (rk_live)', () => {
const wf = makeWorkflow({ stripeKey: 'rk_live_1234567890abcdef12345' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('stripe_key');
});
});
// ===========================================================================
// PII patterns — true positives
// ===========================================================================
describe('PII pattern matching — true positives', () => {
it('should detect email address', () => {
const wf = makeWorkflow({ recipient: 'john.doe@example.com' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('email');
});
it('should detect credit card number with spaces', () => {
const wf = makeWorkflow({ cardNumber: '4111 1111 1111 1111' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('credit_card');
});
it('should detect credit card number with dashes', () => {
const wf = makeWorkflow({ cardNumber: '4111-1111-1111-1111' });
const detections = scanWorkflow(wf);
expect(firstLabel(detections)).toBe('credit_card');
});
});
// ===========================================================================
// True negatives — strings that should NOT be detected
// ===========================================================================
describe('true negatives', () => {
it('should not flag a short string that looks like a key prefix', () => {
const wf = makeWorkflow({ key: 'sk-abc' });
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
it('should not flag normal URLs without embedded auth', () => {
const wf = makeWorkflow({ url: 'https://example.com/api/v1/path' });
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
it('should not flag a safe short string', () => {
const wf = makeWorkflow({ value: 'hello world, this is a normal string' });
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
it('should not flag strings shorter than 9 characters', () => {
// collectStrings skips strings with length <= 8
const wf = makeWorkflow({ key: '12345678' });
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
});
// ===========================================================================
// Expression skipping
// ===========================================================================
describe('expression skipping', () => {
it('should skip strings starting with = even if they contain a key pattern', () => {
const wf = makeWorkflow({
apiKey: '={{ $json.apiKey }}',
header: '={{ "sk-proj-" + $json.secret123456789 }}',
});
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
it('should skip strings starting with {{ even if they contain a key pattern', () => {
const wf = makeWorkflow({
token: '{{ $json.token }}',
auth: '{{ "Bearer " + $json.accessToken12345678 }}',
});
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
it('should skip mixed expression and literal if expression comes first', () => {
const wf = makeWorkflow({
mixed: '={{ "AKIA" + "1234567890ABCDEF" }}',
});
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
});
// ===========================================================================
// Field skipping
// ===========================================================================
describe('field skipping', () => {
it('should not scan values under the credentials key', () => {
const wf = makeWorkflow({
credentials: {
httpHeaderAuth: {
id: 'cred-123',
name: 'sk-proj-abc123def456ghi789jkl0',
},
},
url: 'https://api.example.com',
});
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
it('should not scan values under the expression key', () => {
const wf = makeWorkflow({
expression: 'sk-proj-abc123def456ghi789jkl0',
url: 'https://api.example.com',
});
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
it('should not scan values under the id key', () => {
const wf = makeWorkflow({
id: 'AKIA1234567890ABCDEF',
url: 'https://api.example.com',
});
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
});
// ===========================================================================
// Depth limit
// ===========================================================================
describe('depth limit', () => {
it('should stop traversing structures nested deeper than 10 levels', () => {
// Build a nested structure 12 levels deep with a secret at the bottom
let nested: Record<string, unknown> = {
secret: 'sk-proj-abc123def456ghi789jkl0',
};
for (let i = 0; i < 12; i++) {
nested = { level: nested };
}
const wf = makeWorkflow(nested);
const detections = scanWorkflow(wf);
// The secret is beyond depth 10, so it should not be found
expect(detections).toHaveLength(0);
});
it('should detect secrets at exactly depth 10', () => {
// Build a structure that puts the secret at depth 10 from the
// parameters level. collectStrings is called with depth=0 for
// node.parameters, so 10 nesting levels should still be traversed.
let nested: Record<string, unknown> = {
secret: 'sk-proj-abc123def456ghi789jkl0',
};
for (let i = 0; i < 9; i++) {
nested = { level: nested };
}
const wf = makeWorkflow(nested);
const detections = scanWorkflow(wf);
expect(detections.length).toBeGreaterThanOrEqual(1);
expect(firstLabel(detections)).toBe('openai_key');
});
});
// ===========================================================================
// maskSecret()
// ===========================================================================
describe('maskSecret()', () => {
it('should mask a long value showing first 6 and last 4 characters', () => {
const result = maskSecret('sk-proj-abc123def456ghi789jkl0');
expect(result).toBe('sk-pro****jkl0');
});
it('should mask a 14-character value with head and tail', () => {
// Exactly at boundary: 14 chars >= 14, so head+tail format
const result = maskSecret('abcdefghijklmn');
expect(result).toBe('abcdef****klmn');
});
it('should fully mask a value shorter than 14 characters', () => {
expect(maskSecret('1234567890')).toBe('****');
expect(maskSecret('short')).toBe('****');
expect(maskSecret('a')).toBe('****');
expect(maskSecret('abcdefghijk')).toBe('****'); // 11 chars
expect(maskSecret('abcdefghijklm')).toBe('****'); // 13 chars
});
it('should handle empty string', () => {
expect(maskSecret('')).toBe('****');
});
});
// ===========================================================================
// Full workflow scan — realistic workflow JSON
// ===========================================================================
describe('full workflow scan', () => {
it('should detect a hardcoded key in a realistic HTTP Request node', () => {
const workflow = {
id: 'wf-42',
name: 'Send Slack Message',
nodes: [
{
name: 'Webhook Trigger',
type: 'n8n-nodes-base.webhook',
parameters: {
path: '/incoming',
method: 'POST',
},
},
{
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {
url: 'https://api.openai.com/v1/chat/completions',
method: 'POST',
headers: {
values: [
{
name: 'Authorization',
value: 'Bearer sk-proj-RealKeyThatShouldNotBeHere1234567890',
},
],
},
body: {
json: {
model: 'gpt-4',
messages: [{ role: 'user', content: 'Hello' }],
},
},
},
},
{
name: 'Slack',
type: 'n8n-nodes-base.slack',
parameters: {
channel: '#general',
text: 'Response received',
},
},
],
};
const detections = scanWorkflow(workflow);
expect(detections.length).toBeGreaterThanOrEqual(1);
const openaiDetection = detections.find((d) => d.label === 'openai_key');
expect(openaiDetection).toBeDefined();
expect(openaiDetection!.location.workflowId).toBe('wf-42');
expect(openaiDetection!.location.workflowName).toBe('Send Slack Message');
expect(openaiDetection!.location.nodeName).toBe('HTTP Request');
expect(openaiDetection!.location.nodeType).toBe('n8n-nodes-base.httpRequest');
// maskedSnippet should not contain the full key
expect(openaiDetection!.maskedSnippet).toContain('****');
});
it('should return empty detections for a clean workflow', () => {
const workflow = {
id: 'wf-clean',
name: 'Clean Workflow',
nodes: [
{
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
parameters: {},
},
{
name: 'Set',
type: 'n8n-nodes-base.set',
parameters: {
values: {
string: [{ name: 'greeting', value: 'Hello World this is safe' }],
},
},
},
],
};
const detections = scanWorkflow(workflow);
expect(detections).toHaveLength(0);
});
});
// ===========================================================================
// pinData / staticData / settings scanning
// ===========================================================================
describe('pinData / staticData / settings scanning', () => {
it('should detect secrets embedded in pinData', () => {
const wf = makeWorkflow(
{ url: 'https://example.com' },
{
pinData: {
'HTTP Request': [
{ json: { apiKey: 'sk-proj-abc123def456ghi789jkl0' } },
],
},
},
);
const detections = scanWorkflow(wf);
const pinDetection = detections.find(
(d) => d.label === 'openai_key' && d.location.nodeName === undefined,
);
expect(pinDetection).toBeDefined();
});
it('should detect secrets embedded in staticData', () => {
const wf = makeWorkflow(
{ url: 'https://example.com' },
{
staticData: {
lastProcessed: {
token: 'ghp_1234567890abcdefghijklmnopqrstuvwxyz',
},
},
},
);
const detections = scanWorkflow(wf);
const staticDetection = detections.find(
(d) => d.label === 'github_pat' && d.location.nodeName === undefined,
);
expect(staticDetection).toBeDefined();
});
it('should detect secrets in workflow settings', () => {
const wf = makeWorkflow(
{ url: 'https://example.com' },
{
settings: {
webhookSecret: 'sk_live_1234567890abcdef12345',
},
},
);
const detections = scanWorkflow(wf);
const settingsDetection = detections.find(
(d) => d.label === 'stripe_key' && d.location.nodeName === undefined,
);
expect(settingsDetection).toBeDefined();
});
it('should not flag pinData / staticData / settings when they are empty', () => {
const wf = makeWorkflow(
{ url: 'https://example.com' },
{
pinData: {},
staticData: {},
settings: {},
},
);
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(0);
});
});
// ===========================================================================
// Detection metadata
// ===========================================================================
describe('detection metadata', () => {
it('should include category and severity on each detection', () => {
const wf = makeWorkflow({ key: 'AKIA1234567890ABCDEF' });
const detections = scanWorkflow(wf);
expect(detections).toHaveLength(1);
expect(detections[0].category).toBe('Cloud/DevOps');
expect(detections[0].severity).toBe('critical');
});
it('should set workflowId to empty string when id is missing', () => {
const wf = {
name: 'No ID Workflow',
nodes: [
{
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: { key: 'AKIA1234567890ABCDEF' },
},
],
};
const detections = scanWorkflow(wf);
expect(detections[0].location.workflowId).toBe('');
});
});
// ===========================================================================
// Pattern completeness sanity check
// ===========================================================================
describe('pattern definitions', () => {
it('should have at least 40 secret patterns defined', () => {
expect(SECRET_PATTERNS.length).toBeGreaterThanOrEqual(40);
});
it('should have PII patterns for email, phone, and credit card', () => {
const labels = PII_PATTERNS.map((p) => p.label);
expect(labels).toContain('email');
expect(labels).toContain('phone');
expect(labels).toContain('credit_card');
});
it('should have every pattern with a non-empty label and category', () => {
for (const p of [...SECRET_PATTERNS, ...PII_PATTERNS]) {
expect(p.label).toBeTruthy();
expect(p.category).toBeTruthy();
expect(['critical', 'high', 'medium']).toContain(p.severity);
}
});
});
});

View File

@@ -0,0 +1,487 @@
import { describe, it, expect } from 'vitest';
import {
scanWorkflows,
type WorkflowSecurityReport,
type AuditFinding,
type CustomCheckType,
} from '@/services/workflow-security-scanner';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeWorkflow(overrides: Record<string, unknown> = {}) {
return {
id: 'wf-1',
name: 'Test Workflow',
active: false,
nodes: [] as any[],
settings: {},
...overrides,
};
}
/** Shortcut to scan a single workflow and return its report. */
function scanOne(
workflow: Record<string, unknown>,
checks?: CustomCheckType[],
): WorkflowSecurityReport {
return scanWorkflows([workflow as any], checks);
}
/** Return findings for a given category. */
function findingsOf(report: WorkflowSecurityReport, category: CustomCheckType): AuditFinding[] {
return report.findings.filter((f) => f.category === category);
}
// ===========================================================================
// Check 1: Hardcoded secrets
// ===========================================================================
describe('workflow-security-scanner', () => {
describe('hardcoded secrets check', () => {
it('should detect a hardcoded secret in node parameters', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {
url: 'https://api.example.com',
headers: {
values: [{ name: 'Authorization', value: 'sk-proj-RealKey1234567890abcdef' }],
},
},
},
],
});
const report = scanOne(wf, ['hardcoded_secrets']);
const secrets = findingsOf(report, 'hardcoded_secrets');
expect(secrets.length).toBeGreaterThanOrEqual(1);
expect(secrets[0].title).toContain('openai_key');
expect(secrets[0].id).toMatch(/^CRED-\d{3}$/);
});
it('should mark PII detections as review_recommended, not auto_fixable', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Send Email',
type: 'n8n-nodes-base.httpRequest',
parameters: { body: { json: { to: 'john.doe@example.com' } } },
},
],
});
const report = scanOne(wf, ['hardcoded_secrets']);
const piiFindings = findingsOf(report, 'hardcoded_secrets').filter(
(f) => f.title.toLowerCase().includes('email'),
);
expect(piiFindings.length).toBeGreaterThanOrEqual(1);
expect(piiFindings[0].remediationType).toBe('review_recommended');
expect(piiFindings[0].remediation).toHaveLength(0);
});
it('should return no findings for a clean workflow', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Set',
type: 'n8n-nodes-base.set',
parameters: { values: { string: [{ name: 'greeting', value: 'hello world is safe' }] } },
},
],
});
const report = scanOne(wf, ['hardcoded_secrets']);
expect(findingsOf(report, 'hardcoded_secrets')).toHaveLength(0);
});
});
// ===========================================================================
// Check 2: Unauthenticated webhooks
// ===========================================================================
describe('unauthenticated webhooks check', () => {
it('should flag a webhook with authentication set to "none"', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook', authentication: 'none' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
const webhooks = findingsOf(report, 'unauthenticated_webhooks');
expect(webhooks).toHaveLength(1);
expect(webhooks[0].title).toContain('Webhook');
});
it('should flag a webhook with no authentication parameter at all', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
expect(findingsOf(report, 'unauthenticated_webhooks')).toHaveLength(1);
});
it('should NOT flag a webhook with headerAuth configured', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook', authentication: 'headerAuth' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
expect(findingsOf(report, 'unauthenticated_webhooks')).toHaveLength(0);
});
it('should NOT flag a webhook with basicAuth configured', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook', authentication: 'basicAuth' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
expect(findingsOf(report, 'unauthenticated_webhooks')).toHaveLength(0);
});
it('should assign severity high when the workflow is active', () => {
const wf = makeWorkflow({
active: true,
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook', authentication: 'none' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
const findings = findingsOf(report, 'unauthenticated_webhooks');
expect(findings[0].severity).toBe('high');
expect(findings[0].description).toContain('active');
});
it('should assign severity medium when the workflow is inactive', () => {
const wf = makeWorkflow({
active: false,
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook', authentication: 'none' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
expect(findingsOf(report, 'unauthenticated_webhooks')[0].severity).toBe('medium');
});
it('should NOT flag respondToWebhook nodes (they are response helpers, not triggers)', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Respond to Webhook',
type: 'n8n-nodes-base.respondToWebhook',
parameters: { respondWith: 'text', responseBody: 'OK' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
expect(findingsOf(report, 'unauthenticated_webhooks')).toHaveLength(0);
});
it('should also detect formTrigger nodes', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Form Trigger',
type: 'n8n-nodes-base.formTrigger',
parameters: { path: '/form' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
expect(findingsOf(report, 'unauthenticated_webhooks')).toHaveLength(1);
});
it('should include remediation steps with auto_fixable type', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook' },
},
],
});
const report = scanOne(wf, ['unauthenticated_webhooks']);
const finding = findingsOf(report, 'unauthenticated_webhooks')[0];
expect(finding.remediationType).toBe('auto_fixable');
expect(finding.remediation).toBeDefined();
expect(finding.remediation!.length).toBeGreaterThanOrEqual(1);
});
});
// ===========================================================================
// Check 3: Error handling gaps
// ===========================================================================
describe('error handling gaps check', () => {
it('should flag a workflow with 3+ nodes and no error handling', () => {
const wf = makeWorkflow({
nodes: [
{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} },
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {} },
{ name: 'Step 2', type: 'n8n-nodes-base.httpRequest', parameters: {} },
],
});
const report = scanOne(wf, ['error_handling']);
const findings = findingsOf(report, 'error_handling');
expect(findings).toHaveLength(1);
expect(findings[0].id).toBe('ERR-001');
expect(findings[0].severity).toBe('medium');
});
it('should NOT flag a workflow with continueOnFail enabled', () => {
const wf = makeWorkflow({
nodes: [
{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} },
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {}, continueOnFail: true },
{ name: 'Step 2', type: 'n8n-nodes-base.httpRequest', parameters: {} },
],
});
const report = scanOne(wf, ['error_handling']);
expect(findingsOf(report, 'error_handling')).toHaveLength(0);
});
it('should NOT flag a workflow with onError set to continueErrorOutput', () => {
const wf = makeWorkflow({
nodes: [
{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} },
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {}, onError: 'continueErrorOutput' },
{ name: 'Step 2', type: 'n8n-nodes-base.httpRequest', parameters: {} },
],
});
const report = scanOne(wf, ['error_handling']);
expect(findingsOf(report, 'error_handling')).toHaveLength(0);
});
it('should NOT flag a workflow with an errorTrigger node', () => {
const wf = makeWorkflow({
nodes: [
{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} },
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {} },
{ name: 'Error Handler', type: 'n8n-nodes-base.errorTrigger', parameters: {} },
],
});
const report = scanOne(wf, ['error_handling']);
expect(findingsOf(report, 'error_handling')).toHaveLength(0);
});
it('should NOT flag a workflow with fewer than 3 nodes', () => {
const wf = makeWorkflow({
nodes: [
{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} },
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {} },
],
});
const report = scanOne(wf, ['error_handling']);
expect(findingsOf(report, 'error_handling')).toHaveLength(0);
});
it('should NOT flag onError=stopWorkflow as valid error handling', () => {
// stopWorkflow is the default and does NOT count as custom error handling
const wf = makeWorkflow({
nodes: [
{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} },
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {}, onError: 'stopWorkflow' },
{ name: 'Step 2', type: 'n8n-nodes-base.httpRequest', parameters: {} },
],
});
const report = scanOne(wf, ['error_handling']);
expect(findingsOf(report, 'error_handling')).toHaveLength(1);
});
});
// ===========================================================================
// Check 4: Data retention settings
// ===========================================================================
describe('data retention settings check', () => {
it('should flag when both save settings are set to all', () => {
const wf = makeWorkflow({
nodes: [{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} }],
settings: {
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
},
});
const report = scanOne(wf, ['data_retention']);
const findings = findingsOf(report, 'data_retention');
expect(findings).toHaveLength(1);
expect(findings[0].id).toBe('RETENTION-001');
expect(findings[0].severity).toBe('low');
});
it('should NOT flag when only error execution is set to all', () => {
const wf = makeWorkflow({
nodes: [{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} }],
settings: {
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'none',
},
});
const report = scanOne(wf, ['data_retention']);
expect(findingsOf(report, 'data_retention')).toHaveLength(0);
});
it('should NOT flag when only success execution is set to all', () => {
const wf = makeWorkflow({
nodes: [{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} }],
settings: {
saveDataErrorExecution: 'none',
saveDataSuccessExecution: 'all',
},
});
const report = scanOne(wf, ['data_retention']);
expect(findingsOf(report, 'data_retention')).toHaveLength(0);
});
it('should NOT flag when no settings are present', () => {
const wf = makeWorkflow({
nodes: [{ name: 'Trigger', type: 'n8n-nodes-base.manualTrigger', parameters: {} }],
});
const report = scanOne(wf, ['data_retention']);
expect(findingsOf(report, 'data_retention')).toHaveLength(0);
});
});
// ===========================================================================
// Selective checks (customChecks filter)
// ===========================================================================
describe('selective checks', () => {
it('should only run the requested checks', () => {
const wf = makeWorkflow({
active: true,
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook', authentication: 'none' },
},
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {} },
{
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {
headers: { values: [{ name: 'Auth', value: 'sk-proj-RealKey1234567890abcdef' }] },
},
},
],
settings: { saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all' },
});
// Only run webhook check
const report = scanOne(wf, ['unauthenticated_webhooks']);
const categories = new Set(report.findings.map((f) => f.category));
expect(categories.has('unauthenticated_webhooks')).toBe(true);
expect(categories.has('hardcoded_secrets')).toBe(false);
expect(categories.has('error_handling')).toBe(false);
expect(categories.has('data_retention')).toBe(false);
});
it('should run all checks when no filter is provided', () => {
const wf = makeWorkflow({
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook' },
},
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {} },
{
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {
headers: { values: [{ name: 'Auth', value: 'sk-proj-RealKey1234567890abcdef' }] },
},
},
],
settings: { saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all' },
});
const report = scanWorkflows([wf as any]);
const categories = new Set(report.findings.map((f) => f.category));
// Should have findings from at least webhook and secrets checks
expect(categories.has('unauthenticated_webhooks')).toBe(true);
expect(categories.has('hardcoded_secrets')).toBe(true);
});
});
// ===========================================================================
// Summary counts
// ===========================================================================
describe('summary counts', () => {
it('should correctly aggregate severity counts', () => {
const wf = makeWorkflow({
active: true,
nodes: [
{
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: { path: '/hook', authentication: 'none' },
},
{ name: 'Step 1', type: 'n8n-nodes-base.set', parameters: {} },
{
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {
headers: { values: [{ name: 'Auth', value: 'sk-proj-RealKey1234567890abcdef' }] },
},
},
],
settings: { saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all' },
});
const report = scanOne(wf);
expect(report.summary.total).toBe(report.findings.length);
expect(
report.summary.critical +
report.summary.high +
report.summary.medium +
report.summary.low,
).toBe(report.summary.total);
});
it('should report correct workflowsScanned count', () => {
const wf1 = makeWorkflow({ id: 'wf-1', name: 'WF1', nodes: [] });
const wf2 = makeWorkflow({ id: 'wf-2', name: 'WF2', nodes: [] });
const report = scanWorkflows([wf1, wf2] as any[]);
expect(report.workflowsScanned).toBe(2);
});
it('should track scan duration in milliseconds', () => {
const wf = makeWorkflow({ nodes: [] });
const report = scanOne(wf);
expect(report.scanDurationMs).toBeGreaterThanOrEqual(0);
});
});
});