chore: Schema-First migration — audit (#39207)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Sam Wooler <swooler592@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-09-28 12:35:38 +00:00 committed by GitHub
parent 8ff280ed3d
commit 9bda02122d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1109 additions and 200 deletions

View File

@ -0,0 +1,123 @@
import '../../openapi-extend';
import { z } from 'zod';
import { auditReportFieldDocs, auditRequestFieldDocs } from './audit-public.openapi';
import { Z } from '../../zod-class';
export const SECURITY_AUDIT_CATEGORIES = [
'credentials',
'database',
'nodes',
'filesystem',
'instance',
] as const;
export class GenerateAuditPublicDto extends Z.class({
additionalOptions: z
.object({
daysAbandonedWorkflow: z
.number()
.int()
.optional()
.openapi(auditRequestFieldDocs.daysAbandonedWorkflow),
categories: z.array(z.enum(SECURITY_AUDIT_CATEGORIES)).optional(),
})
.optional(),
}) {
// Every field is optional, so an omitted body should generate the default audit
static override safeParse(data: unknown) {
return super.safeParse(data ?? {});
}
static override parse(data: unknown) {
return super.parse(data ?? {});
}
}
const riskLocationSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('node'),
workflowId: z.string(),
workflowName: z.string(),
nodeId: z.string(),
nodeName: z.string(),
nodeType: z.string(),
}),
z.object({
kind: z.literal('credential'),
id: z.string(),
name: z.string(),
}),
z.object({
kind: z.literal('community'),
nodeType: z.string(),
packageUrl: z.string(),
}),
z.object({
kind: z.literal('custom'),
nodeType: z.string(),
filePath: z.string(),
}),
]);
const versionNodeArraySchema = z.array(z.unknown());
// Registered under a refId so the generator emits it once as a shared component instead of
// inlining it under each of the five named risk reports below.
const versionSchema = z
.object({
name: z.string(),
nodes: versionNodeArraySchema,
createdAt: z.string(),
description: z.string(),
documentationUrl: z.string(),
hasBreakingChange: z.boolean(),
// The version-notifications API returns `null`, not `false`/`''`, when a release has no
// security fix or issue to report.
hasSecurityFix: z.boolean().nullable(),
hasSecurityIssue: z.boolean().nullable(),
securityIssueFixVersion: z.string().nullable(),
})
.openapi('AuditVersion');
const riskReportSectionSchema = z.object({
title: z.string(),
description: z.string(),
recommendation: z.string(),
location: z.array(riskLocationSchema).optional(),
settings: z.record(z.string(), z.unknown()).optional(),
nextVersions: z.array(versionSchema).optional(),
});
const riskReportSchema = z.object({
risk: z.enum(SECURITY_AUDIT_CATEGORIES),
sections: z.array(riskReportSectionSchema),
});
const auditReportsPublicSchema = z.object({
'Credentials Risk Report': riskReportSchema.openapi(auditReportFieldDocs.credentials).optional(),
'Database Risk Report': riskReportSchema.openapi(auditReportFieldDocs.database).optional(),
'Filesystem Risk Report': riskReportSchema.openapi(auditReportFieldDocs.filesystem).optional(),
'Nodes Risk Report': riskReportSchema.openapi(auditReportFieldDocs.nodes).optional(),
'Instance Risk Report': riskReportSchema.openapi(auditReportFieldDocs.instance).optional(),
});
export const auditPublicSchema = z.union([
auditReportsPublicSchema,
z.array(z.unknown()).length(0).openapi({ maxItems: 0 }),
]);
export type AuditPublic = z.infer<typeof auditPublicSchema>;
export class AuditPublicDto {
static schema = auditPublicSchema;
static safeParse(data: unknown) {
return auditPublicSchema.safeParse(data);
}
static parse(data: unknown): AuditPublic {
return auditPublicSchema.parse(data);
}
}

View File

@ -0,0 +1,117 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
export const auditRequestFieldDocs = {
daysAbandonedWorkflow: {
description: 'Days for a workflow to be considered abandoned if not executed',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const auditReportFieldDocs: Record<string, ZodOpenAPIMetadata> = {
credentials: {
example: {
risk: 'credentials',
sections: [
{
title: 'Credentials not used in any workflow',
description:
'These credentials are not used in any workflow. Keeping unused credentials in your instance is an unneeded security risk.',
recommendation: 'Consider deleting these credentials if you no longer need them.',
location: [{ kind: 'credential', id: '1', name: 'My Test Account' }],
},
],
},
},
database: {
example: {
risk: 'database',
sections: [
{
title: 'Expressions in "Execute Query" fields in SQL nodes',
description:
'This SQL node has 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.',
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: [
{
kind: 'node',
workflowId: '1',
workflowName: 'My Workflow',
nodeId: '51eb5852-ce0b-4806-b4ff-e41322a4041a',
nodeName: 'MySQL',
nodeType: 'n8n-nodes-base.mySql',
},
],
},
],
},
},
filesystem: {
example: {
risk: 'filesystem',
sections: [
{
title: 'Nodes that interact with the filesystem',
description:
'This node reads from and writes to any accessible file in the host filesystem. Sensitive file content may be manipulated through a node operation.',
recommendation:
'Consider protecting any sensitive files in the host filesystem, or refactoring the workflow so that it does not require host filesystem interaction.',
location: [
{
kind: 'node',
workflowId: '1',
workflowName: 'My Workflow',
nodeId: '51eb5852-ce0b-4806-b4ff-e41322a4041a',
nodeName: 'Ready Binary file',
nodeType: 'n8n-nodes-base.readBinaryFile',
},
],
},
],
},
},
nodes: {
example: {
risk: 'nodes',
sections: [
{
title: 'Community nodes',
description:
'This node is sourced from the community. Community nodes are not vetted by the n8n team and have full access to the host system.',
recommendation:
'Consider reviewing the source code in any community nodes installed in this n8n instance, and uninstalling any community nodes no longer used.',
location: [
{
kind: 'community',
nodeType: 'n8n-nodes-test.test',
packageUrl: 'https://www.npmjs.com/package/n8n-nodes-test',
},
],
},
],
},
},
instance: {
example: {
risk: 'instance',
sections: [
{
title: 'Unprotected webhooks in instance',
description:
'These webhook nodes have the "Authentication" field set to "None" and are 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.',
recommendation:
'Consider setting the "Authentication" field to an option other than "None", or validating the payload with one of the following nodes.',
location: [
{
kind: 'node',
workflowId: '1',
workflowName: 'My Workflow',
nodeId: '51eb5852-ce0b-4806-b4ff-e41322a4041a',
nodeName: 'Webhook',
nodeType: 'n8n-nodes-base.webhook',
},
],
},
],
},
},
};

View File

@ -54,6 +54,14 @@ export type { DiscoverDataPublic } from './discover/discover-public.dto';
export { BreakingChangeReportQueryDto } from './breaking-changes/breaking-change-report-query.dto';
export {
AuditPublicDto,
GenerateAuditPublicDto,
SECURITY_AUDIT_CATEGORIES,
auditPublicSchema,
type AuditPublic,
} from './audit/audit-public.dto';
export { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
export { EmbedLoginQueryDto } from './auth/embed-login-query.dto';

View File

@ -137,7 +137,6 @@ export default defineConfig(
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
// must fail CI. Entries are removed as each handler becomes a controller.
files: [
'./src/public-api/v1/handlers/audit/audit.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/ldap/ldap.handler.ts',

View File

@ -141,7 +141,6 @@ export default defineConfig({
// `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler
// must fail CI. Entries are removed as each handler becomes a controller.
files: [
'./src/public-api/v1/handlers/audit/audit.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.columns.handler.ts',
'./src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts',
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',

View File

@ -15,7 +15,6 @@ import type {
import type { AuthenticatedRequest } from '@n8n/db';
import type { AuthlessRequest } from '@/requests';
import type { Risk } from '@/security-audit/types';
export type PaginatedRequest = AuthenticatedRequest<
{},
@ -185,18 +184,6 @@ export declare namespace CommunityPackageRequest {
type Uninstall = AuthenticatedRequest<{ name: string }>;
}
// ----------------------------------
// /audit
// ----------------------------------
export declare namespace AuditRequest {
type Generate = AuthenticatedRequest<
{},
{},
{ additionalOptions?: { categories?: Risk.Category[]; daysAbandonedWorkflow?: number } }
>;
}
export declare namespace LogStreamingRequest {
type GetEventTypes = AuthenticatedRequest;
type GetDestinations = AuthenticatedRequest;

View File

@ -0,0 +1,39 @@
import { AuditPublicDto, GenerateAuditPublicDto, type AuditPublic } from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import {
ApiDescription,
ApiErrorResponse,
ApiKeyScope,
ApiResponse,
ApiSummary,
ApiTags,
Body,
Post,
PublicApiController,
} from '@n8n/decorators';
import type { Response } from 'express';
import { SecurityAuditService } from '@/security-audit/security-audit.service';
@PublicApiController('/audit')
export class AuditPublicController {
constructor(private readonly securityAuditService: SecurityAuditService) {}
@Post('/')
@ApiKeyScope('securityAudit:generate')
@ApiSummary('Generate an audit')
@ApiDescription('Generate a security audit for your n8n instance.')
@ApiTags(['Audit'])
@ApiResponse(200, AuditPublicDto)
@ApiErrorResponse(500)
async generateAudit(
_req: AuthenticatedRequest,
_res: Response,
@Body body: GenerateAuditPublicDto,
): Promise<AuditPublic> {
return await this.securityAuditService.run(
body.additionalOptions?.categories,
body.additionalOptions?.daysAbandonedWorkflow,
);
}
}

View File

@ -3,6 +3,7 @@
* decorator metadata is registered before PublicApiControllerRegistry /
* scope-parity / discover run.
*/
import './audit.public.controller';
import './community-packages.public.controller';
import './credential-type-policies.public.controller';
import './credentials.public.controller';

View File

@ -1,27 +0,0 @@
import { Container } from '@n8n/di';
import type { AuditRequest } from '@/public-api/types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import { apiKeyHasScopeWithGlobalScopeFallback } from '../../shared/middlewares/global.middleware';
type AuditHandlers = {
generateAudit: PublicAPIEndpoint<AuditRequest.Generate>;
};
const auditHandlers: AuditHandlers = {
generateAudit: [
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'securityAudit:generate' }),
async (req, res) => {
const { SecurityAuditService } = await import('@/security-audit/security-audit.service.js');
const result = await Container.get(SecurityAuditService).run(
req.body?.additionalOptions?.categories,
req.body?.additionalOptions?.daysAbandonedWorkflow,
);
return res.json(result);
},
],
};
export = auditHandlers;

View File

@ -1,37 +0,0 @@
post:
x-eov-operation-id: generateAudit
x-required-scope: securityAudit:generate
x-eov-operation-handler: v1/handlers/audit/audit.handler
tags:
- Audit
summary: Generate an audit
description: Generate a security audit for your n8n instance.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
additionalOptions:
type: object
properties:
daysAbandonedWorkflow:
type: integer
description: Days for a workflow to be considered abandoned if not executed
categories:
type: array
items:
type: string
enum: ['credentials', 'database', 'nodes', 'filesystem', 'instance']
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/audit.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'500':
description: Internal server error.

View File

@ -0,0 +1,643 @@
operationId: generateAudit
tags:
- Audit
summary: Generate an audit
description: Generate a security audit for your n8n instance.
x-required-scope: securityAudit:generate
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
requestBody:
content:
application/json:
schema:
type: object
properties:
additionalOptions:
type: object
properties:
daysAbandonedWorkflow:
type: integer
description: Days for a workflow to be considered abandoned if not executed
categories:
type: array
items:
type: string
enum:
- credentials
- database
- nodes
- filesystem
- instance
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
anyOf:
- type: object
properties:
Credentials Risk Report:
type: object
properties:
risk:
type: string
enum:
- credentials
- database
- nodes
- filesystem
- instance
sections:
type: array
items:
type: object
properties:
title:
type: string
description:
type: string
recommendation:
type: string
location:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- node
workflowId:
type: string
workflowName:
type: string
nodeId:
type: string
nodeName:
type: string
nodeType:
type: string
required:
- kind
- workflowId
- workflowName
- nodeId
- nodeName
- nodeType
- type: object
properties:
kind:
type: string
enum:
- credential
id:
type: string
name:
type: string
required:
- kind
- id
- name
- type: object
properties:
kind:
type: string
enum:
- community
nodeType:
type: string
packageUrl:
type: string
required:
- kind
- nodeType
- packageUrl
- type: object
properties:
kind:
type: string
enum:
- custom
nodeType:
type: string
filePath:
type: string
required:
- kind
- nodeType
- filePath
settings:
type: object
additionalProperties:
nullable: true
nextVersions:
type: array
items:
$ref: ../../../../shared/spec/schemas/auditVersion.generated.yml
required:
- title
- description
- recommendation
required:
- risk
- sections
example:
risk: credentials
sections:
- title: Credentials not used in any workflow
description: These credentials are not used in any workflow. Keeping unused credentials in your instance is an unneeded security risk.
recommendation: Consider deleting these credentials if you no longer need them.
location:
- kind: credential
id: '1'
name: My Test Account
Database Risk Report:
type: object
properties:
risk:
type: string
enum:
- credentials
- database
- nodes
- filesystem
- instance
sections:
type: array
items:
type: object
properties:
title:
type: string
description:
type: string
recommendation:
type: string
location:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- node
workflowId:
type: string
workflowName:
type: string
nodeId:
type: string
nodeName:
type: string
nodeType:
type: string
required:
- kind
- workflowId
- workflowName
- nodeId
- nodeName
- nodeType
- type: object
properties:
kind:
type: string
enum:
- credential
id:
type: string
name:
type: string
required:
- kind
- id
- name
- type: object
properties:
kind:
type: string
enum:
- community
nodeType:
type: string
packageUrl:
type: string
required:
- kind
- nodeType
- packageUrl
- type: object
properties:
kind:
type: string
enum:
- custom
nodeType:
type: string
filePath:
type: string
required:
- kind
- nodeType
- filePath
settings:
type: object
additionalProperties:
nullable: true
nextVersions:
type: array
items:
$ref: ../../../../shared/spec/schemas/auditVersion.generated.yml
required:
- title
- description
- recommendation
required:
- risk
- sections
example:
risk: database
sections:
- title: Expressions in "Execute Query" fields in SQL nodes
description: This SQL node has 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.
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:
- kind: node
workflowId: '1'
workflowName: My Workflow
nodeId: 51eb5852-ce0b-4806-b4ff-e41322a4041a
nodeName: MySQL
nodeType: n8n-nodes-base.mySql
Filesystem Risk Report:
type: object
properties:
risk:
type: string
enum:
- credentials
- database
- nodes
- filesystem
- instance
sections:
type: array
items:
type: object
properties:
title:
type: string
description:
type: string
recommendation:
type: string
location:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- node
workflowId:
type: string
workflowName:
type: string
nodeId:
type: string
nodeName:
type: string
nodeType:
type: string
required:
- kind
- workflowId
- workflowName
- nodeId
- nodeName
- nodeType
- type: object
properties:
kind:
type: string
enum:
- credential
id:
type: string
name:
type: string
required:
- kind
- id
- name
- type: object
properties:
kind:
type: string
enum:
- community
nodeType:
type: string
packageUrl:
type: string
required:
- kind
- nodeType
- packageUrl
- type: object
properties:
kind:
type: string
enum:
- custom
nodeType:
type: string
filePath:
type: string
required:
- kind
- nodeType
- filePath
settings:
type: object
additionalProperties:
nullable: true
nextVersions:
type: array
items:
$ref: ../../../../shared/spec/schemas/auditVersion.generated.yml
required:
- title
- description
- recommendation
required:
- risk
- sections
example:
risk: filesystem
sections:
- title: Nodes that interact with the filesystem
description: This node reads from and writes to any accessible file in the host filesystem. Sensitive file content may be manipulated through a node operation.
recommendation: Consider protecting any sensitive files in the host filesystem, or refactoring the workflow so that it does not require host filesystem interaction.
location:
- kind: node
workflowId: '1'
workflowName: My Workflow
nodeId: 51eb5852-ce0b-4806-b4ff-e41322a4041a
nodeName: Ready Binary file
nodeType: n8n-nodes-base.readBinaryFile
Nodes Risk Report:
type: object
properties:
risk:
type: string
enum:
- credentials
- database
- nodes
- filesystem
- instance
sections:
type: array
items:
type: object
properties:
title:
type: string
description:
type: string
recommendation:
type: string
location:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- node
workflowId:
type: string
workflowName:
type: string
nodeId:
type: string
nodeName:
type: string
nodeType:
type: string
required:
- kind
- workflowId
- workflowName
- nodeId
- nodeName
- nodeType
- type: object
properties:
kind:
type: string
enum:
- credential
id:
type: string
name:
type: string
required:
- kind
- id
- name
- type: object
properties:
kind:
type: string
enum:
- community
nodeType:
type: string
packageUrl:
type: string
required:
- kind
- nodeType
- packageUrl
- type: object
properties:
kind:
type: string
enum:
- custom
nodeType:
type: string
filePath:
type: string
required:
- kind
- nodeType
- filePath
settings:
type: object
additionalProperties:
nullable: true
nextVersions:
type: array
items:
$ref: ../../../../shared/spec/schemas/auditVersion.generated.yml
required:
- title
- description
- recommendation
required:
- risk
- sections
example:
risk: nodes
sections:
- title: Community nodes
description: This node is sourced from the community. Community nodes are not vetted by the n8n team and have full access to the host system.
recommendation: Consider reviewing the source code in any community nodes installed in this n8n instance, and uninstalling any community nodes no longer used.
location:
- kind: community
nodeType: n8n-nodes-test.test
packageUrl: https://www.npmjs.com/package/n8n-nodes-test
Instance Risk Report:
type: object
properties:
risk:
type: string
enum:
- credentials
- database
- nodes
- filesystem
- instance
sections:
type: array
items:
type: object
properties:
title:
type: string
description:
type: string
recommendation:
type: string
location:
type: array
items:
oneOf:
- type: object
properties:
kind:
type: string
enum:
- node
workflowId:
type: string
workflowName:
type: string
nodeId:
type: string
nodeName:
type: string
nodeType:
type: string
required:
- kind
- workflowId
- workflowName
- nodeId
- nodeName
- nodeType
- type: object
properties:
kind:
type: string
enum:
- credential
id:
type: string
name:
type: string
required:
- kind
- id
- name
- type: object
properties:
kind:
type: string
enum:
- community
nodeType:
type: string
packageUrl:
type: string
required:
- kind
- nodeType
- packageUrl
- type: object
properties:
kind:
type: string
enum:
- custom
nodeType:
type: string
filePath:
type: string
required:
- kind
- nodeType
- filePath
settings:
type: object
additionalProperties:
nullable: true
nextVersions:
type: array
items:
$ref: ../../../../shared/spec/schemas/auditVersion.generated.yml
required:
- title
- description
- recommendation
required:
- risk
- sections
example:
risk: instance
sections:
- title: Unprotected webhooks in instance
description: These webhook nodes have the "Authentication" field set to "None" and are 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.
recommendation: Consider setting the "Authentication" field to an option other than "None", or validating the payload with one of the following nodes.
location:
- kind: node
workflowId: '1'
workflowName: My Workflow
nodeId: 51eb5852-ce0b-4806-b4ff-e41322a4041a
nodeName: Webhook
nodeType: n8n-nodes-base.webhook
- type: array
items:
nullable: true
maxItems: 0
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml
'500':
$ref: ../../../../shared/spec/responses/internalServerError.yml

View File

@ -1,105 +0,0 @@
type: object
properties:
Credentials Risk Report:
type: object
example:
risk: credentials
sections:
[
{
title: Credentials not used in any workflow,
description: These credentials are not used in any workflow. Keeping unused credentials in your instance is an unneeded security risk.,
recommendation: Consider deleting these credentials if you no longer need them.,
location: [{ kind: credential, id: '1', name: My Test Account }],
},
]
Database Risk Report:
type: object
example:
risk: database
sections:
[
{
title: Expressions in "Execute Query" fields in SQL nodes,
description: This SQL node has 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.,
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:
[
{
kind: node,
workflowId: '1',
workflowName: 'My Workflow',
nodeId: 51eb5852-ce0b-4806-b4ff-e41322a4041a,
nodeName: 'MySQL',
nodeType: n8n-nodes-base.mySql,
},
],
},
]
Filesystem Risk Report:
type: object
example:
risk: filesystem
sections:
[
{
title: Nodes that interact with the filesystem,
description: This node reads from and writes to any accessible file in the host filesystem. Sensitive file content may be manipulated through a node operation.,
recommendation: Consider protecting any sensitive files in the host filesystem,
or refactoring the workflow so that it does not require host filesystem interaction.,
location:
[
{
kind: node,
workflowId: '1',
workflowName: 'My Workflow',
nodeId: 51eb5852-ce0b-4806-b4ff-e41322a4041a,
nodeName: 'Ready Binary file',
nodeType: n8n-nodes-base.readBinaryFile,
},
],
},
]
Nodes Risk Report:
type: object
example:
risk: nodes
sections:
[
{
title: Community nodes,
description: This node is sourced from the community. Community nodes are not vetted by the n8n team and have full access to the host system.,
recommendation: Consider reviewing the source code in any community nodes installed in this n8n instance,
and uninstalling any community nodes no longer used.,
location:
[
{
kind: community,
nodeType: n8n-nodes-test.test,
packageUrl: https://www.npmjs.com/package/n8n-nodes-test,
},
],
},
]
Instance Risk Report:
type: object
example:
risk: execution
sections:
[
{
title: Unprotected webhooks in instance,
description: These webhook nodes have the "Authentication" field set to "None" and are 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.,
recommendation: Consider setting the "Authentication" field to an option other than "None",
or validating the payload with one of the following nodes.,
location:
[
{
kind: community,
nodeType: n8n-nodes-test.test,
packageUrl: https://www.npmjs.com/package/n8n-nodes-test,
},
],
},
]

View File

@ -39,6 +39,7 @@ export const ERROR_RESPONSE_REFS = {
409: { $ref: '../../../../shared/spec/responses/conflict.yml' },
415: { $ref: '../../../../shared/spec/responses/unsupportedMediaType.yml' },
422: { $ref: '../../../../shared/spec/responses/unprocessableEntity.yml' },
500: { $ref: '../../../../shared/spec/responses/internalServerError.yml' },
503: { $ref: '../../../../shared/spec/responses/serviceUnavailable.yml' },
} as const satisfies Record<number, { $ref: string }>;
@ -61,6 +62,7 @@ export const ERROR_RESPONSE_DESCRIPTIONS: Record<DocumentedErrorStatus, string>
409: 'Conflict',
415: 'Unsupported media type.',
422: 'Unprocessable Entity',
500: 'Internal server error.',
503: 'The requested service is temporarily unavailable.',
};

View File

@ -3,6 +3,9 @@ info:
title: decorator-routes
version: 0.0.0
paths:
/audit:
post:
$ref: ./handlers/audit/spec/paths/generateAudit.generated.yml
/community-packages:
get:
$ref: ./handlers/community-packages/spec/paths/getInstalledPackages.generated.yml

View File

@ -79,8 +79,6 @@ tags:
description: Operations about workflows
paths:
/audit:
$ref: './handlers/audit/spec/paths/audit.yml'
/settings/ldap:
$ref: './handlers/ldap/spec/paths/settings.ldap.yml'
/settings/ldap/sync:

View File

@ -14,5 +14,7 @@ UnsupportedMediaType:
$ref: './unsupportedMediaType.yml'
UnprocessableEntity:
$ref: './unprocessableEntity.yml'
InternalServerError:
$ref: './internalServerError.yml'
ServiceUnavailable:
$ref: './serviceUnavailable.yml'

View File

@ -0,0 +1 @@
description: Internal server error.

View File

@ -12,5 +12,3 @@ WorkflowSettings:
$ref: './../../../handlers/workflows/spec/schemas/workflowSettings.yml'
WorkflowList:
$ref: './../../../handlers/workflows/spec/schemas/workflowList.yml'
Audit:
$ref: './../../../handlers/audit/spec/schemas/audit.yml'

View File

@ -0,0 +1,35 @@
type: object
properties:
name:
type: string
nodes:
type: array
items:
nullable: true
createdAt:
type: string
description:
type: string
documentationUrl:
type: string
hasBreakingChange:
type: boolean
hasSecurityFix:
type: boolean
nullable: true
hasSecurityIssue:
type: boolean
nullable: true
securityIssueFixVersion:
type: string
nullable: true
required:
- name
- nodes
- createdAt
- description
- documentationUrl
- hasBreakingChange
- hasSecurityFix
- hasSecurityIssue
- securityIssueFixVersion

View File

@ -1,16 +1,10 @@
import type { Risk } from '@/security-audit/types';
import { SECURITY_AUDIT_CATEGORIES } from '@n8n/api-types';
/**
* Risk categories
*/
export const RISK_CATEGORIES: Risk.Category[] = [
'credentials',
'database',
'nodes',
'instance',
'filesystem',
];
export const RISK_CATEGORIES = [...SECURITY_AUDIT_CATEGORIES];
/**
* Node types

View File

@ -1,7 +1,9 @@
import type { IWorkflowBase } from 'n8n-workflow';
import type { RISK_CATEGORIES } from '@/security-audit/constants';
export namespace Risk {
export type Category = 'database' | 'credentials' | 'nodes' | 'instance' | 'filesystem';
export type Category = (typeof RISK_CATEGORIES)[number];
type CredLocation = {
kind: 'credential';
@ -79,9 +81,9 @@ export namespace n8n {
description: string;
documentationUrl: string;
hasBreakingChange: boolean;
hasSecurityFix: boolean;
hasSecurityIssue: boolean;
securityIssueFixVersion: string;
hasSecurityFix: boolean | null;
hasSecurityIssue: boolean | null;
securityIssueFixVersion: string | null;
};
}

View File

@ -0,0 +1,127 @@
import { createWorkflow, testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import nock from 'nock';
import { randomUUID } from 'node:crypto';
import { createOwnerWithApiKey } from '../shared/db/users';
import { simulateUpToDateInstance } from '../security-audit/utils';
import * as utils from '../shared/utils';
const testServer = utils.setupTestServer({
endpointGroups: ['publicApi'],
modules: ['community-packages'],
});
let scopedOwner: User;
let unscopedOwner: User;
beforeAll(async () => {
scopedOwner = await createOwnerWithApiKey({ scopes: ['securityAudit:generate'] });
unscopedOwner = await createOwnerWithApiKey({ scopes: ['workflow:list'] });
});
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity']);
nock.cleanAll();
});
describe('POST /audit', () => {
test('returns the requested security audit reports', async () => {
await createWorkflow({
nodes: [
{
id: randomUUID(),
name: 'Read file',
type: 'n8n-nodes-base.readBinaryFile',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
});
const response = await testServer
.publicApiAgentFor(scopedOwner)
.post('/audit')
.send({ additionalOptions: { categories: ['filesystem'] } })
.expect(200);
expect(response.body).toEqual({
'Filesystem Risk Report': {
risk: 'filesystem',
sections: [
expect.objectContaining({
title: 'Nodes that interact with the filesystem',
location: [
expect.objectContaining({
kind: 'node',
nodeName: 'Read file',
nodeType: 'n8n-nodes-base.readBinaryFile',
}),
],
}),
],
},
});
});
test('returns an empty array when the audit finds no risks', async () => {
const response = await testServer
.publicApiAgentFor(scopedOwner)
.post('/audit')
.send({ additionalOptions: { categories: ['filesystem'] } })
.expect(200);
expect(response.body).toEqual([]);
});
test('generates the default audit when no body is sent', async () => {
simulateUpToDateInstance();
const response = await testServer.publicApiAgentFor(scopedOwner).post('/audit').expect(200);
expect(response.body).toEqual({
'Instance Risk Report': {
risk: 'instance',
sections: [expect.objectContaining({ title: 'Security settings' })],
},
});
});
test('accepts a zero daysAbandonedWorkflow', async () => {
const response = await testServer
.publicApiAgentFor(scopedOwner)
.post('/audit')
.send({ additionalOptions: { daysAbandonedWorkflow: 0, categories: ['filesystem'] } })
.expect(200);
expect(response.body).toEqual([]);
});
test('ignores an unknown request field', async () => {
simulateUpToDateInstance();
const response = await testServer
.publicApiAgentFor(scopedOwner)
.post('/audit')
.send({ unknown: true })
.expect(200);
expect(response.body).toEqual({
'Instance Risk Report': {
risk: 'instance',
sections: [expect.objectContaining({ title: 'Security settings' })],
},
});
});
test('rejects an API key without the securityAudit:generate scope', async () => {
const response = await testServer
.publicApiAgentFor(unscopedOwner)
.post('/audit')
.send({})
.expect(403);
expect(response.body).toEqual({ message: 'Forbidden' });
});
});