perf(core): Lazyload security audit reporters (#7696)

Also converting to service.

Followup to https://github.com/n8n-io/n8n/pull/7663
This commit is contained in:
Iván Ovejero 2023-11-13 11:50:43 +01:00 committed by GitHub
parent a08fca51d9
commit b2ca050031
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 779 additions and 718 deletions

View file

@ -9,7 +9,7 @@ import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { UserManagementMailer } from '@/UserManagement/email'; import type { UserManagementMailer } from '@/UserManagement/email';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/security-audit/types';
export type AuthlessRequest< export type AuthlessRequest<
RouteParams = {}, RouteParams = {},

View file

@ -1,14 +1,15 @@
import { authorize } from '@/PublicApi/v1/shared/middlewares/global.middleware'; import { authorize } from '@/PublicApi/v1/shared/middlewares/global.middleware';
import { audit } from '@/audit';
import type { Response } from 'express'; import type { Response } from 'express';
import type { AuditRequest } from '@/PublicApi/types'; import type { AuditRequest } from '@/PublicApi/types';
import Container from 'typedi';
export = { export = {
generateAudit: [ generateAudit: [
authorize(['owner']), authorize(['owner']),
async (req: AuditRequest.Generate, res: Response): Promise<Response> => { async (req: AuditRequest.Generate, res: Response): Promise<Response> => {
try { try {
const result = await audit( const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service');
const result = await Container.get(SecurityAuditService).run(
req.body?.additionalOptions?.categories, req.body?.additionalOptions?.categories,
req.body?.additionalOptions?.daysAbandonedWorkflow, req.body?.additionalOptions?.daysAbandonedWorkflow,
); );

View file

@ -1,69 +0,0 @@
import { separate } from '@/utils';
import config from '@/config';
import { RISK_CATEGORIES } from '@/audit/constants';
import { toReportTitle } from '@/audit/utils';
import { reportCredentialsRisk } from '@/audit/risks/credentials.risk';
import { reportDatabaseRisk } from '@/audit/risks/database.risk';
import { reportNodesRisk } from '@/audit/risks/nodes.risk';
import { reportFilesystemRisk } from '@/audit/risks/filesystem.risk';
import { reportInstanceRisk } from '@/audit/risks/instance.risk';
import type { Risk } from '@/audit/types';
import Container from 'typedi';
import { WorkflowRepository } from '@db/repositories/workflow.repository';
export const SYNC_MAP: Record<string, Risk.SyncReportFn> = {
database: reportDatabaseRisk,
filesystem: reportFilesystemRisk,
};
export const ASYNC_MAP: Record<string, Risk.AsyncReportFn> = {
credentials: reportCredentialsRisk,
nodes: reportNodesRisk,
instance: reportInstanceRisk,
};
export const isAsync = (c: Risk.Category) => Object.keys(ASYNC_MAP).includes(c);
export async function audit(
categories: Risk.Category[] = RISK_CATEGORIES,
daysAbandonedWorkflow?: number,
) {
if (categories.length === 0) categories = RISK_CATEGORIES;
const daysFromEnv = config.getEnv('security.audit.daysAbandonedWorkflow');
if (daysAbandonedWorkflow) {
config.set('security.audit.daysAbandonedWorkflow', daysAbandonedWorkflow);
}
const workflows = await Container.get(WorkflowRepository).find({
select: ['id', 'name', 'active', 'nodes', 'connections'],
});
const [asyncCategories, syncCategories] = separate(categories, isAsync);
const reports: Risk.Report[] = [];
if (asyncCategories.length > 0) {
const promises = asyncCategories.map(async (c) => ASYNC_MAP[c](workflows));
const asyncReports = await Promise.all(promises);
asyncReports.forEach((r) => r !== null && reports.push(r));
}
if (syncCategories.length > 0) {
const syncReports = syncCategories.map((c) => SYNC_MAP[c](workflows));
syncReports.forEach((r) => r !== null && reports.push(r));
}
if (daysAbandonedWorkflow) {
config.set('security.audit.daysAbandonedWorkflow', daysFromEnv); // restore env
}
if (reports.length === 0) return []; // trigger empty state
return reports.reduce<Risk.Audit>((acc, cur) => {
acc[toReportTitle(cur.risk)] = cur;
return acc;
}, {});
}

View file

@ -1,146 +0,0 @@
import { In, MoreThanOrEqual } from 'typeorm';
import { DateUtils } from 'typeorm/util/DateUtils';
import { Container } from 'typedi';
import type { IWorkflowBase } from 'n8n-workflow';
import config from '@/config';
import { CREDENTIALS_REPORT } from '@/audit/constants';
import type { Risk } from '@/audit/types';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { ExecutionRepository } from '@db/repositories/execution.repository';
import { ExecutionDataRepository } from '@db/repositories/executionData.repository';
async function getAllCredsInUse(workflows: WorkflowEntity[]) {
const credsInAnyUse = new Set<string>();
const credsInActiveUse = new Set<string>();
workflows.forEach((workflow) => {
workflow.nodes.forEach((node) => {
if (!node.credentials) return;
Object.values(node.credentials).forEach((cred) => {
if (!cred?.id) return;
credsInAnyUse.add(cred.id);
if (workflow.active) credsInActiveUse.add(cred.id);
});
});
});
return {
credsInAnyUse,
credsInActiveUse,
};
}
async function getAllExistingCreds() {
const credentials = await Container.get(CredentialsRepository).find({ select: ['id', 'name'] });
return credentials.map(({ id, name }) => ({ kind: 'credential' as const, id, name }));
}
async function getExecutedWorkflowsInPastDays(days: number): Promise<IWorkflowBase[]> {
const date = new Date();
date.setDate(date.getDate() - days);
const executionIds = await Container.get(ExecutionRepository)
.find({
select: ['id'],
where: {
startedAt: MoreThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date) as Date),
},
})
.then((executions) => executions.map(({ id }) => id));
return Container.get(ExecutionDataRepository)
.find({
select: ['workflowData'],
where: {
executionId: In(executionIds),
},
})
.then((executionData) => executionData.map(({ workflowData }) => workflowData));
}
/**
* Return IDs of credentials in workflows executed in the past n days.
*/
async function getCredsInRecentlyExecutedWorkflows(days: number) {
const executedWorkflows = await getExecutedWorkflowsInPastDays(days);
return executedWorkflows.reduce<Set<string>>((acc, { nodes }) => {
nodes.forEach((node) => {
if (node.credentials) {
Object.values(node.credentials).forEach((c) => {
if (c.id) acc.add(c.id);
});
}
});
return acc;
}, new Set());
}
export async function reportCredentialsRisk(workflows: WorkflowEntity[]) {
const days = config.getEnv('security.audit.daysAbandonedWorkflow');
const allExistingCreds = await getAllExistingCreds();
const { credsInAnyUse, credsInActiveUse } = await getAllCredsInUse(workflows);
const recentlyExecutedCreds = await getCredsInRecentlyExecutedWorkflows(days);
const credsNotInAnyUse = allExistingCreds.filter((c) => !credsInAnyUse.has(c.id));
const credsNotInActiveUse = allExistingCreds.filter((c) => !credsInActiveUse.has(c.id));
const credsNotRecentlyExecuted = allExistingCreds.filter((c) => !recentlyExecutedCreds.has(c.id));
const issues = [credsNotInAnyUse, credsNotInActiveUse, credsNotRecentlyExecuted];
if (issues.every((i) => i.length === 0)) return null;
const report: Risk.StandardReport = {
risk: CREDENTIALS_REPORT.RISK,
sections: [],
};
const hint = 'Keeping unused credentials in your instance is an unneeded security risk.';
const recommendation = 'Consider deleting these credentials if you no longer need them.';
const sentenceStart = ({ length }: { length: number }) =>
length > 1 ? 'These credentials are' : 'This credential is';
if (credsNotInAnyUse.length > 0) {
report.sections.push({
title: CREDENTIALS_REPORT.SECTIONS.CREDS_NOT_IN_ANY_USE,
description: [sentenceStart(credsNotInAnyUse), 'not used in any workflow.', hint].join(' '),
recommendation,
location: credsNotInAnyUse,
});
}
if (credsNotInActiveUse.length > 0) {
report.sections.push({
title: CREDENTIALS_REPORT.SECTIONS.CREDS_NOT_IN_ACTIVE_USE,
description: [sentenceStart(credsNotInActiveUse), 'not used in active workflows.', hint].join(
' ',
),
recommendation,
location: credsNotInActiveUse,
});
}
if (credsNotRecentlyExecuted.length > 0) {
report.sections.push({
title: CREDENTIALS_REPORT.SECTIONS.CREDS_NOT_RECENTLY_EXECUTED,
description: [
sentenceStart(credsNotRecentlyExecuted),
`not used in recently executed workflows, i.e. workflows executed in the past ${days} days.`,
hint,
].join(' '),
recommendation,
location: credsNotRecentlyExecuted,
});
}
return report;
}

View file

@ -1,106 +0,0 @@
import { toFlaggedNode } from '@/audit/utils';
import {
SQL_NODE_TYPES,
DATABASE_REPORT,
DB_QUERY_PARAMS_DOCS_URL,
SQL_NODE_TYPES_WITH_QUERY_PARAMS,
} from '@/audit/constants';
import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types';
function getIssues(workflows: Workflow[]) {
return workflows.reduce<{ [sectionTitle: string]: Risk.NodeLocation[] }>(
(acc, workflow) => {
workflow.nodes.forEach((node) => {
if (!SQL_NODE_TYPES.has(node.type)) return;
if (node.parameters === undefined) return;
if (node.parameters.operation !== 'executeQuery') return;
if (typeof node.parameters.query === 'string' && node.parameters.query.startsWith('=')) {
acc.expressionsInQueries.push(toFlaggedNode({ node, workflow }));
}
if (!SQL_NODE_TYPES_WITH_QUERY_PARAMS.has(node.type)) return;
if (!node.parameters.additionalFields) {
acc.unusedQueryParams.push(toFlaggedNode({ node, workflow }));
}
if (typeof node.parameters.additionalFields !== 'object') return;
if (node.parameters.additionalFields === null) return;
if (!('queryParams' in node.parameters.additionalFields)) {
acc.unusedQueryParams.push(toFlaggedNode({ node, workflow }));
}
if (
'queryParams' in node.parameters.additionalFields &&
typeof node.parameters.additionalFields.queryParams === 'string' &&
node.parameters.additionalFields.queryParams.startsWith('=')
) {
acc.expressionsInQueryParams.push(toFlaggedNode({ node, workflow }));
}
});
return acc;
},
{ expressionsInQueries: [], expressionsInQueryParams: [], unusedQueryParams: [] },
);
}
export function reportDatabaseRisk(workflows: Workflow[]) {
const { expressionsInQueries, expressionsInQueryParams, unusedQueryParams } =
getIssues(workflows);
const issues = [expressionsInQueries, expressionsInQueryParams, unusedQueryParams];
if (issues.every((i) => i.length === 0)) return null;
const report: Risk.StandardReport = {
risk: DATABASE_REPORT.RISK,
sections: [],
};
const sentenceStart = ({ length }: { length: number }) =>
length > 1 ? 'These SQL nodes have' : 'This SQL node has';
if (expressionsInQueries.length > 0) {
report.sections.push({
title: DATABASE_REPORT.SECTIONS.EXPRESSIONS_IN_QUERIES,
description: [
sentenceStart(expressionsInQueries),
'an expression in the "Query" field of an "Execute Query" operation. Building a SQL query with an expression may lead to a SQL injection attack.',
].join(' '),
recommendation:
'Consider using the "Query Parameters" field to pass parameters to the query, or validating the input of the expression in the "Query" field.',
location: expressionsInQueries,
});
}
if (expressionsInQueryParams.length > 0) {
report.sections.push({
title: DATABASE_REPORT.SECTIONS.EXPRESSIONS_IN_QUERY_PARAMS,
description: [
sentenceStart(expressionsInQueryParams),
'an expression in the "Query Parameters" field of an "Execute Query" operation. Building a SQL query with an expression may lead to a SQL injection attack.',
].join(' '),
recommendation:
'Consider validating the input of the expression in the "Query Parameters" field.',
location: expressionsInQueryParams,
});
}
if (unusedQueryParams.length > 0) {
report.sections.push({
title: DATABASE_REPORT.SECTIONS.UNUSED_QUERY_PARAMS,
description: [
sentenceStart(unusedQueryParams),
'no "Query Parameters" field in the "Execute Query" operation. Building a SQL query with unsanitized data may lead to a SQL injection attack.',
].join(' '),
recommendation: `Consider using the "Query Parameters" field to sanitize parameters passed to the query. See: ${DB_QUERY_PARAMS_DOCS_URL}`,
location: unusedQueryParams,
});
}
return report;
}

View file

@ -1,35 +0,0 @@
import { getNodeTypes } from '@/audit/utils';
import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/audit/constants';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types';
export function reportFilesystemRisk(workflows: WorkflowEntity[]) {
const fsInteractionNodeTypes = getNodeTypes(workflows, (node) =>
FILESYSTEM_INTERACTION_NODE_TYPES.has(node.type),
);
if (fsInteractionNodeTypes.length === 0) return null;
const report: Risk.StandardReport = {
risk: FILESYSTEM_REPORT.RISK,
sections: [],
};
const sentenceStart = ({ length }: { length: number }) =>
length > 1 ? 'These nodes read from and write to' : 'This node reads from and writes to';
if (fsInteractionNodeTypes.length > 0) {
report.sections.push({
title: FILESYSTEM_REPORT.SECTIONS.FILESYSTEM_INTERACTION_NODES,
description: [
sentenceStart(fsInteractionNodeTypes),
'any accessible file in the host filesystem. Sensitive file content may be manipulated through a node operation.',
].join(' '),
recommendation:
'Consider protecting any sensitive files in the host filesystem, or refactoring the workflow so that it does not require host filesystem interaction.',
location: fsInteractionNodeTypes,
});
}
return report;
}

View file

@ -1,202 +0,0 @@
import axios from 'axios';
import { Container } from 'typedi';
import { InstanceSettings } from 'n8n-core';
import config from '@/config';
import { toFlaggedNode } from '@/audit/utils';
import { separate } from '@/utils';
import {
ENV_VARS_DOCS_URL,
INSTANCE_REPORT,
WEBHOOK_NODE_TYPE,
WEBHOOK_VALIDATOR_NODE_TYPES,
} from '@/audit/constants';
import { getN8nPackageJson, inDevelopment } from '@/constants';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { Risk, n8n } from '@/audit/types';
import { isApiEnabled } from '@/PublicApi';
function getSecuritySettings() {
if (config.getEnv('deployment.type') === 'cloud') return null;
const settings: Record<string, unknown> = {};
settings.features = {
communityPackagesEnabled: config.getEnv('nodes.communityPackages.enabled'),
versionNotificationsEnabled: config.getEnv('versionNotifications.enabled'),
templatesEnabled: config.getEnv('templates.enabled'),
publicApiEnabled: isApiEnabled(),
};
settings.auth = {
authExcludeEndpoints: config.getEnv('security.excludeEndpoints') || 'none',
};
settings.nodes = {
nodesExclude: config.getEnv('nodes.exclude') ?? 'none',
nodesInclude: config.getEnv('nodes.include') ?? 'none',
};
settings.telemetry = {
diagnosticsEnabled: config.getEnv('diagnostics.enabled'),
};
return settings;
}
/**
* Whether a webhook node has a direct child assumed to validate its payload.
*/
function hasValidatorChild({
node,
workflow,
}: {
node: WorkflowEntity['nodes'][number];
workflow: WorkflowEntity;
}) {
const childNodeNames = workflow.connections[node.name]?.main[0].map((i) => i.node);
if (!childNodeNames) return false;
return childNodeNames.some((name) =>
workflow.nodes.find((n) => n.name === name && WEBHOOK_VALIDATOR_NODE_TYPES.has(n.type)),
);
}
function getUnprotectedWebhookNodes(workflows: WorkflowEntity[]) {
return workflows.reduce<Risk.NodeLocation[]>((acc, workflow) => {
if (!workflow.active) return acc;
workflow.nodes.forEach((node) => {
if (
node.type === WEBHOOK_NODE_TYPE &&
node.parameters.authentication === undefined &&
!hasValidatorChild({ node, workflow })
) {
acc.push(toFlaggedNode({ node, workflow }));
}
});
return acc;
}, []);
}
async function getNextVersions(currentVersionName: string) {
const BASE_URL = config.getEnv('versionNotifications.endpoint');
const { instanceId } = Container.get(InstanceSettings);
const response = await axios.get<n8n.Version[]>(BASE_URL + currentVersionName, {
// eslint-disable-next-line @typescript-eslint/naming-convention
headers: { 'n8n-instance-id': instanceId },
});
return response.data;
}
function removeIconData(versions: n8n.Version[]) {
return versions.map((version) => {
if (version.nodes.length === 0) return version;
version.nodes.forEach((node) => delete node.iconData);
return version;
});
}
function classify(versions: n8n.Version[], currentVersionName: string) {
const [pass, fail] = separate(versions, (v) => v.name === currentVersionName);
return { currentVersion: pass[0], nextVersions: fail };
}
export async function getOutdatedState() {
let versions = [];
const localVersion = getN8nPackageJson().version;
try {
versions = await getNextVersions(localVersion).then(removeIconData);
} catch (error) {
if (inDevelopment) {
console.error('Failed to fetch n8n versions. Skipping outdated instance report...');
}
return null;
}
const { currentVersion, nextVersions } = classify(versions, localVersion);
const nextVersionsNumber = nextVersions.length;
if (nextVersionsNumber === 0) return null;
const description = [
`This n8n instance is outdated. Currently at version ${
currentVersion.name
}, missing ${nextVersionsNumber} ${nextVersionsNumber > 1 ? 'updates' : 'update'}.`,
];
const upcomingSecurityUpdates = nextVersions.some((v) => v.hasSecurityIssue || v.hasSecurityFix);
if (upcomingSecurityUpdates) description.push('Newer versions contain security updates.');
return {
description: description.join(' '),
nextVersions,
};
}
export async function reportInstanceRisk(workflows: WorkflowEntity[]) {
const unprotectedWebhooks = getUnprotectedWebhookNodes(workflows);
const outdatedState = await getOutdatedState();
const securitySettings = getSecuritySettings();
if (unprotectedWebhooks.length === 0 && outdatedState === null && securitySettings === null) {
return null;
}
const report: Risk.InstanceReport = {
risk: INSTANCE_REPORT.RISK,
sections: [],
};
if (unprotectedWebhooks.length > 0) {
const sentenceStart = ({ length }: { length: number }) =>
length > 1 ? 'These webhook nodes have' : 'This webhook node has';
const recommendedValidators = [...WEBHOOK_VALIDATOR_NODE_TYPES]
.filter((nodeType) => !nodeType.endsWith('function') || !nodeType.endsWith('functionItem'))
.join(',');
report.sections.push({
title: INSTANCE_REPORT.SECTIONS.UNPROTECTED_WEBHOOKS,
description: [
sentenceStart(unprotectedWebhooks),
`the "Authentication" field set to "None" and ${
unprotectedWebhooks.length > 1 ? 'are' : 'is'
} not directly connected to a node to validate the payload. Every unprotected webhook allows your workflow to be called by any third party who knows the webhook URL.`,
].join(' '),
recommendation: `Consider setting the "Authentication" field to an option other than "None", or validating the payload with one of the following nodes: ${recommendedValidators}.`,
location: unprotectedWebhooks,
});
}
if (outdatedState !== null) {
report.sections.push({
title: INSTANCE_REPORT.SECTIONS.OUTDATED_INSTANCE,
description: outdatedState.description,
recommendation:
'Consider updating this n8n instance to the latest version to prevent security vulnerabilities.',
nextVersions: outdatedState.nextVersions,
});
}
if (securitySettings !== null) {
report.sections.push({
title: INSTANCE_REPORT.SECTIONS.SECURITY_SETTINGS,
description: 'This n8n instance has the following security settings.',
recommendation: `Consider adjusting the security settings for your n8n instance based on your needs. See: ${ENV_VARS_DOCS_URL}`,
settings: securitySettings,
});
}
return report;
}

View file

@ -1,116 +0,0 @@
import * as path from 'path';
import glob from 'fast-glob';
import { Container } from 'typedi';
import config from '@/config';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { getNodeTypes } from '@/audit/utils';
import {
OFFICIAL_RISKY_NODE_TYPES,
ENV_VARS_DOCS_URL,
NODES_REPORT,
COMMUNITY_NODES_RISKS_URL,
NPM_PACKAGE_URL,
} from '@/audit/constants';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types';
async function getCommunityNodeDetails() {
if (!config.getEnv('nodes.communityPackages.enabled')) return [];
const { CommunityPackagesService } = await import('@/services/communityPackages.service');
const installedPackages = await Container.get(CommunityPackagesService).getAllInstalledPackages();
return installedPackages.reduce<Risk.CommunityNodeDetails[]>((acc, pkg) => {
pkg.installedNodes.forEach((node) =>
acc.push({
kind: 'community',
nodeType: node.type,
packageUrl: [NPM_PACKAGE_URL, pkg.packageName].join('/'),
}),
);
return acc;
}, []);
}
async function getCustomNodeDetails() {
const customNodeTypes: Risk.CustomNodeDetails[] = [];
const nodesAndCredentials = Container.get(LoadNodesAndCredentials);
for (const customDir of nodesAndCredentials.getCustomDirectories()) {
const customNodeFiles = await glob('**/*.node.js', { cwd: customDir, absolute: true });
for (const nodeFile of customNodeFiles) {
const [fileName] = path.parse(nodeFile).name.split('.');
customNodeTypes.push({
kind: 'custom',
nodeType: ['CUSTOM', fileName].join('.'),
filePath: nodeFile,
});
}
}
return customNodeTypes;
}
export async function reportNodesRisk(workflows: WorkflowEntity[]) {
const officialRiskyNodes = getNodeTypes(workflows, (node) =>
OFFICIAL_RISKY_NODE_TYPES.has(node.type),
);
const [communityNodes, customNodes] = await Promise.all([
getCommunityNodeDetails(),
getCustomNodeDetails(),
]);
const issues = [officialRiskyNodes, communityNodes, customNodes];
if (issues.every((i) => i.length === 0)) return null;
const report: Risk.StandardReport = {
risk: NODES_REPORT.RISK,
sections: [],
};
const sentenceStart = (length: number) => (length > 1 ? 'These nodes are' : 'This node is');
if (officialRiskyNodes.length > 0) {
report.sections.push({
title: NODES_REPORT.SECTIONS.OFFICIAL_RISKY_NODES,
description: [
sentenceStart(officialRiskyNodes.length),
"part of n8n's official nodes and may be used to fetch and run any arbitrary code in the host system. This may lead to exploits such as remote code execution.",
].join(' '),
recommendation: `Consider reviewing the parameters in these nodes, replacing them with app nodes where possible, and not loading unneeded node types with the NODES_EXCLUDE environment variable. See: ${ENV_VARS_DOCS_URL}`,
location: officialRiskyNodes,
});
}
if (communityNodes.length > 0) {
report.sections.push({
title: NODES_REPORT.SECTIONS.COMMUNITY_NODES,
description: [
sentenceStart(communityNodes.length),
`sourced from the n8n community. Community nodes are not vetted by the n8n team and have full access to the host system. See: ${COMMUNITY_NODES_RISKS_URL}`,
].join(' '),
recommendation:
'Consider reviewing the source code in any community nodes installed in this n8n instance, and uninstalling any community nodes no longer in use.',
location: communityNodes,
});
}
if (customNodes.length > 0) {
report.sections.push({
title: NODES_REPORT.SECTIONS.CUSTOM_NODES,
description: [
sentenceStart(communityNodes.length),
'unpublished and located in the host system. Custom nodes are not vetted by the n8n team and have full access to the host system.',
].join(' '),
recommendation:
'Consider reviewing the source code in any custom node installed in this n8n instance, and removing any custom nodes no longer in use.',
location: customNodes,
});
}
return report;
}

View file

@ -1,8 +1,8 @@
import { flags } from '@oclif/command'; import { flags } from '@oclif/command';
import { audit } from '@/audit'; import { SecurityAuditService } from '@/security-audit/SecurityAudit.service';
import { RISK_CATEGORIES } from '@/audit/constants'; import { RISK_CATEGORIES } from '@/security-audit/constants';
import config from '@/config'; import config from '@/config';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/security-audit/types';
import { BaseCommand } from './BaseCommand'; import { BaseCommand } from './BaseCommand';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
@ -49,7 +49,10 @@ export class SecurityAudit extends BaseCommand {
throw new Error([message, hint].join('. ')); throw new Error([message, hint].join('. '));
} }
const result = await audit(categories, auditFlags['days-abandoned-workflow']); const result = await Container.get(SecurityAuditService).run(
categories,
auditFlags['days-abandoned-workflow'],
);
if (Array.isArray(result) && result.length === 0) { if (Array.isArray(result) && result.length === 0) {
this.logger.info('No security issues found'); this.logger.info('No security issues found');

View file

@ -0,0 +1,63 @@
import Container, { Service } from 'typedi';
import config from '@/config';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { RISK_CATEGORIES } from '@/security-audit/constants';
import { toReportTitle } from '@/security-audit/utils';
import type { Risk, RiskReporter } from '@/security-audit/types';
@Service()
export class SecurityAuditService {
constructor(private readonly workflowRepository: WorkflowRepository) {}
private reporters: {
[name: string]: RiskReporter;
} = {};
async run(categories: Risk.Category[] = RISK_CATEGORIES, daysAbandonedWorkflow?: number) {
if (categories.length === 0) categories = RISK_CATEGORIES;
await this.initReporters(categories);
const daysFromEnv = config.getEnv('security.audit.daysAbandonedWorkflow');
if (daysAbandonedWorkflow) {
config.set('security.audit.daysAbandonedWorkflow', daysAbandonedWorkflow);
}
const workflows = await this.workflowRepository.find({
select: ['id', 'name', 'active', 'nodes', 'connections'],
});
const promises = categories.map(async (c) => this.reporters[c].report(workflows));
const reports = (await Promise.all(promises)).filter((r): r is Risk.Report => r !== null);
if (daysAbandonedWorkflow) {
config.set('security.audit.daysAbandonedWorkflow', daysFromEnv); // restore env
}
if (reports.length === 0) return []; // trigger empty state
return reports.reduce<Risk.Audit>((acc, cur) => {
acc[toReportTitle(cur.risk)] = cur;
return acc;
}, {});
}
async initReporters(categories: Risk.Category[]) {
for (const category of categories) {
const className = category.charAt(0).toUpperCase() + category.slice(1) + 'RiskReporter';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const RiskReporterModule = await import(`@/security-audit/risk-reporters/${className}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const RiskReporterClass = RiskReporterModule[className] as { new (): RiskReporter };
this.reporters[category] = Container.get(RiskReporterClass);
}
}
}

View file

@ -1,4 +1,4 @@
import type { Risk } from '@/audit/types'; import type { Risk } from '@/security-audit/types';
/** /**
* Risk categories * Risk categories

View file

@ -0,0 +1,159 @@
import { In, MoreThanOrEqual } from 'typeorm';
import { DateUtils } from 'typeorm/util/DateUtils';
import { Service } from 'typedi';
import type { IWorkflowBase } from 'n8n-workflow';
import config from '@/config';
import { CREDENTIALS_REPORT } from '@/security-audit/constants';
import type { RiskReporter, Risk } from '@/security-audit/types';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { ExecutionRepository } from '@db/repositories/execution.repository';
import { ExecutionDataRepository } from '@db/repositories/executionData.repository';
@Service()
export class CredentialsRiskReporter implements RiskReporter {
constructor(
private readonly credentialsRepository: CredentialsRepository,
private readonly executionRepository: ExecutionRepository,
private readonly executionDataRepository: ExecutionDataRepository,
) {}
async report(workflows: WorkflowEntity[]) {
const days = config.getEnv('security.audit.daysAbandonedWorkflow');
const allExistingCreds = await this.getAllExistingCreds();
const { credsInAnyUse, credsInActiveUse } = await this.getAllCredsInUse(workflows);
const recentlyExecutedCreds = await this.getCredsInRecentlyExecutedWorkflows(days);
const credsNotInAnyUse = allExistingCreds.filter((c) => !credsInAnyUse.has(c.id));
const credsNotInActiveUse = allExistingCreds.filter((c) => !credsInActiveUse.has(c.id));
const credsNotRecentlyExecuted = allExistingCreds.filter(
(c) => !recentlyExecutedCreds.has(c.id),
);
const issues = [credsNotInAnyUse, credsNotInActiveUse, credsNotRecentlyExecuted];
if (issues.every((i) => i.length === 0)) return null;
const report: Risk.StandardReport = {
risk: CREDENTIALS_REPORT.RISK,
sections: [],
};
const hint = 'Keeping unused credentials in your instance is an unneeded security risk.';
const recommendation = 'Consider deleting these credentials if you no longer need them.';
const sentenceStart = ({ length }: { length: number }) =>
length > 1 ? 'These credentials are' : 'This credential is';
if (credsNotInAnyUse.length > 0) {
report.sections.push({
title: CREDENTIALS_REPORT.SECTIONS.CREDS_NOT_IN_ANY_USE,
description: [sentenceStart(credsNotInAnyUse), 'not used in any workflow.', hint].join(' '),
recommendation,
location: credsNotInAnyUse,
});
}
if (credsNotInActiveUse.length > 0) {
report.sections.push({
title: CREDENTIALS_REPORT.SECTIONS.CREDS_NOT_IN_ACTIVE_USE,
description: [
sentenceStart(credsNotInActiveUse),
'not used in active workflows.',
hint,
].join(' '),
recommendation,
location: credsNotInActiveUse,
});
}
if (credsNotRecentlyExecuted.length > 0) {
report.sections.push({
title: CREDENTIALS_REPORT.SECTIONS.CREDS_NOT_RECENTLY_EXECUTED,
description: [
sentenceStart(credsNotRecentlyExecuted),
`not used in recently executed workflows, i.e. workflows executed in the past ${days} days.`,
hint,
].join(' '),
recommendation,
location: credsNotRecentlyExecuted,
});
}
return report;
}
private async getAllCredsInUse(workflows: WorkflowEntity[]) {
const credsInAnyUse = new Set<string>();
const credsInActiveUse = new Set<string>();
workflows.forEach((workflow) => {
workflow.nodes.forEach((node) => {
if (!node.credentials) return;
Object.values(node.credentials).forEach((cred) => {
if (!cred?.id) return;
credsInAnyUse.add(cred.id);
if (workflow.active) credsInActiveUse.add(cred.id);
});
});
});
return {
credsInAnyUse,
credsInActiveUse,
};
}
private async getAllExistingCreds() {
const credentials = await this.credentialsRepository.find({ select: ['id', 'name'] });
return credentials.map(({ id, name }) => ({ kind: 'credential' as const, id, name }));
}
private async getExecutedWorkflowsInPastDays(days: number): Promise<IWorkflowBase[]> {
const date = new Date();
date.setDate(date.getDate() - days);
const executionIds = await this.executionRepository
.find({
select: ['id'],
where: {
startedAt: MoreThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(date) as Date),
},
})
.then((executions) => executions.map(({ id }) => id));
return this.executionDataRepository
.find({
select: ['workflowData'],
where: {
executionId: In(executionIds),
},
})
.then((executionData) => executionData.map(({ workflowData }) => workflowData));
}
/**
* Return IDs of credentials in workflows executed in the past n days.
*/
private async getCredsInRecentlyExecutedWorkflows(days: number) {
const executedWorkflows = await this.getExecutedWorkflowsInPastDays(days);
return executedWorkflows.reduce<Set<string>>((acc, { nodes }) => {
nodes.forEach((node) => {
if (node.credentials) {
Object.values(node.credentials).forEach((c) => {
if (c.id) acc.add(c.id);
});
}
});
return acc;
}, new Set());
}
}

View file

@ -0,0 +1,110 @@
import { toFlaggedNode } from '@/security-audit/utils';
import {
SQL_NODE_TYPES,
DATABASE_REPORT,
DB_QUERY_PARAMS_DOCS_URL,
SQL_NODE_TYPES_WITH_QUERY_PARAMS,
} from '@/security-audit/constants';
import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
import type { RiskReporter, Risk } from '@/security-audit/types';
import { Service } from 'typedi';
@Service()
export class DatabaseRiskReporter implements RiskReporter {
async report(workflows: Workflow[]) {
const { expressionsInQueries, expressionsInQueryParams, unusedQueryParams } =
this.getIssues(workflows);
const issues = [expressionsInQueries, expressionsInQueryParams, unusedQueryParams];
if (issues.every((i) => i.length === 0)) return null;
const report: Risk.StandardReport = {
risk: DATABASE_REPORT.RISK,
sections: [],
};
const sentenceStart = ({ length }: { length: number }) =>
length > 1 ? 'These SQL nodes have' : 'This SQL node has';
if (expressionsInQueries.length > 0) {
report.sections.push({
title: DATABASE_REPORT.SECTIONS.EXPRESSIONS_IN_QUERIES,
description: [
sentenceStart(expressionsInQueries),
'an expression in the "Query" field of an "Execute Query" operation. Building a SQL query with an expression may lead to a SQL injection attack.',
].join(' '),
recommendation:
'Consider using the "Query Parameters" field to pass parameters to the query, or validating the input of the expression in the "Query" field.',
location: expressionsInQueries,
});
}
if (expressionsInQueryParams.length > 0) {
report.sections.push({
title: DATABASE_REPORT.SECTIONS.EXPRESSIONS_IN_QUERY_PARAMS,
description: [
sentenceStart(expressionsInQueryParams),
'an expression in the "Query Parameters" field of an "Execute Query" operation. Building a SQL query with an expression may lead to a SQL injection attack.',
].join(' '),
recommendation:
'Consider validating the input of the expression in the "Query Parameters" field.',
location: expressionsInQueryParams,
});
}
if (unusedQueryParams.length > 0) {
report.sections.push({
title: DATABASE_REPORT.SECTIONS.UNUSED_QUERY_PARAMS,
description: [
sentenceStart(unusedQueryParams),
'no "Query Parameters" field in the "Execute Query" operation. Building a SQL query with unsanitized data may lead to a SQL injection attack.',
].join(' '),
recommendation: `Consider using the "Query Parameters" field to sanitize parameters passed to the query. See: ${DB_QUERY_PARAMS_DOCS_URL}`,
location: unusedQueryParams,
});
}
return report;
}
private getIssues(workflows: Workflow[]) {
return workflows.reduce<{ [sectionTitle: string]: Risk.NodeLocation[] }>(
(acc, workflow) => {
workflow.nodes.forEach((node) => {
if (!SQL_NODE_TYPES.has(node.type)) return;
if (node.parameters === undefined) return;
if (node.parameters.operation !== 'executeQuery') return;
if (typeof node.parameters.query === 'string' && node.parameters.query.startsWith('=')) {
acc.expressionsInQueries.push(toFlaggedNode({ node, workflow }));
}
if (!SQL_NODE_TYPES_WITH_QUERY_PARAMS.has(node.type)) return;
if (!node.parameters.additionalFields) {
acc.unusedQueryParams.push(toFlaggedNode({ node, workflow }));
}
if (typeof node.parameters.additionalFields !== 'object') return;
if (node.parameters.additionalFields === null) return;
if (!('queryParams' in node.parameters.additionalFields)) {
acc.unusedQueryParams.push(toFlaggedNode({ node, workflow }));
}
if (
'queryParams' in node.parameters.additionalFields &&
typeof node.parameters.additionalFields.queryParams === 'string' &&
node.parameters.additionalFields.queryParams.startsWith('=')
) {
acc.expressionsInQueryParams.push(toFlaggedNode({ node, workflow }));
}
});
return acc;
},
{ expressionsInQueries: [], expressionsInQueryParams: [], unusedQueryParams: [] },
);
}
}

View file

@ -0,0 +1,39 @@
import { getNodeTypes } from '@/security-audit/utils';
import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/security-audit/constants';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { RiskReporter, Risk } from '@/security-audit/types';
import { Service } from 'typedi';
@Service()
export class FilesystemRiskReporter implements RiskReporter {
async report(workflows: WorkflowEntity[]) {
const fsInteractionNodeTypes = getNodeTypes(workflows, (node) =>
FILESYSTEM_INTERACTION_NODE_TYPES.has(node.type),
);
if (fsInteractionNodeTypes.length === 0) return null;
const report: Risk.StandardReport = {
risk: FILESYSTEM_REPORT.RISK,
sections: [],
};
const sentenceStart = ({ length }: { length: number }) =>
length > 1 ? 'These nodes read from and write to' : 'This node reads from and writes to';
if (fsInteractionNodeTypes.length > 0) {
report.sections.push({
title: FILESYSTEM_REPORT.SECTIONS.FILESYSTEM_INTERACTION_NODES,
description: [
sentenceStart(fsInteractionNodeTypes),
'any accessible file in the host filesystem. Sensitive file content may be manipulated through a node operation.',
].join(' '),
recommendation:
'Consider protecting any sensitive files in the host filesystem, or refactoring the workflow so that it does not require host filesystem interaction.',
location: fsInteractionNodeTypes,
});
}
return report;
}
}

View file

@ -0,0 +1,209 @@
import axios from 'axios';
import { Service } from 'typedi';
import { InstanceSettings } from 'n8n-core';
import config from '@/config';
import { toFlaggedNode } from '@/security-audit/utils';
import { separate } from '@/utils';
import {
ENV_VARS_DOCS_URL,
INSTANCE_REPORT,
WEBHOOK_NODE_TYPE,
WEBHOOK_VALIDATOR_NODE_TYPES,
} from '@/security-audit/constants';
import { getN8nPackageJson, inDevelopment } from '@/constants';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { RiskReporter, Risk, n8n } from '@/security-audit/types';
import { isApiEnabled } from '@/PublicApi';
@Service()
export class InstanceRiskReporter implements RiskReporter {
constructor(private readonly instanceSettings: InstanceSettings) {}
async report(workflows: WorkflowEntity[]) {
const unprotectedWebhooks = this.getUnprotectedWebhookNodes(workflows);
const outdatedState = await this.getOutdatedState();
const securitySettings = this.getSecuritySettings();
if (unprotectedWebhooks.length === 0 && outdatedState === null && securitySettings === null) {
return null;
}
const report: Risk.Report = {
risk: INSTANCE_REPORT.RISK,
sections: [],
};
if (unprotectedWebhooks.length > 0) {
const sentenceStart = ({ length }: { length: number }) =>
length > 1 ? 'These webhook nodes have' : 'This webhook node has';
const recommendedValidators = [...WEBHOOK_VALIDATOR_NODE_TYPES]
.filter((nodeType) => !nodeType.endsWith('function') || !nodeType.endsWith('functionItem'))
.join(',');
report.sections.push({
title: INSTANCE_REPORT.SECTIONS.UNPROTECTED_WEBHOOKS,
description: [
sentenceStart(unprotectedWebhooks),
`the "Authentication" field set to "None" and ${
unprotectedWebhooks.length > 1 ? 'are' : 'is'
} not directly connected to a node to validate the payload. Every unprotected webhook allows your workflow to be called by any third party who knows the webhook URL.`,
].join(' '),
recommendation: `Consider setting the "Authentication" field to an option other than "None", or validating the payload with one of the following nodes: ${recommendedValidators}.`,
location: unprotectedWebhooks,
});
}
if (outdatedState !== null) {
report.sections.push({
title: INSTANCE_REPORT.SECTIONS.OUTDATED_INSTANCE,
description: outdatedState.description,
recommendation:
'Consider updating this n8n instance to the latest version to prevent security vulnerabilities.',
nextVersions: outdatedState.nextVersions,
});
}
if (securitySettings !== null) {
report.sections.push({
title: INSTANCE_REPORT.SECTIONS.SECURITY_SETTINGS,
description: 'This n8n instance has the following security settings.',
recommendation: `Consider adjusting the security settings for your n8n instance based on your needs. See: ${ENV_VARS_DOCS_URL}`,
settings: securitySettings,
});
}
return report;
}
private getSecuritySettings() {
if (config.getEnv('deployment.type') === 'cloud') return null;
const settings: Record<string, unknown> = {};
settings.features = {
communityPackagesEnabled: config.getEnv('nodes.communityPackages.enabled'),
versionNotificationsEnabled: config.getEnv('versionNotifications.enabled'),
templatesEnabled: config.getEnv('templates.enabled'),
publicApiEnabled: isApiEnabled(),
};
settings.auth = {
authExcludeEndpoints: config.getEnv('security.excludeEndpoints') || 'none',
};
settings.nodes = {
nodesExclude: config.getEnv('nodes.exclude') ?? 'none',
nodesInclude: config.getEnv('nodes.include') ?? 'none',
};
settings.telemetry = {
diagnosticsEnabled: config.getEnv('diagnostics.enabled'),
};
return settings;
}
/**
* Whether a webhook node has a direct child assumed to validate its payload.
*/
private hasValidatorChild({
node,
workflow,
}: {
node: WorkflowEntity['nodes'][number];
workflow: WorkflowEntity;
}) {
const childNodeNames = workflow.connections[node.name]?.main[0].map((i) => i.node);
if (!childNodeNames) return false;
return childNodeNames.some((name) =>
workflow.nodes.find((n) => n.name === name && WEBHOOK_VALIDATOR_NODE_TYPES.has(n.type)),
);
}
private getUnprotectedWebhookNodes(workflows: WorkflowEntity[]) {
return workflows.reduce<Risk.NodeLocation[]>((acc, workflow) => {
if (!workflow.active) return acc;
workflow.nodes.forEach((node) => {
if (
node.type === WEBHOOK_NODE_TYPE &&
node.parameters.authentication === undefined &&
!this.hasValidatorChild({ node, workflow })
) {
acc.push(toFlaggedNode({ node, workflow }));
}
});
return acc;
}, []);
}
private async getNextVersions(currentVersionName: string) {
const BASE_URL = config.getEnv('versionNotifications.endpoint');
const { instanceId } = this.instanceSettings;
const response = await axios.get<n8n.Version[]>(BASE_URL + currentVersionName, {
// eslint-disable-next-line @typescript-eslint/naming-convention
headers: { 'n8n-instance-id': instanceId },
});
return response.data;
}
private removeIconData(versions: n8n.Version[]) {
return versions.map((version) => {
if (version.nodes.length === 0) return version;
version.nodes.forEach((node) => delete node.iconData);
return version;
});
}
private classify(versions: n8n.Version[], currentVersionName: string) {
const [pass, fail] = separate(versions, (v) => v.name === currentVersionName);
return { currentVersion: pass[0], nextVersions: fail };
}
private async getOutdatedState() {
let versions = [];
const localVersion = getN8nPackageJson().version;
try {
versions = await this.getNextVersions(localVersion).then((v) => this.removeIconData(v));
} catch (error) {
if (inDevelopment) {
console.error('Failed to fetch n8n versions. Skipping outdated instance report...');
}
return null;
}
const { currentVersion, nextVersions } = this.classify(versions, localVersion);
const nextVersionsNumber = nextVersions.length;
if (nextVersionsNumber === 0) return null;
const description = [
`This n8n instance is outdated. Currently at version ${
currentVersion.name
}, missing ${nextVersionsNumber} ${nextVersionsNumber > 1 ? 'updates' : 'update'}.`,
];
const upcomingSecurityUpdates = nextVersions.some(
(v) => v.hasSecurityIssue || v.hasSecurityFix,
);
if (upcomingSecurityUpdates) description.push('Newer versions contain security updates.');
return {
description: description.join(' '),
nextVersions,
};
}
}

View file

@ -0,0 +1,123 @@
import * as path from 'path';
import glob from 'fast-glob';
import { Service } from 'typedi';
import config from '@/config';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { getNodeTypes } from '@/security-audit/utils';
import {
OFFICIAL_RISKY_NODE_TYPES,
ENV_VARS_DOCS_URL,
NODES_REPORT,
COMMUNITY_NODES_RISKS_URL,
NPM_PACKAGE_URL,
} from '@/security-audit/constants';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { Risk, RiskReporter } from '@/security-audit/types';
import { CommunityPackagesService } from '@/services/communityPackages.service';
@Service()
export class NodesRiskReporter implements RiskReporter {
constructor(
private readonly loadNodesAndCredentials: LoadNodesAndCredentials,
private readonly communityPackagesService: CommunityPackagesService,
) {}
async report(workflows: WorkflowEntity[]) {
const officialRiskyNodes = getNodeTypes(workflows, (node) =>
OFFICIAL_RISKY_NODE_TYPES.has(node.type),
);
const [communityNodes, customNodes] = await Promise.all([
this.getCommunityNodeDetails(),
this.getCustomNodeDetails(),
]);
const issues = [officialRiskyNodes, communityNodes, customNodes];
if (issues.every((i) => i.length === 0)) return null;
const report: Risk.StandardReport = {
risk: NODES_REPORT.RISK,
sections: [],
};
const sentenceStart = (length: number) => (length > 1 ? 'These nodes are' : 'This node is');
if (officialRiskyNodes.length > 0) {
report.sections.push({
title: NODES_REPORT.SECTIONS.OFFICIAL_RISKY_NODES,
description: [
sentenceStart(officialRiskyNodes.length),
"part of n8n's official nodes and may be used to fetch and run any arbitrary code in the host system. This may lead to exploits such as remote code execution.",
].join(' '),
recommendation: `Consider reviewing the parameters in these nodes, replacing them with app nodes where possible, and not loading unneeded node types with the NODES_EXCLUDE environment variable. See: ${ENV_VARS_DOCS_URL}`,
location: officialRiskyNodes,
});
}
if (communityNodes.length > 0) {
report.sections.push({
title: NODES_REPORT.SECTIONS.COMMUNITY_NODES,
description: [
sentenceStart(communityNodes.length),
`sourced from the n8n community. Community nodes are not vetted by the n8n team and have full access to the host system. See: ${COMMUNITY_NODES_RISKS_URL}`,
].join(' '),
recommendation:
'Consider reviewing the source code in any community nodes installed in this n8n instance, and uninstalling any community nodes no longer in use.',
location: communityNodes,
});
}
if (customNodes.length > 0) {
report.sections.push({
title: NODES_REPORT.SECTIONS.CUSTOM_NODES,
description: [
sentenceStart(communityNodes.length),
'unpublished and located in the host system. Custom nodes are not vetted by the n8n team and have full access to the host system.',
].join(' '),
recommendation:
'Consider reviewing the source code in any custom node installed in this n8n instance, and removing any custom nodes no longer in use.',
location: customNodes,
});
}
return report;
}
private async getCommunityNodeDetails() {
if (!config.getEnv('nodes.communityPackages.enabled')) return [];
const installedPackages = await this.communityPackagesService.getAllInstalledPackages();
return installedPackages.reduce<Risk.CommunityNodeDetails[]>((acc, pkg) => {
pkg.installedNodes.forEach((node) =>
acc.push({
kind: 'community',
nodeType: node.type,
packageUrl: [NPM_PACKAGE_URL, pkg.packageName].join('/'),
}),
);
return acc;
}, []);
}
private async getCustomNodeDetails() {
const customNodeTypes: Risk.CustomNodeDetails[] = [];
for (const customDir of this.loadNodesAndCredentials.getCustomDirectories()) {
const customNodeFiles = await glob('**/*.node.js', { cwd: customDir, absolute: true });
for (const nodeFile of customNodeFiles) {
const [fileName] = path.parse(nodeFile).name.split('.');
customNodeTypes.push({
kind: 'custom',
nodeType: ['CUSTOM', fileName].join('.'),
filePath: nodeFile,
});
}
}
return customNodeTypes;
}
}

View file

@ -84,3 +84,7 @@ export namespace n8n {
securityIssueFixVersion: string; securityIssueFixVersion: string;
}; };
} }
export interface RiskReporter {
report(workflows: Workflow[]): Promise<Risk.Report | null>;
}

View file

@ -1,5 +1,5 @@
import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity'; import type { WorkflowEntity as Workflow } from '@db/entities/WorkflowEntity';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/security-audit/types';
type Node = Workflow['nodes'][number]; type Node = Workflow['nodes'][number];

View file

@ -1,7 +1,7 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import config from '@/config'; import config from '@/config';
import { audit } from '@/audit'; import { SecurityAuditService } from '@/security-audit/SecurityAudit.service';
import { CREDENTIALS_REPORT } from '@/audit/constants'; import { CREDENTIALS_REPORT } from '@/security-audit/constants';
import { getRiskSection } from './utils'; import { getRiskSection } from './utils';
import * as testDb from '../shared/testDb'; import * as testDb from '../shared/testDb';
import { generateNanoId } from '@db/utils/generators'; import { generateNanoId } from '@db/utils/generators';
@ -11,8 +11,12 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository';
import { ExecutionRepository } from '@db/repositories/execution.repository'; import { ExecutionRepository } from '@db/repositories/execution.repository';
import { ExecutionDataRepository } from '@db/repositories/executionData.repository'; import { ExecutionDataRepository } from '@db/repositories/executionData.repository';
let securityAuditService: SecurityAuditService;
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init();
securityAuditService = new SecurityAuditService(Container.get(WorkflowRepository));
}); });
beforeEach(async () => { beforeEach(async () => {
@ -54,7 +58,7 @@ test('should report credentials not in any use', async () => {
Container.get(WorkflowRepository).save(workflowDetails), Container.get(WorkflowRepository).save(workflowDetails),
]); ]);
const testAudit = await audit(['credentials']); const testAudit = await securityAuditService.run(['credentials']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -99,7 +103,7 @@ test('should report credentials not in active use', async () => {
await Container.get(WorkflowRepository).save(workflowDetails); await Container.get(WorkflowRepository).save(workflowDetails);
const testAudit = await audit(['credentials']); const testAudit = await securityAuditService.run(['credentials']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -167,7 +171,7 @@ test('should report credential in not recently executed workflow', async () => {
workflowData: workflow, workflowData: workflow,
}); });
const testAudit = await audit(['credentials']); const testAudit = await securityAuditService.run(['credentials']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -236,7 +240,7 @@ test('should not report credentials in recently executed workflow', async () =>
workflowData: workflow, workflowData: workflow,
}); });
const testAudit = await audit(['credentials']); const testAudit = await securityAuditService.run(['credentials']);
expect(testAudit).toBeEmptyArray(); expect(testAudit).toBeEmptyArray();
}); });

View file

@ -1,18 +1,22 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { audit } from '@/audit'; import { SecurityAuditService } from '@/security-audit/SecurityAudit.service';
import { import {
DATABASE_REPORT, DATABASE_REPORT,
SQL_NODE_TYPES, SQL_NODE_TYPES,
SQL_NODE_TYPES_WITH_QUERY_PARAMS, SQL_NODE_TYPES_WITH_QUERY_PARAMS,
} from '@/audit/constants'; } from '@/security-audit/constants';
import { getRiskSection, saveManualTriggerWorkflow } from './utils'; import { getRiskSection, saveManualTriggerWorkflow } from './utils';
import * as testDb from '../shared/testDb'; import * as testDb from '../shared/testDb';
import { generateNanoId } from '@db/utils/generators'; import { generateNanoId } from '@db/utils/generators';
import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository';
import Container from 'typedi'; import Container from 'typedi';
let securityAuditService: SecurityAuditService;
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init();
securityAuditService = new SecurityAuditService(Container.get(WorkflowRepository));
}); });
beforeEach(async () => { beforeEach(async () => {
@ -56,7 +60,7 @@ test('should report expressions in queries', async () => {
await Promise.all(promises); await Promise.all(promises);
const testAudit = await audit(['database']); const testAudit = await securityAuditService.run(['database']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -111,7 +115,7 @@ test('should report expressions in query params', async () => {
await Promise.all(promises); await Promise.all(promises);
const testAudit = await audit(['database']); const testAudit = await securityAuditService.run(['database']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -163,7 +167,7 @@ test('should report unused query params', async () => {
await Promise.all(promises); await Promise.all(promises);
const testAudit = await audit(['database']); const testAudit = await securityAuditService.run(['database']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -183,7 +187,7 @@ test('should report unused query params', async () => {
test('should not report non-database node', async () => { test('should not report non-database node', async () => {
await saveManualTriggerWorkflow(); await saveManualTriggerWorkflow();
const testAudit = await audit(['database']); const testAudit = await securityAuditService.run(['database']);
expect(testAudit).toBeEmptyArray(); expect(testAudit).toBeEmptyArray();
}); });

View file

@ -1,13 +1,17 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { audit } from '@/audit'; import { SecurityAuditService } from '@/security-audit/SecurityAudit.service';
import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/audit/constants'; import { FILESYSTEM_INTERACTION_NODE_TYPES, FILESYSTEM_REPORT } from '@/security-audit/constants';
import { getRiskSection, saveManualTriggerWorkflow } from './utils'; import { getRiskSection, saveManualTriggerWorkflow } from './utils';
import * as testDb from '../shared/testDb'; import * as testDb from '../shared/testDb';
import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository';
import Container from 'typedi'; import Container from 'typedi';
let securityAuditService: SecurityAuditService;
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init();
securityAuditService = new SecurityAuditService(Container.get(WorkflowRepository));
}); });
beforeEach(async () => { beforeEach(async () => {
@ -48,7 +52,7 @@ test('should report filesystem interaction nodes', async () => {
await Promise.all(promises); await Promise.all(promises);
const testAudit = await audit(['filesystem']); const testAudit = await securityAuditService.run(['filesystem']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -68,7 +72,7 @@ test('should report filesystem interaction nodes', async () => {
test('should not report non-filesystem-interaction node', async () => { test('should not report non-filesystem-interaction node', async () => {
await saveManualTriggerWorkflow(); await saveManualTriggerWorkflow();
const testAudit = await audit(['filesystem']); const testAudit = await securityAuditService.run(['filesystem']);
expect(testAudit).toBeEmptyArray(); expect(testAudit).toBeEmptyArray();
}); });

View file

@ -1,6 +1,6 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { audit } from '@/audit'; import { SecurityAuditService } from '@/security-audit/SecurityAudit.service';
import { INSTANCE_REPORT, WEBHOOK_VALIDATOR_NODE_TYPES } from '@/audit/constants'; import { INSTANCE_REPORT, WEBHOOK_VALIDATOR_NODE_TYPES } from '@/security-audit/constants';
import { import {
getRiskSection, getRiskSection,
saveManualTriggerWorkflow, saveManualTriggerWorkflow,
@ -9,15 +9,19 @@ import {
simulateUpToDateInstance, simulateUpToDateInstance,
} from './utils'; } from './utils';
import * as testDb from '../shared/testDb'; import * as testDb from '../shared/testDb';
import { toReportTitle } from '@/audit/utils'; import { toReportTitle } from '@/security-audit/utils';
import config from '@/config'; import config from '@/config';
import { generateNanoId } from '@db/utils/generators'; import { generateNanoId } from '@db/utils/generators';
import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository';
import Container from 'typedi'; import Container from 'typedi';
let securityAuditService: SecurityAuditService;
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init();
securityAuditService = new SecurityAuditService(Container.get(WorkflowRepository));
simulateUpToDateInstance(); simulateUpToDateInstance();
}); });
@ -56,7 +60,7 @@ test('should report webhook lacking authentication', async () => {
await Container.get(WorkflowRepository).save(details); await Container.get(WorkflowRepository).save(details);
const testAudit = await audit(['instance']); const testAudit = await securityAuditService.run(['instance']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -103,10 +107,12 @@ test('should not report webhooks having basic or header auth', async () => {
await Promise.all(promises); await Promise.all(promises);
const testAudit = await audit(['instance']); const testAudit = await securityAuditService.run(['instance']);
if (Array.isArray(testAudit)) fail('audit is empty');
if (Array.isArray(testAudit)) fail('Audit is empty');
const report = testAudit[toReportTitle('instance')]; const report = testAudit[toReportTitle('instance')];
if (!report) { if (!report) {
fail('Expected test audit to have instance risk report'); fail('Expected test audit to have instance risk report');
} }
@ -164,7 +170,7 @@ test('should not report webhooks validated by direct children', async () => {
await Promise.all(promises); await Promise.all(promises);
const testAudit = await audit(['instance']); const testAudit = await securityAuditService.run(['instance']);
if (Array.isArray(testAudit)) fail('audit is empty'); if (Array.isArray(testAudit)) fail('audit is empty');
const report = testAudit[toReportTitle('instance')]; const report = testAudit[toReportTitle('instance')];
@ -180,7 +186,7 @@ test('should not report webhooks validated by direct children', async () => {
test('should not report non-webhook node', async () => { test('should not report non-webhook node', async () => {
await saveManualTriggerWorkflow(); await saveManualTriggerWorkflow();
const testAudit = await audit(['instance']); const testAudit = await securityAuditService.run(['instance']);
if (Array.isArray(testAudit)) fail('audit is empty'); if (Array.isArray(testAudit)) fail('audit is empty');
const report = testAudit[toReportTitle('instance')]; const report = testAudit[toReportTitle('instance')];
@ -197,7 +203,7 @@ test('should not report non-webhook node', async () => {
test('should report outdated instance when outdated', async () => { test('should report outdated instance when outdated', async () => {
simulateOutdatedInstanceOnce(); simulateOutdatedInstanceOnce();
const testAudit = await audit(['instance']); const testAudit = await securityAuditService.run(['instance']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -215,7 +221,7 @@ test('should report outdated instance when outdated', async () => {
}); });
test('should not report outdated instance when up to date', async () => { test('should not report outdated instance when up to date', async () => {
const testAudit = await audit(['instance']); const testAudit = await securityAuditService.run(['instance']);
if (Array.isArray(testAudit)) fail('audit is empty'); if (Array.isArray(testAudit)) fail('audit is empty');
const report = testAudit[toReportTitle('instance')]; const report = testAudit[toReportTitle('instance')];
@ -231,7 +237,7 @@ test('should not report outdated instance when up to date', async () => {
test('should report security settings', async () => { test('should report security settings', async () => {
config.set('diagnostics.enabled', true); config.set('diagnostics.enabled', true);
const testAudit = await audit(['instance']); const testAudit = await securityAuditService.run(['instance']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,

View file

@ -1,8 +1,8 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { audit } from '@/audit'; import { SecurityAuditService } from '@/security-audit/SecurityAudit.service';
import { OFFICIAL_RISKY_NODE_TYPES, NODES_REPORT } from '@/audit/constants'; import { OFFICIAL_RISKY_NODE_TYPES, NODES_REPORT } from '@/security-audit/constants';
import { toReportTitle } from '@/audit/utils'; import { toReportTitle } from '@/security-audit/utils';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { NodeTypes } from '@/NodeTypes'; import { NodeTypes } from '@/NodeTypes';
import { CommunityPackagesService } from '@/services/communityPackages.service'; import { CommunityPackagesService } from '@/services/communityPackages.service';
@ -18,8 +18,12 @@ mockInstance(NodeTypes);
const communityPackagesService = mockInstance(CommunityPackagesService); const communityPackagesService = mockInstance(CommunityPackagesService);
Container.set(CommunityPackagesService, communityPackagesService); Container.set(CommunityPackagesService, communityPackagesService);
let securityAuditService: SecurityAuditService;
beforeAll(async () => { beforeAll(async () => {
await testDb.init(); await testDb.init();
securityAuditService = new SecurityAuditService(Container.get(WorkflowRepository));
}); });
beforeEach(async () => { beforeEach(async () => {
@ -59,7 +63,7 @@ test('should report risky official nodes', async () => {
await Promise.all(promises); await Promise.all(promises);
const testAudit = await audit(['nodes']); const testAudit = await securityAuditService.run(['nodes']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,
@ -80,10 +84,12 @@ test('should not report non-risky official nodes', async () => {
communityPackagesService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE); communityPackagesService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE);
await saveManualTriggerWorkflow(); await saveManualTriggerWorkflow();
const testAudit = await audit(['nodes']); const testAudit = await securityAuditService.run(['nodes']);
if (Array.isArray(testAudit)) return; if (Array.isArray(testAudit)) return;
const report = testAudit[toReportTitle('nodes')]; const report = testAudit[toReportTitle('nodes')];
if (!report) return; if (!report) return;
for (const section of report.sections) { for (const section of report.sections) {
@ -94,7 +100,7 @@ test('should not report non-risky official nodes', async () => {
test('should report community nodes', async () => { test('should report community nodes', async () => {
communityPackagesService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE); communityPackagesService.getAllInstalledPackages.mockResolvedValue(MOCK_PACKAGE);
const testAudit = await audit(['nodes']); const testAudit = await securityAuditService.run(['nodes']);
const section = getRiskSection( const section = getRiskSection(
testAudit, testAudit,

View file

@ -1,9 +1,9 @@
import nock from 'nock'; import nock from 'nock';
import config from '@/config'; import config from '@/config';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { toReportTitle } from '@/audit/utils'; import { toReportTitle } from '@/security-audit/utils';
import * as constants from '@/constants'; import * as constants from '@/constants';
import type { Risk } from '@/audit/types'; import type { Risk } from '@/security-audit/types';
import type { InstalledNodes } from '@db/entities/InstalledNodes'; import type { InstalledNodes } from '@db/entities/InstalledNodes';
import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { InstalledPackages } from '@db/entities/InstalledPackages';
import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { WorkflowRepository } from '@db/repositories/workflow.repository';