mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-04-07 01:53:08 +00:00
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:
committed by
GitHub
parent
12d7d5bdb6
commit
796c427317
340
tests/unit/services/audit-report-builder.test.ts
Normal file
340
tests/unit/services/audit-report-builder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
558
tests/unit/services/credential-scanner.test.ts
Normal file
558
tests/unit/services/credential-scanner.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
487
tests/unit/services/workflow-security-scanner.test.ts
Normal file
487
tests/unit/services/workflow-security-scanner.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user