diff --git a/public/controllers/management/components/management/reporting/reporting-table.js b/public/controllers/management/components/management/reporting/reporting-table.js index 50381facea..0435cc3b3a 100644 --- a/public/controllers/management/components/management/reporting/reporting-table.js +++ b/public/controllers/management/components/management/reporting/reporting-table.js @@ -75,7 +75,7 @@ class WzReportingTable extends Component { async getItems() { try { const rawItems = await this.reportingHandler.listReports(); - const items = ((rawItems || {}).data || {}).reports || []; + const {reports: items = [], path} = rawItems?.data; this.setState({ items, isProcessing: false, diff --git a/public/react-services/reporting.js b/public/react-services/reporting.js index 8a053b9b24..b74ed090ff 100644 --- a/public/react-services/reporting.js +++ b/public/react-services/reporting.js @@ -91,16 +91,11 @@ export class ReportingService { const appliedFilters = await this.visHandlers.getAppliedFilters(syscollectorFilters); const array = await this.vis2png.checkArray(visualizationIDList); - const name = `wazuh-${ - agents ? `agent-${agents}` : 'overview' - }-${tab}-${(Date.now() / 1000) | 0}.pdf`; const browserTimezone = moment.tz.guess(true); const data = { array, - name, - title: agents ? `Agents ${tab}` : `Overview ${tab}`, filters: appliedFilters.filters, time: appliedFilters.time, searchBar: appliedFilters.searchBar, @@ -152,15 +147,10 @@ export class ReportingService { this.$rootScope.reportStatus = 'Generating PDF document...'; this.$rootScope.$applyAsync(); - const docType = type === 'agentConfig' ? `wazuh-agent-${obj.id}` : `wazuh-group-${obj.name}`; - - const name = `${docType}-configuration-${(Date.now() / 1000) | 0}.pdf`; const browserTimezone = moment.tz.guess(true); const data = { - name, filters: [type === 'agentConfig' ? { agent: obj.id } : { group: obj.name }], - tab: type, browserTimezone, components, apiId: JSON.parse(AppState.getCurrentAPI()).id diff --git a/server/controllers/wazuh-reporting-security-endpoint-handler.test.ts b/server/controllers/wazuh-reporting-security-endpoint-handler.test.ts new file mode 100644 index 0000000000..634b04192f --- /dev/null +++ b/server/controllers/wazuh-reporting-security-endpoint-handler.test.ts @@ -0,0 +1,220 @@ +import md5 from 'md5'; +import fs from 'fs'; +import { WazuhReportingCtrl } from './wazuh-reporting'; + +jest.mock('../lib/logger', () => ({ + log: jest.fn() +})); + +jest.mock('../lib/reporting/printer', () => { + class ReportPrinterMock { + constructor() { } + addContent() { } + addConfigTables() { } + addTables() { } + addTimeRangeAndFilters() { } + addVisualizations() { } + formatDate() { } + checkTitle() { } + addSimpleTable() { } + addList() { } + addNewLine() { } + addContentWithNewLine() { } + addAgentsFilters() { } + print() { } + } + return { + ReportPrinter: ReportPrinterMock + } +}); + +const getMockerUserContext = (username: string) => ({ username, hashUsername: md5(username) }); + +const mockContext = (username: string) => ({ + wazuh: { + security: { + getCurrentUser: () => getMockerUserContext(username) + } + } +}); + +const mockResponse = () => ({ + ok: (body) => body, + custom: (body) => body, + badRequest: (body) => body +}); + +const endpointController = new WazuhReportingCtrl(); + +console.warn(` +Theses tests don't have in account the validation of endpoint parameters. +The endpoints related to an specific file. +This validation could prevent the endpoint handler is executed, so these tests +don't cover the reality. +`); + +describe('[security] Report endpoints guard related to a file. Parameter defines or builds the filename.', () => { + let routeHandler = null; + const routeHandlerResponse = 'Endpoint handler executed.'; + + beforeEach(() => { + routeHandler = jest.fn(() => routeHandlerResponse); + }); + + afterEach(() => { + routeHandler = null; + }); + + it.each` + testTitle | username | filename | endpointProtected + ${'Execute endpoint handler'} | ${'admin'} | ${'wazuh-module-overview-general-1234.pdf'} | ${false} + ${'Endpoint protected'} | ${'admin'} | ${'../wazuh-module-overview-general-1234.pdf'} | ${true} + ${'Endpoint protected'} | ${'admin'} | ${'wazuh-module-overview-../general-1234.pdf'} | ${true} + ${'Endpoint protected'} | ${'admin'} | ${'custom../wazuh-module-overview-general-1234.pdf'} | ${true} + ${'Execute endpoint handler'} | ${'../../etc'} | ${'wazuh-module-agents-001-general-1234.pdf'} | ${false} + ${'Endpoint protected'} | ${'../../etc'} | ${'../wazuh-module-agents-001-general-1234.pdf'} | ${true} + ${'Endpoint protected'} | ${'../../etc'} | ${'wazuh-module-overview-../general-1234.pdf'} | ${true} + ${'Endpoint protected'} | ${'../../etc'} | ${'custom../wazuh-module-overview-general-1234.pdf'} | ${true} + `(`$testTitle + username: $username + filename: $filename + endpointProtected: $endpointProtected`, async ({ username, filename, endpointProtected }) => { + const response = await endpointController.checkReportsUserDirectoryIsValidRouteDecorator( + routeHandler, + function getFilename(request) { + return request.params.name + } + )(mockContext(username), { params: { name: filename } }, mockResponse()); + if (endpointProtected) { + + expect(response.body.message).toBe('5040 - You shall not pass!'); + expect(routeHandler.mock.calls).toHaveLength(0); + } else { + expect(routeHandler.mock.calls).toHaveLength(1); + expect(response).toBe(routeHandlerResponse); + } + }); + +}); + +describe('[security] GET /reports', () => { + + it.each` + username + ${'admin'} + ${'../../etc'} + `(`Get user reports: GET /reports + username: $username`, async ({ username }) => { + jest.spyOn(fs, 'readdirSync').mockImplementation(() => []); + + const response = await endpointController.getReports(mockContext(username), {}, mockResponse()); + expect(response.body.reports).toHaveLength(0); + }); +}); + +describe('[security] GET /reports/{name}', () => { + + it.each` + titleTest | username | filename | valid + ${'Get report'} | ${'admin'} | ${'wazuh-module-overview-1234.pdf'} | ${true} + ${'Endpoint protected'} | ${'admin'} | ${'../wazuh-module-overview-1234.pdf'} | ${false} + ${'Get report'} | ${'../../etc'} | ${'wazuh-module-overview-1234.pdf'} | ${true} + ${'Endpoint protected'} | ${'../../etc'} | ${'../wazuh-module-overview-1234.pdf'} | ${false} + `(`$titleTest: GET /reports/$filename + username: $username + valid: $valid`, async ({ username, filename, valid }) => { + const fileContent = 'content file'; + jest.spyOn(fs, 'readFileSync').mockImplementation(() => fileContent); + + const response = await endpointController.getReportByName(mockContext(username), { params: { name: filename } }, mockResponse()); + if (valid) { + expect(response.headers['Content-Type']).toBe('application/pdf'); + expect(response.body).toBe('content file'); + } else { + expect(response.body.message).toBe('5040 - You shall not pass!'); + } + }); +}); + +describe('[security] POST /reports', () => { + jest.mock('../lib/filesystem', () => ({ + createDataDirectoryIfNotExists: jest.fn() + })); + + it.each` + titleTest | username | moduleID | valid + ${'Get report'} | ${'admin'} | ${'general'} | ${true} + ${'Endpoint protected'} | ${'admin'} | ${'../general'} | ${false} + ${'Get report'} | ${'../../etc'} | ${'general'} | ${true} + ${'Endpoint protected'} | ${'../../etc'} | ${'../general'} | ${false} + `(`$titleTest: POST /reports/modules/$moduleID + username: $username + valid: $valid`, async ({ username, moduleID, valid }) => { + jest.spyOn(endpointController, 'renderHeader').mockImplementation(() => true); + jest.spyOn(endpointController, 'sanitizeKibanaFilters').mockImplementation(() => [false, false]); + jest.spyOn(endpointController, 'extendedInformation').mockImplementation(() => true); + + const mockRequest = { + body: { + array: [], + agents: false, + browserTimezone: '', + searchBar: '', + filters: [], + time: { + from: '', + to: '' + }, + tables: [], + section: 'overview', + indexPatternTitle: 'wazuh-alerts-*', + apiId: 'default', + tab: moduleID + }, + params: { + moduleID: moduleID + } + }; + + const response = await endpointController.createReportsModules(mockContext(username), mockRequest, mockResponse()); + + if (valid) { + expect(response.body.success).toBe(true); + expect(response.body.message).toMatch(new RegExp(`Report wazuh-module-overview-${moduleID}`)); + } else { + expect(response.body.message).toBe('5040 - You shall not pass!'); + }; + }); +}); + +describe('[security] DELETE /reports/', () => { + let mockFsUnlinkSync; + + afterEach(() => { + mockFsUnlinkSync.mockClear(); + }); + + it.each` + titleTest | username | filename | valid + ${'Delete report'} | ${'admin'} | ${'wazuh-module-overview-1234.pdf'} | ${true} + ${'Endpoint protected'} | ${'admin'} | ${'../wazuh-module-overview-1234.pdf'} | ${false} + ${'Endpoint protected'} | ${'admin'} | ${'custom../wazuh-module-overview-1234.pdf'}| ${false} + ${'Delete report'} | ${'../../etc'} | ${'wazuh-module-overview-1234.pdf'} | ${true} + ${'Endpoint protected'} | ${'../../etc'} | ${'../wazuh-module-overview-1234.pdf'} | ${false} + ${'Endpoint protected'} | ${'../../etc'} | ${'custom../wazuh-module-overview-1234.pdf'}| ${false} + `(`[security] DELETE /reports/$filename + username: $username + valid: $valid`, async ({ filename, username, valid }) => { + mockFsUnlinkSync = jest.spyOn(fs, 'unlinkSync').mockImplementation(() => { }); + + const response = await endpointController.deleteReportByName(mockContext(username), { params: { name: filename } }, mockResponse()); + + if (valid) { + expect(response.body.error).toBe(0); + expect(mockFsUnlinkSync.mock.calls).toHaveLength(1); + } else { + expect(response.body.message).toBe('5040 - You shall not pass!'); + expect(mockFsUnlinkSync.mock.calls).toHaveLength(0); + }; + }); +}); \ No newline at end of file diff --git a/server/controllers/wazuh-reporting-security-endpoint-parameters-validation.test.ts b/server/controllers/wazuh-reporting-security-endpoint-parameters-validation.test.ts new file mode 100644 index 0000000000..55b33257b7 --- /dev/null +++ b/server/controllers/wazuh-reporting-security-endpoint-parameters-validation.test.ts @@ -0,0 +1,288 @@ +import { Router } from '../../../../src/core/server/http/router/router'; +import { HttpServer } from '../../../../src/core/server/http/http_server'; +import { loggingSystemMock } from '../../../../src/core/server/logging/logging_system.mock'; +import { ByteSizeValue } from '@kbn/config-schema'; +import supertest from 'supertest'; +import { WazuhReportingRoutes } from '../routes/wazuh-reporting'; +import md5 from 'md5'; +import { createDataDirectoryIfNotExists, createDirectoryIfNotExists } from '../lib/filesystem'; +import { WAZUH_DATA_ABSOLUTE_PATH, WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH, WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH } from '../../common/constants'; +import { execSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; + +const loggingService = loggingSystemMock.create(); +const logger = loggingService.get(); +const context = { + wazuh: { + security: { + getCurrentUser: (request) => { + // x-test-username header doesn't exist when the platform or plugin are running. + // It is used to generate the output of this method so we can simulate the user + // that does the request to the endpoint and is expected by the endpoint handlers + // of the plugin. + const username = request.headers['x-test-username']; + return { username, hashUsername: md5(username) } + } + } + } +}; +const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, context); +let server, innerServer; + +beforeAll(async () => { + // Create /data/wazuh directory. + createDataDirectoryIfNotExists(); + // Create /data/wazuh/downloads directory. + createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH); + // Create /data/wazuh/downloads/reports directory. + createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH); + // Create report files + [ + { name: md5('admin'), files: ['wazuh-module-overview-general-1234.pdf'] }, + { name: md5('../../etc'), files: ['wazuh-module-overview-general-1234.pdf'] } + ].forEach(({ name, files }) => { + createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, name)); + + if (files) { + files.forEach(filename => fs.closeSync(fs.openSync(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, name, filename), 'w'))); + }; + }); + + // Create server + const config = { + name: 'plugin_platform', + host: '127.0.0.1', + maxPayload: new ByteSizeValue(1024), + port: 10002, + ssl: { enabled: false }, + compression: { enabled: true }, + requestId: { + allowFromAnyIp: true, + ipAllowlist: [], + }, + } as any; + server = new HttpServer(loggingService, 'tests'); + const router = new Router('', logger, enhanceWithContext); + const { registerRouter, server: innerServerTest, ...rest } = await server.setup(config); + innerServer = innerServerTest; + + // Register routes + WazuhReportingRoutes(router); + + // Register router + registerRouter(router); + + // start server + await server.start(); +}); + +afterAll(async () => { + // Remove /data/wazuh directory. + execSync(`rm -rf ${WAZUH_DATA_ABSOLUTE_PATH}`); + + // Stop server + await server.stop(); +}); + +describe('[endpoint] GET /reports', () => { + it.each` + username + ${'admin'} + ${'../../etc'} + `(`Get reports of user GET /reports - 200 + username: $username`, async ({ username }) => { + const response = await supertest(innerServer.listener) + .get('/reports') + .set('x-test-username', username) + .expect(200); + + expect(response.body.reports).toBeDefined(); + }); +}); + +describe('[endpoint][security] GET /reports/{name} - Parameters validation', () => { + it.each` + testTitle | username | filename | responseStatusCode | responseBodyMessage + ${'Get report by filename'} | ${'admin'} | ${'wazuh-module-overview-general-1234.pdf'} | ${200} | ${null} + ${'Invalid parameters'} | ${'admin'} | ${'..%2fwazuh-module-overview-general-1234.pdf'} | ${400} | ${'[request params.name]: must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.'} + ${'Invalid parameters'} | ${'admin'} | ${'custom..%2fwazuh-module-overview-general-1234.pdf'} | ${400} | ${'[request params.name]: must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.'} + ${'Route not found'} | ${'admin'} | ${'../custom..%2fwazuh-module-overview-general-1234.pdf'} | ${404} | ${/Not Found/} + ${'Get report by filename'} | ${'../../etc'} | ${'wazuh-module-overview-general-1234.pdf'} | ${200} | ${null} + ${'Invalid parameters'} | ${'../../etc'} | ${'..%2fwazuh-module-overview-general-1234.pdf'} | ${400} | ${'[request params.name]: must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.'} + ${'Invalid parameters'} | ${'../../etc'} | ${'custom..%2fwazuh-module-overview-general-1234.pdf'} | ${400} | ${'[request params.name]: must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.'} + ${'Route not found'} | ${'../../etc'} | ${'../custom..%2fwazuh-module-overview-general-1234.pdf'} | ${404} | ${/Not Found/} + `(`$testTitle: GET /reports/$filename - responseStatusCode: $responseStatusCode + username: $username + responseBodyMessage: $responseBodyMessage`, async ({ username, filename, responseStatusCode, responseBodyMessage }) => { + const response = await supertest(innerServer.listener) + .get(`/reports/${filename}`) + .set('x-test-username', username) + .expect(responseStatusCode); + if (responseStatusCode === 200) { + expect(response.header['content-type']).toMatch(/application\/pdf/); + expect(response.body instanceof Buffer).toBe(true); + }; + if (responseBodyMessage) { + expect(response.body.message).toMatch(responseBodyMessage); + }; + }); +}); + +describe('[endpoint][security] POST /reports/modules/{moduleID} - Parameters validation', () => { + it.each` + testTitle | username | moduleID | agents | responseStatusCode | responseBodyMessage + ${'Invalid paramenters'} | ${'admin'} | ${'..general'} | ${false} | ${400} | ${/\[request params.moduleID\]: types that failed validation:/} + ${'Route not found'} | ${'admin'} | ${'../general'} | ${false} | ${404} | ${/Not Found/} + ${'Route not found'} | ${'admin'} | ${'../general'} | ${'001'} | ${404} | ${/Not Found/} + ${'Invalid paramenters'} | ${'admin'} | ${'..%2fgeneral'} | ${'../001'} | ${400} | ${/\[request params.moduleID\]: types that failed validation:/} + ${'Invalid paramenters'} | ${'admin'} | ${'..%2fgeneral'} | ${'001'} | ${400} | ${/\[request params.moduleID\]: types that failed validation:/} + ${'Invalid paramenters'} | ${'admin'} | ${'general'} | ${'..001'} | ${400} | ${/\[request body.agents\]: types that failed validation:/} + ${'Invalid paramenters'} | ${'admin'} | ${'general'} | ${'../001'} | ${400} | ${/\[request body.agents\]: types that failed validation:/} + `(`$testTitle: GET /reports/modules/$moduleID - responseStatusCode: $responseStatusCode + username: $username + agents: $agents + responseBodyMessage: $responseBodyMessage`, async ({ username, moduleID, agents, responseStatusCode, responseBodyMessage }) => { + const response = await supertest(innerServer.listener) + .post(`/reports/modules/${moduleID}`) + .set('x-test-username', username) + .send({ + array: [], + agents: agents, + browserTimezone: '', + searchBar: '', + filters: [], + time: { + from: '', + to: '' + }, + tables: [], + section: 'overview', + indexPatternTitle: 'wazuh-alerts-*', + apiId: 'default' + }) + .expect(responseStatusCode); + if (responseBodyMessage) { + expect(response.body.message).toMatch(responseBodyMessage); + }; + }); +}); + +describe('[endpoint][security] POST /reports/groups/{groupID} - Parameters validation', () => { + it.each` + testTitle | username | groupID | responseStatusCode | responseBodyMessage + ${'Invalid parameters'} | ${'admin'} | ${'..%2fdefault'} | ${400} | ${'[request params.groupID]: must be A-z, 0-9, _, . are allowed. It must not be ., .. or all.'} + ${'Route not found'} | ${'admin'} | ${'../default'} | ${404} | ${/Not Found/} + ${'Invalid parameters'} | ${'../../etc'} | ${'..%2fdefault'} | ${400} | ${'[request params.groupID]: must be A-z, 0-9, _, . are allowed. It must not be ., .. or all.'} + ${'Route not found'} | ${'../../etc'} | ${'../default'} | ${404} | ${/Not Found/} + `(`$testTitle: GET /reports/groups/$groupID - $responseStatusCode + username: $username + responseBodyMessage: $responseBodyMessage`, async ({ username, groupID, responseStatusCode, responseBodyMessage }) => { + const response = await supertest(innerServer.listener) + .post(`/reports/groups/${groupID}`) + .set('x-test-username', username) + .send({ + browserTimezone: '', + components: { '1': true }, + section: '', + apiId: 'default' + }) + .expect(responseStatusCode); + if (responseBodyMessage) { + expect(response.body.message).toMatch(responseBodyMessage); + }; + }); +}); + +describe('[endpoint][security] POST /reports/agents/{agentID} - Parameters validation', () => { + it.each` + testTitle |username | agentID | responseStatusCode | responseBodyMessage + ${'Invalid parameters'} | ${'admin'} | ${'..001'} | ${400} | ${/\[request params.agentID\]: must be 0-9 are allowed/} + ${'Route not found'} | ${'admin'} | ${'../001'} | ${404} | ${/Not Found/} + ${'Invalid parameters'} | ${'admin'} | ${'..%2f001'} | ${400} | ${/\[request params.agentID\]: must be 0-9 are allowed/} + ${'Invalid parameters'} | ${'admin'} | ${'1'} | ${400} | ${/\[request params.agentID\]: value has length \[1\] but it must have a minimum length of \[3\]./} + ${'Invalid parameters'} | ${'../../etc'} | ${'..001'} | ${400} | ${/\[request params.agentID\]: must be 0-9 are allowed/} + ${'Route not found'} | ${'../../etc'} | ${'../001'} | ${404} | ${/Not Found/} + ${'Invalid parameters'} | ${'../../etc'} | ${'..%2f001'} | ${400} | ${/\[request params.agentID\]: must be 0-9 are allowed/} + ${'Invalid parameters'} | ${'../../etc'} | ${'1'} | ${400} | ${/\[request params.agentID\]: value has length \[1\] but it must have a minimum length of \[3\]./} + `(`$testTitle: GET /reports/agents/$agentID - $responseStatusCode + username: $username + responseBodyMessage: $responseBodyMessage`, async ({ username, agentID, responseStatusCode, responseBodyMessage }) => { + const response = await supertest(innerServer.listener) + .post(`/reports/agents/${agentID}`) + .set('x-test-username', username) + .send({ + array: [], + agents: agentID, + browserTimezone: '', + searchBar: '', + filters: [], + time: { + from: '', + to: '' + }, + tables: [], + section: 'overview', + indexPatternTitle: 'wazuh-alerts-*', + apiId: 'default' + }) + .expect(responseStatusCode); + if (responseBodyMessage) { + expect(response.body.message).toMatch(responseBodyMessage); + }; + }); +}); + +describe('[endpoint][security] POST /reports/agents/{agentID}/inventory - Parameters validation', () => { + it.each` + testTitle | username | agentID | responseStatusCode | responseBodyMessage + ${'Invalid parameters'} | ${'admin'} | ${'..001'} | ${400} | ${/\[request params.agentID\]: must be 0-9 are allowed/} + ${'Route not found'} | ${'admin'} | ${'../001'} | ${404} | ${/Not Found/} + ${'Invalid parameters'} | ${'admin'} | ${'..%2f001'} | ${400} | ${/\[request params.agentID\]: must be 0-9 are allowed/} + ${'Invalid parameters'} | ${'admin'} | ${'1'} | ${400} | ${/\[request params.agentID\]: value has length \[1\] but it must have a minimum length of \[3\]./} + ${'Invalid parameters'} | ${'../../etc'} | ${'..001'} | ${400} | ${/\[request params.agentID\]: must be 0-9 are allowed/} + ${'Route not found'} | ${'../../etc'} | ${'../001'} | ${404} | ${/Not Found/} + ${'Invalid parameters'} | ${'../../etc'} | ${'..%2f001'} | ${400} | ${/\[request params.agentID\]: must be 0-9 are allowed/} + ${'Invalid parameters'} | ${'../../etc'} | ${'1'} | ${400} | ${/\[request params.agentID\]: value has length \[1\] but it must have a minimum length of \[3\]./} + `(`$testTitle: GET /reports/agents/$agentID/inventory - $responseStatusCode + username: $username + responseBodyMessage: $responseBodyMessage`, async ({ username, agentID, responseStatusCode, responseBodyMessage }) => { + const response = await supertest(innerServer.listener) + .post(`/reports/agents/${agentID}/inventory`) + .set('x-test-username', username) + .send({ + browserTimezone: '', + components: { '1': true }, + section: '', + apiId: 'default' + }) + .expect(responseStatusCode); + if (responseBodyMessage) { + expect(response.body.message).toMatch(responseBodyMessage); + }; + }); +}); + +describe('[endpoint][security] DELETE /reports/{name} - Parameters validation', () => { + it.each` + testTitle | username | filename | responseStatusCode | responseBodyMessage + ${'Delete report file'} | ${'admin'} | ${'wazuh-module-overview-general-1234.pdf'} | ${200} | ${null} + ${'Invalid parameters'} | ${'admin'} | ${'..%2fwazuh-module-overview-general-1234.pdf'} | ${400} | ${'[request params.name]: must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.'} + ${'Invalid parameters'} | ${'admin'} | ${'custom..%2fwazuh-module-overview-general-1234.pdf'} | ${400} | ${'[request params.name]: must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.'} + ${'Route not found'} | ${'admin'} | ${'../wazuh-module-overview-general-1234.pdf'} | ${404} | ${/Not Found/} + ${'Delete report file'} | ${'../../etc'} | ${'wazuh-module-overview-general-1234.pdf'} | ${200} | ${null} + ${'Invalid parameters'} | ${'../../etc'} | ${'..%2fwazuh-module-overview-general-1234.pdf'} | ${400} | ${'[request params.name]: must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.'} + ${'Invalid parameters'} | ${'../../etc'} | ${'custom..%2fwazuh-module-overview-general-1234.pdf'} | ${400} | ${'[request params.name]: must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.'} + ${'Route not found'} | ${'../../etc'} | ${'../wazuh-module-overview-general-1234.pdf'} | ${404} | ${/Not Found/} + `(`$testTitle: DELETE /reports/$filename - $responseStatusCode + username: $username + responseBodyMessage: $responseBodyMessage`, async ({ username, filename, responseStatusCode, responseBodyMessage }) => { + const response = await supertest(innerServer.listener) + .delete(`/reports/${filename}`) + .set('x-test-username', username) + .expect(responseStatusCode); + if (responseBodyMessage) { + expect(response.body.message).toMatch(responseBodyMessage); + }; + }); +}); \ No newline at end of file diff --git a/server/controllers/wazuh-reporting.ts b/server/controllers/wazuh-reporting.ts index e131b2da24..5f83c50221 100644 --- a/server/controllers/wazuh-reporting.ts +++ b/server/controllers/wazuh-reporting.ts @@ -1129,11 +1129,11 @@ export class WazuhReportingCtrl { * @param {Object} response * @returns {*} reports list or ErrorResponse */ - async createReportsModules( + createReportsModules = this.checkReportsUserDirectoryIsValidRouteDecorator(async ( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory - ) { + ) => { try { log('reporting:createReportsModules', `Report started`, 'info'); const { @@ -1144,7 +1144,6 @@ export class WazuhReportingCtrl { filters, time, tables, - name, section, indexPatternTitle, apiId @@ -1153,11 +1152,11 @@ export class WazuhReportingCtrl { const { from, to } = time || {}; // Init const printer = new ReportPrinter(); - const { username: userID } = await context.wazuh.security.getCurrentUser(request, context); + createDataDirectoryIfNotExists(); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH); - createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID)); + createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, context.wazuhEndpointParams.hashUsername)); await this.renderHeader(context, printer, section, moduleID, agents, apiId); @@ -1195,18 +1194,18 @@ export class WazuhReportingCtrl { printer.addAgentsFilters(agentsFilter); } - await printer.print(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID, name)); + await printer.print(context.wazuhEndpointParams.pathFilename); return response.ok({ body: { success: true, - message: `Report ${name} was created`, + message: `Report ${context.wazuhEndpointParams.filename} was created`, }, }); } catch (error) { return ErrorResponse(error.message || error, 5029, 500, response); } - } + },({body:{ agents }, params: { moduleID }}) => `wazuh-module-${agents ? `agents-${agents}` : 'overview'}-${moduleID}-${this.generateReportTimestamp()}.pdf`) /** * Create a report for the groups @@ -1215,23 +1214,22 @@ export class WazuhReportingCtrl { * @param {Object} response * @returns {*} reports list or ErrorResponse */ - async createReportsGroups( + createReportsGroups = this.checkReportsUserDirectoryIsValidRouteDecorator(async( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory - ) { + ) => { try { log('reporting:createReportsGroups', `Report started`, 'info'); - const { name, components, apiId } = request.body; + const { components, apiId } = request.body; const { groupID } = request.params; // Init const printer = new ReportPrinter(); - const { username: userID } = await context.wazuh.security.getCurrentUser(request, context); createDataDirectoryIfNotExists(); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH); - createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID)); + createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, context.wazuhEndpointParams.hashUsername)); let tables = []; const equivalences = { @@ -1466,19 +1464,19 @@ export class WazuhReportingCtrl { ); } - await printer.print(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID, name)); + await printer.print(context.wazuhEndpointParams.pathFilename); return response.ok({ body: { success: true, - message: `Report ${name} was created`, + message: `Report ${context.wazuhEndpointParams.filename} was created`, }, }); } catch (error) { log('reporting:createReportsGroups', error.message || error); return ErrorResponse(error.message || error, 5029, 500, response); } - } + }, ({params: { groupID }}) => `wazuh-group-configuration-${groupID}-${this.generateReportTimestamp()}.pdf`) /** * Create a report for the agents @@ -1487,23 +1485,21 @@ export class WazuhReportingCtrl { * @param {Object} response * @returns {*} reports list or ErrorResponse */ - async createReportsAgents( + createReportsAgentsConfiguration = this.checkReportsUserDirectoryIsValidRouteDecorator( async ( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory - ) { + ) => { try { - log('reporting:createReportsAgents', `Report started`, 'info'); - const { name, components, apiId } = request.body; + log('reporting:createReportsAgentsConfiguration', `Report started`, 'info'); + const { components, apiId } = request.body; const { agentID } = request.params; const printer = new ReportPrinter(); - - const { username: userID } = await context.wazuh.security.getCurrentUser(request, context); createDataDirectoryIfNotExists(); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH); - createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID)); + createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, context.wazuhEndpointParams.hashUsername)); let wmodulesResponse = {}; let tables = []; @@ -1524,7 +1520,7 @@ export class WazuhReportingCtrl { for (let config of AgentConfiguration.configurations) { let titleOfSection = false; log( - 'reporting:createReportsAgents', + 'reporting:createReportsAgentsConfiguration', `Iterate over ${config.sections.length} configuration sections`, 'debug' ); @@ -1537,7 +1533,7 @@ export class WazuhReportingCtrl { let idx = 0; const configs = (section.config || []).concat(section.wodle || []); log( - 'reporting:createReportsAgents', + 'reporting:createReportsAgentsConfiguration', `Iterate over ${configs.length} configuration blocks`, 'debug' ); @@ -1715,19 +1711,19 @@ export class WazuhReportingCtrl { } } - await printer.print(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID, name)); + await printer.print(context.wazuhEndpointParams.pathFilename); return response.ok({ body: { success: true, - message: `Report ${name} was created`, + message: `Report ${context.wazuhEndpointParams.filename} was created`, }, }); } catch (error) { - log('reporting:createReportsAgents', error.message || error); + log('reporting:createReportsAgentsConfiguration', error.message || error); return ErrorResponse(error.message || error, 5029, 500, response); } - } + }, ({ params: { agentID }}) => `wazuh-agent-configuration-${agentID}-${this.generateReportTimestamp()}.pdf`) /** * Create a report for the agents @@ -1736,24 +1732,24 @@ export class WazuhReportingCtrl { * @param {Object} response * @returns {*} reports list or ErrorResponse */ - async createReportsAgentsInventory( + createReportsAgentsInventory = this.checkReportsUserDirectoryIsValidRouteDecorator( async ( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory - ) { + ) => { try { log('reporting:createReportsAgentsInventory', `Report started`, 'info'); - const { searchBar, filters, time, name, indexPatternTitle, apiId } = request.body; + const { searchBar, filters, time, indexPatternTitle, apiId } = request.body; const { agentID } = request.params; const { from, to } = time || {}; // Init const printer = new ReportPrinter(); - const { username: userID } = await context.wazuh.security.getCurrentUser(request, context); + const { hashUsername } = await context.wazuh.security.getCurrentUser(request, context); createDataDirectoryIfNotExists(); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH); - createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID)); + createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, hashUsername)); log('reporting:createReportsAgentsInventory', `Syscollector report`, 'debug'); const sanitizedFilters = filters ? this.sanitizeKibanaFilters(filters, searchBar) : false; @@ -1949,19 +1945,19 @@ export class WazuhReportingCtrl { .forEach((table) => printer.addSimpleTable(table)); // Print the document - await printer.print(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID, name)); + await printer.print(context.wazuhEndpointParams.pathFilename); return response.ok({ body: { success: true, - message: `Report ${name} was created`, + message: `Report ${context.wazuhEndpointParams.filename} was created`, }, }); } catch (error) { log('reporting:createReportsAgents', error.message || error); return ErrorResponse(error.message || error, 5029, 500, response); } - } + }, ({params: { agentID }}) => `wazuh-agent-inventory-${agentID}-${this.generateReportTimestamp()}.pdf`) /** * Fetch the reports list @@ -1977,18 +1973,18 @@ export class WazuhReportingCtrl { ) { try { log('reporting:getReports', `Fetching created reports`, 'info'); - const { username: userID } = await context.wazuh.security.getCurrentUser(request, context); + const { hashUsername } = await context.wazuh.security.getCurrentUser(request, context); createDataDirectoryIfNotExists(); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH); createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH); - const userReportsDirectory = path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID); - createDirectoryIfNotExists(userReportsDirectory); - log('reporting:getReports', `Directory: ${userReportsDirectory}`, 'debug'); + const userReportsDirectoryPath = path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, hashUsername); + createDirectoryIfNotExists(userReportsDirectoryPath); + log('reporting:getReports', `Directory: ${userReportsDirectoryPath}`, 'debug'); const sortReportsByDate = (a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0); - const reports = fs.readdirSync(userReportsDirectory).map((file) => { - const stats = fs.statSync(userReportsDirectory + '/' + file); + const reports = fs.readdirSync(userReportsDirectoryPath).map((file) => { + const stats = fs.statSync(userReportsDirectoryPath + '/' + file); // Get the file creation time (bithtime). It returns the first value that is a truthy value of next file stats: birthtime, mtime, ctime and atime. // This solves some OSs can have the bithtimeMs equal to 0 and returns the date like 1970-01-01 const birthTimeField = ['birthtime', 'mtime', 'ctime', 'atime'].find( @@ -2019,17 +2015,14 @@ export class WazuhReportingCtrl { * @param {Object} response * @returns {Object} report or ErrorResponse */ - async getReportByName( + getReportByName = this.checkReportsUserDirectoryIsValidRouteDecorator(async ( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory - ) { + ) => { try { - log('reporting:getReportByName', `Getting ${request.params.name} report`, 'debug'); - const { username: userID } = await context.wazuh.security.getCurrentUser(request, context); - const reportFileBuffer = fs.readFileSync( - path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID, request.params.name) - ); + log('reporting:getReportByName', `Getting ${context.wazuhEndpointParams.pathFilename} report`, 'debug'); + const reportFileBuffer = fs.readFileSync(context.wazuhEndpointParams.pathFilename); return response.ok({ headers: { 'Content-Type': 'application/pdf' }, body: reportFileBuffer, @@ -2038,7 +2031,7 @@ export class WazuhReportingCtrl { log('reporting:getReportByName', error.message || error); return ErrorResponse(error.message || error, 5030, 500, response); } - } + }, (request) => request.params.name) /** * Delete specific report @@ -2047,18 +2040,15 @@ export class WazuhReportingCtrl { * @param {Object} response * @returns {Object} status obj or ErrorResponse */ - async deleteReportByName( + deleteReportByName = this.checkReportsUserDirectoryIsValidRouteDecorator(async ( context: RequestHandlerContext, request: OpenSearchDashboardsRequest, response: OpenSearchDashboardsResponseFactory - ) { + ) => { try { - log('reporting:deleteReportByName', `Deleting ${request.params.name} report`, 'debug'); - const { username: userID } = await context.wazuh.security.getCurrentUser(request, context); - fs.unlinkSync( - path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, userID, request.params.name) - ); - log('reporting:deleteReportByName', `${request.params.name} report was deleted`, 'info'); + log('reporting:deleteReportByName', `Deleting ${context.wazuhEndpointParams.pathFilename} report`, 'debug'); + fs.unlinkSync(context.wazuhEndpointParams.pathFilename); + log('reporting:deleteReportByName', `${context.wazuhEndpointParams.pathFilename} report was deleted`, 'info'); return response.ok({ body: { error: 0 }, }); @@ -2066,5 +2056,38 @@ export class WazuhReportingCtrl { log('reporting:deleteReportByName', error.message || error); return ErrorResponse(error.message || error, 5032, 500, response); } + },(request) => request.params.name) + + checkReportsUserDirectoryIsValidRouteDecorator(routeHandler, reportFileNameAccessor){ + return (async ( + context: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) => { + try{ + const { username, hashUsername } = await context.wazuh.security.getCurrentUser(request, context); + const userReportsDirectoryPath = path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, hashUsername); + const filename = reportFileNameAccessor(request); + const pathFilename = path.join(userReportsDirectoryPath, filename); + log('reporting:checkReportsUserDirectoryIsValidRouteDecorator', `Checking the user ${username}(${hashUsername}) can do actions in the reports file: ${pathFilename}`, 'debug'); + if(!pathFilename.startsWith(userReportsDirectoryPath) || pathFilename.includes('../')){ + log('security:reporting:checkReportsUserDirectoryIsValidRouteDecorator', `User ${username}(${hashUsername}) tried to access to a non user report file: ${pathFilename}`, 'warn'); + return response.badRequest({ + body: { + message: '5040 - You shall not pass!' + } + }); + }; + log('reporting:checkReportsUserDirectoryIsValidRouteDecorator', 'Checking the user can do actions in the reports file', 'debug'); + return await routeHandler.bind(this)({...context, wazuhEndpointParams: { hashUsername, filename, pathFilename }}, request, response); + }catch(error){ + log('reporting:checkReportsUserDirectoryIsValidRouteDecorator', error.message || error); + return ErrorResponse(error.message || error, 5040, 500, response); + } + }) + } + + private generateReportTimestamp(){ + return `${(Date.now() / 1000) | 0}`; } } diff --git a/server/lib/error-response.ts b/server/lib/error-response.ts index a331908083..601e0bcec0 100644 --- a/server/lib/error-response.ts +++ b/server/lib/error-response.ts @@ -61,9 +61,9 @@ export function ErrorResponse( message.includes('ENOENT') && message.toLowerCase().includes('no such file or directory') && message.toLowerCase().includes('data') && - code === 5029 + code === 5029 || code === 5030 || code === 5031 || code === 5032 ) { - filteredMessage = 'Reporting was aborted'; + filteredMessage = 'Reporting was aborted - no such file or directory'; } else if (isString && code === 5029) { filteredMessage = `Reporting was aborted (${message})`; } diff --git a/server/lib/security-factory/factories/default-factory.ts b/server/lib/security-factory/factories/default-factory.ts index 2d4a2c58a0..8caea7bdb8 100644 --- a/server/lib/security-factory/factories/default-factory.ts +++ b/server/lib/security-factory/factories/default-factory.ts @@ -1,12 +1,14 @@ import { ISecurityFactory } from '../'; import { OpenSearchDashboardsRequest, RequestHandlerContext } from 'src/core/server'; +import md5 from 'md5'; export class DefaultFactory implements ISecurityFactory{ platform: string = ''; async getCurrentUser(request: OpenSearchDashboardsRequest, context?:RequestHandlerContext) { return { username: 'elastic', - authContext: {username: 'elastic',} + authContext: { username: 'elastic' }, + hashUsername: md5('elastic') }; } } diff --git a/server/lib/security-factory/factories/opendistro-factory.ts b/server/lib/security-factory/factories/opendistro-factory.ts new file mode 100644 index 0000000000..b6d6e820f9 --- /dev/null +++ b/server/lib/security-factory/factories/opendistro-factory.ts @@ -0,0 +1,30 @@ +import { ISecurityFactory } from '../' +import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { WAZUH_SECURITY_PLUGIN_OPEN_DISTRO_FOR_ELASTICSEARCH } from '../../../../common/constants'; +import md5 from 'md5'; + +export class OpendistroFactory implements ISecurityFactory { + platform: string = WAZUH_SECURITY_PLUGIN_OPEN_DISTRO_FOR_ELASTICSEARCH; + + constructor(private opendistroSecurityKibana: any) { + } + + async getCurrentUser(request: KibanaRequest, context:RequestHandlerContext) { + try { + const params = { + path: `/_opendistro/_security/api/account`, + method: 'GET', + }; + + const {body: authContext} = await context.core.elasticsearch.client.asCurrentUser.transport.request(params); + const username = this.getUserName(authContext); + return { username, authContext, hashUsername: md5(username) }; + } catch (error) { + throw error; + } + } + + getUserName(authContext:any) { + return authContext['user_name'] + } +} \ No newline at end of file diff --git a/server/lib/security-factory/factories/opensearch-dashboards-security-factory.ts b/server/lib/security-factory/factories/opensearch-dashboards-security-factory.ts index f242609d67..b0cf81dbfc 100644 --- a/server/lib/security-factory/factories/opensearch-dashboards-security-factory.ts +++ b/server/lib/security-factory/factories/opensearch-dashboards-security-factory.ts @@ -1,6 +1,7 @@ import { ISecurityFactory } from '..' import { OpenSearchDashboardsRequest, RequestHandlerContext } from 'src/core/server'; import { WAZUH_SECURITY_PLUGIN_OPENSEARCH_DASHBOARDS_SECURITY } from '../../../../common/constants'; +import md5 from 'md5'; export class OpenSearchDashboardsSecurityFactory implements ISecurityFactory { platform: string = WAZUH_SECURITY_PLUGIN_OPENSEARCH_DASHBOARDS_SECURITY; @@ -17,7 +18,7 @@ export class OpenSearchDashboardsSecurityFactory implements ISecurityFactory { const {body: authContext} = await context.core.opensearch.client.asCurrentUser.transport.request(params); const username = this.getUserName(authContext); - return {username, authContext}; + return { username, authContext, hashUsername: md5(username) }; } catch (error) { throw error; } diff --git a/server/lib/security-factory/factories/xpack-factory.ts b/server/lib/security-factory/factories/xpack-factory.ts new file mode 100644 index 0000000000..18c8d1ab7e --- /dev/null +++ b/server/lib/security-factory/factories/xpack-factory.ts @@ -0,0 +1,25 @@ +import { ISecurityFactory } from '../' +import { SecurityPluginSetup } from 'x-pack/plugins/security/server'; +import { KibanaRequest } from 'src/core/server'; +import { WAZUH_SECURITY_PLUGIN_XPACK_SECURITY } from '../../../../common/constants'; +import md5 from 'md5'; + +export class XpackFactory implements ISecurityFactory { + platform: string = WAZUH_SECURITY_PLUGIN_XPACK_SECURITY; + constructor(private security: SecurityPluginSetup) {} + + async getCurrentUser(request: KibanaRequest) { + try { + const authContext = await this.security.authc.getCurrentUser(request); + if(!authContext) return {username: 'elastic', authContext: { username: 'elastic'}}; + const username = this.getUserName(authContext); + return { username, authContext, hashUsername: md5(username) }; + } catch (error) { + throw error; + } + } + + getUserName(authContext:any) { + return authContext['username']; + } +} \ No newline at end of file diff --git a/server/plugin.ts b/server/plugin.ts index 9f6dab4796..788d281ea2 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -29,7 +29,7 @@ import { import { WazuhPluginSetup, WazuhPluginStart, PluginSetup } from './types'; import { SecurityObj, ISecurityFactory } from './lib/security-factory'; import { setupRoutes } from './routes'; -import { jobInitializeRun, jobMonitoringRun, jobSchedulerRun, jobQueueRun } from './start'; +import { jobInitializeRun, jobMonitoringRun, jobSchedulerRun, jobQueueRun, jobMigrationTasksRun } from './start'; import { getCookieValueByName } from './lib/cookie'; import * as ApiInterceptor from './lib/api-interceptor'; import { schema, TypeOf } from '@osd/config-schema'; @@ -134,6 +134,16 @@ export class WazuhPlugin implements Plugin { server: contextServer }); + // Migration tasks + jobMigrationTasksRun({ + core, + wazuh: { + logger: this.logger.get('migration-task'), + api: wazuhApiClient + }, + server: contextServer + }); + // Monitoring jobMonitoringRun({ core, diff --git a/server/routes/wazuh-reporting.ts b/server/routes/wazuh-reporting.ts index 515bb05e06..946e73ac5d 100644 --- a/server/routes/wazuh-reporting.ts +++ b/server/routes/wazuh-reporting.ts @@ -16,6 +16,44 @@ import { schema } from '@osd/config-schema'; export function WazuhReportingRoutes(router: IRouter) { const ctrl = new WazuhReportingCtrl(); + const agentIDValidation = schema.string({ + minLength: 3, + validate: (agentID: string) => /^\d{3,}$/.test(agentID) ? undefined : 'must be 0-9 are allowed' + }); + + const groupIDValidation = schema.string({ + minLength: 1, + validate: (agentID: string) => /^(?!^(\.{1,2}|all)$)[\w\.\-]+$/.test(agentID) ? undefined : 'must be A-z, 0-9, _, . are allowed. It must not be ., .. or all.' + }); + + const ReportFilenameValidation = schema.string({ + validate: (agentID: string) => /^[\w\-\.]+\.pdf$/.test(agentID) ? undefined : 'must be A-z, 0-9, _, ., and - are allowed. It must end with .pdf.' + }); + + const moduleIDValidation = schema.oneOf([ + schema.literal('general'), + schema.literal('fim'), + schema.literal('aws'), + schema.literal('gcp'), + schema.literal('pm'), + schema.literal('audit'), + schema.literal('sca'), + schema.literal('office'), + schema.literal('github'), + schema.literal('ciscat'), + schema.literal('vuls'), + schema.literal('mitre'), + schema.literal('virustotal'), + schema.literal('docker'), + schema.literal('osquery'), + schema.literal('oscap'), + schema.literal('pci'), + schema.literal('hipaa'), + schema.literal('nist'), + schema.literal('gdpr'), + schema.literal('tsc'), + ]); + router.post({ path: '/reports/modules/{moduleID}', validate: { @@ -23,9 +61,8 @@ export function WazuhReportingRoutes(router: IRouter) { array: schema.any(), browserTimezone: schema.string(), filters: schema.maybe(schema.any()), - agents: schema.maybe(schema.oneOf([schema.string(), schema.boolean()])), + agents: schema.maybe(schema.oneOf([agentIDValidation, schema.boolean()])), components: schema.maybe(schema.any()), - name: schema.string(), searchBar: schema.maybe(schema.string()), section: schema.maybe(schema.string()), tab: schema.string(), @@ -34,12 +71,11 @@ export function WazuhReportingRoutes(router: IRouter) { from: schema.string(), to: schema.string() }), schema.string()]), - title: schema.maybe(schema.string()), indexPatternTitle: schema.string(), apiId: schema.string() }), params: schema.object({ - moduleID: schema.string() + moduleID: moduleIDValidation }) } }, @@ -53,13 +89,11 @@ export function WazuhReportingRoutes(router: IRouter) { browserTimezone: schema.string(), filters: schema.maybe(schema.any()), components: schema.maybe(schema.any()), - name: schema.string(), section: schema.maybe(schema.string()), - tab: schema.string(), apiId: schema.string() }), params: schema.object({ - groupID: schema.string() + groupID: groupIDValidation }) } }, @@ -73,17 +107,15 @@ export function WazuhReportingRoutes(router: IRouter) { browserTimezone: schema.string(), filters: schema.any(), components: schema.maybe(schema.any()), - name: schema.string(), section: schema.maybe(schema.string()), - tab: schema.string(), apiId: schema.string() }), params: schema.object({ - agentID: schema.string() + agentID: agentIDValidation }) } }, - (context, request, response) => ctrl.createReportsAgents(context, request, response) + (context, request, response) => ctrl.createReportsAgentsConfiguration(context, request, response) ); router.post({ @@ -95,7 +127,6 @@ export function WazuhReportingRoutes(router: IRouter) { filters: schema.maybe(schema.any()), agents: schema.maybe(schema.oneOf([schema.string(), schema.boolean()])), components: schema.maybe(schema.any()), - name: schema.string(), searchBar: schema.maybe(schema.oneOf([schema.string(), schema.boolean()])), section: schema.maybe(schema.string()), tab: schema.string(), @@ -104,12 +135,11 @@ export function WazuhReportingRoutes(router: IRouter) { from: schema.string(), to: schema.string() }), schema.string()]), - title: schema.maybe(schema.string()), indexPatternTitle: schema.string(), apiId: schema.string() }), params: schema.object({ - agentID: schema.string() + agentID: agentIDValidation }) } }, @@ -121,7 +151,7 @@ export function WazuhReportingRoutes(router: IRouter) { path: '/reports/{name}', validate: { params: schema.object({ - name: schema.string() + name: ReportFilenameValidation }) } }, @@ -133,7 +163,7 @@ export function WazuhReportingRoutes(router: IRouter) { path: '/reports/{name}', validate: { params: schema.object({ - name: schema.string() + name: ReportFilenameValidation }) } }, diff --git a/server/start/index.ts b/server/start/index.ts index d21f7d3109..39dd0a51a3 100644 --- a/server/start/index.ts +++ b/server/start/index.ts @@ -2,4 +2,5 @@ export * from './cron-scheduler'; export * from './initialize'; export * from './monitoring'; export * from './queue'; -export * from './tryCatchForIndexPermissionError'; \ No newline at end of file +export * from './tryCatchForIndexPermissionError'; +export * from './migration-tasks'; \ No newline at end of file diff --git a/server/start/migration-tasks/index.ts b/server/start/migration-tasks/index.ts new file mode 100644 index 0000000000..025751c0cc --- /dev/null +++ b/server/start/migration-tasks/index.ts @@ -0,0 +1,9 @@ +import migrateReportsDirectoryName from "./reports_directory_name"; + +export function jobMigrationTasksRun(context) { + const migrationTasks = [ + migrateReportsDirectoryName + ]; + + migrationTasks.forEach(task => task(context)); +} \ No newline at end of file diff --git a/server/start/migration-tasks/reports_directory_name.test.ts b/server/start/migration-tasks/reports_directory_name.test.ts new file mode 100644 index 0000000000..3cb71073b3 --- /dev/null +++ b/server/start/migration-tasks/reports_directory_name.test.ts @@ -0,0 +1,162 @@ +import fs from 'fs'; +import md5 from 'md5'; +import { execSync } from 'child_process'; +import path from 'path'; +import { WAZUH_DATA_ABSOLUTE_PATH, WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH, WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH } from '../../../common/constants'; +import { createDataDirectoryIfNotExists, createDirectoryIfNotExists } from '../../lib/filesystem'; +import migrateReportsDirectoryName, { isMD5 } from './reports_directory_name'; + +function mockContextCreator(loggerLevel: string) { + const logs = []; + const levels = ['debug', 'info', 'warn', 'error']; + + function createLogger(level: string) { + return jest.fn(function (message: string) { + const levelLogIncluded: number = levels.findIndex((level) => level === loggerLevel); + levelLogIncluded > -1 + && levels.slice(levelLogIncluded).includes(level) + && logs.push({ level, message }); + }); + }; + + const ctx = { + wazuh: { + logger: { + info: createLogger('info'), + warn: createLogger('warn'), + error: createLogger('error'), + debug: createLogger('debug') + } + }, + /* Mocked logs getter. It is only for testing purpose.*/ + _getLogs(logLevel: string) { + return logLevel ? logs.filter(({ level }) => level === logLevel) : logs; + } + } + return ctx; +}; + +jest.mock('../../lib/logger', () => ({ + log: jest.fn() +})); + +beforeAll(() => { + // Create /data/wazuh directory. + createDataDirectoryIfNotExists(); + // Create /data/wazuh/downloads directory. + createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_DIRECTORY_PATH); +}); + +afterAll(() => { + // Remove /data/wazuh directory. + execSync(`rm -rf ${WAZUH_DATA_ABSOLUTE_PATH}`); +}); + +describe("[migration] `reports` directory doesn't exist", () => { + let mockContext = mockContextCreator('debug'); + + it("Debug mode - Task started and skipped because of the `reports` directory doesn't exit", () => { + // Migrate the directories + migrateReportsDirectoryName(mockContext); + // Logs that the task started and skipped. + expect(mockContext._getLogs('debug').filter(({ message }) => message.includes("Task started"))).toHaveLength(1); + expect(mockContext._getLogs('debug').filter(({ message }) => message.includes("Reports directory doesn't exist. The task is not required. Skip."))).toHaveLength(1); + }); + +}); + +describe('[migration] Rename the subdirectories of `reports` directory', () => { + let mockContext = null; + + beforeEach(() => { + mockContext = mockContextCreator('info'); + // Create /data/wazuh/downloads/reports directory. + createDirectoryIfNotExists(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH); + }); + + afterEach(() => { + mockContext = null; + execSync(`rm -rf ${WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH}`); + }); + + const userNameDirectory1 = { name: 'user1', files: 0 }; + const userNameDirectory2 = { name: 'user2', files: 0 }; + const userNameDirectory3 = { name: 'user3', files: 0 }; + const userNameDirectory4 = { name: 'user4', files: 0 }; + const userNameDirectory1MD5 = { name: md5('user1'), files: 0 }; + const userNameDirectory1MD5WithFiles = { name: md5('user1'), files: 1 }; + const userNameDirectory2MD5WithFiles = { name: md5('user2'), files: 1 }; + const userNameDirectory1WithFiles = { name: 'user1', files: 1 }; + const userNameDirectory2WithFiles = { name: 'user2', files: 0 }; + + const userDirectoriesTest1 = []; + const userDirectoriesTest2 = [userNameDirectory1, userNameDirectory2]; + const userDirectoriesTest3 = [userNameDirectory1, userNameDirectory2, userNameDirectory3, userNameDirectory4]; + const userDirectoriesTest4 = [userNameDirectory1, userNameDirectory1MD5]; + const userDirectoriesTest5 = [{ ...userNameDirectory1, errorRenaming: true }, userNameDirectory1MD5WithFiles, userNameDirectory2]; + const userDirectoriesTest6 = [{ ...userNameDirectory1, errorRenaming: true }, userNameDirectory1MD5WithFiles, { ...userNameDirectory2, errorRenaming: true }, userNameDirectory2MD5WithFiles]; + const userDirectoriesTest7 = [userNameDirectory1WithFiles, userNameDirectory2WithFiles]; + const userDirectoriesTest8 = [userNameDirectory1MD5WithFiles, userNameDirectory2MD5WithFiles]; + + function formatUserDirectoriesTest(inputs: any) { + return inputs.length + ? inputs.map(input => `[${input.name}:${input.files}${input.errorRenaming ? ' (Error: renaming)' : ''}]`).join(', ') + : 'None' + }; + + it.each` + directories | foundRequireRenamingDirectories | renamedDirectories | title + ${userDirectoriesTest1} | ${0} | ${0} | ${formatUserDirectoriesTest(userDirectoriesTest1)} + ${userDirectoriesTest2} | ${2} | ${2} | ${formatUserDirectoriesTest(userDirectoriesTest2)} + ${userDirectoriesTest3} | ${4} | ${4} | ${formatUserDirectoriesTest(userDirectoriesTest3)} + ${userDirectoriesTest4} | ${1} | ${1} | ${formatUserDirectoriesTest(userDirectoriesTest4)} + ${userDirectoriesTest5} | ${2} | ${1} | ${formatUserDirectoriesTest(userDirectoriesTest5)} + ${userDirectoriesTest6} | ${2} | ${0} | ${formatUserDirectoriesTest(userDirectoriesTest6)} + ${userDirectoriesTest7} | ${2} | ${2} | ${formatUserDirectoriesTest(userDirectoriesTest7)} + ${userDirectoriesTest8} | ${0} | ${0} | ${formatUserDirectoriesTest(userDirectoriesTest8)} + `('Migrate Directories: $title - FoundRequireRenamingDirectories: $foundRequireRenamingDirectories - renamedDirectories: $renamedDirectories.', ({ directories, foundRequireRenamingDirectories, renamedDirectories }) => { + + const errorRenamingDirectoryMessages = foundRequireRenamingDirectories - renamedDirectories; + // Create directories and file/s within directory. + directories.forEach(({ name, files }) => { + createDirectoryIfNotExists(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, name)); + if (files) { + Array.from(Array(files).keys()).forEach(indexFile => { + fs.closeSync(fs.openSync(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, name, `report_${indexFile}.pdf`), 'w')); + }); + } + }); + + // Migrate the directories. + migrateReportsDirectoryName(mockContext); + + // Check the quantity of directories were found for renaming renaming. + expect(mockContext._getLogs().filter(({ message }) => message.includes('Found reports directory to migrate'))).toHaveLength(foundRequireRenamingDirectories); + // Check the quantity of directories were renamed. + expect(mockContext._getLogs().filter(({ message }) => message.includes('Renamed directory ['))).toHaveLength(renamedDirectories); + expect(mockContext._getLogs('error').filter(({ message }) => message.includes(`Error renaming directory [`))).toHaveLength(errorRenamingDirectoryMessages); + + directories.forEach(({ name, ...rest }) => { + if (!rest.errorRenaming) { + if (isMD5(name)) { + // If directory name is a valid MD5, the directory should exist. + expect(fs.existsSync(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, name))).toBe(true); + } else { + // If directory name is not a valid MD5, the directory should be renamed. New directory exists and old directory doesn't exist. + expect(mockContext._getLogs().filter(({ message }) => message.includes(`Renamed directory [${name}`))).toHaveLength(1); + expect(mockContext._getLogs().filter(({ message }) => message.includes(`Found reports directory to migrate: [${name}`))).toHaveLength(1); + expect(fs.existsSync(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, md5(name)))).toBe(true); + expect(!fs.existsSync(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, name))).toBe(true); + }; + } else { + // Check there was an error renaming the directory because of the directory exist and contains files. + expect(mockContext._getLogs().filter(({ message }) => message.includes(`Found reports directory to migrate: [${name}`))).toHaveLength(1); + expect( + mockContext._getLogs('error').some(({ message }) => message.includes(`Error renaming directory [${name}`)) + ).toBe(true); + expect(fs.existsSync(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, name))).toBe(true); + expect(fs.existsSync(path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, md5(name)))).toBe(true); + } + }); + }); +}); diff --git a/server/start/migration-tasks/reports_directory_name.ts b/server/start/migration-tasks/reports_directory_name.ts new file mode 100644 index 0000000000..df81b8851e --- /dev/null +++ b/server/start/migration-tasks/reports_directory_name.ts @@ -0,0 +1,68 @@ +import fs from 'fs'; +import md5 from 'md5'; +import path from 'path'; +import { WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH } from '../../../common/constants'; +import { log } from '../../lib/logger'; + +/** + * This task renames the report user folder from username to hashed username. + * @param context + * @returns + */ +export default function migrateReportsDirectoryName(context) { + + // Create a wrapper function that logs to plugin files and platform logging system + const createLog = (level: string) => (message) => { + log('migration:reportsDirectoryName', message, level); + context.wazuh.logger[level](`migration:reportsDirectoryName: ${message}`); + }; + + // Create the logger + const logger = { + info: createLog('info'), + warn: createLog('warn'), + error: createLog('error'), + debug: createLog('debug'), + }; + + try { + logger.debug('Task started'); + + // Skip the task if the directory that stores the reports files doesn't exist in the file system + if (!fs.existsSync(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH)) { + logger.debug("Reports directory doesn't exist. The task is not required. Skip."); + return; + }; + + // Read the directories/files in the reports path + logger.debug(`Reading reports directory: ${WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH}`); + fs.readdirSync(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, { withFileTypes: true }) + .forEach((fileDirent) => { + // If it is a directory and has not a valid MD5 hash, continue the task. + if (fileDirent.isDirectory() && !isMD5(fileDirent.name)) { + // Generate the origin and target path and hash the name + const originDirectoryPath = path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, fileDirent.name); + const targetDirectoryName = md5(fileDirent.name); + const targetDirectoryPath = path.join(WAZUH_DATA_DOWNLOADS_REPORTS_DIRECTORY_PATH, targetDirectoryName); + try { + logger.info(`Found reports directory to migrate: [${fileDirent.name}]`); + // Rename the directory from origin to target path + fs.renameSync(originDirectoryPath, targetDirectoryPath); + logger.info(`Renamed directory [${fileDirent.name} (${originDirectoryPath})] to [${targetDirectoryName} (${targetDirectoryPath})]`); + } catch (error) { + logger.error(`Error renaming directory [${fileDirent.name} (${originDirectoryPath})] to [${targetDirectoryName} (${targetDirectoryPath})]: ${error.message}`); + } + }; + }); + logger.debug('Task finished'); + } catch (error) { + logger.error(`Error: ${error.message}`); + }; +} + +// Check that the text is a valid MD5 hash +// https://melvingeorge.me/blog/check-if-string-is-valid-md5-hash-javascript +export function isMD5(text: string) { + const regexMD5 = /^[a-f0-9]{32}$/gi; + return regexMD5.test(text); +} \ No newline at end of file