Skip to content
Merged
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: 8 additions & 0 deletions .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ jobs:
GS_BASE_64_ENCODED_PRIVATE_KEY_DATA: ${{ secrets.GS_BASE_64_ENCODED_PRIVATE_KEY_DATA }}
GS_BUCKET: ${{ secrets.GS_BUCKET }}
GS_PROJECT_ID: ${{ secrets.GS_PROJECT_ID }}
## Malware Scanner (Basic Auth)
MALWARE_SCANNER_URL: ${{ secrets.MALWARE_SCANNER_URL }}
MALWARE_SCANNER_USERNAME: ${{ secrets.MALWARE_SCANNER_USERNAME }}
MALWARE_SCANNER_PASSWORD: ${{ secrets.MALWARE_SCANNER_PASSWORD }}
## Malware Scanner (mTLS)
MALWARE_SCANNER_MTLS_URI: ${{ secrets.MALWARE_SCANNER_MTLS_URI }}
MALWARE_SCANNER_MTLS_CERTIFICATE: ${{ secrets.MALWARE_SCANNER_MTLS_CERTIFICATE }}
MALWARE_SCANNER_MTLS_KEY: ${{ secrets.MALWARE_SCANNER_MTLS_KEY }}
strategy:
fail-fast: false
matrix:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
import com.sap.cds.feature.attachments.handler.draftservice.DraftPatchAttachmentsHandler;
import com.sap.cds.feature.attachments.service.AttachmentService;
import com.sap.cds.feature.attachments.service.AttachmentsServiceImpl;
import com.sap.cds.feature.attachments.service.MalwareScannerServiceImpl;
import com.sap.cds.feature.attachments.service.handler.DefaultAttachmentsServiceHandler;
import com.sap.cds.feature.attachments.service.handler.DefaultMalwareScannerServiceHandler;
import com.sap.cds.feature.attachments.service.handler.transaction.EndTransactionMalwareScanProvider;
import com.sap.cds.feature.attachments.service.handler.transaction.EndTransactionMalwareScanRunner;
import com.sap.cds.feature.attachments.service.malware.AttachmentMalwareScanner;
Expand Down Expand Up @@ -88,6 +90,7 @@ public void environment(CdsRuntimeConfigurer configurer) {
@Override
public void services(CdsRuntimeConfigurer configurer) {
configurer.service(new AttachmentsServiceImpl());
configurer.service(new MalwareScannerServiceImpl());
}

@Override
Expand Down Expand Up @@ -137,6 +140,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
configurer.eventHandler(
new DefaultAttachmentsServiceHandler(malwareScanEndTransactionListener));

// register event handler for malware scanner service
configurer.eventHandler(new DefaultMalwareScannerServiceHandler(scanClient));

MarkAsDeletedAttachmentEvent deleteEvent =
new MarkAsDeletedAttachmentEvent(outboxedAttachmentService);
ModifyAttachmentEventFactory eventFactory =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.service;

import com.sap.cds.feature.attachments.service.malware.client.MalwareScanResultStatus;
import com.sap.cds.services.Service;
import java.io.InputStream;

/**
* The {@link MalwareScannerService} provides malware scanning capabilities independent of the
* {@link AttachmentService}. It can be injected from the CAP service catalog and used to scan
* arbitrary content for malware.
*/
public interface MalwareScannerService extends Service {

/** The {@link MalwareScannerService} uses this name in the service catalog */
String DEFAULT_NAME = "MalwareScannerService$Default";

/** This event is emitted when content shall be scanned for malware */
String EVENT_SCAN_CONTENT = "SCAN_CONTENT";

/**
* Scans the given content for malware.
*
* @param content the {@link InputStream} of the content to scan
* @return the result of the malware scan
* @see MalwareScanResultStatus
*/
MalwareScanResultStatus scanContent(InputStream content);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.service;

import com.sap.cds.feature.attachments.service.malware.client.MalwareScanResultStatus;
import com.sap.cds.feature.attachments.service.model.servicehandler.MalwareScanEventContext;
import com.sap.cds.services.ServiceDelegator;
import java.io.InputStream;

/**
* Implementation of the {@link MalwareScannerService} interface. The main purpose of this class is
* to set data in the corresponding context and to call the emit method for the
* MalwareScannerService.
*/
public class MalwareScannerServiceImpl extends ServiceDelegator implements MalwareScannerService {

public MalwareScannerServiceImpl() {
super(DEFAULT_NAME);
}

@Override
public MalwareScanResultStatus scanContent(InputStream content) {
var scanContext = MalwareScanEventContext.create();
scanContext.setContent(content);

emit(scanContext);

return scanContext.getScanResult();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.service.handler;

import com.sap.cds.feature.attachments.service.MalwareScannerService;
import com.sap.cds.feature.attachments.service.malware.client.MalwareScanClient;
import com.sap.cds.feature.attachments.service.malware.client.MalwareScanResultStatus;
import com.sap.cds.feature.attachments.service.model.servicehandler.MalwareScanEventContext;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.HandlerOrder;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The class {@link DefaultMalwareScannerServiceHandler} is the default event handler for the {@link
* MalwareScannerService}. It delegates to the {@link MalwareScanClient} to perform the actual
* malware scan.
*/
@ServiceName(value = "*", type = MalwareScannerService.class)
public class DefaultMalwareScannerServiceHandler implements EventHandler {

private static final int DEFAULT_ON = 10 * HandlerOrder.AFTER + HandlerOrder.LATE;

private static final Logger logger =
LoggerFactory.getLogger(DefaultMalwareScannerServiceHandler.class);

private final MalwareScanClient malwareScanClient;

/**
* Constructs a new instance of {@link DefaultMalwareScannerServiceHandler}.
*
* @param malwareScanClient an optional {@link MalwareScanClient} instance, may be {@code null} if
* no malware scanner service binding is available
*/
public DefaultMalwareScannerServiceHandler(MalwareScanClient malwareScanClient) {
this.malwareScanClient = malwareScanClient;
}

@On
@HandlerOrder(DEFAULT_ON)
void scanContent(MalwareScanEventContext context) {
if (malwareScanClient == null) {
logger.info("No malware scanner service binding available. Returning NO_SCANNER status.");
context.setScanResult(MalwareScanResultStatus.NO_SCANNER);
context.setCompleted();
return;
}

try {
logger.debug("Starting malware scan of content.");
MalwareScanResultStatus result = malwareScanClient.scanContent(context.getContent());
context.setScanResult(result);
logger.debug("Malware scan completed with status '{}'.", result);
} catch (Exception e) {
logger.error("Error during malware scan.", e);
context.setScanResult(MalwareScanResultStatus.FAILED);
}
context.setCompleted();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.service.model.servicehandler;

import com.sap.cds.feature.attachments.service.MalwareScannerService;
import com.sap.cds.feature.attachments.service.malware.client.MalwareScanResultStatus;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.EventName;
import java.io.InputStream;

/** The {@link MalwareScanEventContext} is used to store the context of a malware scan event. */
@EventName(MalwareScannerService.EVENT_SCAN_CONTENT)
public interface MalwareScanEventContext extends EventContext {

/**
* Creates an {@link EventContext} already overlaid with this interface. The event is set to be
* {@link MalwareScannerService#EVENT_SCAN_CONTENT}
*
* @return the {@link MalwareScanEventContext}
*/
static MalwareScanEventContext create() {
return EventContext.create(MalwareScanEventContext.class, null);
}

/**
* @return the content {@link InputStream} to scan for malware
*/
InputStream getContent();

/**
* Sets the content to scan for malware
*
* @param content the content {@link InputStream} to scan
*/
void setContent(InputStream content);

/**
* @return the result of the malware scan or {@code null} if not yet scanned
*/
MalwareScanResultStatus getScanResult();

/**
* Sets the result of the malware scan
*
* @param scanResult the result of the malware scan
*/
void setScanResult(MalwareScanResultStatus scanResult);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
import com.sap.cds.feature.attachments.handler.draftservice.DraftCancelAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.draftservice.DraftPatchAttachmentsHandler;
import com.sap.cds.feature.attachments.service.AttachmentService;
import com.sap.cds.feature.attachments.service.MalwareScannerService;
import com.sap.cds.feature.attachments.service.handler.DefaultAttachmentsServiceHandler;
import com.sap.cds.feature.attachments.service.handler.DefaultMalwareScannerServiceHandler;
import com.sap.cds.feature.attachments.service.malware.DefaultAttachmentMalwareScanner;
import com.sap.cds.services.Service;
import com.sap.cds.services.ServiceCatalog;
Expand Down Expand Up @@ -96,13 +98,16 @@ void setup() {
void serviceIsRegistered() {
cut.services(configurer);

verify(configurer).service(serviceArgumentCaptor.capture());
verify(configurer, times(2)).service(serviceArgumentCaptor.capture());
var services = serviceArgumentCaptor.getAllValues();
assertThat(services).hasSize(1);
assertThat(services).hasSize(2);

var attachmentServiceFound = services.stream().anyMatch(AttachmentService.class::isInstance);
var malwareScannerServiceFound =
services.stream().anyMatch(MalwareScannerService.class::isInstance);

assertThat(attachmentServiceFound).isTrue();
assertThat(malwareScannerServiceFound).isTrue();
}

@Test
Expand All @@ -119,7 +124,7 @@ void handlersAreRegistered() {

cut.eventHandlers(configurer);

var handlerSize = 8;
var handlerSize = 9;
verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize);
}
Expand All @@ -139,14 +144,15 @@ void handlersAreRegisteredWithoutOutboxService() {

cut.eventHandlers(configurer);

var handlerSize = 8;
var handlerSize = 9;
verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize);
}

private void checkHandlers(List<EventHandler> handlers, int handlerSize) {
assertThat(handlers).hasSize(handlerSize);
isHandlerForClassIncluded(handlers, DefaultAttachmentsServiceHandler.class);
isHandlerForClassIncluded(handlers, DefaultMalwareScannerServiceHandler.class);
isHandlerForClassIncluded(handlers, CreateAttachmentsHandler.class);
isHandlerForClassIncluded(handlers, UpdateAttachmentsHandler.class);
isHandlerForClassIncluded(handlers, DeleteAttachmentsHandler.class);
Expand All @@ -167,11 +173,12 @@ void lessHandlersAreRegistered() {

cut.eventHandlers(configurer);

var handlerSize = 1;
var handlerSize = 2;
verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
var handlers = handlerArgumentCaptor.getAllValues();
assertThat(handlers).hasSize(handlerSize);
isHandlerForClassIncluded(handlers, DefaultAttachmentsServiceHandler.class);
isHandlerForClassIncluded(handlers, DefaultMalwareScannerServiceHandler.class);
// event handlers for application services are not registered
isHandlerForClassMissing(handlers, CreateAttachmentsHandler.class);
isHandlerForClassMissing(handlers, UpdateAttachmentsHandler.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import com.sap.cds.feature.attachments.service.malware.client.MalwareScanResultStatus;
import com.sap.cds.feature.attachments.service.model.servicehandler.MalwareScanEventContext;
import com.sap.cds.services.environment.CdsEnvironment;
import com.sap.cds.services.environment.CdsProperties;
import com.sap.cds.services.handler.Handler;
import com.sap.cds.services.impl.ServiceSPI;
import com.sap.cds.services.runtime.CdsRuntime;
import java.io.InputStream;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;

class MalwareScannerServiceImplTest {

private MalwareScannerServiceImpl cut;
private Handler handler;
private ServiceSPI serviceSpi;

@BeforeEach
void setup() {
cut = new MalwareScannerServiceImpl();

CdsEnvironment env = mock(CdsEnvironment.class);
CdsProperties props = new CdsProperties();
when(env.getCdsProperties()).thenReturn(props);
CdsRuntime runtime = mock(CdsRuntime.class);
when(runtime.getEnvironment()).thenReturn(env);
handler = mock(Handler.class);
serviceSpi = (ServiceSPI) cut.getDelegatedService();
serviceSpi.setCdsRuntime(runtime);
}

@ParameterizedTest
@EnumSource(MalwareScanResultStatus.class)
void scanContentReturnsCorrectStatus(MalwareScanResultStatus expectedStatus) {
var contextReference = new AtomicReference<MalwareScanEventContext>();
var stream = mock(InputStream.class);
doAnswer(
input -> {
var context = (MalwareScanEventContext) input.getArgument(0);
contextReference.set(context);
context.setCompleted();
context.setScanResult(expectedStatus);
return null;
})
.when(handler)
.process(any());
serviceSpi.on(MalwareScannerService.EVENT_SCAN_CONTENT, "", handler);

var result = cut.scanContent(stream);

assertThat(result).isEqualTo(expectedStatus);
assertThat(contextReference.get().getContent()).isEqualTo(stream);
}
}
Loading
Loading