diff --git a/tdrive/backend/node/bin/config/cli.json b/tdrive/backend/node/bin/config/cli.json index 438f4772..df708794 100644 --- a/tdrive/backend/node/bin/config/cli.json +++ b/tdrive/backend/node/bin/config/cli.json @@ -128,14 +128,25 @@ "featureManageAccess": true, "defaultCompany": "00000000-0000-4000-0000-000000000000", "defaultUserQuota": 200000000, - "featureAntivirus": false + "featureAntivirus": false, + "featureAvStatusAllowed": { + "download": ["uploaded", "safe", "scanning", "scan_failed", "malicious"], + "share": ["uploaded", "safe", "scanning", "skipped"], + "rename": ["uploaded", "safe", "scanning"], + "move": ["uploaded", "safe", "scanning"], + "rescan": ["scan_failed"], + "copy_link": ["uploaded", "safe", "scanning"], + "manage_access": ["uploaded", "safe", "scanning", "skipped"], + "version": ["uploaded", "safe", "scanning"] + } }, "av": { "host": "av", "port": 3310, "debugMode": false, "timeout": 2000, - "maxFileSize": 26214400 + "maxFileSize": 4294967295, + "deleteInfectedFiles": false }, "applications": { "grid": [ diff --git a/tdrive/backend/node/bin/config/custom-environment-variables.json b/tdrive/backend/node/bin/config/custom-environment-variables.json index b14e85f3..6fb3066b 100644 --- a/tdrive/backend/node/bin/config/custom-environment-variables.json +++ b/tdrive/backend/node/bin/config/custom-environment-variables.json @@ -149,13 +149,18 @@ "featureDisplayEmail": "ENABLE_FEATURE_DISPLAY_EMAIL", "featureUserQuota": "ENABLE_FEATURE_USER_QUOTA", "featureManageAccess": "ENABLE_FEATURE_MANAGE_ACCESS", - "featureAntivirus": "ENABLE_FEATURE_ANTIVIRUS" + "featureAntivirus": "ENABLE_FEATURE_ANTIVIRUS", + "featureAvStatusAllowed": { + "__name": "FEATURE_AV_STATUS_ALLOWED", + "__format": "json" + } }, "av": { "host": "AV_HOST", "port": "AV_PORT", "debugMode": "AV_DEBUG_MODE", "timeout": "AV_TIMEOUT", - "maxFileSize": "AV_MAX_FILE_SIZE" + "maxFileSize": "AV_MAX_FILE_SIZE", + "deleteInfectedFiles": "AV_DELETE_INFECTED_FILES" } } diff --git a/tdrive/backend/node/config/custom-environment-variables.json b/tdrive/backend/node/config/custom-environment-variables.json index b14e85f3..6fb3066b 100644 --- a/tdrive/backend/node/config/custom-environment-variables.json +++ b/tdrive/backend/node/config/custom-environment-variables.json @@ -149,13 +149,18 @@ "featureDisplayEmail": "ENABLE_FEATURE_DISPLAY_EMAIL", "featureUserQuota": "ENABLE_FEATURE_USER_QUOTA", "featureManageAccess": "ENABLE_FEATURE_MANAGE_ACCESS", - "featureAntivirus": "ENABLE_FEATURE_ANTIVIRUS" + "featureAntivirus": "ENABLE_FEATURE_ANTIVIRUS", + "featureAvStatusAllowed": { + "__name": "FEATURE_AV_STATUS_ALLOWED", + "__format": "json" + } }, "av": { "host": "AV_HOST", "port": "AV_PORT", "debugMode": "AV_DEBUG_MODE", "timeout": "AV_TIMEOUT", - "maxFileSize": "AV_MAX_FILE_SIZE" + "maxFileSize": "AV_MAX_FILE_SIZE", + "deleteInfectedFiles": "AV_DELETE_INFECTED_FILES" } } diff --git a/tdrive/backend/node/config/default.json b/tdrive/backend/node/config/default.json index 07abb71a..d8735dc4 100644 --- a/tdrive/backend/node/config/default.json +++ b/tdrive/backend/node/config/default.json @@ -129,14 +129,25 @@ "featureManageAccess": true, "defaultCompany": "00000000-0000-4000-0000-000000000000", "defaultUserQuota": 200000000, - "featureAntivirus": false + "featureAntivirus": false, + "featureAvStatusAllowed": { + "download": ["uploaded", "safe", "scanning", "scan_failed", "malicious"], + "share": ["uploaded", "safe", "scanning", "skipped"], + "rename": ["uploaded", "safe", "scanning"], + "move": ["uploaded", "safe", "scanning"], + "rescan": ["scan_failed"], + "copy_link": ["uploaded", "safe", "scanning"], + "manage_access": ["uploaded", "safe", "scanning", "skipped"], + "version": ["uploaded", "safe", "scanning"] + } }, "av": { "host": "av", "port": 3310, "debugMode": false, "timeout": 2000, - "maxFileSize": 26214400 + "maxFileSize": 4294967295, + "deleteInfectedFiles": false }, "applications": { "grid": [ diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-infected-document-deletion-alert.eta b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-infected-document-deletion-alert.eta new file mode 100644 index 00000000..d9cf48fa --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-infected-document-deletion-alert.eta @@ -0,0 +1,43 @@ +<% layout('./_structure') %> +<% it.title = 'Twake Drive Infected Document Deletion Alert' %> + +<%~ includeFile("../common/_body.eta", { + paragraphs: [ + ` + + + + + + +
+
+ Important: Antivirus Alert on Your Twake Drive +
+
+ `, + ` + + + + + + +
+
+ A file on your Twake Drive was flagged during an antivirus scan and has been deleted to protect your account. +
+
+ + File: ${it.notifications[0].item.name} + +
+
+ + Issue: ${ it.notifications[0].item.av_status === "scan_failed" ? "Scan Failed 🔍" : it.notifications[0].item.av_status === "malicious" ? "Malicious Content Detected ⚠" : "File too large to be scanned đŸš«" } + +
+
+ ` + ] +}) %> diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-infected-document-deletion-alert.subject.eta b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-infected-document-deletion-alert.subject.eta new file mode 100644 index 00000000..7caa9877 --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/en/notification-infected-document-deletion-alert.subject.eta @@ -0,0 +1 @@ +Twake Drive Infected Document Deletion Alert \ No newline at end of file diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-infected-document-deletion-alert.eta b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-infected-document-deletion-alert.eta new file mode 100644 index 00000000..621d6159 --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-infected-document-deletion-alert.eta @@ -0,0 +1,43 @@ +<% layout('./_structure') %> +<% it.title = 'Alerte de suppression de document infecté sur Twake Drive' %> + +<%~ includeFile("../common/_body.eta", { + paragraphs: [ + ` + + + + + + +
+
+ Important : Alerte antivirus sur votre Twake Drive +
+
+ `, + ` + + + + + + +
+
+ Un fichier sur votre Twake Drive a Ă©tĂ© signalĂ© lors d’une analyse antivirus et a Ă©tĂ© supprimĂ© afin de protĂ©ger votre compte. +
+
+ + Fichier: ${it.notifications[0].item.name} + +
+
+ + ProblĂšme: ${ it.notifications[0].item.av_status === "scan_failed" ? "Échec de l’analyse 🔍" : it.notifications[0].item.av_status === "malicious" ? "Contenu Malveillant DĂ©tectĂ© ⚠" : "Fichier trop volumineux pour ĂȘtre analysĂ© đŸš«" } + +
+
+ ` + ] +}) %> diff --git a/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-infected-document-deletion-alert.subject.eta b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-infected-document-deletion-alert.subject.eta new file mode 100644 index 00000000..5eb6f2b6 --- /dev/null +++ b/tdrive/backend/node/src/core/platform/services/email-pusher/templates/fr/notification-infected-document-deletion-alert.subject.eta @@ -0,0 +1 @@ +Alerte de suppression de document infecté sur Twake Drive \ No newline at end of file diff --git a/tdrive/backend/node/src/services/av/service/index.ts b/tdrive/backend/node/src/services/av/service/index.ts index 867f54f5..10c0a4ee 100644 --- a/tdrive/backend/node/src/services/av/service/index.ts +++ b/tdrive/backend/node/src/services/av/service/index.ts @@ -3,7 +3,7 @@ import { getLogger, logger, TdriveLogger } from "../../../core/platform/framewor import NodeClam from "clamscan"; import { AVStatus, DriveFile } from "src/services/documents/entities/drive-file"; import { FileVersion } from "src/services/documents/entities/file-version"; -import { DriveExecutionContext } from "src/services/documents/types"; +import { DriveExecutionContext, NotificationActionType } from "../../documents/types"; import globalResolver from "../../../services/global-resolver"; import { getFilePath } from "../../files/services"; import { getConfigOrDefault } from "../../../utils/get-config"; @@ -14,7 +14,8 @@ export class AVServiceImpl implements TdriveServiceProvider, Initializable { av: NodeClam = null; logger: TdriveLogger = getLogger("Antivirus Service"); avEnabled: boolean = getConfigOrDefault("drive.featureAntivirus", false); - private MAX_FILE_SIZE: number = getConfigOrDefault("av.maxFileSize", 26214400); // 25 MB + deleteInfectedFileEnabled: boolean = getConfigOrDefault("av.deleteInfectedFiles", false); + private MAX_FILE_SIZE: number = getConfigOrDefault("av.maxFileSize", 4294967295); // 4GB async init(): Promise { try { @@ -56,6 +57,7 @@ export class AVServiceImpl implements TdriveServiceProvider, Initializable { this.logger.error(`File ${version.file_metadata.external_id} not found`); throw AVException.fileNotFound(`File ${version.file_metadata.external_id} not found`); } + // check if the file is too large if (file.upload_data.size > this.MAX_FILE_SIZE) { this.logger.info( @@ -79,6 +81,11 @@ export class AVServiceImpl implements TdriveServiceProvider, Initializable { } else if (isInfected) { await onScanComplete("malicious"); this.logger.info(`Item ${item.id} is malicious. Viruses found: ${viruses.join(", ")}`); + + // Delete infected files if feature flag is enabled + if (this.deleteInfectedFileEnabled) { + await this.deleteInfectedFile(item, context); + } } else { await onScanComplete("safe"); this.logger.info(`Item ${item.id} is safe with no viruses detected.`); @@ -95,4 +102,27 @@ export class AVServiceImpl implements TdriveServiceProvider, Initializable { throw AVException.scanFailed("Document scanning encountered an error"); } } + + async deleteInfectedFile(item: Partial, context: DriveExecutionContext) { + try { + // Delete infected file for permanent + await globalResolver.services.documents.documents.delete(item.id, null, context, true); + this.logger.info(`Infected file ${item.id} was automatically deleted`); + + // Send notification to user about the file deletion + globalResolver.services.documents.engine.notifyInfectedDocumentRemoved({ + context, + item: { + ...item, + company_id: context.company.id, + } as DriveFile, + type: NotificationActionType.DIRECT, + notificationReceiver: context.user.id, + notificationEmitter: "", + }); + this.logger.info(`Sent notification to ${context.user.id} about file deletion`); + } catch (error) { + this.logger.error(`Failed to delete infected file ${item.id}: ${error}`); + } + } } diff --git a/tdrive/backend/node/src/services/documents/services/engine/index.ts b/tdrive/backend/node/src/services/documents/services/engine/index.ts index 4c2b7a56..79aeb85c 100644 --- a/tdrive/backend/node/src/services/documents/services/engine/index.ts +++ b/tdrive/backend/node/src/services/documents/services/engine/index.ts @@ -83,6 +83,13 @@ export class DocumentsEngine implements Initializable { }, ); + localEventBus.subscribe( + DocumentEvents.INFECTED_DOCUMENT_REMOVED, + async (e: NotificationPayloadType) => { + await this.DispatchDocumentEvent(e, DocumentEvents.INFECTED_DOCUMENT_REMOVED); + }, + ); + return this; } @@ -97,4 +104,8 @@ export class DocumentsEngine implements Initializable { notifyDocumentAVScanAlert(notificationPayload: NotificationPayloadType) { localEventBus.publish(DocumentEvents.DOCUMENT_AV_SCAN_ALERT, notificationPayload); } + + notifyInfectedDocumentRemoved(notificationPayload: NotificationPayloadType) { + localEventBus.publish(DocumentEvents.INFECTED_DOCUMENT_REMOVED, notificationPayload); + } } diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index caa42cc5..a9464970 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -718,6 +718,7 @@ export class DocumentsService { id: string | RootType | TrashType, item?: DriveFile, context?: DriveExecutionContext, + isPermanentlyDelete?: boolean, ): Promise => { if (!id) { //We can't remove the root folder @@ -864,6 +865,10 @@ export class DocumentsService { } await this.update(item.id, item, context); + + if (isPermanentlyDelete) { + await this.delete(item.id, item, context); + } } await updateItemSize(previousParentId, this.repository, context); } @@ -1833,7 +1838,10 @@ export class DocumentsService { item.av_status = status; await this.repository.save(item); - if (["malicious", "scan_failed"].includes(status)) { + if ( + (!globalResolver.services.av?.deleteInfectedFileEnabled && status === "malicious") || + status === "scan_failed" + ) { await this.notifyAVScanAlert(item, context); } }; diff --git a/tdrive/backend/node/src/services/documents/types.ts b/tdrive/backend/node/src/services/documents/types.ts index db3adb5b..e453bcc1 100644 --- a/tdrive/backend/node/src/services/documents/types.ts +++ b/tdrive/backend/node/src/services/documents/types.ts @@ -123,12 +123,14 @@ export enum DocumentEvents { DOCUMENT_SAHRED = "document_shared", DOCUMENT_VERSION_UPDATED = "document_version_updated", DOCUMENT_AV_SCAN_ALERT = "document_av_scan_alert", + INFECTED_DOCUMENT_REMOVED = "infected_document_removed", } export const eventToTemplateMap: Record = { [DocumentEvents.DOCUMENT_AV_SCAN_ALERT]: "notification-document-av-scan-alert", [DocumentEvents.DOCUMENT_VERSION_UPDATED]: "notification-document-version-updated", [DocumentEvents.DOCUMENT_SAHRED]: "notification-document-shared", + [DocumentEvents.INFECTED_DOCUMENT_REMOVED]: "notification-infected-document-deletion-alert", }; export enum NotificationActionType { diff --git a/tdrive/backend/node/src/services/user/utils.ts b/tdrive/backend/node/src/services/user/utils.ts index 3f93bfbb..c769b3bc 100644 --- a/tdrive/backend/node/src/services/user/utils.ts +++ b/tdrive/backend/node/src/services/user/utils.ts @@ -70,6 +70,8 @@ export function formatCompany( [CompanyFeaturesEnum.COMPANY_AV_ENABLED]: JSON.parse( config.get("drive.featureAntivirus") || "false", ), + [CompanyFeaturesEnum.COMPANY_AV_STATUS_ALLOWED]: + config.get("drive.featureAvStatusAllowed") || {}, }, { ...(res.plan?.features || {}), diff --git a/tdrive/backend/node/src/services/user/web/schemas.ts b/tdrive/backend/node/src/services/user/web/schemas.ts index e9d93650..976b8667 100644 --- a/tdrive/backend/node/src/services/user/web/schemas.ts +++ b/tdrive/backend/node/src/services/user/web/schemas.ts @@ -97,6 +97,7 @@ export const companyObjectSchema = { [CompanyFeaturesEnum.COMPANY_USER_QUOTA]: { type: "boolean" }, [CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]: { type: "boolean" }, [CompanyFeaturesEnum.COMPANY_AV_ENABLED]: { type: "boolean" }, + [CompanyFeaturesEnum.COMPANY_AV_STATUS_ALLOWED]: {} as { [key: string]: string[] }, guests: { type: "number" }, // to rename or delete members: { type: "number" }, // to rename or delete storage: { type: "number" }, // to rename or delete diff --git a/tdrive/backend/node/src/services/user/web/types.ts b/tdrive/backend/node/src/services/user/web/types.ts index a7186cc1..88a64c6a 100644 --- a/tdrive/backend/node/src/services/user/web/types.ts +++ b/tdrive/backend/node/src/services/user/web/types.ts @@ -88,6 +88,7 @@ export enum CompanyFeaturesEnum { COMPANY_USER_QUOTA = "company:user_quota", COMPANY_MANAGE_ACCESS = "company:managed_access", COMPANY_AV_ENABLED = "company:av_enabled", + COMPANY_AV_STATUS_ALLOWED = "company:av_status_allowed", } export type CompanyFeaturesObject = { @@ -102,6 +103,7 @@ export type CompanyFeaturesObject = { [CompanyFeaturesEnum.COMPANY_USER_QUOTA]?: boolean; [CompanyFeaturesEnum.COMPANY_MANAGE_ACCESS]?: boolean; [CompanyFeaturesEnum.COMPANY_AV_ENABLED]?: boolean; + [CompanyFeaturesEnum.COMPANY_AV_STATUS_ALLOWED]?: { [key: string]: string[] }; }; export type CompanyLimitsObject = { diff --git a/tdrive/frontend/src/app/features/global/services/feature-toggles-service.ts b/tdrive/frontend/src/app/features/global/services/feature-toggles-service.ts index 8e792a22..22e2b07c 100644 --- a/tdrive/frontend/src/app/features/global/services/feature-toggles-service.ts +++ b/tdrive/frontend/src/app/features/global/services/feature-toggles-service.ts @@ -13,9 +13,10 @@ export enum FeatureNames { COMPANY_USER_QUOTA = 'company:user_quota', COMPANY_MANAGE_ACCESS = 'company:managed_access', COMPANY_AV_ENABLED = 'company:av_enabled', + COMPANY_AV_STATUS_ALLOWED = 'company:av_status_allowed', } -export type FeatureValueType = boolean | number; +export type FeatureValueType = boolean | number | { [key: string]: string[] }; const availableFeaturesWithDefaults = new Map(); @@ -33,6 +34,7 @@ availableFeaturesWithDefaults.set(FeatureNames.COMPANY_DISPLAY_EMAIL, true); availableFeaturesWithDefaults.set(FeatureNames.COMPANY_USER_QUOTA, false); availableFeaturesWithDefaults.set(FeatureNames.COMPANY_MANAGE_ACCESS, true); availableFeaturesWithDefaults.set(FeatureNames.COMPANY_AV_ENABLED, false); +availableFeaturesWithDefaults.set(FeatureNames.COMPANY_AV_STATUS_ALLOWED, {}); /** * ChannelServiceImpl that allow you to manage feature flipping in Tdrive using react feature toggles diff --git a/tdrive/frontend/src/app/views/client/body/drive/context-menu.tsx b/tdrive/frontend/src/app/views/client/body/drive/context-menu.tsx index e7cd7b8a..01c1a187 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/context-menu.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/context-menu.tsx @@ -70,6 +70,17 @@ export const useOnBuildContextMenu = ( !item?.is_directory && (item?.av_status || '').length > 0 && !['uploaded', 'scanning', 'safe'].includes(item?.av_status || ''); + + const avStatusAllowed: { [key: string]: string[] } = FeatureTogglesService.getFeatureValue(FeatureNames.COMPANY_AV_STATUS_ALLOWED); + const isCheckFileActionByAvStatus = !item?.is_directory && (item?.av_status || '').length > 0; + const isAllowToShare = isCheckFileActionByAvStatus && avStatusAllowed['share']?.includes(item?.av_status as string); + const isAllowToManageAccess = isCheckFileActionByAvStatus && avStatusAllowed['manage_access']?.includes(item?.av_status as string); + const isAllowToRescan = isCheckFileActionByAvStatus && avStatusAllowed['rescan']?.includes(item?.av_status as string); + const isAllowToDownload = isCheckFileActionByAvStatus && avStatusAllowed['download']?.includes(item?.av_status as string); + const isAllowToMove = isCheckFileActionByAvStatus && avStatusAllowed['move']?.includes(item?.av_status as string); + const isAllowToRename = isCheckFileActionByAvStatus && avStatusAllowed['rename']?.includes(item?.av_status as string); + const isAllowToCopyLink = isCheckFileActionByAvStatus && avStatusAllowed['copy_link']?.includes(item?.av_status as string); + const isAllowToCreateVersion = isCheckFileActionByAvStatus && avStatusAllowed['version']?.includes(item?.av_status as string); let menu: any[] = []; @@ -89,7 +100,7 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'share-alt', text: Languages.t('components.item_context_menu.share'), - hide: hideShareItem || notSafe, + hide: hideShareItem || !isAllowToShare, onClick: () => setPublicLinkModalState({ open: true, id: item.id }), }, { @@ -97,7 +108,7 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'users-alt', text: Languages.t('components.item_context_menu.manage_access'), - hide: hideManageAccessItem || notSafe, + hide: hideManageAccessItem || !isAllowToManageAccess, onClick: () => setAccessModalState({ open: true, id: item.id }), }, { @@ -105,7 +116,7 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'shield-check', text: Languages.t('components.item_context_menu.rescan_document'), - hide: !(item.av_status === 'scan_failed'), + hide: !isAllowToRescan, onClick: () => { reScan(item); }, @@ -122,12 +133,13 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'download-alt', text: Languages.t('components.item_context_menu.download'), + hide: !isAllowToDownload, onClick: () => { if (item && item.is_directory) { downloadZip([item!.id]); console.log(item!.id); } else { - download(item.id, notSafe); + download(item.id, !isAllowToDownload); } }, }, @@ -149,7 +161,7 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'folder-question', text: Languages.t('components.item_context_menu.move'), - hide: access === 'read' || inTrash || inPublicSharing || notSafe, + hide: access === 'read' || inTrash || inPublicSharing || !isAllowToMove, onClick: () => setSelectorModalState({ open: true, @@ -175,7 +187,7 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'file-edit-alt', text: Languages.t('components.item_context_menu.rename'), - hide: access === 'read' || inTrash || notSafe, + hide: access === 'read' || inTrash || !isAllowToRename, onClick: () => setPropertiesModalState({ open: true, id: item.id, inPublicSharing }), }, { @@ -187,7 +199,7 @@ export const useOnBuildContextMenu = ( !item.access_info.public?.level || item.access_info.public?.level === 'none' || inTrash || - notSafe, + !isAllowToCopyLink, onClick: () => { copyToClipboard(getPublicLink(item || parent?.item)); ToasterService.success( @@ -200,7 +212,7 @@ export const useOnBuildContextMenu = ( type: 'menu', icon: 'history', text: Languages.t('components.item_context_menu.versions'), - hide: item.is_directory || inTrash || notSafe, + hide: item.is_directory || inTrash || !isAllowToCreateVersion, onClick: () => setVersionModal({ open: true, id: item.id }), }, { type: 'separator', hide: access !== 'manage' || inTrash || notSafe },