mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-04-04 16:43:11 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1750fb4acf | ||
|
|
796c427317 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.47.1] - 2026-04-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Credential get fallback** — `n8n_manage_credentials({action: "get"})` now falls back to list + filter when `GET /credentials/:id` returns 403 Forbidden or 405 Method Not Allowed, since this endpoint is not in the n8n public API
|
||||
- **Credential update accepts `type` field** — `n8n_manage_credentials({action: "update"})` now forwards the optional `type` field to the n8n API, which some n8n versions require in the PATCH payload
|
||||
- **Credential response stripping** — `create` and `update` handlers now strip the `data` field from responses (defense-in-depth, matching the `get` handler pattern)
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.47.0] - 2026-04-04
|
||||
|
||||
### Added
|
||||
|
||||
- **`n8n_audit_instance` tool** — Security audit combining n8n's built-in `POST /audit` API (5 risk categories: credentials, database, nodes, instance, filesystem) with deep workflow scanning. Custom checks include 50+ regex patterns for hardcoded secrets (OpenAI, AWS, Stripe, GitHub, Slack, SendGrid, and more), unauthenticated webhook detection, error handling gap analysis, data retention risk assessment, and PII detection. Returns a compact markdown report grouped by workflow with a Remediation Playbook showing auto-fixable items, items requiring review, and items requiring user action. Inspired by [Audit n8n Workflows Security](https://wotai.co/blog/audit-n8n-workflows-security)
|
||||
- **`n8n_manage_credentials` tool** — Full credential CRUD with schema discovery. Actions: list, get, create, update, delete, getSchema. Enables AI agents to create credentials and assign them to workflow nodes as part of security remediation. Credential secret values are never logged or returned in responses (defense-in-depth)
|
||||
- **Credential scanner service** (`src/services/credential-scanner.ts`) — 50+ regex patterns ported from the production cache ingestion pipeline, covering AI/ML keys, cloud/DevOps tokens, GitHub PATs, payment keys, email/marketing APIs, and more. Per-node scanning with masked output
|
||||
- **Workflow security scanner** (`src/services/workflow-security-scanner.ts`) — 4 configurable checks: hardcoded secrets, unauthenticated webhooks (excludes respondToWebhook), error handling gaps (3+ node threshold), data retention settings
|
||||
- **Audit report builder** (`src/services/audit-report-builder.ts`) — Generates compact grouped-by-workflow markdown with tables, built-in audit rendering, and a Remediation Playbook with tool chains for auto-fixing
|
||||
|
||||
### Changed
|
||||
|
||||
- **CLAUDE.md** — Removed Session Persistence section (no longer needed), added OSS sensitivity notice to prevent secrets from landing in committed files
|
||||
- **API client request interceptor** — Now redacts request body for `/credentials` endpoints to prevent secret leakage in debug logs
|
||||
- **Credential handler responses** — All credential handlers (get, create, update) strip the `data` field from responses as defense-in-depth against future n8n versions returning decrypted values
|
||||
|
||||
### Security
|
||||
|
||||
- **Secret masking at scan time** — `maskSecret()` is called immediately during scanning; raw values are never stored in detection results
|
||||
- **Credential body redaction** — API client interceptor suppresses body logging for credential endpoints
|
||||
- **Cursor dedup guard** — `listAllWorkflows()` tracks seen cursors to prevent infinite pagination loops
|
||||
- **PII findings classified as review** — PII detections (email, phone, credit card) are marked as `review_recommended` instead of `auto_fixable`, preventing nonsensical auto-remediation
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.46.1] - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -2,6 +2,8 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> **Note:** This file is committed to a public OSS repository. Never add sensitive information (API keys, internal URLs, credentials, private infrastructure details) here.
|
||||
|
||||
## Project Overview
|
||||
|
||||
n8n-mcp is a comprehensive documentation and knowledge server that provides AI assistants with complete access to n8n node information through the Model Context Protocol (MCP). It serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively.
|
||||
@@ -195,35 +197,6 @@ The MCP server exposes tools in several categories:
|
||||
### Development Best Practices
|
||||
- Run typecheck and lint after every code change
|
||||
|
||||
### Session Persistence Feature (v2.24.1)
|
||||
|
||||
**Location:**
|
||||
- Types: `src/types/session-state.ts`
|
||||
- Implementation: `src/http-server-single-session.ts` (lines 698-702, 1444-1584)
|
||||
- Wrapper: `src/mcp-engine.ts` (lines 123-169)
|
||||
- Tests: `tests/unit/http-server/session-persistence.test.ts`, `tests/unit/mcp-engine/session-persistence.test.ts`
|
||||
|
||||
**Key Features:**
|
||||
- **Export/Restore API**: `exportSessionState()` and `restoreSessionState()` methods
|
||||
- **Multi-tenant support**: Enables zero-downtime deployments for SaaS platforms
|
||||
- **Security-first**: API keys exported as plaintext - downstream MUST encrypt
|
||||
- **Dormant sessions**: Restored sessions recreate transports on first request
|
||||
- **Automatic expiration**: Respects `sessionTimeout` setting (default 30 min)
|
||||
- **MAX_SESSIONS limit**: Caps at 100 concurrent sessions (configurable via N8N_MCP_MAX_SESSIONS env var)
|
||||
|
||||
**Important Implementation Notes:**
|
||||
- Only exports sessions with valid n8nApiUrl and n8nApiKey in context
|
||||
- Skips expired sessions during both export and restore
|
||||
- Uses `validateInstanceContext()` for data integrity checks
|
||||
- Handles null/invalid session gracefully with warnings
|
||||
- Session metadata (timestamps) and context (credentials) are persisted
|
||||
- Transport and server objects are NOT persisted (recreated on-demand)
|
||||
|
||||
**Testing:**
|
||||
- 22 unit tests covering export, restore, edge cases, and round-trip cycles
|
||||
- Tests use current timestamps to avoid expiration issues
|
||||
- Integration with multi-tenant backends documented in README.md
|
||||
|
||||
# important-instruction-reminders
|
||||
Do what has been asked; nothing more, nothing less.
|
||||
NEVER create files unless they're absolutely necessary for achieving your goal.
|
||||
|
||||
@@ -987,6 +987,12 @@ These tools require `N8N_API_URL` and `N8N_API_KEY` in your configuration.
|
||||
- `action: 'get'` - Get execution details by ID
|
||||
- `action: 'delete'` - Delete execution records
|
||||
|
||||
#### Credential Management
|
||||
- **`n8n_manage_credentials`** - Manage n8n credentials (list, get, create, update, delete, getSchema)
|
||||
|
||||
#### Security & Audit
|
||||
- **`n8n_audit_instance`** - Security audit combining n8n's built-in audit API with deep workflow scanning (50+ secret patterns, webhook auth, error handling, data retention). Returns actionable remediation playbook.
|
||||
|
||||
#### System Tools
|
||||
- **`n8n_health_check`** - Check n8n API connectivity and features
|
||||
|
||||
|
||||
7
dist/mcp/handlers-n8n-manager.d.ts
vendored
7
dist/mcp/handlers-n8n-manager.d.ts
vendored
@@ -37,4 +37,11 @@ export declare function handleInsertRows(args: unknown, context?: InstanceContex
|
||||
export declare function handleUpdateRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleUpsertRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleDeleteRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleListCredentials(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleGetCredential(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleCreateCredential(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleUpdateCredential(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleDeleteCredential(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleGetCredentialSchema(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
export declare function handleAuditInstance(args: unknown, context?: InstanceContext): Promise<McpToolResponse>;
|
||||
//# sourceMappingURL=handlers-n8n-manager.d.ts.map
|
||||
2
dist/mcp/handlers-n8n-manager.d.ts.map
vendored
2
dist/mcp/handlers-n8n-manager.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"handlers-n8n-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/handlers-n8n-manager.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"handlers-n8n-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/handlers-n8n-manager.ts"],"names":[],"mappings":"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"}
|
||||
244
dist/mcp/handlers-n8n-manager.js
vendored
244
dist/mcp/handlers-n8n-manager.js
vendored
@@ -67,7 +67,16 @@ exports.handleInsertRows = handleInsertRows;
|
||||
exports.handleUpdateRows = handleUpdateRows;
|
||||
exports.handleUpsertRows = handleUpsertRows;
|
||||
exports.handleDeleteRows = handleDeleteRows;
|
||||
exports.handleListCredentials = handleListCredentials;
|
||||
exports.handleGetCredential = handleGetCredential;
|
||||
exports.handleCreateCredential = handleCreateCredential;
|
||||
exports.handleUpdateCredential = handleUpdateCredential;
|
||||
exports.handleDeleteCredential = handleDeleteCredential;
|
||||
exports.handleGetCredentialSchema = handleGetCredentialSchema;
|
||||
exports.handleAuditInstance = handleAuditInstance;
|
||||
const n8n_api_client_1 = require("../services/n8n-api-client");
|
||||
const workflow_security_scanner_1 = require("../services/workflow-security-scanner");
|
||||
const audit_report_builder_1 = require("../services/audit-report-builder");
|
||||
const n8n_api_1 = require("../config/n8n-api");
|
||||
const n8n_api_2 = require("../types/n8n-api");
|
||||
const n8n_validation_1 = require("../services/n8n-validation");
|
||||
@@ -2129,7 +2138,7 @@ const deleteRowsSchema = tableIdSchema.extend({
|
||||
returnData: zod_1.z.boolean().optional(),
|
||||
dryRun: zod_1.z.boolean().optional(),
|
||||
});
|
||||
function handleDataTableError(error) {
|
||||
function handleCrudError(error) {
|
||||
if (error instanceof zod_1.z.ZodError) {
|
||||
return { success: false, error: 'Invalid input', details: { errors: error.errors } };
|
||||
}
|
||||
@@ -2158,7 +2167,7 @@ async function handleCreateTable(args, context) {
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleListTables(args, context) {
|
||||
@@ -2176,7 +2185,7 @@ async function handleListTables(args, context) {
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleGetTable(args, context) {
|
||||
@@ -2187,7 +2196,7 @@ async function handleGetTable(args, context) {
|
||||
return { success: true, data: dataTable };
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleUpdateTable(args, context) {
|
||||
@@ -2205,7 +2214,7 @@ async function handleUpdateTable(args, context) {
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleDeleteTable(args, context) {
|
||||
@@ -2216,7 +2225,7 @@ async function handleDeleteTable(args, context) {
|
||||
return { success: true, message: `Data table ${tableId} deleted successfully` };
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleGetRows(args, context) {
|
||||
@@ -2241,7 +2250,7 @@ async function handleGetRows(args, context) {
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleInsertRows(args, context) {
|
||||
@@ -2256,7 +2265,7 @@ async function handleInsertRows(args, context) {
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleUpdateRows(args, context) {
|
||||
@@ -2271,7 +2280,7 @@ async function handleUpdateRows(args, context) {
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleUpsertRows(args, context) {
|
||||
@@ -2286,7 +2295,7 @@ async function handleUpsertRows(args, context) {
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleDeleteRows(args, context) {
|
||||
@@ -2305,7 +2314,220 @@ async function handleDeleteRows(args, context) {
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
const listCredentialsSchema = zod_1.z.object({}).passthrough();
|
||||
const getCredentialSchema = zod_1.z.object({
|
||||
id: zod_1.z.string({ required_error: 'Credential ID is required' }),
|
||||
});
|
||||
const createCredentialSchema = zod_1.z.object({
|
||||
name: zod_1.z.string({ required_error: 'Credential name is required' }),
|
||||
type: zod_1.z.string({ required_error: 'Credential type is required' }),
|
||||
data: zod_1.z.record(zod_1.z.any(), { required_error: 'Credential data is required' }),
|
||||
});
|
||||
const updateCredentialSchema = zod_1.z.object({
|
||||
id: zod_1.z.string({ required_error: 'Credential ID is required' }),
|
||||
name: zod_1.z.string().optional(),
|
||||
data: zod_1.z.record(zod_1.z.any()).optional(),
|
||||
});
|
||||
const deleteCredentialSchema = zod_1.z.object({
|
||||
id: zod_1.z.string({ required_error: 'Credential ID is required' }),
|
||||
});
|
||||
const getCredentialSchemaTypeSchema = zod_1.z.object({
|
||||
type: zod_1.z.string({ required_error: 'Credential type is required' }),
|
||||
});
|
||||
async function handleListCredentials(args, context) {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
listCredentialsSchema.parse(args);
|
||||
const result = await client.listCredentials();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
credentials: result.data,
|
||||
count: result.data.length,
|
||||
nextCursor: result.nextCursor || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleGetCredential(args, context) {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = getCredentialSchema.parse(args);
|
||||
const credential = await client.getCredential(id);
|
||||
const { data: _sensitiveData, ...safeCred } = credential;
|
||||
return {
|
||||
success: true,
|
||||
data: safeCred,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleCreateCredential(args, context) {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { name, type, data } = createCredentialSchema.parse(args);
|
||||
logger_1.logger.info(`Creating credential: name="${name}", type="${type}"`);
|
||||
const credential = await client.createCredential({ name, type, data });
|
||||
const { data: _sensitiveData, ...safeCred } = credential;
|
||||
return {
|
||||
success: true,
|
||||
data: safeCred,
|
||||
message: `Credential "${name}" (type: ${type}) created with ID ${credential.id}`,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleUpdateCredential(args, context) {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id, name, data } = updateCredentialSchema.parse(args);
|
||||
logger_1.logger.info(`Updating credential: id="${id}"${name ? `, name="${name}"` : ''}`);
|
||||
const updatePayload = {};
|
||||
if (name !== undefined)
|
||||
updatePayload.name = name;
|
||||
if (data !== undefined)
|
||||
updatePayload.data = data;
|
||||
const credential = await client.updateCredential(id, updatePayload);
|
||||
const { data: _sensitiveData, ...safeCred } = credential;
|
||||
return {
|
||||
success: true,
|
||||
data: safeCred,
|
||||
message: `Credential ${id} updated successfully`,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleDeleteCredential(args, context) {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = deleteCredentialSchema.parse(args);
|
||||
logger_1.logger.info(`Deleting credential: id="${id}"`);
|
||||
await client.deleteCredential(id);
|
||||
return {
|
||||
success: true,
|
||||
message: `Credential ${id} deleted successfully`,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
async function handleGetCredentialSchema(args, context) {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { type } = getCredentialSchemaTypeSchema.parse(args);
|
||||
const schema = await client.getCredentialSchema(type);
|
||||
return {
|
||||
success: true,
|
||||
data: schema,
|
||||
message: `Schema for credential type "${type}"`,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
const auditInstanceSchema = zod_1.z.object({
|
||||
categories: zod_1.z.array(zod_1.z.enum([
|
||||
'credentials', 'database', 'nodes', 'instance', 'filesystem',
|
||||
])).optional(),
|
||||
includeCustomScan: zod_1.z.boolean().optional().default(true),
|
||||
daysAbandonedWorkflow: zod_1.z.number().optional(),
|
||||
customChecks: zod_1.z.array(zod_1.z.enum([
|
||||
'hardcoded_secrets', 'unauthenticated_webhooks', 'error_handling', 'data_retention',
|
||||
])).optional(),
|
||||
});
|
||||
async function handleAuditInstance(args, context) {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = auditInstanceSchema.parse(args);
|
||||
const totalStart = Date.now();
|
||||
const warnings = [];
|
||||
let builtinAudit = null;
|
||||
let builtinAuditMs = 0;
|
||||
try {
|
||||
const auditStart = Date.now();
|
||||
builtinAudit = await client.generateAudit({
|
||||
categories: input.categories,
|
||||
daysAbandonedWorkflow: input.daysAbandonedWorkflow,
|
||||
});
|
||||
builtinAuditMs = Date.now() - auditStart;
|
||||
}
|
||||
catch (auditError) {
|
||||
builtinAuditMs = Date.now() - totalStart;
|
||||
const msg = auditError?.statusCode === 404
|
||||
? 'Built-in audit endpoint not available on this n8n version.'
|
||||
: `Built-in audit failed: ${auditError?.message || 'unknown error'}`;
|
||||
warnings.push(msg);
|
||||
logger_1.logger.warn(`Audit: ${msg}`);
|
||||
}
|
||||
let customReport = null;
|
||||
let workflowFetchMs = 0;
|
||||
let customScanMs = 0;
|
||||
if (input.includeCustomScan) {
|
||||
try {
|
||||
const fetchStart = Date.now();
|
||||
const allWorkflows = await client.listAllWorkflows();
|
||||
workflowFetchMs = Date.now() - fetchStart;
|
||||
logger_1.logger.info(`Audit: fetched ${allWorkflows.length} workflows for scanning`);
|
||||
const scanStart = Date.now();
|
||||
customReport = (0, workflow_security_scanner_1.scanWorkflows)(allWorkflows, input.customChecks);
|
||||
customScanMs = Date.now() - scanStart;
|
||||
logger_1.logger.info(`Audit: custom scan found ${customReport.summary.total} findings across ${customReport.workflowsScanned} workflows`);
|
||||
}
|
||||
catch (scanError) {
|
||||
warnings.push(`Custom scan failed: ${scanError?.message || 'unknown error'}`);
|
||||
logger_1.logger.warn(`Audit: custom scan failed: ${scanError?.message}`);
|
||||
}
|
||||
}
|
||||
const totalMs = Date.now() - totalStart;
|
||||
const apiConfig = context?.n8nApiUrl
|
||||
? { baseUrl: context.n8nApiUrl }
|
||||
: (0, n8n_api_1.getN8nApiConfig)();
|
||||
const instanceUrl = apiConfig?.baseUrl || 'unknown';
|
||||
const report = (0, audit_report_builder_1.buildAuditReport)({
|
||||
builtinAudit,
|
||||
customReport,
|
||||
performance: { builtinAuditMs, workflowFetchMs, customScanMs, totalMs },
|
||||
instanceUrl,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
report: report.markdown,
|
||||
summary: report.summary,
|
||||
},
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof zod_1.z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid audit parameters',
|
||||
details: { issues: error.errors },
|
||||
};
|
||||
}
|
||||
if (error instanceof n8n_errors_1.N8nApiError) {
|
||||
return {
|
||||
success: false,
|
||||
error: (0, n8n_errors_1.getUserFriendlyErrorMessage)(error),
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=handlers-n8n-manager.js.map
|
||||
2
dist/mcp/handlers-n8n-manager.js.map
vendored
2
dist/mcp/handlers-n8n-manager.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/mcp/server.d.ts.map
vendored
2
dist/mcp/server.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"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"}
|
||||
24
dist/mcp/server.js
vendored
24
dist/mcp/server.js
vendored
@@ -730,6 +730,14 @@ class N8NDocumentationMCPServer {
|
||||
? { valid: true, errors: [] }
|
||||
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
|
||||
break;
|
||||
case 'n8n_manage_credentials':
|
||||
validationResult = args.action
|
||||
? { valid: true, errors: [] }
|
||||
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
|
||||
break;
|
||||
case 'n8n_audit_instance':
|
||||
validationResult = { valid: true, errors: [] };
|
||||
break;
|
||||
case 'n8n_deploy_template':
|
||||
validationResult = args.templateId !== undefined
|
||||
? { valid: true, errors: [] }
|
||||
@@ -1140,6 +1148,22 @@ class N8NDocumentationMCPServer {
|
||||
throw new Error(`Unknown action: ${dtAction}. Valid actions: createTable, listTables, getTable, updateTable, deleteTable, getRows, insertRows, updateRows, upsertRows, deleteRows`);
|
||||
}
|
||||
}
|
||||
case 'n8n_manage_credentials': {
|
||||
this.validateToolParams(name, args, ['action']);
|
||||
const credAction = args.action;
|
||||
switch (credAction) {
|
||||
case 'list': return n8nHandlers.handleListCredentials(args, this.instanceContext);
|
||||
case 'get': return n8nHandlers.handleGetCredential(args, this.instanceContext);
|
||||
case 'create': return n8nHandlers.handleCreateCredential(args, this.instanceContext);
|
||||
case 'update': return n8nHandlers.handleUpdateCredential(args, this.instanceContext);
|
||||
case 'delete': return n8nHandlers.handleDeleteCredential(args, this.instanceContext);
|
||||
case 'getSchema': return n8nHandlers.handleGetCredentialSchema(args, this.instanceContext);
|
||||
default:
|
||||
throw new Error(`Unknown action: ${credAction}. Valid actions: list, get, create, update, delete, getSchema`);
|
||||
}
|
||||
}
|
||||
case 'n8n_audit_instance':
|
||||
return n8nHandlers.handleAuditInstance(args, this.instanceContext);
|
||||
case 'n8n_generate_workflow': {
|
||||
this.validateToolParams(name, args, ['description']);
|
||||
if (this.generateWorkflowHandler && this.instanceContext) {
|
||||
|
||||
2
dist/mcp/server.js.map
vendored
2
dist/mcp/server.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/mcp/tool-docs/index.d.ts.map
vendored
2
dist/mcp/tool-docs/index.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/mcp/tool-docs/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AA8B5C,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAqChE,CAAC;AAGF,YAAY,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/mcp/tool-docs/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAgC5C,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAuChE,CAAC;AAGF,YAAY,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC"}
|
||||
4
dist/mcp/tool-docs/index.js
vendored
4
dist/mcp/tool-docs/index.js
vendored
@@ -11,6 +11,7 @@ const workflow_management_1 = require("./workflow_management");
|
||||
exports.toolsDocumentation = {
|
||||
tools_documentation: system_1.toolsDocumentationDoc,
|
||||
n8n_health_check: system_1.n8nHealthCheckDoc,
|
||||
n8n_audit_instance: system_1.n8nAuditInstanceDoc,
|
||||
ai_agents_guide: guides_1.aiAgentsGuide,
|
||||
search_nodes: discovery_1.searchNodesDoc,
|
||||
get_node: configuration_1.getNodeDoc,
|
||||
@@ -31,6 +32,7 @@ exports.toolsDocumentation = {
|
||||
n8n_workflow_versions: workflow_management_1.n8nWorkflowVersionsDoc,
|
||||
n8n_deploy_template: workflow_management_1.n8nDeployTemplateDoc,
|
||||
n8n_manage_datatable: workflow_management_1.n8nManageDatatableDoc,
|
||||
n8n_generate_workflow: workflow_management_1.n8nGenerateWorkflowDoc
|
||||
n8n_generate_workflow: workflow_management_1.n8nGenerateWorkflowDoc,
|
||||
n8n_manage_credentials: workflow_management_1.n8nManageCredentialsDoc
|
||||
};
|
||||
//# sourceMappingURL=index.js.map
|
||||
2
dist/mcp/tool-docs/index.js.map
vendored
2
dist/mcp/tool-docs/index.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/mcp/tool-docs/index.ts"],"names":[],"mappings":";;;AAGA,2CAA6C;AAC7C,mDAA6C;AAC7C,6CAAoE;AACpE,2CAAiE;AACjE,qCAGkB;AAClB,qCAAyC;AACzC,+DAe+B;AAGlB,QAAA,kBAAkB,GAAsC;IAEnE,mBAAmB,EAAE,8BAAqB;IAC1C,gBAAgB,EAAE,0BAAiB;IAGnC,eAAe,EAAE,sBAAa;IAG9B,YAAY,EAAE,0BAAc;IAG5B,QAAQ,EAAE,0BAAU;IAGpB,aAAa,EAAE,4BAAe;IAC9B,iBAAiB,EAAE,gCAAmB;IAGtC,YAAY,EAAE,0BAAc;IAC5B,gBAAgB,EAAE,8BAAkB;IAGpC,mBAAmB,EAAE,0CAAoB;IACzC,gBAAgB,EAAE,uCAAiB;IACnC,wBAAwB,EAAE,8CAAwB;IAClD,2BAA2B,EAAE,iDAA2B;IACxD,mBAAmB,EAAE,0CAAoB;IACzC,kBAAkB,EAAE,yCAAmB;IACvC,qBAAqB,EAAE,4CAAsB;IAC7C,oBAAoB,EAAE,2CAAqB;IAC3C,iBAAiB,EAAE,wCAAkB;IACrC,cAAc,EAAE,sCAAgB;IAChC,qBAAqB,EAAE,4CAAsB;IAC7C,mBAAmB,EAAE,0CAAoB;IACzC,oBAAoB,EAAE,2CAAqB;IAC3C,qBAAqB,EAAE,4CAAsB;CAC9C,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/mcp/tool-docs/index.ts"],"names":[],"mappings":";;;AAGA,2CAA6C;AAC7C,mDAA6C;AAC7C,6CAAoE;AACpE,2CAAiE;AACjE,qCAIkB;AAClB,qCAAyC;AACzC,+DAgB+B;AAGlB,QAAA,kBAAkB,GAAsC;IAEnE,mBAAmB,EAAE,8BAAqB;IAC1C,gBAAgB,EAAE,0BAAiB;IACnC,kBAAkB,EAAE,4BAAmB;IAGvC,eAAe,EAAE,sBAAa;IAG9B,YAAY,EAAE,0BAAc;IAG5B,QAAQ,EAAE,0BAAU;IAGpB,aAAa,EAAE,4BAAe;IAC9B,iBAAiB,EAAE,gCAAmB;IAGtC,YAAY,EAAE,0BAAc;IAC5B,gBAAgB,EAAE,8BAAkB;IAGpC,mBAAmB,EAAE,0CAAoB;IACzC,gBAAgB,EAAE,uCAAiB;IACnC,wBAAwB,EAAE,8CAAwB;IAClD,2BAA2B,EAAE,iDAA2B;IACxD,mBAAmB,EAAE,0CAAoB;IACzC,kBAAkB,EAAE,yCAAmB;IACvC,qBAAqB,EAAE,4CAAsB;IAC7C,oBAAoB,EAAE,2CAAqB;IAC3C,iBAAiB,EAAE,wCAAkB;IACrC,cAAc,EAAE,sCAAgB;IAChC,qBAAqB,EAAE,4CAAsB;IAC7C,mBAAmB,EAAE,0CAAoB;IACzC,oBAAoB,EAAE,2CAAqB;IAC3C,qBAAqB,EAAE,4CAAsB;IAC7C,sBAAsB,EAAE,6CAAuB;CAChD,CAAC"}
|
||||
1
dist/mcp/tool-docs/system/index.d.ts
vendored
1
dist/mcp/tool-docs/system/index.d.ts
vendored
@@ -1,3 +1,4 @@
|
||||
export { toolsDocumentationDoc } from './tools-documentation';
|
||||
export { n8nHealthCheckDoc } from './n8n-health-check';
|
||||
export { n8nAuditInstanceDoc } from './n8n-audit-instance';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
2
dist/mcp/tool-docs/system/index.d.ts.map
vendored
2
dist/mcp/tool-docs/system/index.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/system/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/system/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC"}
|
||||
4
dist/mcp/tool-docs/system/index.js
vendored
4
dist/mcp/tool-docs/system/index.js
vendored
@@ -1,8 +1,10 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.n8nHealthCheckDoc = exports.toolsDocumentationDoc = void 0;
|
||||
exports.n8nAuditInstanceDoc = exports.n8nHealthCheckDoc = exports.toolsDocumentationDoc = void 0;
|
||||
var tools_documentation_1 = require("./tools-documentation");
|
||||
Object.defineProperty(exports, "toolsDocumentationDoc", { enumerable: true, get: function () { return tools_documentation_1.toolsDocumentationDoc; } });
|
||||
var n8n_health_check_1 = require("./n8n-health-check");
|
||||
Object.defineProperty(exports, "n8nHealthCheckDoc", { enumerable: true, get: function () { return n8n_health_check_1.n8nHealthCheckDoc; } });
|
||||
var n8n_audit_instance_1 = require("./n8n-audit-instance");
|
||||
Object.defineProperty(exports, "n8nAuditInstanceDoc", { enumerable: true, get: function () { return n8n_audit_instance_1.n8nAuditInstanceDoc; } });
|
||||
//# sourceMappingURL=index.js.map
|
||||
2
dist/mcp/tool-docs/system/index.js.map
vendored
2
dist/mcp/tool-docs/system/index.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/system/index.ts"],"names":[],"mappings":";;;AAAA,6DAA8D;AAArD,4HAAA,qBAAqB,OAAA;AAC9B,uDAAuD;AAA9C,qHAAA,iBAAiB,OAAA"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/system/index.ts"],"names":[],"mappings":";;;AAAA,6DAA8D;AAArD,4HAAA,qBAAqB,OAAA;AAC9B,uDAAuD;AAA9C,qHAAA,iBAAiB,OAAA;AAC1B,2DAA2D;AAAlD,yHAAA,mBAAmB,OAAA"}
|
||||
@@ -12,4 +12,5 @@ export { n8nWorkflowVersionsDoc } from './n8n-workflow-versions';
|
||||
export { n8nDeployTemplateDoc } from './n8n-deploy-template';
|
||||
export { n8nManageDatatableDoc } from './n8n-manage-datatable';
|
||||
export { n8nGenerateWorkflowDoc } from './n8n-generate-workflow';
|
||||
export { n8nManageCredentialsDoc } from './n8n-manage-credentials';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AACtE,OAAO,EAAE,2BAA2B,EAAE,MAAM,+BAA+B,CAAC;AAC5E,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AACtE,OAAO,EAAE,2BAA2B,EAAE,MAAM,+BAA+B,CAAC;AAC5E,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC"}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.n8nGenerateWorkflowDoc = exports.n8nManageDatatableDoc = exports.n8nDeployTemplateDoc = exports.n8nWorkflowVersionsDoc = exports.n8nExecutionsDoc = exports.n8nTestWorkflowDoc = exports.n8nAutofixWorkflowDoc = exports.n8nValidateWorkflowDoc = exports.n8nListWorkflowsDoc = exports.n8nDeleteWorkflowDoc = exports.n8nUpdatePartialWorkflowDoc = exports.n8nUpdateFullWorkflowDoc = exports.n8nGetWorkflowDoc = exports.n8nCreateWorkflowDoc = void 0;
|
||||
exports.n8nManageCredentialsDoc = exports.n8nGenerateWorkflowDoc = exports.n8nManageDatatableDoc = exports.n8nDeployTemplateDoc = exports.n8nWorkflowVersionsDoc = exports.n8nExecutionsDoc = exports.n8nTestWorkflowDoc = exports.n8nAutofixWorkflowDoc = exports.n8nValidateWorkflowDoc = exports.n8nListWorkflowsDoc = exports.n8nDeleteWorkflowDoc = exports.n8nUpdatePartialWorkflowDoc = exports.n8nUpdateFullWorkflowDoc = exports.n8nGetWorkflowDoc = exports.n8nCreateWorkflowDoc = void 0;
|
||||
var n8n_create_workflow_1 = require("./n8n-create-workflow");
|
||||
Object.defineProperty(exports, "n8nCreateWorkflowDoc", { enumerable: true, get: function () { return n8n_create_workflow_1.n8nCreateWorkflowDoc; } });
|
||||
var n8n_get_workflow_1 = require("./n8n-get-workflow");
|
||||
@@ -29,4 +29,6 @@ var n8n_manage_datatable_1 = require("./n8n-manage-datatable");
|
||||
Object.defineProperty(exports, "n8nManageDatatableDoc", { enumerable: true, get: function () { return n8n_manage_datatable_1.n8nManageDatatableDoc; } });
|
||||
var n8n_generate_workflow_1 = require("./n8n-generate-workflow");
|
||||
Object.defineProperty(exports, "n8nGenerateWorkflowDoc", { enumerable: true, get: function () { return n8n_generate_workflow_1.n8nGenerateWorkflowDoc; } });
|
||||
var n8n_manage_credentials_1 = require("./n8n-manage-credentials");
|
||||
Object.defineProperty(exports, "n8nManageCredentialsDoc", { enumerable: true, get: function () { return n8n_manage_credentials_1.n8nManageCredentialsDoc; } });
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/index.ts"],"names":[],"mappings":";;;AAAA,6DAA6D;AAApD,2HAAA,oBAAoB,OAAA;AAC7B,uDAAuD;AAA9C,qHAAA,iBAAiB,OAAA;AAC1B,uEAAsE;AAA7D,oIAAA,wBAAwB,OAAA;AACjC,6EAA4E;AAAnE,0IAAA,2BAA2B,OAAA;AACpC,6DAA6D;AAApD,2HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,yHAAA,mBAAmB,OAAA;AAC5B,iEAAiE;AAAxD,+HAAA,sBAAsB,OAAA;AAC/B,+DAA+D;AAAtD,6HAAA,qBAAqB,OAAA;AAC9B,yDAAyD;AAAhD,uHAAA,kBAAkB,OAAA;AAC3B,mDAAoD;AAA3C,kHAAA,gBAAgB,OAAA;AACzB,iEAAiE;AAAxD,+HAAA,sBAAsB,OAAA;AAC/B,6DAA6D;AAApD,2HAAA,oBAAoB,OAAA;AAC7B,+DAA+D;AAAtD,6HAAA,qBAAqB,OAAA;AAC9B,iEAAiE;AAAxD,+HAAA,sBAAsB,OAAA"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/index.ts"],"names":[],"mappings":";;;AAAA,6DAA6D;AAApD,2HAAA,oBAAoB,OAAA;AAC7B,uDAAuD;AAA9C,qHAAA,iBAAiB,OAAA;AAC1B,uEAAsE;AAA7D,oIAAA,wBAAwB,OAAA;AACjC,6EAA4E;AAAnE,0IAAA,2BAA2B,OAAA;AACpC,6DAA6D;AAApD,2HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,yHAAA,mBAAmB,OAAA;AAC5B,iEAAiE;AAAxD,+HAAA,sBAAsB,OAAA;AAC/B,+DAA+D;AAAtD,6HAAA,qBAAqB,OAAA;AAC9B,yDAAyD;AAAhD,uHAAA,kBAAkB,OAAA;AAC3B,mDAAoD;AAA3C,kHAAA,gBAAgB,OAAA;AACzB,iEAAiE;AAAxD,+HAAA,sBAAsB,OAAA;AAC/B,6DAA6D;AAApD,2HAAA,oBAAoB,OAAA;AAC7B,+DAA+D;AAAtD,6HAAA,qBAAqB,OAAA;AAC9B,iEAAiE;AAAxD,+HAAA,sBAAsB,OAAA;AAC/B,mEAAmE;AAA1D,iIAAA,uBAAuB,OAAA"}
|
||||
2
dist/mcp/tools-n8n-manager.d.ts.map
vendored
2
dist/mcp/tools-n8n-manager.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"tools-n8n-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/tools-n8n-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAQ1C,eAAO,MAAM,kBAAkB,EAAE,cAAc,EAgrB9C,CAAC"}
|
||||
{"version":3,"file":"tools-n8n-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/tools-n8n-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAQ1C,eAAO,MAAM,kBAAkB,EAAE,cAAc,EA6uB9C,CAAC"}
|
||||
61
dist/mcp/tools-n8n-manager.js
vendored
61
dist/mcp/tools-n8n-manager.js
vendored
@@ -635,6 +635,27 @@ exports.n8nManagementTools = [
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'n8n_manage_credentials',
|
||||
description: 'Manage n8n credentials. Actions: list, get, create, update, delete, getSchema. Use getSchema to discover required fields before creating. SECURITY: credential data values are never logged.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['list', 'get', 'create', 'update', 'delete', 'getSchema'], description: 'Action to perform' },
|
||||
id: { type: 'string', description: 'Credential ID (required for get, update, delete)' },
|
||||
name: { type: 'string', description: 'Credential name (required for create)' },
|
||||
type: { type: 'string', description: 'Credential type e.g. httpHeaderAuth, httpBasicAuth, oAuth2Api (required for create, getSchema)' },
|
||||
data: { type: 'object', description: 'Credential data fields - use getSchema to discover required fields (required for create, optional for update)' },
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
annotations: {
|
||||
title: 'Manage Credentials',
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'n8n_generate_workflow',
|
||||
description: 'Generate an n8n workflow from a natural language description using AI. ' +
|
||||
@@ -675,5 +696,45 @@ exports.n8nManagementTools = [
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'n8n_audit_instance',
|
||||
description: `Security audit of n8n instance. Combines n8n's built-in audit API (credentials, database, nodes, instance, filesystem risks) with deep workflow scanning (hardcoded secrets via 50+ regex patterns, unauthenticated webhooks, error handling gaps, data retention risks). Returns actionable markdown report with remediation steps using n8n_manage_credentials and n8n_update_partial_workflow.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
categories: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['credentials', 'database', 'nodes', 'instance', 'filesystem'],
|
||||
},
|
||||
description: 'Built-in audit categories to check (default: all 5)',
|
||||
},
|
||||
includeCustomScan: {
|
||||
type: 'boolean',
|
||||
description: 'Run deep workflow scanning for secrets, webhooks, error handling (default: true)',
|
||||
},
|
||||
daysAbandonedWorkflow: {
|
||||
type: 'number',
|
||||
description: 'Days threshold for abandoned workflow detection (default: 90)',
|
||||
},
|
||||
customChecks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['hardcoded_secrets', 'unauthenticated_webhooks', 'error_handling', 'data_retention'],
|
||||
},
|
||||
description: 'Specific custom checks to run (default: all 4)',
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: {
|
||||
title: 'Audit Instance Security',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
//# sourceMappingURL=tools-n8n-manager.js.map
|
||||
2
dist/mcp/tools-n8n-manager.js.map
vendored
2
dist/mcp/tools-n8n-manager.js.map
vendored
File diff suppressed because one or more lines are too long
6
dist/services/n8n-api-client.d.ts
vendored
6
dist/services/n8n-api-client.d.ts
vendored
@@ -24,6 +24,11 @@ export declare class N8nApiClient {
|
||||
activateWorkflow(id: string): Promise<Workflow>;
|
||||
deactivateWorkflow(id: string): Promise<Workflow>;
|
||||
listWorkflows(params?: WorkflowListParams): Promise<WorkflowListResponse>;
|
||||
generateAudit(options?: {
|
||||
categories?: string[];
|
||||
daysAbandonedWorkflow?: number;
|
||||
}): Promise<any>;
|
||||
listAllWorkflows(): Promise<Workflow[]>;
|
||||
getExecution(id: string, includeData?: boolean): Promise<Execution>;
|
||||
listExecutions(params?: ExecutionListParams): Promise<ExecutionListResponse>;
|
||||
deleteExecution(id: string): Promise<void>;
|
||||
@@ -33,6 +38,7 @@ export declare class N8nApiClient {
|
||||
createCredential(credential: Partial<Credential>): Promise<Credential>;
|
||||
updateCredential(id: string, credential: Partial<Credential>): Promise<Credential>;
|
||||
deleteCredential(id: string): Promise<void>;
|
||||
getCredentialSchema(typeName: string): Promise<any>;
|
||||
listTags(params?: TagListParams): Promise<TagListResponse>;
|
||||
createTag(tag: Partial<Tag>): Promise<Tag>;
|
||||
updateTag(id: string, tag: Partial<Tag>): Promise<Tag>;
|
||||
|
||||
2
dist/services/n8n-api-client.d.ts.map
vendored
2
dist/services/n8n-api-client.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"n8n-api-client.d.ts","sourceRoot":"","sources":["../../src/services/n8n-api-client.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"n8n-api-client.d.ts","sourceRoot":"","sources":["../../src/services/n8n-api-client.ts"],"names":[],"mappings":"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"}
|
||||
44
dist/services/n8n-api-client.js
vendored
44
dist/services/n8n-api-client.js
vendored
@@ -61,9 +61,10 @@ class N8nApiClient {
|
||||
},
|
||||
});
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const isSensitive = config.url?.includes('/credentials') && config.method !== 'get';
|
||||
logger_1.logger.debug(`n8n API Request: ${config.method?.toUpperCase()} ${config.url}`, {
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
data: isSensitive ? '[REDACTED]' : config.data,
|
||||
});
|
||||
return config;
|
||||
}, (error) => {
|
||||
@@ -229,6 +230,38 @@ class N8nApiClient {
|
||||
throw (0, n8n_errors_1.handleN8nApiError)(error);
|
||||
}
|
||||
}
|
||||
async generateAudit(options) {
|
||||
try {
|
||||
const additionalOptions = {};
|
||||
if (options?.categories)
|
||||
additionalOptions.categories = options.categories;
|
||||
if (options?.daysAbandonedWorkflow !== undefined)
|
||||
additionalOptions.daysAbandonedWorkflow = options.daysAbandonedWorkflow;
|
||||
const body = Object.keys(additionalOptions).length > 0 ? { additionalOptions } : {};
|
||||
const response = await this.client.post('/audit', body);
|
||||
return response.data;
|
||||
}
|
||||
catch (error) {
|
||||
throw (0, n8n_errors_1.handleN8nApiError)(error);
|
||||
}
|
||||
}
|
||||
async listAllWorkflows() {
|
||||
const allWorkflows = [];
|
||||
let cursor;
|
||||
const seenCursors = new Set();
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 50;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const params = { limit: PAGE_SIZE, cursor };
|
||||
const response = await this.listWorkflows(params);
|
||||
allWorkflows.push(...response.data);
|
||||
if (!response.nextCursor || seenCursors.has(response.nextCursor))
|
||||
break;
|
||||
seenCursors.add(response.nextCursor);
|
||||
cursor = response.nextCursor;
|
||||
}
|
||||
return allWorkflows;
|
||||
}
|
||||
async getExecution(id, includeData = false) {
|
||||
try {
|
||||
const response = await this.client.get(`/executions/${id}`, {
|
||||
@@ -338,6 +371,15 @@ class N8nApiClient {
|
||||
throw (0, n8n_errors_1.handleN8nApiError)(error);
|
||||
}
|
||||
}
|
||||
async getCredentialSchema(typeName) {
|
||||
try {
|
||||
const response = await this.client.get(`/credentials/schema/${typeName}`);
|
||||
return response.data;
|
||||
}
|
||||
catch (error) {
|
||||
throw (0, n8n_errors_1.handleN8nApiError)(error);
|
||||
}
|
||||
}
|
||||
async listTags(params = {}) {
|
||||
try {
|
||||
const response = await this.client.get('/tags', { params });
|
||||
|
||||
2
dist/services/n8n-api-client.js.map
vendored
2
dist/services/n8n-api-client.js.map
vendored
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.46.1",
|
||||
"version": "2.47.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.46.1",
|
||||
"version": "2.47.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.28.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.46.1",
|
||||
"version": "2.47.1",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { N8nApiClient } from '../services/n8n-api-client';
|
||||
import { scanWorkflows, type CustomCheckType } from '../services/workflow-security-scanner';
|
||||
import { buildAuditReport } from '../services/audit-report-builder';
|
||||
import { getN8nApiConfig, getN8nApiConfigFromContext } from '../config/n8n-api';
|
||||
import {
|
||||
Workflow,
|
||||
@@ -2789,7 +2791,8 @@ const deleteRowsSchema = tableIdSchema.extend({
|
||||
dryRun: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function handleDataTableError(error: unknown): McpToolResponse {
|
||||
/** Shared error handler for data table and credential operations. */
|
||||
function handleCrudError(error: unknown): McpToolResponse {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: 'Invalid input', details: { errors: error.errors } };
|
||||
}
|
||||
@@ -2818,7 +2821,7 @@ export async function handleCreateTable(args: unknown, context?: InstanceContext
|
||||
message: `Data table "${dataTable.name}" created with ID: ${dataTable.id}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2836,7 +2839,7 @@ export async function handleListTables(args: unknown, context?: InstanceContext)
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2847,7 +2850,7 @@ export async function handleGetTable(args: unknown, context?: InstanceContext):
|
||||
const dataTable = await client.getDataTable(tableId);
|
||||
return { success: true, data: dataTable };
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2865,7 +2868,7 @@ export async function handleUpdateTable(args: unknown, context?: InstanceContext
|
||||
(hasColumns ? '. Note: columns parameter was ignored — table schema is immutable after creation via the public API' : ''),
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2876,7 +2879,7 @@ export async function handleDeleteTable(args: unknown, context?: InstanceContext
|
||||
await client.deleteDataTable(tableId);
|
||||
return { success: true, message: `Data table ${tableId} deleted successfully` };
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2901,7 +2904,7 @@ export async function handleGetRows(args: unknown, context?: InstanceContext): P
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2916,7 +2919,7 @@ export async function handleInsertRows(args: unknown, context?: InstanceContext)
|
||||
message: `Rows inserted into data table ${tableId}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2931,7 +2934,7 @@ export async function handleUpdateRows(args: unknown, context?: InstanceContext)
|
||||
message: params.dryRun ? 'Dry run: rows matched (no changes applied)' : 'Rows updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2946,7 +2949,7 @@ export async function handleUpsertRows(args: unknown, context?: InstanceContext)
|
||||
message: params.dryRun ? 'Dry run: upsert previewed (no changes applied)' : 'Row upserted successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2965,6 +2968,268 @@ export async function handleDeleteRows(args: unknown, context?: InstanceContext)
|
||||
message: params.dryRun ? 'Dry run: rows matched for deletion (no changes applied)' : 'Rows deleted successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Credential Management Handlers
|
||||
// ========================================================================
|
||||
|
||||
// SECURITY: Never log credential data values (they contain secrets like API keys, passwords).
|
||||
// Only log credential name, type, and ID.
|
||||
|
||||
const listCredentialsSchema = z.object({}).passthrough();
|
||||
|
||||
const getCredentialSchema = z.object({
|
||||
id: z.string({ required_error: 'Credential ID is required' }),
|
||||
});
|
||||
|
||||
const createCredentialSchema = z.object({
|
||||
name: z.string({ required_error: 'Credential name is required' }),
|
||||
type: z.string({ required_error: 'Credential type is required' }),
|
||||
data: z.record(z.any(), { required_error: 'Credential data is required' }),
|
||||
});
|
||||
|
||||
const updateCredentialSchema = z.object({
|
||||
id: z.string({ required_error: 'Credential ID is required' }),
|
||||
name: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
data: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
const deleteCredentialSchema = z.object({
|
||||
id: z.string({ required_error: 'Credential ID is required' }),
|
||||
});
|
||||
|
||||
const getCredentialSchemaTypeSchema = z.object({
|
||||
type: z.string({ required_error: 'Credential type is required' }),
|
||||
});
|
||||
|
||||
export async function handleListCredentials(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
listCredentialsSchema.parse(args);
|
||||
const result = await client.listCredentials();
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
credentials: result.data,
|
||||
count: result.data.length,
|
||||
nextCursor: result.nextCursor || undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetCredential(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = getCredentialSchema.parse(args);
|
||||
let credential;
|
||||
try {
|
||||
credential = await client.getCredential(id);
|
||||
} catch (getError: unknown) {
|
||||
// GET /credentials/:id is not in the n8n public API — fall back to list + filter
|
||||
const status = (getError as { statusCode?: number }).statusCode;
|
||||
const msg = (getError as Error).message ?? '';
|
||||
const isUnsupported = status === 405 || status === 403 || msg.includes('not allowed');
|
||||
if (!isUnsupported) {
|
||||
throw getError;
|
||||
}
|
||||
const list = await client.listCredentials();
|
||||
credential = list.data.find((c) => c.id === id);
|
||||
if (!credential) {
|
||||
return { success: false, error: `Credential ${id} not found` };
|
||||
}
|
||||
}
|
||||
// Strip sensitive data field — defense in depth against future n8n versions returning decrypted values
|
||||
const { data: _sensitiveData, ...safeCred } = credential;
|
||||
return {
|
||||
success: true,
|
||||
data: safeCred,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCreateCredential(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { name, type, data } = createCredentialSchema.parse(args);
|
||||
logger.info(`Creating credential: name="${name}", type="${type}"`);
|
||||
const credential = await client.createCredential({ name, type, data });
|
||||
const { data: _sensitiveData, ...safeCred } = credential;
|
||||
return {
|
||||
success: true,
|
||||
data: safeCred,
|
||||
message: `Credential "${name}" (type: ${type}) created with ID ${credential.id}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleUpdateCredential(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id, name, type, data } = updateCredentialSchema.parse(args);
|
||||
logger.info(`Updating credential: id="${id}"${name ? `, name="${name}"` : ''}`);
|
||||
const updatePayload: Record<string, any> = {};
|
||||
if (name !== undefined) updatePayload.name = name;
|
||||
if (type !== undefined) updatePayload.type = type;
|
||||
if (data !== undefined) updatePayload.data = data;
|
||||
const credential = await client.updateCredential(id, updatePayload);
|
||||
const { data: _sensitiveData, ...safeCred } = credential;
|
||||
return {
|
||||
success: true,
|
||||
data: safeCred,
|
||||
message: `Credential ${id} updated successfully`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteCredential(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id } = deleteCredentialSchema.parse(args);
|
||||
logger.info(`Deleting credential: id="${id}"`);
|
||||
await client.deleteCredential(id);
|
||||
return {
|
||||
success: true,
|
||||
message: `Credential ${id} deleted successfully`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetCredentialSchema(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { type } = getCredentialSchemaTypeSchema.parse(args);
|
||||
const schema = await client.getCredentialSchema(type);
|
||||
return {
|
||||
success: true,
|
||||
data: schema,
|
||||
message: `Schema for credential type "${type}"`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleCrudError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audit Instance ─────────────────────────────────────────────────────────
|
||||
|
||||
const auditInstanceSchema = z.object({
|
||||
categories: z.array(z.enum([
|
||||
'credentials', 'database', 'nodes', 'instance', 'filesystem',
|
||||
])).optional(),
|
||||
includeCustomScan: z.boolean().optional().default(true),
|
||||
daysAbandonedWorkflow: z.number().optional(),
|
||||
customChecks: z.array(z.enum([
|
||||
'hardcoded_secrets', 'unauthenticated_webhooks', 'error_handling', 'data_retention',
|
||||
])).optional(),
|
||||
});
|
||||
|
||||
export async function handleAuditInstance(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = auditInstanceSchema.parse(args);
|
||||
|
||||
const totalStart = Date.now();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Phase A: n8n built-in audit
|
||||
let builtinAudit: any = null;
|
||||
let builtinAuditMs = 0;
|
||||
try {
|
||||
const auditStart = Date.now();
|
||||
builtinAudit = await client.generateAudit({
|
||||
categories: input.categories,
|
||||
daysAbandonedWorkflow: input.daysAbandonedWorkflow,
|
||||
});
|
||||
builtinAuditMs = Date.now() - auditStart;
|
||||
} catch (auditError: any) {
|
||||
builtinAuditMs = Date.now() - totalStart;
|
||||
const msg = auditError?.statusCode === 404
|
||||
? 'Built-in audit endpoint not available on this n8n version.'
|
||||
: `Built-in audit failed: ${auditError?.message || 'unknown error'}`;
|
||||
warnings.push(msg);
|
||||
logger.warn(`Audit: ${msg}`);
|
||||
}
|
||||
|
||||
// Phase B: Custom workflow scanning
|
||||
let customReport = null;
|
||||
let workflowFetchMs = 0;
|
||||
let customScanMs = 0;
|
||||
|
||||
if (input.includeCustomScan) {
|
||||
try {
|
||||
const fetchStart = Date.now();
|
||||
const allWorkflows = await client.listAllWorkflows();
|
||||
workflowFetchMs = Date.now() - fetchStart;
|
||||
|
||||
logger.info(`Audit: fetched ${allWorkflows.length} workflows for scanning`);
|
||||
|
||||
const scanStart = Date.now();
|
||||
customReport = scanWorkflows(
|
||||
allWorkflows,
|
||||
input.customChecks as CustomCheckType[] | undefined,
|
||||
);
|
||||
customScanMs = Date.now() - scanStart;
|
||||
|
||||
logger.info(`Audit: custom scan found ${customReport.summary.total} findings across ${customReport.workflowsScanned} workflows`);
|
||||
} catch (scanError: any) {
|
||||
warnings.push(`Custom scan failed: ${scanError?.message || 'unknown error'}`);
|
||||
logger.warn(`Audit: custom scan failed: ${scanError?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalMs = Date.now() - totalStart;
|
||||
|
||||
// Build the API URL for the report (mask the key)
|
||||
const apiConfig = context?.n8nApiUrl
|
||||
? { baseUrl: context.n8nApiUrl }
|
||||
: getN8nApiConfig();
|
||||
const instanceUrl = apiConfig?.baseUrl || 'unknown';
|
||||
|
||||
// Build unified markdown report
|
||||
const report = buildAuditReport({
|
||||
builtinAudit,
|
||||
customReport,
|
||||
performance: { builtinAuditMs, workflowFetchMs, customScanMs, totalMs },
|
||||
instanceUrl,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
report: report.markdown,
|
||||
summary: report.summary,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid audit parameters',
|
||||
details: { issues: error.errors },
|
||||
};
|
||||
}
|
||||
if (error instanceof N8nApiError) {
|
||||
return {
|
||||
success: false,
|
||||
error: getUserFriendlyErrorMessage(error),
|
||||
};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1048,6 +1048,15 @@ export class N8NDocumentationMCPServer {
|
||||
? { valid: true, errors: [] }
|
||||
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
|
||||
break;
|
||||
case 'n8n_manage_credentials':
|
||||
validationResult = args.action
|
||||
? { valid: true, errors: [] }
|
||||
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
|
||||
break;
|
||||
case 'n8n_audit_instance':
|
||||
// No required parameters - all are optional
|
||||
validationResult = { valid: true, errors: [] };
|
||||
break;
|
||||
case 'n8n_deploy_template':
|
||||
// Requires templateId parameter
|
||||
validationResult = args.templateId !== undefined
|
||||
@@ -1538,6 +1547,25 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
case 'n8n_manage_credentials': {
|
||||
this.validateToolParams(name, args, ['action']);
|
||||
const credAction = args.action;
|
||||
switch (credAction) {
|
||||
case 'list': return n8nHandlers.handleListCredentials(args, this.instanceContext);
|
||||
case 'get': return n8nHandlers.handleGetCredential(args, this.instanceContext);
|
||||
case 'create': return n8nHandlers.handleCreateCredential(args, this.instanceContext);
|
||||
case 'update': return n8nHandlers.handleUpdateCredential(args, this.instanceContext);
|
||||
case 'delete': return n8nHandlers.handleDeleteCredential(args, this.instanceContext);
|
||||
case 'getSchema': return n8nHandlers.handleGetCredentialSchema(args, this.instanceContext);
|
||||
default:
|
||||
throw new Error(`Unknown action: ${credAction}. Valid actions: list, get, create, update, delete, getSchema`);
|
||||
}
|
||||
}
|
||||
|
||||
case 'n8n_audit_instance':
|
||||
// No required parameters - all are optional
|
||||
return n8nHandlers.handleAuditInstance(args, this.instanceContext);
|
||||
|
||||
case 'n8n_generate_workflow': {
|
||||
this.validateToolParams(name, args, ['description']);
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { validateNodeDoc, validateWorkflowDoc } from './validation';
|
||||
import { getTemplateDoc, searchTemplatesDoc } from './templates';
|
||||
import {
|
||||
toolsDocumentationDoc,
|
||||
n8nHealthCheckDoc
|
||||
n8nHealthCheckDoc,
|
||||
n8nAuditInstanceDoc
|
||||
} from './system';
|
||||
import { aiAgentsGuide } from './guides';
|
||||
import {
|
||||
@@ -24,7 +25,8 @@ import {
|
||||
n8nWorkflowVersionsDoc,
|
||||
n8nDeployTemplateDoc,
|
||||
n8nManageDatatableDoc,
|
||||
n8nGenerateWorkflowDoc
|
||||
n8nGenerateWorkflowDoc,
|
||||
n8nManageCredentialsDoc
|
||||
} from './workflow_management';
|
||||
|
||||
// Combine all tool documentations into a single object
|
||||
@@ -32,6 +34,7 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
|
||||
// System tools
|
||||
tools_documentation: toolsDocumentationDoc,
|
||||
n8n_health_check: n8nHealthCheckDoc,
|
||||
n8n_audit_instance: n8nAuditInstanceDoc,
|
||||
|
||||
// Guides
|
||||
ai_agents_guide: aiAgentsGuide,
|
||||
@@ -64,7 +67,8 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
|
||||
n8n_workflow_versions: n8nWorkflowVersionsDoc,
|
||||
n8n_deploy_template: n8nDeployTemplateDoc,
|
||||
n8n_manage_datatable: n8nManageDatatableDoc,
|
||||
n8n_generate_workflow: n8nGenerateWorkflowDoc
|
||||
n8n_generate_workflow: n8nGenerateWorkflowDoc,
|
||||
n8n_manage_credentials: n8nManageCredentialsDoc
|
||||
};
|
||||
|
||||
// Re-export types
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { toolsDocumentationDoc } from './tools-documentation';
|
||||
export { n8nHealthCheckDoc } from './n8n-health-check';
|
||||
export { n8nHealthCheckDoc } from './n8n-health-check';
|
||||
export { n8nAuditInstanceDoc } from './n8n-audit-instance';
|
||||
106
src/mcp/tool-docs/system/n8n-audit-instance.ts
Normal file
106
src/mcp/tool-docs/system/n8n-audit-instance.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const n8nAuditInstanceDoc: ToolDocumentation = {
|
||||
name: 'n8n_audit_instance',
|
||||
category: 'system',
|
||||
essentials: {
|
||||
description: 'Security audit combining n8n built-in audit with deep workflow scanning',
|
||||
keyParameters: ['categories', 'includeCustomScan', 'customChecks'],
|
||||
example: 'n8n_audit_instance({}) for full audit, n8n_audit_instance({customChecks: ["hardcoded_secrets", "unauthenticated_webhooks"]}) for specific checks',
|
||||
performance: 'Moderate - fetches all workflows (2-30s depending on instance size)',
|
||||
tips: [
|
||||
'Returns actionable markdown with remediation steps',
|
||||
'Use n8n_manage_credentials to fix credential findings',
|
||||
'Custom scan covers 50+ secret patterns including API keys, tokens, and passwords',
|
||||
'Built-in audit checks credentials, database, nodes, instance, and filesystem risks',
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Performs a comprehensive security audit of the configured n8n instance by combining two scanning approaches:
|
||||
|
||||
**Built-in Audit (via n8n API):**
|
||||
- credentials: Unused credentials, shared credentials with elevated access
|
||||
- database: Database-level security settings and exposure
|
||||
- nodes: Community nodes with known vulnerabilities, outdated nodes
|
||||
- instance: Instance configuration risks (e.g., public registration, weak auth)
|
||||
- filesystem: File system access and permission risks
|
||||
|
||||
**Custom Deep Scan (workflow analysis):**
|
||||
- hardcoded_secrets: Scans all workflow node parameters for hardcoded API keys, tokens, passwords, and connection strings using 50+ regex patterns
|
||||
- unauthenticated_webhooks: Detects webhook nodes without authentication configured
|
||||
- error_handling: Identifies workflows without error handling or notification on failure
|
||||
- data_retention: Flags workflows with excessive data retention or no cleanup
|
||||
|
||||
The report is returned as actionable markdown with severity ratings, affected resources, and specific remediation steps referencing other MCP tools.`,
|
||||
parameters: {
|
||||
categories: {
|
||||
type: 'array of string',
|
||||
required: false,
|
||||
description: 'Built-in audit categories to check',
|
||||
default: ['credentials', 'database', 'nodes', 'instance', 'filesystem'],
|
||||
enum: ['credentials', 'database', 'nodes', 'instance', 'filesystem'],
|
||||
},
|
||||
includeCustomScan: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Run deep workflow scanning for secrets, webhooks, error handling',
|
||||
default: true,
|
||||
},
|
||||
daysAbandonedWorkflow: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'Days threshold for abandoned workflow detection',
|
||||
default: 90,
|
||||
},
|
||||
customChecks: {
|
||||
type: 'array of string',
|
||||
required: false,
|
||||
description: 'Specific custom checks to run (defaults to all 4 if includeCustomScan is true)',
|
||||
default: ['hardcoded_secrets', 'unauthenticated_webhooks', 'error_handling', 'data_retention'],
|
||||
enum: ['hardcoded_secrets', 'unauthenticated_webhooks', 'error_handling', 'data_retention'],
|
||||
},
|
||||
},
|
||||
returns: `Markdown-formatted security audit report containing:
|
||||
- Summary table with finding counts by severity (critical, high, medium, low)
|
||||
- Findings grouped by workflow with per-workflow tables (ID, severity, finding, node, fix type)
|
||||
- Built-in audit section with n8n's own risk assessments (nodes, instance, credentials, database, filesystem)
|
||||
- Remediation Playbook aggregated by finding type: auto-fixable (secrets, webhooks), requires review (error handling, PII), requires user action (data retention, instance updates)
|
||||
- Tool chains for auto-fixing reference n8n_get_workflow, n8n_manage_credentials, n8n_update_partial_workflow`,
|
||||
examples: [
|
||||
'// Full audit with all checks\nn8n_audit_instance({})',
|
||||
'// Built-in audit only (no workflow scanning)\nn8n_audit_instance({includeCustomScan: false})',
|
||||
'// Only check for hardcoded secrets and unauthenticated webhooks\nn8n_audit_instance({customChecks: ["hardcoded_secrets", "unauthenticated_webhooks"]})',
|
||||
'// Only run built-in credential and instance checks\nn8n_audit_instance({categories: ["credentials", "instance"], includeCustomScan: false})',
|
||||
'// Adjust abandoned workflow threshold\nn8n_audit_instance({daysAbandonedWorkflow: 30})',
|
||||
],
|
||||
useCases: [
|
||||
'Regular security audits of n8n instances',
|
||||
'Detecting hardcoded secrets before they become a breach',
|
||||
'Identifying unauthenticated webhook endpoints exposed to the internet',
|
||||
'Compliance checks for data retention and error handling policies',
|
||||
'Pre-deployment security review of new workflows',
|
||||
'Remediation workflow: audit, review findings, fix with n8n_manage_credentials or n8n_update_partial_workflow',
|
||||
],
|
||||
performance: `Execution time depends on instance size:
|
||||
- Small instances (<20 workflows): 2-5s
|
||||
- Medium instances (20-100 workflows): 5-15s
|
||||
- Large instances (100+ workflows): 15-30s
|
||||
The built-in audit is a single API call. The custom scan fetches all workflows and analyzes each one.`,
|
||||
bestPractices: [
|
||||
'Run a full audit periodically (e.g., weekly) to catch new issues',
|
||||
'Use customChecks to focus on specific concerns when time is limited',
|
||||
'Address critical and high severity findings first',
|
||||
'After fixing findings, re-run the audit to verify remediation',
|
||||
'Combine with n8n_health_check for a complete instance health picture',
|
||||
'Use n8n_manage_credentials to rotate or replace exposed credentials',
|
||||
],
|
||||
pitfalls: [
|
||||
'Large instances with many workflows may take 30+ seconds to scan',
|
||||
'Built-in audit API may not be available on older n8n versions (pre-1.x)',
|
||||
'Custom scan analyzes stored workflow definitions only, not runtime values from expressions',
|
||||
'Requires N8N_API_URL and N8N_API_KEY to be configured',
|
||||
'Findings from the built-in audit depend on n8n version and may vary',
|
||||
],
|
||||
relatedTools: ['n8n_manage_credentials', 'n8n_update_partial_workflow', 'n8n_health_check'],
|
||||
}
|
||||
};
|
||||
@@ -12,3 +12,4 @@ export { n8nWorkflowVersionsDoc } from './n8n-workflow-versions';
|
||||
export { n8nDeployTemplateDoc } from './n8n-deploy-template';
|
||||
export { n8nManageDatatableDoc } from './n8n-manage-datatable';
|
||||
export { n8nGenerateWorkflowDoc } from './n8n-generate-workflow';
|
||||
export { n8nManageCredentialsDoc } from './n8n-manage-credentials';
|
||||
|
||||
106
src/mcp/tool-docs/workflow_management/n8n-manage-credentials.ts
Normal file
106
src/mcp/tool-docs/workflow_management/n8n-manage-credentials.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const n8nManageCredentialsDoc: ToolDocumentation = {
|
||||
name: 'n8n_manage_credentials',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'CRUD operations for n8n credentials with schema discovery',
|
||||
keyParameters: ['action', 'type', 'name', 'data'],
|
||||
example: 'n8n_manage_credentials({action: "getSchema", type: "httpHeaderAuth"}) then n8n_manage_credentials({action: "create", name: "My Auth", type: "httpHeaderAuth", data: {name: "X-API-Key", value: "secret"}})',
|
||||
performance: 'Fast - single API call per action',
|
||||
tips: [
|
||||
'Always use getSchema first to discover required fields before creating credentials',
|
||||
'Credential data values are never logged for security',
|
||||
'Use with n8n_audit_instance to fix security findings',
|
||||
'Actions: list, get, create, update, delete, getSchema',
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Manage n8n credentials through a unified interface. Supports full lifecycle operations:
|
||||
|
||||
**Discovery:**
|
||||
- **getSchema**: Retrieve the schema for a credential type, showing all required and optional fields with their types and descriptions. Always call this before creating credentials to know the exact field names and formats.
|
||||
|
||||
**Read Operations:**
|
||||
- **list**: List all credentials with their names, types, and IDs. Does not return credential data values.
|
||||
- **get**: Get a specific credential by ID, including its metadata and data fields.
|
||||
|
||||
**Write Operations:**
|
||||
- **create**: Create a new credential with a name, type, and data fields. Requires name, type, and data.
|
||||
- **update**: Update an existing credential by ID. Can update name and/or data fields.
|
||||
- **delete**: Permanently delete a credential by ID.
|
||||
|
||||
**Security:** Credential data values (API keys, passwords, tokens) are never written to logs. The n8n API encrypts stored credential data at rest.`,
|
||||
parameters: {
|
||||
action: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Operation to perform on credentials',
|
||||
enum: ['list', 'get', 'create', 'update', 'delete', 'getSchema'],
|
||||
},
|
||||
id: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Credential ID (required for get, update, delete)',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Credential display name (required for create, optional for update)',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Credential type identifier, e.g. httpHeaderAuth, httpBasicAuth, oAuth2Api (required for create and getSchema)',
|
||||
examples: ['httpHeaderAuth', 'httpBasicAuth', 'oAuth2Api', 'slackApi', 'gmailOAuth2Api'],
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
description: 'Credential data fields as key-value pairs. Use getSchema to discover required fields (required for create, optional for update)',
|
||||
},
|
||||
},
|
||||
returns: `Depends on action:
|
||||
- list: Array of credentials with id, name, type, createdAt, updatedAt
|
||||
- get: Full credential object with id, name, type, and data fields
|
||||
- create: Created credential object with id, name, type
|
||||
- update: Updated credential object
|
||||
- delete: Success confirmation message
|
||||
- getSchema: Schema object with field definitions including name, type, required status, description, and default values`,
|
||||
examples: [
|
||||
'// Discover schema before creating\nn8n_manage_credentials({action: "getSchema", type: "httpHeaderAuth"})',
|
||||
'// Create an HTTP header auth credential\nn8n_manage_credentials({action: "create", name: "My API Key", type: "httpHeaderAuth", data: {name: "X-API-Key", value: "sk-abc123"}})',
|
||||
'// List all credentials\nn8n_manage_credentials({action: "list"})',
|
||||
'// Get a specific credential\nn8n_manage_credentials({action: "get", id: "123"})',
|
||||
'// Update credential data\nn8n_manage_credentials({action: "update", id: "123", data: {value: "new-secret-value"}})',
|
||||
'// Rename a credential\nn8n_manage_credentials({action: "update", id: "123", name: "Renamed Credential"})',
|
||||
'// Delete a credential\nn8n_manage_credentials({action: "delete", id: "123"})',
|
||||
'// Create basic auth credential\nn8n_manage_credentials({action: "create", name: "Service Auth", type: "httpBasicAuth", data: {user: "admin", password: "secret"}})',
|
||||
],
|
||||
useCases: [
|
||||
'Provisioning credentials for new workflow integrations',
|
||||
'Rotating API keys and secrets on a schedule',
|
||||
'Remediating security findings from n8n_audit_instance',
|
||||
'Discovering available credential types and their required fields',
|
||||
'Bulk credential management across n8n instances',
|
||||
'Replacing hardcoded secrets with proper credential references',
|
||||
],
|
||||
performance: 'Fast response expected: single HTTP API call per action, typically <200ms.',
|
||||
bestPractices: [
|
||||
'Always call getSchema before create to discover required fields and their formats',
|
||||
'Use descriptive names that identify the service and purpose (e.g., "Slack - Production Bot")',
|
||||
'Rotate credentials regularly by updating data fields',
|
||||
'After creating credentials, reference them in workflows instead of hardcoding secrets',
|
||||
'Use n8n_audit_instance to find credentials that need rotation or cleanup',
|
||||
'Verify credential validity by testing the workflow after creation',
|
||||
],
|
||||
pitfalls: [
|
||||
'delete is permanent and cannot be undone - workflows using the credential will break',
|
||||
'Credential type must match exactly (case-sensitive) - use getSchema to verify',
|
||||
'OAuth2 credentials may require browser-based authorization flow that cannot be completed via API alone',
|
||||
'The list action does not return credential data values for security',
|
||||
'Requires N8N_API_URL and N8N_API_KEY to be configured',
|
||||
],
|
||||
relatedTools: ['n8n_audit_instance', 'n8n_create_workflow', 'n8n_update_partial_workflow', 'n8n_health_check'],
|
||||
}
|
||||
};
|
||||
@@ -654,6 +654,27 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'n8n_manage_credentials',
|
||||
description: 'Manage n8n credentials. Actions: list, get, create, update, delete, getSchema. Use getSchema to discover required fields before creating. SECURITY: credential data values are never logged.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['list', 'get', 'create', 'update', 'delete', 'getSchema'], description: 'Action to perform' },
|
||||
id: { type: 'string', description: 'Credential ID (required for get, update, delete)' },
|
||||
name: { type: 'string', description: 'Credential name (required for create)' },
|
||||
type: { type: 'string', description: 'Credential type e.g. httpHeaderAuth, httpBasicAuth, oAuth2Api (required for create, getSchema)' },
|
||||
data: { type: 'object', description: 'Credential data fields - use getSchema to discover required fields (required for create, optional for update)' },
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
annotations: {
|
||||
title: 'Manage Credentials',
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'n8n_generate_workflow',
|
||||
description: 'Generate an n8n workflow from a natural language description using AI. ' +
|
||||
@@ -694,4 +715,44 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'n8n_audit_instance',
|
||||
description: `Security audit of n8n instance. Combines n8n's built-in audit API (credentials, database, nodes, instance, filesystem risks) with deep workflow scanning (hardcoded secrets via 50+ regex patterns, unauthenticated webhooks, error handling gaps, data retention risks). Returns actionable markdown report with remediation steps using n8n_manage_credentials and n8n_update_partial_workflow.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
categories: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['credentials', 'database', 'nodes', 'instance', 'filesystem'],
|
||||
},
|
||||
description: 'Built-in audit categories to check (default: all 5)',
|
||||
},
|
||||
includeCustomScan: {
|
||||
type: 'boolean',
|
||||
description: 'Run deep workflow scanning for secrets, webhooks, error handling (default: true)',
|
||||
},
|
||||
daysAbandonedWorkflow: {
|
||||
type: 'number',
|
||||
description: 'Days threshold for abandoned workflow detection (default: 90)',
|
||||
},
|
||||
customChecks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['hardcoded_secrets', 'unauthenticated_webhooks', 'error_handling', 'data_retention'],
|
||||
},
|
||||
description: 'Specific custom checks to run (default: all 4)',
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: {
|
||||
title: 'Audit Instance Security',
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
459
src/services/audit-report-builder.ts
Normal file
459
src/services/audit-report-builder.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Audit Report Builder
|
||||
*
|
||||
* Builds an actionable markdown security audit report that unifies
|
||||
* findings from both the n8n built-in audit endpoint and the custom
|
||||
* workflow security scanner. Produces a structured summary alongside
|
||||
* the markdown so callers can branch on severity counts.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types – imported from the workflow security scanner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type {
|
||||
AuditSeverity,
|
||||
RemediationType,
|
||||
AuditFinding,
|
||||
WorkflowSecurityReport,
|
||||
} from './workflow-security-scanner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuditReportInput {
|
||||
builtinAudit: any; // Raw response from n8n POST /audit (object with report keys, or [] if empty)
|
||||
customReport: WorkflowSecurityReport | null;
|
||||
performance: {
|
||||
builtinAuditMs: number;
|
||||
workflowFetchMs: number;
|
||||
customScanMs: number;
|
||||
totalMs: number;
|
||||
};
|
||||
instanceUrl: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface UnifiedAuditReport {
|
||||
markdown: string;
|
||||
summary: {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
totalFindings: number;
|
||||
workflowsScanned: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Severity sort order — most severe first. */
|
||||
const SEVERITY_ORDER: Record<AuditSeverity, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
};
|
||||
|
||||
/** Map remediation type to a short fix label for the table column. */
|
||||
const FIX_LABEL: Record<RemediationType, string> = {
|
||||
auto_fixable: 'Auto-fix',
|
||||
user_input_needed: 'User input',
|
||||
user_action_needed: 'User action',
|
||||
review_recommended: 'Review',
|
||||
};
|
||||
|
||||
/** Returns true if the built-in audit response contains report data. */
|
||||
function isPopulatedAudit(builtinAudit: any): builtinAudit is Record<string, any> {
|
||||
if (Array.isArray(builtinAudit)) return false;
|
||||
return typeof builtinAudit === 'object' && builtinAudit !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over all (reportKey, section) pairs in the built-in audit response.
|
||||
* Handles both `{ sections: [...] }` and direct array formats.
|
||||
*/
|
||||
function forEachBuiltinSection(
|
||||
builtinAudit: Record<string, any>,
|
||||
callback: (reportKey: string, section: any) => void,
|
||||
): void {
|
||||
for (const reportKey of Object.keys(builtinAudit)) {
|
||||
const report = builtinAudit[reportKey];
|
||||
const sections = Array.isArray(report) ? report : (report?.sections ?? []);
|
||||
if (!Array.isArray(sections)) continue;
|
||||
for (const section of sections) {
|
||||
callback(reportKey, section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the location array from a section (n8n uses both "location" and "locations"). */
|
||||
function getSectionLocations(section: any): any[] | null {
|
||||
const arr = section?.location ?? section?.locations;
|
||||
return Array.isArray(arr) ? arr : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count location items across all sections of the built-in audit response.
|
||||
*/
|
||||
function countBuiltinLocations(builtinAudit: any): number {
|
||||
if (!isPopulatedAudit(builtinAudit)) return 0;
|
||||
|
||||
let count = 0;
|
||||
forEachBuiltinSection(builtinAudit, (_key, section) => {
|
||||
const locations = getSectionLocations(section);
|
||||
if (locations) count += locations.length;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group findings by workflow, returning a Map keyed by workflowId.
|
||||
* Each value includes workflow metadata and sorted findings.
|
||||
*/
|
||||
interface WorkflowGroup {
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
workflowActive: boolean;
|
||||
findings: AuditFinding[];
|
||||
/** Worst severity in the group (lower = more severe). */
|
||||
worstSeverity: number;
|
||||
}
|
||||
|
||||
function groupByWorkflow(findings: AuditFinding[]): WorkflowGroup[] {
|
||||
const map = new Map<string, WorkflowGroup>();
|
||||
|
||||
for (const f of findings) {
|
||||
const wfId = f.location.workflowId;
|
||||
let group = map.get(wfId);
|
||||
if (!group) {
|
||||
group = {
|
||||
workflowId: wfId,
|
||||
workflowName: f.location.workflowName,
|
||||
workflowActive: f.location.workflowActive ?? false,
|
||||
findings: [],
|
||||
worstSeverity: SEVERITY_ORDER[f.severity],
|
||||
};
|
||||
map.set(wfId, group);
|
||||
}
|
||||
group.findings.push(f);
|
||||
const sev = SEVERITY_ORDER[f.severity];
|
||||
if (sev < group.worstSeverity) {
|
||||
group.worstSeverity = sev;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort workflows: worst severity first, then by name
|
||||
const groups = Array.from(map.values());
|
||||
groups.sort((a, b) => a.worstSeverity - b.worstSeverity || a.workflowName.localeCompare(b.workflowName));
|
||||
|
||||
// Sort findings within each workflow by severity
|
||||
for (const g of groups) {
|
||||
g.findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the built-in audit section. Handles both empty (no issues) and
|
||||
* populated responses with report keys.
|
||||
*/
|
||||
function renderBuiltinAudit(builtinAudit: any): string {
|
||||
if (!isPopulatedAudit(builtinAudit)) {
|
||||
return 'No issues found by n8n built-in audit.';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
let currentKey = '';
|
||||
|
||||
forEachBuiltinSection(builtinAudit, (reportKey, section) => {
|
||||
if (reportKey !== currentKey) {
|
||||
if (currentKey) lines.push('');
|
||||
lines.push(`### ${reportKey}`);
|
||||
currentKey = reportKey;
|
||||
}
|
||||
|
||||
const title = section.title || section.name || 'Unknown';
|
||||
const description = section.description || '';
|
||||
const recommendation = section.recommendation || '';
|
||||
|
||||
lines.push(`- **${title}:** ${description}`);
|
||||
if (recommendation) {
|
||||
lines.push(` - Recommendation: ${recommendation}`);
|
||||
}
|
||||
|
||||
const locations = getSectionLocations(section);
|
||||
if (locations && locations.length > 0) {
|
||||
lines.push(` - Affected: ${locations.length} items`);
|
||||
}
|
||||
|
||||
// Special handling for Instance Risk Report fields
|
||||
if (reportKey === 'Instance Risk Report') {
|
||||
if (Array.isArray(section.nextVersions) && section.nextVersions.length > 0) {
|
||||
const versionNames = section.nextVersions
|
||||
.map((v: any) => (typeof v === 'string' ? v : v.name || String(v)))
|
||||
.join(', ');
|
||||
lines.push(` - Available versions: ${versionNames}`);
|
||||
}
|
||||
|
||||
if (section.settings && typeof section.settings === 'object') {
|
||||
const entries = Object.entries(section.settings);
|
||||
if (entries.length > 0) {
|
||||
lines.push(' - Security settings:');
|
||||
for (const [key, value] of entries) {
|
||||
lines.push(` - ${key}: ${JSON.stringify(value)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (lines.length === 0) {
|
||||
return 'No issues found by n8n built-in audit.';
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract actionable items from the built-in audit for the playbook section.
|
||||
*/
|
||||
interface BuiltinActionables {
|
||||
outdatedInstance: boolean;
|
||||
communityNodeCount: number;
|
||||
}
|
||||
|
||||
function extractBuiltinActionables(builtinAudit: any): BuiltinActionables {
|
||||
const result: BuiltinActionables = { outdatedInstance: false, communityNodeCount: 0 };
|
||||
if (!isPopulatedAudit(builtinAudit)) return result;
|
||||
|
||||
forEachBuiltinSection(builtinAudit, (_key, section) => {
|
||||
const title = (section.title || section.name || '').toLowerCase();
|
||||
|
||||
if (title.includes('outdated') || title.includes('update')) {
|
||||
result.outdatedInstance = true;
|
||||
}
|
||||
|
||||
if (title.includes('community') || title.includes('custom node')) {
|
||||
const locations = getSectionLocations(section);
|
||||
result.communityNodeCount += locations ? locations.length : 1;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Remediation Playbook — aggregated by finding type with tool
|
||||
* flow described once per type.
|
||||
*/
|
||||
function renderRemediationPlaybook(
|
||||
findings: AuditFinding[],
|
||||
builtinAudit: any,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Count findings by category
|
||||
const byCat: Record<string, AuditFinding[]> = {};
|
||||
for (const f of findings) {
|
||||
if (!byCat[f.category]) byCat[f.category] = [];
|
||||
byCat[f.category].push(f);
|
||||
}
|
||||
|
||||
// Unique workflow count per category
|
||||
const uniqueWorkflows = (items: AuditFinding[]): number =>
|
||||
new Set(items.map(f => f.location.workflowId)).size;
|
||||
|
||||
// --- Auto-fixable by agent ---
|
||||
const autoFixCategories = ['hardcoded_secrets', 'unauthenticated_webhooks'];
|
||||
const hasAutoFix = autoFixCategories.some(cat => byCat[cat] && byCat[cat].length > 0);
|
||||
|
||||
if (hasAutoFix) {
|
||||
lines.push('### Auto-fixable by agent');
|
||||
lines.push('');
|
||||
|
||||
if (byCat['hardcoded_secrets']?.length) {
|
||||
const autoFixSecrets = byCat['hardcoded_secrets'].filter(f => f.remediationType === 'auto_fixable');
|
||||
if (autoFixSecrets.length > 0) {
|
||||
const wfCount = uniqueWorkflows(autoFixSecrets);
|
||||
lines.push(`**Hardcoded secrets** (${autoFixSecrets.length} across ${wfCount} workflow${wfCount !== 1 ? 's' : ''}):`);
|
||||
lines.push('Steps: `n8n_get_workflow` -> extract value -> `n8n_manage_credentials({action: "create"})` -> `n8n_update_partial_workflow({operations: [{type: "updateNode"}]})` to reference credential.');
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (byCat['unauthenticated_webhooks']?.length) {
|
||||
const items = byCat['unauthenticated_webhooks'];
|
||||
const wfCount = uniqueWorkflows(items);
|
||||
lines.push(`**Unauthenticated webhooks** (${items.length} across ${wfCount} workflow${wfCount !== 1 ? 's' : ''}):`);
|
||||
lines.push('Steps: `n8n_manage_credentials({action: "create", type: "httpHeaderAuth"})` with random secret -> `n8n_update_partial_workflow` to set `authentication: "headerAuth"` and assign credential.');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Requires review ---
|
||||
const reviewCategories = ['error_handling'];
|
||||
const piiFindings = findings.filter(f => f.category === 'hardcoded_secrets' && f.remediationType === 'review_recommended');
|
||||
const hasReview = reviewCategories.some(cat => byCat[cat] && byCat[cat].length > 0) || piiFindings.length > 0;
|
||||
|
||||
if (hasReview) {
|
||||
lines.push('### Requires review');
|
||||
|
||||
if (byCat['error_handling']?.length) {
|
||||
const wfCount = uniqueWorkflows(byCat['error_handling']);
|
||||
lines.push(`**Error handling gaps** (${wfCount} workflow${wfCount !== 1 ? 's' : ''}): Add Error Trigger nodes or set continueOnFail on critical nodes.`);
|
||||
}
|
||||
|
||||
if (piiFindings.length > 0) {
|
||||
lines.push(`**PII in parameters** (${piiFindings.length} finding${piiFindings.length !== 1 ? 's' : ''}): Review whether hardcoded PII (emails, phones) is necessary or should use expressions.`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Requires your action ---
|
||||
const retentionFindings = byCat['data_retention'] || [];
|
||||
const builtinActions = extractBuiltinActionables(builtinAudit);
|
||||
const hasUserAction = retentionFindings.length > 0 || builtinActions.outdatedInstance || builtinActions.communityNodeCount > 0;
|
||||
|
||||
if (hasUserAction) {
|
||||
lines.push('### Requires your action');
|
||||
|
||||
if (retentionFindings.length > 0) {
|
||||
const wfCount = uniqueWorkflows(retentionFindings);
|
||||
lines.push(`**Data retention** (${wfCount} workflow${wfCount !== 1 ? 's' : ''}): Configure execution data pruning in n8n Settings -> Executions.`);
|
||||
}
|
||||
|
||||
if (builtinActions.outdatedInstance) {
|
||||
lines.push('**Outdated instance**: Update n8n to latest version.');
|
||||
}
|
||||
|
||||
if (builtinActions.communityNodeCount > 0) {
|
||||
lines.push(`**Community nodes** (${builtinActions.communityNodeCount}): Review installed community packages.`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildAuditReport(input: AuditReportInput): UnifiedAuditReport {
|
||||
const { builtinAudit, customReport, performance, instanceUrl, warnings } = input;
|
||||
|
||||
// --- Compute summary counts ---
|
||||
const customSummary = customReport?.summary ?? { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
|
||||
const builtinCount = countBuiltinLocations(builtinAudit);
|
||||
|
||||
const summary: UnifiedAuditReport['summary'] = {
|
||||
critical: customSummary.critical,
|
||||
high: customSummary.high,
|
||||
medium: customSummary.medium,
|
||||
low: customSummary.low + builtinCount, // built-in issues counted as low by default
|
||||
totalFindings: customSummary.total + builtinCount,
|
||||
workflowsScanned: customReport?.workflowsScanned ?? 0,
|
||||
};
|
||||
|
||||
// --- Build markdown ---
|
||||
const md: string[] = [];
|
||||
|
||||
// Header
|
||||
md.push('# n8n Security Audit Report');
|
||||
md.push(`Generated: ${new Date().toISOString()} | Instance: ${instanceUrl}`);
|
||||
md.push('');
|
||||
|
||||
// Summary table
|
||||
md.push('## Summary');
|
||||
md.push('| Severity | Count |');
|
||||
md.push('|----------|-------|');
|
||||
md.push(`| Critical | ${summary.critical} |`);
|
||||
md.push(`| High | ${summary.high} |`);
|
||||
md.push(`| Medium | ${summary.medium} |`);
|
||||
md.push(`| Low | ${summary.low} |`);
|
||||
md.push(`| **Total** | **${summary.totalFindings}** |`);
|
||||
md.push('');
|
||||
md.push(
|
||||
`Workflows scanned: ${summary.workflowsScanned} | Scan duration: ${(performance.totalMs / 1000).toFixed(1)}s`,
|
||||
);
|
||||
md.push('');
|
||||
|
||||
// Warnings
|
||||
if (warnings && warnings.length > 0) {
|
||||
for (const w of warnings) {
|
||||
md.push(`- ${w}`);
|
||||
}
|
||||
md.push('');
|
||||
}
|
||||
|
||||
md.push('---');
|
||||
md.push('');
|
||||
|
||||
// --- Findings by Workflow ---
|
||||
if (customReport && customReport.findings.length > 0) {
|
||||
md.push('## Findings by Workflow');
|
||||
md.push('');
|
||||
|
||||
const workflowGroups = groupByWorkflow(customReport.findings);
|
||||
|
||||
for (const group of workflowGroups) {
|
||||
const activeTag = group.workflowActive ? ' [ACTIVE]' : '';
|
||||
md.push(`### "${group.workflowName}" (id: ${group.workflowId})${activeTag} — ${group.findings.length} finding${group.findings.length !== 1 ? 's' : ''}`);
|
||||
md.push('');
|
||||
md.push('| ID | Severity | Finding | Node | Fix |');
|
||||
md.push('|----|----------|---------|------|-----|');
|
||||
|
||||
for (const f of group.findings) {
|
||||
const node = f.location.nodeName || '\u2014';
|
||||
const fix = FIX_LABEL[f.remediationType];
|
||||
const sevLabel = f.severity.charAt(0).toUpperCase() + f.severity.slice(1);
|
||||
md.push(`| ${f.id} | ${sevLabel} | ${f.title} | ${node} | ${fix} |`);
|
||||
}
|
||||
|
||||
md.push('');
|
||||
}
|
||||
|
||||
md.push('---');
|
||||
md.push('');
|
||||
}
|
||||
|
||||
// --- Built-in audit ---
|
||||
md.push('## n8n Built-in Audit Results');
|
||||
md.push('');
|
||||
md.push(renderBuiltinAudit(builtinAudit));
|
||||
md.push('');
|
||||
md.push('---');
|
||||
md.push('');
|
||||
|
||||
// --- Remediation Playbook ---
|
||||
md.push('## Remediation Playbook');
|
||||
md.push('');
|
||||
const playbook = renderRemediationPlaybook(customReport?.findings ?? [], builtinAudit);
|
||||
if (playbook.trim().length > 0) {
|
||||
md.push(playbook);
|
||||
} else {
|
||||
md.push('No remediation actions needed.');
|
||||
md.push('');
|
||||
}
|
||||
md.push('---');
|
||||
md.push('');
|
||||
|
||||
// Performance footer
|
||||
md.push(
|
||||
`Scan performance: built-in ${performance.builtinAuditMs}ms | fetch ${performance.workflowFetchMs}ms | custom ${performance.customScanMs}ms`,
|
||||
);
|
||||
|
||||
return {
|
||||
markdown: md.join('\n'),
|
||||
summary,
|
||||
};
|
||||
}
|
||||
297
src/services/credential-scanner.ts
Normal file
297
src/services/credential-scanner.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Regex-based credential and PII scanner for n8n workflows.
|
||||
*
|
||||
* TypeScript port of pii_prescreen.py. Catches API keys, secrets, and PII
|
||||
* with deterministic patterns. Covers 50+ service-specific key prefixes
|
||||
* plus generic PII patterns (email, phone, credit card).
|
||||
*
|
||||
* SECURITY: Raw secret values are never stored in detection results.
|
||||
* The maskSecret() function is called at scan time so only masked
|
||||
* snippets appear in the output.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SecretPattern {
|
||||
regex: RegExp;
|
||||
label: string;
|
||||
category: string;
|
||||
severity: 'critical' | 'high' | 'medium';
|
||||
}
|
||||
|
||||
export interface ScanDetection {
|
||||
label: string;
|
||||
category: string;
|
||||
severity: 'critical' | 'high' | 'medium';
|
||||
location: {
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
nodeName?: string;
|
||||
nodeType?: string;
|
||||
};
|
||||
maskedSnippet?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skip fields - structural / template fields that should not be scanned
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKIP_FIELDS = new Set<string>([
|
||||
'expression',
|
||||
'id',
|
||||
'typeVersion',
|
||||
'position',
|
||||
'credentials',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret patterns (instant reject)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SECRET_PATTERNS: SecretPattern[] = [
|
||||
// ── AI / ML ──────────────────────────────────────────────────────────────
|
||||
{ regex: /sk-(?:proj-)?[A-Za-z0-9]{20,}/, label: 'openai_key', category: 'AI/ML', severity: 'critical' },
|
||||
{ regex: /sk-ant-[A-Za-z0-9_-]{20,}/, label: 'anthropic_key', category: 'AI/ML', severity: 'critical' },
|
||||
{ regex: /gsk_[a-zA-Z0-9]{48,}/, label: 'groq_key', category: 'AI/ML', severity: 'critical' },
|
||||
{ regex: /r8_[a-zA-Z0-9]{37}/, label: 'replicate_key', category: 'AI/ML', severity: 'critical' },
|
||||
{ regex: /hf_[a-zA-Z]{34}/, label: 'huggingface_key', category: 'AI/ML', severity: 'critical' },
|
||||
{ regex: /pplx-[a-zA-Z0-9]{48}/, label: 'perplexity_key', category: 'AI/ML', severity: 'critical' },
|
||||
|
||||
// ── Cloud / DevOps ───────────────────────────────────────────────────────
|
||||
{ regex: /AKIA[A-Z0-9]{16}/, label: 'aws_key', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
{ regex: /AIza[A-Za-z0-9_-]{35}/, label: 'google_api_key', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
{ regex: /dop_v1_[a-f0-9]{64}/, label: 'digitalocean_pat', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
{ regex: /do[or]_v1_[a-f0-9]{64}/, label: 'digitalocean_token', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
{ regex: /v(?:cp|ci|ck)_[a-zA-Z0-9]{24,}/, label: 'vercel_token', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
{ regex: /nfp_[a-zA-Z0-9]{40,}/, label: 'netlify_pat', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
{ regex: /HRKU-AA[0-9a-zA-Z_-]{58}/, label: 'heroku_key', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
{ regex: /glpat-[\w-]{20}/, label: 'gitlab_pat', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
{ regex: /npm_[a-z0-9]{36}/, label: 'npm_token', category: 'Cloud/DevOps', severity: 'critical' },
|
||||
|
||||
// ── GitHub ───────────────────────────────────────────────────────────────
|
||||
{ regex: /ghp_[A-Za-z0-9]{36,}/, label: 'github_pat', category: 'GitHub', severity: 'critical' },
|
||||
{ regex: /gho_[A-Za-z0-9]{36,}/, label: 'github_oauth', category: 'GitHub', severity: 'critical' },
|
||||
{ regex: /ghs_[A-Za-z0-9]{36,}/, label: 'github_server', category: 'GitHub', severity: 'critical' },
|
||||
{ regex: /ghr_[A-Za-z0-9]{36,}/, label: 'github_refresh', category: 'GitHub', severity: 'critical' },
|
||||
|
||||
// ── Auth tokens ──────────────────────────────────────────────────────────
|
||||
{ regex: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, label: 'jwt_token', category: 'Auth tokens', severity: 'critical' },
|
||||
{ regex: /sbp_[a-f0-9]{40,}/, label: 'supabase_secret', category: 'Auth tokens', severity: 'critical' },
|
||||
|
||||
// ── Communication ────────────────────────────────────────────────────────
|
||||
{ regex: /xox[bps]-[0-9]{10,}-[A-Za-z0-9-]+/, label: 'slack_token', category: 'Communication', severity: 'critical' },
|
||||
{ regex: /\b\d{8,10}:A[a-zA-Z0-9_-]{34}\b/, label: 'telegram_bot', category: 'Communication', severity: 'critical' },
|
||||
|
||||
// ── Payment ──────────────────────────────────────────────────────────────
|
||||
{ regex: /[sr]k_(?:live|test)_[A-Za-z0-9]{20,}/, label: 'stripe_key', category: 'Payment', severity: 'critical' },
|
||||
{ regex: /sq0(?:atp|csp)-[0-9A-Za-z_-]{22,}/, label: 'square_key', category: 'Payment', severity: 'critical' },
|
||||
{ regex: /rzp_(?:live|test)_[a-zA-Z0-9]{14,}/, label: 'razorpay_key', category: 'Payment', severity: 'critical' },
|
||||
|
||||
// ── Email / Marketing ────────────────────────────────────────────────────
|
||||
{ regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/, label: 'sendgrid_key', category: 'Email/Marketing', severity: 'critical' },
|
||||
{ regex: /key-[a-f0-9]{32}/, label: 'mailgun_key', category: 'Email/Marketing', severity: 'high' },
|
||||
{ regex: /xkeysib-[a-f0-9]{64}-[a-zA-Z0-9]{16}/, label: 'brevo_key', category: 'Email/Marketing', severity: 'critical' },
|
||||
{ regex: /(?<!\w)re_[a-zA-Z0-9_]{32,}/, label: 'resend_key', category: 'Email/Marketing', severity: 'critical' },
|
||||
{ regex: /[a-f0-9]{32}-us\d{1,2}\b/, label: 'mailchimp_key', category: 'Email/Marketing', severity: 'critical' },
|
||||
|
||||
// ── E-commerce ───────────────────────────────────────────────────────────
|
||||
{ regex: /shp(?:at|ca|pa|ss)_[a-fA-F0-9]{32,}/, label: 'shopify_token', category: 'E-commerce', severity: 'critical' },
|
||||
|
||||
// ── Productivity / CRM ───────────────────────────────────────────────────
|
||||
{ regex: /ntn_[0-9]{11}[A-Za-z0-9]{35}/, label: 'notion_token', category: 'Productivity/CRM', severity: 'critical' },
|
||||
{ regex: /secret_[a-zA-Z0-9]{43}\b/, label: 'notion_legacy', category: 'Productivity/CRM', severity: 'critical' },
|
||||
{ regex: /lin_api_[a-zA-Z0-9]{40}/, label: 'linear_key', category: 'Productivity/CRM', severity: 'critical' },
|
||||
{ regex: /CFPAT-[a-zA-Z0-9_-]{43,}/, label: 'contentful_pat', category: 'Productivity/CRM', severity: 'critical' },
|
||||
{ regex: /ATATT[a-zA-Z0-9_-]{50,}/, label: 'atlassian_token', category: 'Productivity/CRM', severity: 'critical' },
|
||||
{ regex: /pat-(?:na1|eu1)-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/, label: 'hubspot_pat', category: 'Productivity/CRM', severity: 'critical' },
|
||||
|
||||
// ── Monitoring / Analytics ───────────────────────────────────────────────
|
||||
{ regex: /sntr[ysu]_[a-zA-Z0-9+/=]{40,}/, label: 'sentry_token', category: 'Monitoring/Analytics', severity: 'critical' },
|
||||
{ regex: /ph[cx]_[a-zA-Z0-9]{32,}/, label: 'posthog_key', category: 'Monitoring/Analytics', severity: 'critical' },
|
||||
{ regex: /gl(?:c|sa)_[A-Za-z0-9+/=_]{32,}/, label: 'grafana_key', category: 'Monitoring/Analytics', severity: 'critical' },
|
||||
{ regex: /NRAK-[A-Z0-9]{27}/, label: 'newrelic_key', category: 'Monitoring/Analytics', severity: 'critical' },
|
||||
|
||||
// ── Database ─────────────────────────────────────────────────────────────
|
||||
{ regex: /pscale_(?:tkn|pw|oauth)_[a-zA-Z0-9=._-]{32,}/, label: 'planetscale_key', category: 'Database', severity: 'critical' },
|
||||
{ regex: /dapi[a-f0-9]{32}/, label: 'databricks_key', category: 'Database', severity: 'critical' },
|
||||
|
||||
// ── Other services ───────────────────────────────────────────────────────
|
||||
{ regex: /SK[a-f0-9]{32}/, label: 'twilio_key', category: 'Other', severity: 'critical' },
|
||||
{ regex: /\bpat[A-Za-z0-9]{10,}\.[A-Za-z0-9]{20,}/, label: 'airtable_pat', category: 'Other', severity: 'critical' },
|
||||
{ regex: /apify_api_[A-Za-z0-9]{20,}/, label: 'apify_key', category: 'Other', severity: 'critical' },
|
||||
{ regex: /figd_[a-zA-Z0-9_-]{40,}/, label: 'figma_pat', category: 'Other', severity: 'critical' },
|
||||
{ regex: /PMAK-[a-f0-9]{24}-[a-f0-9]{34}/, label: 'postman_key', category: 'Other', severity: 'critical' },
|
||||
{ regex: /dp\.(?:pt|st|sa)\.[a-zA-Z0-9._-]{40,}/, label: 'doppler_token', category: 'Other', severity: 'critical' },
|
||||
|
||||
// ── Generic patterns (keep last - catch-all) ────────────────────────────
|
||||
{ regex: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/, label: 'private_key', category: 'Generic', severity: 'critical' },
|
||||
{ regex: /Bearer\s+[A-Za-z0-9._-]{32,}/i, label: 'bearer_token', category: 'Generic', severity: 'high' },
|
||||
{ regex: /(?:https?|postgres|mysql|mongodb|redis|amqp):\/\/[^:"\s]+:[^@"\s]+@[^\s"]+/, label: 'url_with_auth', category: 'Generic', severity: 'critical' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PII patterns (instant reject)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PII_PATTERNS: SecretPattern[] = [
|
||||
// Email addresses (but not template expressions like {{$json.email}})
|
||||
{ regex: /(?<!\{)\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b(?!\})/, label: 'email', category: 'PII', severity: 'medium' },
|
||||
// Phone numbers (international formats)
|
||||
{ regex: /(?<!\d)\+?\d{1,3}[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?!\d)/, label: 'phone', category: 'PII', severity: 'medium' },
|
||||
// Credit card numbers
|
||||
{ regex: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/, label: 'credit_card', category: 'PII', severity: 'high' },
|
||||
];
|
||||
|
||||
// Combined patterns for internal use
|
||||
const ALL_PATTERNS: SecretPattern[] = [...SECRET_PATTERNS, ...PII_PATTERNS];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// maskSecret - shows first 6 + last 4 chars, masks the rest with ****
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Masks a secret value, showing only the first 6 and last 4 characters.
|
||||
* Values shorter than 14 characters get fully masked to avoid leaking
|
||||
* most of the original content.
|
||||
*/
|
||||
export function maskSecret(value: string): string {
|
||||
if (value.length < 14) {
|
||||
return '****';
|
||||
}
|
||||
const head = value.slice(0, 6);
|
||||
const tail = value.slice(-4);
|
||||
return `${head}****${tail}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// collectStrings - recursively collect scannable string values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function collectStrings(
|
||||
obj: unknown,
|
||||
parts: string[],
|
||||
depth: number = 0,
|
||||
): void {
|
||||
if (depth > 10) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
// Skip pure expression strings like "={{ $json.email }}" or "{{ ... }}"
|
||||
if (obj.startsWith('=') || obj.startsWith('{{')) {
|
||||
return;
|
||||
}
|
||||
// Skip very short strings (booleans, ops like "get")
|
||||
if (obj.length > 8) {
|
||||
parts.push(obj);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
collectStrings(item, parts, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
for (const [key, val] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (SKIP_FIELDS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
collectStrings(val, parts, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow scanning input type (loose, to accept various workflow shapes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScanWorkflowInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
nodes: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
notes?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
settings?: Record<string, unknown>;
|
||||
staticData?: Record<string, unknown>;
|
||||
pinData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scanText - match collected strings against all patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scanText(
|
||||
parts: string[],
|
||||
location: ScanDetection['location'],
|
||||
detections: ScanDetection[],
|
||||
): void {
|
||||
const text = parts.join('\n');
|
||||
for (const pattern of ALL_PATTERNS) {
|
||||
const match = pattern.regex.exec(text);
|
||||
if (match) {
|
||||
detections.push({
|
||||
label: pattern.label,
|
||||
category: pattern.category,
|
||||
severity: pattern.severity,
|
||||
location,
|
||||
maskedSnippet: maskSecret(match[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scanWorkflow - scan a workflow for secrets and PII
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scans an n8n workflow for embedded secrets and PII.
|
||||
*
|
||||
* Scans per-node so detections include the specific node name/type.
|
||||
* Top-level fields (pinData, staticData, settings) are attributed to
|
||||
* the workflow level (no specific node).
|
||||
*
|
||||
* SECURITY: Raw secret values are never stored in the returned detections.
|
||||
* Only masked snippets (via maskSecret()) appear in the output.
|
||||
*/
|
||||
export function scanWorkflow(workflow: ScanWorkflowInput): ScanDetection[] {
|
||||
const detections: ScanDetection[] = [];
|
||||
const baseLocation = {
|
||||
workflowId: workflow.id ?? '',
|
||||
workflowName: workflow.name ?? '',
|
||||
};
|
||||
|
||||
// Scan each node individually for precise location reporting
|
||||
for (const node of workflow.nodes ?? []) {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (node.name && node.name.length > 8) parts.push(node.name);
|
||||
if (node.notes && node.notes.length > 8) parts.push(node.notes);
|
||||
if (node.parameters) collectStrings(node.parameters, parts);
|
||||
|
||||
scanText(parts, { ...baseLocation, nodeName: node.name, nodeType: node.type }, detections);
|
||||
}
|
||||
|
||||
// Scan top-level fields: pinData, staticData, settings
|
||||
for (const key of ['pinData', 'staticData', 'settings'] as const) {
|
||||
const data = (workflow as unknown as Record<string, unknown>)[key];
|
||||
if (data != null && typeof data === 'object') {
|
||||
const parts: string[] = [];
|
||||
collectStrings(data, parts);
|
||||
scanText(parts, { ...baseLocation }, detections);
|
||||
}
|
||||
}
|
||||
|
||||
return detections;
|
||||
}
|
||||
@@ -77,9 +77,11 @@ export class N8nApiClient {
|
||||
// Request interceptor for logging
|
||||
this.client.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Redact request body for credential endpoints to prevent secret leakage
|
||||
const isSensitive = config.url?.includes('/credentials') && config.method !== 'get';
|
||||
logger.debug(`n8n API Request: ${config.method?.toUpperCase()} ${config.url}`, {
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
data: isSensitive ? '[REDACTED]' : config.data,
|
||||
});
|
||||
return config;
|
||||
},
|
||||
@@ -133,13 +135,7 @@ export class N8nApiClient {
|
||||
* Internal method to fetch version once
|
||||
*/
|
||||
private async fetchVersionOnce(): Promise<N8nVersionInfo | null> {
|
||||
// Check if already cached globally
|
||||
let version = getCachedVersion(this.baseUrl);
|
||||
if (!version) {
|
||||
// Fetch from server
|
||||
version = await fetchN8nVersion(this.baseUrl);
|
||||
}
|
||||
return version;
|
||||
return getCachedVersion(this.baseUrl) ?? await fetchN8nVersion(this.baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,6 +305,40 @@ export class N8nApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Audit
|
||||
async generateAudit(options?: { categories?: string[]; daysAbandonedWorkflow?: number }): Promise<any> {
|
||||
try {
|
||||
const additionalOptions: Record<string, unknown> = {};
|
||||
if (options?.categories) additionalOptions.categories = options.categories;
|
||||
if (options?.daysAbandonedWorkflow !== undefined) additionalOptions.daysAbandonedWorkflow = options.daysAbandonedWorkflow;
|
||||
|
||||
const body = Object.keys(additionalOptions).length > 0 ? { additionalOptions } : {};
|
||||
const response = await this.client.post('/audit', body);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all workflows with pagination (for audit scanning)
|
||||
async listAllWorkflows(): Promise<Workflow[]> {
|
||||
const allWorkflows: Workflow[] = [];
|
||||
let cursor: string | undefined;
|
||||
const seenCursors = new Set<string>();
|
||||
const PAGE_SIZE = 100;
|
||||
const MAX_PAGES = 50; // Safety limit: 5000 workflows max
|
||||
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const params: WorkflowListParams = { limit: PAGE_SIZE, cursor };
|
||||
const response = await this.listWorkflows(params);
|
||||
allWorkflows.push(...response.data);
|
||||
if (!response.nextCursor || seenCursors.has(response.nextCursor)) break;
|
||||
seenCursors.add(response.nextCursor);
|
||||
cursor = response.nextCursor;
|
||||
}
|
||||
return allWorkflows;
|
||||
}
|
||||
|
||||
// Execution Management
|
||||
async getExecution(id: string, includeData = false): Promise<Execution> {
|
||||
try {
|
||||
@@ -461,6 +491,15 @@ export class N8nApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getCredentialSchema(typeName: string): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get(`/credentials/schema/${typeName}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Tag Management
|
||||
/**
|
||||
* Lists tags from n8n instance.
|
||||
|
||||
347
src/services/workflow-security-scanner.ts
Normal file
347
src/services/workflow-security-scanner.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Workflow security scanner that orchestrates 4 security checks on n8n workflows:
|
||||
* 1. Hardcoded secrets (via credential-scanner)
|
||||
* 2. Unauthenticated webhooks
|
||||
* 3. Error handling gaps
|
||||
* 4. Data retention settings
|
||||
*/
|
||||
|
||||
import { scanWorkflow, type ScanDetection } from './credential-scanner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AuditSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||
export type RemediationType = 'auto_fixable' | 'user_input_needed' | 'user_action_needed' | 'review_recommended';
|
||||
export type CustomCheckType = 'hardcoded_secrets' | 'unauthenticated_webhooks' | 'error_handling' | 'data_retention';
|
||||
|
||||
export interface AuditFinding {
|
||||
id: string;
|
||||
severity: AuditSeverity;
|
||||
category: CustomCheckType;
|
||||
title: string;
|
||||
description: string;
|
||||
recommendation: string;
|
||||
remediationType: RemediationType;
|
||||
remediation?: {
|
||||
tool: string;
|
||||
args: Record<string, unknown>;
|
||||
description: string;
|
||||
}[];
|
||||
location: {
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
workflowActive?: boolean;
|
||||
nodeName?: string;
|
||||
nodeType?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowSecurityReport {
|
||||
findings: AuditFinding[];
|
||||
workflowsScanned: number;
|
||||
scanDurationMs: number;
|
||||
summary: {
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow input type (loose, to accept various workflow shapes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WorkflowInput {
|
||||
id?: string;
|
||||
name: string;
|
||||
nodes: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
notes?: string;
|
||||
continueOnFail?: boolean;
|
||||
onError?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
active?: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
staticData?: Record<string, unknown>;
|
||||
connections?: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 1: Hardcoded secrets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function checkHardcodedSecrets(workflow: WorkflowInput): AuditFinding[] {
|
||||
const detections: ScanDetection[] = scanWorkflow({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
nodes: workflow.nodes,
|
||||
settings: workflow.settings,
|
||||
staticData: workflow.staticData,
|
||||
});
|
||||
|
||||
return detections.map((detection, index): AuditFinding => {
|
||||
const workflowId = workflow.id ?? '';
|
||||
const nodeName = detection.location.nodeName ?? '';
|
||||
const isPii = detection.category.toLowerCase() === 'pii';
|
||||
|
||||
return {
|
||||
id: `CRED-${String(index + 1).padStart(3, '0')}`,
|
||||
severity: detection.severity as AuditSeverity,
|
||||
category: 'hardcoded_secrets',
|
||||
title: `Hardcoded ${detection.label} detected`,
|
||||
description: `Found a hardcoded ${detection.label} (${detection.category}) in ${nodeName ? `node "${nodeName}"` : 'workflow-level settings'}. Masked value: ${detection.maskedSnippet ?? 'N/A'}.`,
|
||||
recommendation: isPii
|
||||
? 'Review whether this PII is necessary in the workflow. If it is test data or a placeholder, consider using n8n expressions or environment variables instead of hardcoded values.'
|
||||
: 'Move this secret into n8n credentials. The agent can extract the hardcoded value from the workflow, create a credential, and update the node automatically.',
|
||||
remediationType: isPii ? 'review_recommended' : 'auto_fixable',
|
||||
remediation: isPii
|
||||
? []
|
||||
: [
|
||||
{
|
||||
tool: 'n8n_get_workflow',
|
||||
args: { id: workflowId },
|
||||
description: `Fetch workflow to extract the hardcoded ${detection.label} from node "${nodeName}"`,
|
||||
},
|
||||
{
|
||||
tool: 'n8n_manage_credentials',
|
||||
args: { action: 'create', type: 'httpHeaderAuth' },
|
||||
description: `Create credential with the extracted value (choose appropriate type for ${detection.label})`,
|
||||
},
|
||||
{
|
||||
tool: 'n8n_update_partial_workflow',
|
||||
args: { id: workflowId, operations: [{ type: 'updateNode', nodeName }] },
|
||||
description: `Update node to use credential and remove hardcoded value`,
|
||||
},
|
||||
],
|
||||
location: {
|
||||
workflowId,
|
||||
workflowName: workflow.name,
|
||||
workflowActive: workflow.active,
|
||||
nodeName: detection.location.nodeName,
|
||||
nodeType: detection.location.nodeType,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 2: Unauthenticated webhooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function checkUnauthenticatedWebhooks(workflow: WorkflowInput): AuditFinding[] {
|
||||
const findings: AuditFinding[] = [];
|
||||
let sequence = 0;
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
const nodeTypeLower = (node.type ?? '').toLowerCase();
|
||||
// respondToWebhook is a response helper, not a trigger — skip it
|
||||
if (nodeTypeLower.includes('respondtowebhook')) {
|
||||
continue;
|
||||
}
|
||||
if (!nodeTypeLower.includes('webhook') && !nodeTypeLower.includes('formtrigger')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auth = node.parameters?.authentication;
|
||||
|
||||
// Skip nodes that already have authentication configured
|
||||
if (typeof auth === 'string' && auth !== '' && auth !== 'none') {
|
||||
continue;
|
||||
}
|
||||
|
||||
sequence++;
|
||||
const workflowId = workflow.id ?? '';
|
||||
const isActive = workflow.active === true;
|
||||
|
||||
findings.push({
|
||||
id: `WEBHOOK-${String(sequence).padStart(3, '0')}`,
|
||||
severity: isActive ? 'high' : 'medium',
|
||||
category: 'unauthenticated_webhooks',
|
||||
title: `Unauthenticated webhook: "${node.name}"`,
|
||||
description: `Webhook node "${node.name}" (${node.type}) has no authentication configured.${isActive ? ' This workflow is active and publicly accessible.' : ''} Anyone with the webhook URL can trigger this workflow.`,
|
||||
recommendation: 'Add authentication to the webhook node. Header-based authentication with a random secret is the simplest approach.',
|
||||
remediationType: 'auto_fixable',
|
||||
remediation: [
|
||||
{
|
||||
tool: 'n8n_manage_credentials',
|
||||
args: { action: 'create', type: 'httpHeaderAuth' },
|
||||
description: `Create httpHeaderAuth credential with a generated random secret`,
|
||||
},
|
||||
{
|
||||
tool: 'n8n_update_partial_workflow',
|
||||
args: { id: workflowId, operations: [{ type: 'updateNode', nodeName: node.name }] },
|
||||
description: `Set authentication to "headerAuth" and assign the credential`,
|
||||
},
|
||||
],
|
||||
location: {
|
||||
workflowId,
|
||||
workflowName: workflow.name,
|
||||
workflowActive: isActive,
|
||||
nodeName: node.name,
|
||||
nodeType: node.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 3: Error handling gaps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function checkErrorHandlingGaps(workflow: WorkflowInput): AuditFinding[] {
|
||||
// Only flag workflows with 3+ nodes
|
||||
if (workflow.nodes.length < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hasContinueOnFail = workflow.nodes.some(
|
||||
(node) => node.continueOnFail === true,
|
||||
);
|
||||
|
||||
const hasOnErrorHandling = workflow.nodes.some(
|
||||
(node) => typeof node.onError === 'string' && node.onError !== 'stopWorkflow',
|
||||
);
|
||||
|
||||
const hasErrorTrigger = workflow.nodes.some(
|
||||
(node) => (node.type ?? '').toLowerCase() === 'n8n-nodes-base.errortrigger',
|
||||
);
|
||||
|
||||
if (hasContinueOnFail || hasOnErrorHandling || hasErrorTrigger) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'ERR-001',
|
||||
severity: 'medium',
|
||||
category: 'error_handling',
|
||||
title: `No error handling in workflow "${workflow.name}"`,
|
||||
description: `Workflow "${workflow.name}" has ${workflow.nodes.length} nodes but no error handling configured. There are no nodes with continueOnFail enabled, no custom onError behavior, and no Error Trigger node.`,
|
||||
recommendation: 'Add error handling to prevent silent failures. Consider adding an Error Trigger node for global error notifications, or set continueOnFail on critical nodes that should not block the workflow.',
|
||||
remediationType: 'review_recommended',
|
||||
location: {
|
||||
workflowId: workflow.id ?? '',
|
||||
workflowName: workflow.name,
|
||||
workflowActive: workflow.active,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check 4: Data retention settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function checkDataRetentionSettings(workflow: WorkflowInput): AuditFinding[] {
|
||||
const settings = workflow.settings;
|
||||
if (!settings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const savesAllData =
|
||||
settings.saveDataErrorExecution === 'all' &&
|
||||
settings.saveDataSuccessExecution === 'all';
|
||||
|
||||
if (!savesAllData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'RETENTION-001',
|
||||
severity: 'low',
|
||||
category: 'data_retention',
|
||||
title: `Excessive data retention in workflow "${workflow.name}"`,
|
||||
description: `Workflow "${workflow.name}" is configured to save execution data for both successful and failed executions. This may store sensitive data in the n8n database longer than necessary.`,
|
||||
recommendation: 'Review data retention settings. Consider setting saveDataSuccessExecution to "none" for workflows that process sensitive data, or configure execution data pruning at the instance level.',
|
||||
remediationType: 'user_action_needed',
|
||||
location: {
|
||||
workflowId: workflow.id ?? '',
|
||||
workflowName: workflow.name,
|
||||
workflowActive: workflow.active,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHECK_MAP: Record<CustomCheckType, (workflow: WorkflowInput) => AuditFinding[]> = {
|
||||
hardcoded_secrets: checkHardcodedSecrets,
|
||||
unauthenticated_webhooks: checkUnauthenticatedWebhooks,
|
||||
error_handling: checkErrorHandlingGaps,
|
||||
data_retention: checkDataRetentionSettings,
|
||||
};
|
||||
|
||||
const ALL_CHECKS = Object.keys(CHECK_MAP) as CustomCheckType[];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Scans one or more n8n workflows for security issues.
|
||||
*
|
||||
* Runs up to 4 checks: hardcoded secrets, unauthenticated webhooks,
|
||||
* error handling gaps, and data retention settings.
|
||||
*
|
||||
* @param workflows - Array of workflow objects to scan
|
||||
* @param checks - Optional subset of checks to run (defaults to all 4)
|
||||
* @returns A security report with all findings and summary counts
|
||||
*/
|
||||
export function scanWorkflows(
|
||||
workflows: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
nodes: any[];
|
||||
active?: boolean;
|
||||
settings?: any;
|
||||
staticData?: any;
|
||||
connections?: any;
|
||||
}>,
|
||||
checks?: CustomCheckType[],
|
||||
): WorkflowSecurityReport {
|
||||
const startTime = Date.now();
|
||||
const checksToRun = checks ?? ALL_CHECKS;
|
||||
const allFindings: AuditFinding[] = [];
|
||||
|
||||
for (const workflow of workflows) {
|
||||
for (const checkType of checksToRun) {
|
||||
const findings = CHECK_MAP[checkType](workflow);
|
||||
allFindings.push(...findings);
|
||||
}
|
||||
}
|
||||
|
||||
const scanDurationMs = Date.now() - startTime;
|
||||
|
||||
const summary = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
total: allFindings.length,
|
||||
};
|
||||
|
||||
for (const finding of allFindings) {
|
||||
summary[finding.severity]++;
|
||||
}
|
||||
|
||||
return {
|
||||
findings: allFindings,
|
||||
workflowsScanned: workflows.length,
|
||||
scanDurationMs,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
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