diff --git a/lib/aws-s3.js b/lib/aws-s3.js index 0f1801f9..949156e2 100644 --- a/lib/aws-s3.js +++ b/lib/aws-s3.js @@ -105,7 +105,11 @@ module.exports = class AWSAttachmentsService extends require("./basic") { const stored = super.put(attachments, metadata, null, isDraftEnabled); await Promise.all([stored, multipartUpload.done()]); - if (this.kind === 's3') scanRequest(attachments, { ID: metadata.ID }, req); + if (this.kind === 's3') { + cds.spawn({ + tenant: req.tenant + }, async () => await scanRequest(req.target, keys, req)); + } DEBUG?.(`[S3 Upload] File uploaded successfully using put to ${this.bucket}`); } catch (err) { console.error(err); // eslint-disable-line no-console @@ -192,28 +196,32 @@ module.exports = class AWSAttachmentsService extends require("./basic") { await Promise.all([multipartUpload.done()]); const keys = { ID: req.data.ID } - scanRequest(req.target, keys, req) + + cds.spawn({ + tenant: req.tenant + }, async () => await scanRequest(req.target, keys, req)); + DEBUG?.(`[S3 Upload] Uploaded file using updateContentHandler for ${req.target.name}`); } } else if (req?.data?.note) { const key = { ID: req.data.ID }; await super.update(req.target, key, { note: req.data.note }); DEBUG?.(`[S3 Upload] Updated file upload with note for ${req.target.name}`); - } else { - next(); } + next(); } async getAttachmentsToDelete({ draftEntity, activeEntity, id }) { - const [draftAttachments, activeAttachments] = await Promise.all([ - SELECT.from(draftEntity).columns("url").where(id), - SELECT.from(activeEntity).columns("url").where(id) - ]); + const attachmentsStored = []; + draftEntity ? attachmentsStored.push(SELECT.from(draftEntity).where(id).columns('url')) : null; + activeEntity ? attachmentsStored.push(SELECT.from(activeEntity).where(id).columns('url')) : null; + + const [draftAttachments, activeAttachments] = await Promise.all(attachmentsStored); const activeUrls = new Set(activeAttachments.map(a => a.url)); - return draftAttachments + + return attachmentsToDelete = draftAttachments .filter(({ url }) => !activeUrls.has(url)) - .map(({ url }) => ({ url })); } async attachDraftDeletionData(req) { diff --git a/lib/malwareScanner.js b/lib/malwareScanner.js index 7b355cfb..d51fe0a9 100644 --- a/lib/malwareScanner.js +++ b/lib/malwareScanner.js @@ -2,10 +2,15 @@ const cds = require('@sap/cds') const DEBUG = cds.debug('attachments') const { SELECT } = cds.ql; + +const STATUS_INFECTED = "Infected"; +const STATUS_CLEAN = "Clean"; +const STATUS_SCANNING = "Scanning"; +const STATUS_FAILED = "Failed"; + async function scanRequest(Attachments, key, req) { DEBUG?.(`[Scan] Scanning file uploaded for ${Attachments}`); const scanEnabled = cds.env.requires?.attachments?.scan ?? true - const AttachmentsSrv = await cds.connect.to("attachments") let draftEntity, activeEntity if (Attachments.isDraft) { @@ -14,74 +19,84 @@ async function scanRequest(Attachments, key, req) { } else { activeEntity = Attachments } - DEBUG?.(`[Scan] activeEntity = ${activeEntity}`); let currEntity = draftEntity == undefined ? activeEntity : draftEntity + DEBUG?.(`[Scan] activeEntity = ${activeEntity}`); + if (!scanEnabled) { - if (cds.env.profiles.some(p => p === "development" || p === "test") && !cds.env.profiles.includes("hybrid")) { - await updateStatus(AttachmentsSrv, key, "Scanning", currEntity, draftEntity, activeEntity) - setTimeout(() => { - DEBUG?.('Malware scanning is disabled. Setting scan status to Clean in development profile.') - updateStatus(AttachmentsSrv, key, "Clean", currEntity, draftEntity, activeEntity) - .catch(e => cds.log('attachments').error(e)) - }, 5000).unref() - return - } else { - return - } + await localMockedScan(key, req, currEntity, draftEntity, activeEntity); + } else { + await remoteScan(key, req, currEntity, draftEntity, activeEntity); } +} - await updateStatus(AttachmentsSrv, key, "Scanning", currEntity, draftEntity, activeEntity) - - const credentials = getCredentials() - const contentStream = await AttachmentsSrv.get(currEntity, key) - let fileContent +async function remoteScan(key, req, currEntity, draftEntity, activeEntity) { + const AttachmentsSrv = await cds.connect.to("attachments"); try { - DEBUG?.(`[Scan] streaming to string`); - fileContent = await streamToString(contentStream) + + DEBUG?.(`[Scan] Executing remote scan for ${currEntity.name}`); + await updateStatus(key, STATUS_SCANNING, currEntity, draftEntity, activeEntity); + + const contentStream = await AttachmentsSrv.get(currEntity, key, req.tenant); + const fileContent = await streamToString(contentStream); + + const malwareScannerResponse = await sendFileToMalwareScanner(fileContent); + + const responseText = await malwareScannerResponse.json(); + + const status = responseText.malwareDetected ? STATUS_INFECTED : STATUS_CLEAN; + + if (status === STATUS_INFECTED) { + DEBUG?.("[Scan] Malware detected in the file, deleting attachment content from db", key); + await AttachmentsSrv.deleteInfectedAttachment(currEntity, key, req); + } + DEBUG?.(`[Scan] Scanned file with status: ${status}`, key); + await updateStatus(key, status, currEntity, draftEntity, activeEntity); } catch (err) { - DEBUG?.("[Scan] Malware Scanning: Cannot read file content", err) - await updateStatus(AttachmentsSrv, key, "Failed", currEntity, draftEntity, activeEntity) - return + DEBUG?.("[Scan] Failed to execute Malware scan - ", err) + await updateStatus(key, STATUS_FAILED, currEntity, draftEntity, activeEntity); } +} - let response; - try { - DEBUG?.("[Scan] Sending file to Malware Scanning Service"); - response = await fetch(`https://${credentials.uri}/scan`, { - method: "POST", - headers: { - Authorization: - "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`, "binary").toString("base64"), - }, - body: fileContent, - }) - } catch (error) { - DEBUG?.("Request to malware scanner failed", error) - await updateStatus(AttachmentsSrv, key, "Failed", currEntity, draftEntity, activeEntity) +async function localMockedScan(key, req, currEntity, draftEntity, activeEntity) { + const AttachmentsSrv = await cds.connect.to("attachments"); + + DEBUG?.(`[Scan] Executing local mocked scan for ${currEntity.name}`); + if (cds.env.profiles.some(p => p === "development" || p === "test") && !cds.env.profiles.includes("hybrid")) { + await updateStatus(key, "Scanning", currEntity, draftEntity, activeEntity) + setTimeout(() => { + DEBUG?.('Malware scanning is disabled. Setting scan status to Clean in development profile.') + updateStatus(key, "Clean", currEntity, draftEntity, activeEntity) + .catch(e => cds.log('attachments').error(e)) + }, 5000).unref() + return + } else { return } +} - try { - const responseText = await response.json() - const status = responseText.malwareDetected ? "Infected" : "Clean" - DEBUG?.(`[Scan] Scanned file with status: ${status}`, key); - if (status === "Infected") { - DEBUG?.("[Scan] Malware detected in the file, deleting attachment content from db", key) - await AttachmentsSrv.deleteInfectedAttachment(currEntity, key, req) - } - await updateStatus(AttachmentsSrv, key, status, currEntity, draftEntity, activeEntity) - } catch (err) { - DEBUG?.("Cannot serialize malware scanner response body", err) - await updateStatus(AttachmentsSrv, key, "Failed", currEntity, draftEntity, activeEntity) - } +async function sendFileToMalwareScanner(fileContent) { + DEBUG?.("[Scan] Sending file to Malware Scanning Service"); + const credentials = getCredentials(); + response = await fetch(`https://${credentials.uri}/scan`, { + method: "POST", + headers: { + Authorization: + "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`, "binary").toString("base64"), + }, + body: fileContent, + }); + return response; } -async function updateStatus(AttachmentsSrv, key, status, currEntity, draftEntity, activeEntity) { +async function updateStatus(key, status, currEntity, draftEntity, activeEntity) { + DEBUG?.(`[Scan] Updating status for ${currEntity} - ${status}`); if (currEntity == draftEntity) { currEntity = await getCurrentEntity(currEntity, activeEntity, key) } - await AttachmentsSrv.update(currEntity, key, { status: status }) + const entityName = typeof currEntity === 'string' ? currEntity : currEntity.name; + await cds.update(entityName, key, { status: status }) + DEBUG?.(`[Scan] Updated status for ${entityName} - ${status}`); } async function getCurrentEntity(draftEntity, activeEntity, key) { @@ -103,17 +118,23 @@ function getCredentials() { try { return cds.env.requires.malwareScanner.credentials; } catch { - throw new Error("SAP Malware Scanning service is not bound."); + throw new Error("[Scan] SAP Malware Scanning service is not bound."); } } function streamToString(stream) { + DEBUG?.(`[Scan] Streaming to string`); const chunks = []; - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) - stream.on('error', (err) => reject(err)) - stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) - }) + try { + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + stream.on('error', (err) => reject(err)) + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + }); + } catch (error) { + throw new Error("[Scan] Error in streaming data from content", error) + } + } module.exports = {