Skip to content

Commit

Permalink
🐞 Add antivirus inside Twake Drive (#725)
Browse files Browse the repository at this point in the history
🐞 Add antivirus inside Twake Drive (#725)
  • Loading branch information
MontaGhanmy authored Nov 19, 2024
1 parent cc53cd9 commit 00f819f
Show file tree
Hide file tree
Showing 49 changed files with 1,341 additions and 257 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
- name: e2e-opensearch-test
run: |
cd tdrive
docker compose -f docker-compose.dev.tests.opensearch.yml up -d --force-recreate opensearch-node1 postgres node
docker compose -f docker-compose.dev.tests.opensearch.yml up -d --force-recreate opensearch-node1 postgres node av
sleep 60
docker compose -f docker-compose.dev.tests.opensearch.yml logs
docker compose -f docker-compose.dev.tests.opensearch.yml run -e NODE_OPTIONS=--unhandled-rejections=warn -e SEARCH_DRIVER=opensearch -e DB_DRIVER=postgres -e PUBSUB_TYPE=local node npm run test:all
Expand Down
10 changes: 9 additions & 1 deletion tdrive/backend/node/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@
"defaultUserQuota": "DRIVE_DEFAULT_USER_QUOTA",
"featureDisplayEmail": "ENABLE_FEATURE_DISPLAY_EMAIL",
"featureUserQuota": "ENABLE_FEATURE_USER_QUOTA",
"featureManageAccess": "ENABLE_FEATURE_MANAGE_ACCESS"
"featureManageAccess": "ENABLE_FEATURE_MANAGE_ACCESS",
"featureAntivirus": "ENABLE_FEATURE_ANTIVIRUS"
},
"av": {
"host": "AV_HOST",
"port": "AV_PORT",
"debugMode": "AV_DEBUG_MODE",
"timeout": "AV_TIMEOUT",
"maxFileSize": "AV_MAX_FILE_SIZE"
}
}
10 changes: 9 additions & 1 deletion tdrive/backend/node/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,15 @@
"featureUserQuota": false,
"featureManageAccess": true,
"defaultCompany": "00000000-0000-4000-0000-000000000000",
"defaultUserQuota": 200000000
"defaultUserQuota": 200000000,
"featureAntivirus": false
},
"av": {
"host": "av",
"port": 3310,
"debugMode": false,
"timeout": 2000,
"maxFileSize": 26214400
},
"applications": {
"grid": [
Expand Down
9 changes: 9 additions & 0 deletions tdrive/backend/node/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tdrive/backend/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"archiver": "^5.3.1",
"axios": "^1.6.8",
"bcrypt": "^5.0.1",
"clamscan": "^2.4.0",
"class-transformer": "^0.3.1",
"cli-table": "^0.3.6",
"config": "^3.3.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<% layout('./_structure') %>
<% it.title = 'Twake Drive Antivirus Alert' %>

<%~ includeFile("../common/_body.eta", {
paragraphs: [
`
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0 0 8px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;font-weight:800;line-height:29px;text-align:center;color:#FF0000;">
Important: Antivirus Alert on Your Twake Drive
</div>
</td>
</tr>
</tbody>
</table>
`,
`
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0 0 16px;word-break:break-word;" >
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
<span style="font-weight: 500">Antivirus scan for a file on your Twake Drive has flagged an issue.</span>
</div>
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
<span style="font-weight: 500">
File: ${it.notifications[0].item.name}
</span>
</div>
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
<span style="color: #FF0000; font-weight: 600;">
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 🚫" }
</span>
</div>
</td>
</tr>
</tbody>
</table>
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;">
<a class="main-button" href="${it.encodedUrl}">
View File Details
</a>
</div>
`
]
}) %>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Twake Drive Antivirus Alert
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<% layout('./_structure') %>
<% it.title = 'Alerte Antivirus dans votre Twake Drive' %>

<%~ includeFile("../common/_body.eta", {
paragraphs: [
`
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0 0 8px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:24px;font-weight:800;line-height:29px;text-align:center;color:#FF0000;">
Important: Alerte Antivirus dans votre Twake Drive
</div>
</td>
</tr>
</tbody>
</table>
`,
`
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%" >
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0 0 16px;word-break:break-word;" >
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
<span style="font-weight: 500">L'analyse antivirus d'un fichier dans votre Twake Drive a signalé un problème.</span>
</div>
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
<span style="font-weight: 500">
Fichier: ${it.notifications[0].item.name}
</span>
</div>
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:29px;text-align:center;color:#000000;">
<span style="color: #FF0000; font-weight: 600;">
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Γ© 🚫" }
</span>
</div>
</td>
</tr>
</tbody>
</table>
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;">
<a class="main-button" href="${it.encodedUrl}">
Voir sur Twake Drive
</a>
</div>
`
]
}) %>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Alerte Antivirus dans votre Twake Drive
15 changes: 15 additions & 0 deletions tdrive/backend/node/src/services/av/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { TdriveService } from "../../core/platform/framework";

export default class AVService extends TdriveService<undefined> {
version = "1";
name = "antivirus";

public async doInit(): Promise<this> {
return this;
}

// TODO: remove
api(): undefined {
return undefined;
}
}
22 changes: 22 additions & 0 deletions tdrive/backend/node/src/services/av/service/av-exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export class AVException extends Error {
constructor(readonly details: string, readonly status: number) {
super();
this.message = details;
}

static initializationFailed(details: string): AVException {
return new AVException(details, 503);
}

static fileNotFound(details: string): AVException {
return new AVException(details, 404);
}

static scanFailed(details: string): AVException {
return new AVException(details, 500);
}

static handleError(cause: Error, newException: AVException): void {
throw cause instanceof AVException ? cause : newException;
}
}
98 changes: 98 additions & 0 deletions tdrive/backend/node/src/services/av/service/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Initializable, TdriveServiceProvider } from "../../../core/platform/framework";
import { getLogger, logger, TdriveLogger } from "../../../core/platform/framework";
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 globalResolver from "../../../services/global-resolver";
import { getFilePath } from "../../files/services";
import { getConfigOrDefault } from "../../../utils/get-config";
import { AVException } from "./av-exception";

export class AVServiceImpl implements TdriveServiceProvider, Initializable {
version: "1";
av: NodeClam = null;
logger: TdriveLogger = getLogger("Antivirus Service");
avEnabled = getConfigOrDefault("drive.featureAntivirus", false);
private MAX_FILE_SIZE = getConfigOrDefault("av.maxFileSize", 26214400); // 25 MB

async init(): Promise<this> {
try {
if (this.avEnabled) {
this.av = await new NodeClam().init({
removeInfected: false, // Do not remove infected files
quarantineInfected: false, // Do not quarantine, just alert
scanLog: null, // No log file for this test
debugMode: getConfigOrDefault("av.debugMode", false), // Enable debug messages
clamdscan: {
host: getConfigOrDefault("av.host", "localhost"), // IP of the server
port: getConfigOrDefault("av.port", 3310) as number, // ClamAV server port
timeout: getConfigOrDefault("av.timeout", 2000), // Timeout for scans
localFallback: true, // Use local clamscan if needed
},
});
}
} catch (error) {
logger.error({ error: `${error}` }, "Error while initializing Antivirus Service");
throw AVException.initializationFailed("Failed to initialize Antivirus service");
}
return this;
}

async scanDocument(
item: Partial<DriveFile>,
version: Partial<FileVersion>,
onScanComplete: (status: AVStatus) => Promise<void>,
context: DriveExecutionContext,
): Promise<AVStatus> {
try {
// get the file from the storage
const file = await globalResolver.services.files.get(
version.file_metadata.external_id,
context,
);

if (!file) {
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(
`File ${file.id} is too large (${file.upload_data.size} bytes) to be scanned. Skipping...`,
);
return "skipped";
}

// read the file from the storage
const readableStream = await globalResolver.platformServices.storage.read(getFilePath(file), {
totalChunks: file.upload_data.chunks,
encryptionAlgo: globalResolver.services.files.getEncryptionAlgorithm(),
encryptionKey: file.encryption_key,
});

// scan the file
this.av.scanStream(readableStream, async (err, { isInfected, viruses }) => {
if (err) {
await onScanComplete("scan_failed");
this.logger.error(`Scan failed for item ${item.id} due to error: ${err.message}`);
} else if (isInfected) {
await onScanComplete("malicious");
this.logger.info(`Item ${item.id} is malicious. Viruses found: ${viruses.join(", ")}`);
} else {
await onScanComplete("safe");
this.logger.info(`Item ${item.id} is safe with no viruses detected.`);
}
});

return "scanning";
} catch (error) {
// mark the file as failed to scan
await onScanComplete("scan_failed");

// log the error
this.logger.error(`Error scanning file ${item.last_version_cache.file_metadata.external_id}`);
throw AVException.scanFailed("Document scanning encountered an error");
}
}
}
17 changes: 17 additions & 0 deletions tdrive/backend/node/src/services/documents/entities/drive-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import * as UUIDTools from "../../../utils/uuid";

export const TYPE = "drive_files";
export type DriveScope = "personal" | "shared";
export type AVStatusSafe = "safe";
export type AVStatusUnsafe = "uploaded" | "scanning" | "scan_failed" | "malicious" | "skipped";
export type AVStatus = AVStatusSafe | AVStatusUnsafe;

/**
* This represents an item in the file hierarchy.
*
Expand All @@ -32,6 +36,15 @@ export type DriveScope = "personal" | "shared";
* if `scope == "personal"`, otherwise the trash of the shared drive
* - `"trash_$userid"`: Trash folder for a given user (same note as `"user_$userid"`)
* - `"shared_with_me"`: for the feature of the same name
*
* The `status` field represents the current scan status of the file,
* which can be one of the following:
* - `"uploaded"`: The file has been uploaded but not yet scanned.
* - `"scanning"`: The file is currently being scanned.
* - `"scan_failed"`: The scan failed, possibly due to an error.
* - `"safe"`: The file has been scanned and marked as safe.
* - `"malicious"`: The file has been marked as potentially malicious.
* - `"skipped"`: The file scan was skipped (file size too big).
*/
@Entity(TYPE, {
globalIndexes: [
Expand Down Expand Up @@ -122,6 +135,10 @@ export class DriveFile {
@Type(() => String)
@Column("scope", "string")
scope: DriveScope;

@Type(() => String)
@Column("av_status", "string")
av_status: AVStatus;
}

const OnlyOfficeSafeDocKeyBase64 = {
Expand Down
Loading

0 comments on commit 00f819f

Please sign in to comment.