Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
The format is based on [Keep a Changelog](http://keepachangelog.com/).

## Version 3.2.0

### Added

- Support for mTLS authentication in malware scanning service

## Version 3.1.0

### Added

- Introduced a sample application in the `/tests/` folder to facilitate local development and testing.
- Introduced a sample application in the `/tests/` folder to facilitate local development and testing.

### Fixed

Expand Down
63 changes: 52 additions & 11 deletions lib/malwareScanner.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const cds = require('@sap/cds')
const { SELECT } = cds.ql
const { logConfig } = require('./logger')
const https = require('https')
const crypto = require("crypto")

async function scanRequest(Attachments, key, req) {
logConfig.processStep('Initiating malware scan request', {
Expand Down Expand Up @@ -99,15 +101,50 @@ async function scanRequest(Attachments, key, req) {
})

// Stream the file directly to the scanner without loading into memory
response = await fetch(`https://${credentials.uri}/scan`, {
const fetchOptions = {
method: "POST",
headers: {
Authorization:
"Basic " + Buffer.from(`${credentials.username}:${credentials.password}`, "binary").toString("base64"),
},
body: contentStream,
duplex: 'half' // Required for streaming request bodies
})
}

if (credentials?.certificate && credentials?.key) {
const cert = new crypto.X509Certificate(credentials.certificate)
const expiryDate = new Date(cert.validTo)
const now = Date.now()

// Show warning if certificate is expired or expiring within 30 days
const msIn30Days = 30 * 24 * 60 * 60 * 1000

if (expiryDate.getTime() < now) {
logConfig.error('Malware scanner certificate expired', { fileId: key.ID, validTo: cert.validTo })
throw new Error('Malware scanner certificate expired')
} else if (expiryDate.getTime() - now < msIn30Days) {
logConfig.warn('Malware scanner certificate expiring soon', { fileId: key.ID, validTo: cert.validTo })
}

// mTLS: set HTTPS agent
fetchOptions.agent = new https.Agent({
cert: credentials.certificate,
key: credentials.key,
rejectUnauthorized: true
})
logConfig.debug('Using mTLS authorization', { fileId: key.ID })
} else if (credentials?.username && credentials?.password) {
// Basic Auth: set Authorization header
logConfig.warn(
'Deprecated: Basic Authentication for malware scanning is deprecated and will be removed in future releases.',
{ fileId: key.ID }
)
fetchOptions.headers = {
Authorization:
"Basic " + Buffer.from(`${credentials.username}:${credentials.password}`, "binary").toString("base64"),
}
logConfig.debug('Using basic authorization', { fileId: key.ID })
} else {
throw new Error("Could not find any credentials to authenticate against malware scanning service, please make sure binding and service key exists.")
}

response = await fetch(`https://${credentials.uri}/scan`, fetchOptions)

} catch (error) {
const scanDuration = Date.now() - scanStartTime
Expand Down Expand Up @@ -189,13 +226,17 @@ function getCredentials() {
throw new Error("SAP Malware Scanning service is not bound.")
}

const requiredFields = ['uri', 'username', 'password']
const missingFields = requiredFields.filter(field => !credentials[field])
const requiredFields = [
['uri', 'certificate', 'key'], // mTLS
['uri', 'username', 'password'] // Basic Auth
]
const missingMTLS = requiredFields[0].filter(field => !credentials[field])
const missingBasic = requiredFields[1].filter(field => !credentials[field])

if (missingFields.length > 0) {
if (missingMTLS.length > 0 && missingBasic.length > 0) {
logConfig.configValidation('malwareScanner.credentials', credentials, false,
`Malware Scanner credentials missing: ${missingFields.join(', ')}`)
throw new Error(`Missing Malware Scanner credentials: ${missingFields.join(', ')}`)
`Malware Scanner credentials missing: mTLS [${missingMTLS.join(', ')}], Basic Auth [${missingBasic.join(', ')}]`)
throw new Error(`Missing Malware Scanner credentials for both mTLS and Basic Auth`)
}

logConfig.configValidation('malwareScanner.credentials', credentials, true,
Expand Down
90 changes: 81 additions & 9 deletions tests/unit/unitTests.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
let mockAttachmentsSrv, key, req = {}

const validCert = "-----BEGIN CERTIFICATE-----\nMIIDbTCCAlWgAwIBAgIUOtfA6VNuNW1ZU4TQmBr1io86kXowDQYJKoZIhvcNAQEL\nBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\nGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAgFw0yNTEwMjMwNzM2NTRaGA8yMDU1\nMTAxNjA3MzY1NFowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx\nITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN\nAQEBBQADggEPADCCAQoCggEBAO+NhuWAbRo+z2a52YfyRtuXEqZySvhlEneaesNT\nXrSZP9tIeGR0wUZOT7no73+SNAjNCuHA/U+jpm1W3po1BtRJTgpDU5+mu2WhsqKi\nGEKkLmBO7d8gHKQyEWoYJc8yqU3UIOtlmTXETEZbW8Ee8/Iaqi1xyGCh3I8H/qiY\nlkFUZX2ZeuFmo1ueR3lTxjujG7q+oK1kDRHrAHcO8WopSnAvcCL47DBhI3fniJo1\nb3tbYGVTGWdx3C9z0SeCdQ4rfLjfMV+0gix9hZCO6Di6f86BUhQpJWmdTALfoY6P\nsP2BRU0Y0KmpQgw4BZvlPvtsAZD10Qhgc3fPuT1+gEqgnK8CAwEAAaNTMFEwHQYD\nVR0OBBYEFMD3McHmLuwnZGc0c7kyjIzf2y6/MB8GA1UdIwQYMBaAFMD3McHmLuwn\nZGc0c7kyjIzf2y6/MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB\nAFB1Z43T4TAdwjhh7ynvw+wqFeWFE3ZUCUjMM/AIckFgG+1XF9aVbr226obsclEc\n+YAdsmrVUY6yPLbfLAJFVP6pMJslq85wF2C+vb61MFZb1NKIFc3HNxlWLAMfGli7\nNvzbRp21a6RLK0tghHdKWuekdit/wfvMqgWUqJI5Pm/NuOupClpCOLQOy9Nxwyyl\nYU8cqOzBgCXyVfMM4IWfkDdFfbdbX3k+mY/jOmC+5qIqPrR0rnvJwVJd3z+pW62D\n42rYCQqToHXqH1LTYEIMiZZFG9ZlpT2RQZFOgLcpsuTQKgo77T32DEvJk018xK0I\nQ/H33UI3Zp4U/YRmL18jyLU=\n-----END CERTIFICATE-----"
const expiredCert = "-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUSIZ70YxKWyJTPhAaHW4lysXYk30wDQYJKoZIhvcNAQEL\nBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\nGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDA4MDEwMTAwMDBaFw0yMDEw\nMDEwMTAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\nHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQDlP6mPHLokEiD2faTK2hBiyv2wKtDNiHt0sSeP8ae7\ns+IAIwAX+pmw7QMIYDcQQO6c7Lfle7c/XtUMirJik3/zdjOfrX1qsxMFXTgnwmqH\n9njrGDgs4OK9+l5fkhtcX8YkxkxfoLSO7gGrO2Xv+KeDVmysD5JBrfp2UQFLPp6/\n6ohAZQeHEqx/snfcypEd+K8llBORsKo7tB15Yt5jRSUsuaiGVPPVcCPi9yUcvXHJ\nA7Jv/c0zSiM23pby39tCnZX0KCyBZ2aJMSiWk+Txd0uib5fX7Ln2AS1wOvjVOLNM\nIwcVibyvEGo5DDEzpt6li1BYpLltxLLqftEpLB5NzA6fAgMBAAGjUzBRMB0GA1Ud\nDgQWBBRyUe78kMLzNpV2q3jf5xT6Cu3KrDAfBgNVHSMEGDAWgBRyUe78kMLzNpV2\nq3jf5xT6Cu3KrDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCG\nLL6V13KzKQlLvvTThywV7rOyKqHtZYPUFV+hnHDLaEfJhqVzFIK4SL+K6/VQrj3B\n3BEWh3tAaeaKwAj6BSGGYH/OCA5Vl4yewFLMfostw7LyrLkHlbkhALmC7j5TWapR\ni/tHFifcAYkQnMip1HrOTeGxjEd4RV2kILIsd8ukNv54KAnsxpIIQt2AOhy6LzIs\nkWJOf0IMAusn/PgXBKBJ+YonsldsavC/TBSi3qZXWygcsD1ISviBNIjcS7hbejU8\nLseGs+B6YGIC/Ow6zD71UuOYvQnvjhJG/syaUoUivVppbxbvZyZi7bg48RZLxxig\np3fwjV9eQceKgnNUvzhg\n-----END CERTIFICATE-----"

jest.mock('@sap/cds', () => ({
ql: { UPDATE: jest.fn(() => ({ with: jest.fn() })) },
debug: jest.fn(),
Expand Down Expand Up @@ -56,13 +59,6 @@ beforeEach(() => {
}
cds.env = {
requires: {
malwareScanner: {
credentials: {
uri: 'scanner.example.com',
username: 'user',
password: 'pass'
}
},
attachments: { scan: true }
},
profiles: []
Expand All @@ -75,7 +71,83 @@ beforeEach(() => {
req = {}
})

describe('scanRequest', () => {
describe('scanRequest with mTLS', () => {
beforeEach(() => {
if (!cds.env.requires.malwareScanner) {
cds.env.requires.malwareScanner = {}
}
cds.env.requires.malwareScanner.credentials = {
uri: 'scanner.example.com',
certificate: validCert,
key: '-----BEGIN PRIVATE KEY-----\nFAKEKEY\n-----END PRIVATE KEY-----'
}
})
it('should raise an error if certificate is expired', async () => {
cds.env.requires.malwareScanner.credentials.certificate = expiredCert

try {
await scanRequest({ name: 'Attachments' }, key, req)
} catch (error) {
expect(error.message).toBe('The provided certificate has expired.')
}

expect(mockAttachmentsSrv.update).toHaveBeenCalledWith(expect.anything(), key, { status: 'Failed' })
})
it('should update status to "Scanning" and "Clean" if no malware detected', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ malwareDetected: false })
})
)
await scanRequest({ name: 'Attachments' }, key, req)
expect(mockAttachmentsSrv.update).toHaveBeenCalled()
expect(mockAttachmentsSrv.deleteInfectedAttachment).not.toHaveBeenCalled()
})

it('should update status to "Infected" and delete content if malware detected', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ malwareDetected: true }),
status: 200
})
)
await scanRequest({ name: 'Attachments' }, key, req)
expect(mockAttachmentsSrv.deleteInfectedAttachment).toHaveBeenCalled()
expect(mockAttachmentsSrv.update).toHaveBeenCalled()
})

it('should update status to "Failed" if fetch throws', async () => {
global.fetch = jest.fn(() => { throw new Error('Network error') })
await scanRequest({ name: 'Attachments' }, key, req)
expect(mockAttachmentsSrv.update).toHaveBeenCalledWith(expect.anything(), key, { status: 'Failed' })
})

it('should handle missing credentials gracefully', async () => {
const Attachments = { name: 'TestAttachments' }
const key = { ID: 'test-id' }
cds.env = { requires: {}, profiles: [] }

try {
await scanRequest(Attachments, key)
} catch (error) {
expect(error.message).toBe("SAP Malware Scanning service is not bound.")
}

expect(mockAttachmentsSrv.update).toHaveBeenCalledWith(expect.anything(), key, { status: 'Failed' })
})
})

describe('scanRequest with basic auth', () => {
beforeEach(() => {
if (!cds.env.requires.malwareScanner) {
cds.env.requires.malwareScanner = {}
}
cds.env.requires.malwareScanner.credentials = {
uri: 'scanner.example.com',
username: 'user',
password: 'pass'
}
})
it('should update status to "Scanning" and "Clean" if no malware detected', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
Expand Down Expand Up @@ -182,4 +254,4 @@ describe('AttachmentsService', () => {
await service.deleteInfectedAttachment(Attachments, key)
expect(cds.ql.UPDATE).toHaveBeenCalledWith(Attachments, key)
})
})
})