Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
fc158d3
feat: handling malware scan concurrency limitation
ansgarlichter Mar 16, 2026
959381c
refactor: enhance readability
ansgarlichter Mar 16, 2026
73b1157
fix: lint
ansgarlichter Mar 16, 2026
f199b57
docs: config for malware scan
ansgarlichter Mar 16, 2026
687bf50
refactor: fallback to official malware scan limits & readability
ansgarlichter Mar 20, 2026
a2d924e
fix: lint
ansgarlichter Mar 24, 2026
6992047
feat: only compute hash once when remote backends are used
ansgarlichter Mar 24, 2026
0901cb0
Merge branch 'main' into feat/malware-scanning-auto-retry
schiwekM Mar 24, 2026
3cb23b4
fix: add cause to thrown error
ansgarlichter Mar 24, 2026
c76a4c0
Merge branch 'feat/malware-scanning-auto-retry' of github.com:ansgarl…
ansgarlichter Mar 24, 2026
7e4d649
Move config to package.json
schiwekM Mar 24, 2026
367ffd2
Update README.md
schiwekM Mar 24, 2026
4baab0a
Merge branch 'main' into feat/malware-scanning-auto-retry
ansgarlichter Mar 25, 2026
220a825
Split malware scanner and attachments for improved readbility
schiwekM Apr 14, 2026
d6a8a1c
Formatting
schiwekM Apr 14, 2026
bce58c3
Update malwareScanner.js
schiwekM Apr 14, 2026
273ee8a
Update malwareScanner.js
schiwekM Apr 14, 2026
3cfd6a6
Merge branch 'main' into pr/411
schiwekM Apr 14, 2026
cc2dd91
Update malwareScanner.js
schiwekM Apr 14, 2026
293631c
Update malwareScanner.js
schiwekM Apr 14, 2026
5b49103
Update CHANGELOG.md
schiwekM Apr 14, 2026
b12b139
Update CHANGELOG.md
schiwekM Apr 14, 2026
dce9470
Update auditLogging.test.js
schiwekM Apr 14, 2026
346ec96
Update malwareScanner.js
schiwekM Apr 14, 2026
1f9280a
Merge branch 'main' into pr/411
schiwekM Apr 14, 2026
68a5f20
Update malwareScanner.js
schiwekM Apr 14, 2026
8b7ddb3
Update malwareScanner.js
schiwekM Apr 14, 2026
f8e1d76
Fix
schiwekM Apr 15, 2026
8de1348
Formatting
schiwekM Apr 15, 2026
e83bb0f
Merge branch 'main' into feat/malware-scanning-auto-retry
schiwekM Apr 15, 2026
9e512db
Update testUtils.js
schiwekM Apr 15, 2026
d2b312b
Update testUtils.js
schiwekM Apr 15, 2026
f2ca6c2
Merge branch 'feat/malware-scanning-auto-retry' of https://github.com…
schiwekM Apr 15, 2026
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).

## Version 3.12.0 - Upcoming

### Added

- A maximum concurrent amount of scans can now be configured for the malware scanner.

### Changed

- The retry logic for the malware scanner was improved to be more robust under high loads.

### Fixed

- Wrong file name being shown when rejecting an attachment due to file size.
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ The `@cap-js/attachments` package is a [CDS plugin](https://cap.cloud.sap/docs/n
- [Changes in the CDS Models](#changes-in-the-cds-models)
- [Storage Targets](#storage-targets)
- [Malware Scanner](#malware-scanner)
- [Rate Limit Handling (Auto-Retry)](#rate-limit-handling-auto-retry)
- [Scan Concurrency Limiting](#scan-concurrency-limiting)
- [Automatic file rescanning](#automatic-file-rescanning)
- [Audit logging](#audit-logging)
- [Visibility Control for Attachments UI Facet Generation](#visibility-control-for-attachments-ui-facet-generation)
Expand Down Expand Up @@ -231,6 +233,60 @@ Scan status codes:
> [!Note]
> If the malware scanner reports a file size larger than the limit specified via [@Validation.Maximum](#specify-the-maximum-file-size) it removes the file and sets the status of the attachment metadata to failed.

#### Rate Limit Handling (Auto-Retry)

The SAP Malware Scanning Service enforces a rate limit of 30 concurrent requests per subaccount. When this limit is exceeded, the service responds with HTTP `429 Too Many Requests`. By default, the plugin automatically retries scan requests that receive a 429 response using exponential backoff with jitter.

You can configure the retry behavior in `package.json` or `.cdsrc.json`:

```json
{
"cds": {
"requires": {
"malwareScanner": {
"retry": {
"maxAttempts": 5,
"initialDelay": 1000,
"maxDelay": 30000
}
}
}
}
}
```

| Option | Default | Description |
| -------------------- | ------- | ------------------------------------------------------ |
| `retry.maxAttempts` | `5` | Total number of attempts including the initial request |
| `retry.initialDelay` | `1000` | Base delay in milliseconds before the first retry |
| `retry.maxDelay` | `30000` | Maximum delay in milliseconds between retries |

When a 429 response includes a `Retry-After` header, the plugin respects that value (capped at `maxDelay`). Only 429 responses trigger retries — other errors fail immediately.

To disable retry and restore the previous behavior (immediate failure on 429), set `retry` to `false`.

#### Scan Concurrency Limiting

To reduce pressure on the shared rate limit, the plugin limits how many scan requests run concurrently within a single process. Excess scans are queued and processed as slots become available.

```json
{
"cds": {
"requires": {
"malwareScanner": {
"maxConcurrentScans": 10
}
}
}
}
```

| Option | Default | Description |
| -------------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `maxConcurrentScans` | `30` | Maximum number of concurrent scan requests per process. Set to `0` to disable (unbounded parallelism). |

A scan that is retrying due to a 429 response holds its concurrency slot during the backoff wait, preventing retry storms from competing with new scans.

#### Automatic file rescanning

According to the recommendation of the [Malware Scanning Service](http://help.sap.com/docs/malware-scanning-service/sap-malware-scanning-service/developing-applications-with-sap-malware-scanning-service), attachments should be rescanned automatically if the last scan is older than 3 days. This behavior can be configured in the attachments settings by specifying the `scanExpiryMs` property:
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
"cds": {
"requires": {
"malwareScanner": {
"retry": {
"maxAttempts": 5,
"initialDelay": 1000,
"maxDelay": 30000,
"maxConcurrentScans": 30
},
"vcap": {
"label": "malware-scanner"
}
Expand Down
15 changes: 10 additions & 5 deletions srv/attachments/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,16 @@ class AttachmentsService extends cds.Service {
res = await Promise.all(
data.map(async (d) => {
const res = await UPSERT(d).into(attachments)
const attachmentForHash = await this.get(attachments, { ID: d.ID })
// If this is just the PUT for metadata, there is not yet any file to retrieve
if (attachmentForHash) {
const hash = await computeHash(attachmentForHash)
await this.update(attachments, { ID: d.ID }, { hash })
// When scanning is enabled, skip hash computation here — the malware
// scanner returns SHA-256 in its response and writes the hash itself.
// This avoids a redundant file read (expensive for object store backends).
const scanEnabled = cds.env.requires?.attachments?.scan !== false
if (!scanEnabled || !this._skipInlineHash) {
const attachmentForHash = await this.get(attachments, { ID: d.ID })
if (attachmentForHash) {
const hash = await computeHash(attachmentForHash)
await this.update(attachments, { ID: d.ID }, { hash })
}
}
return res
}),
Expand Down
3 changes: 3 additions & 0 deletions srv/attachments/object-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ module.exports = class RemoteAttachmentsService extends require("./basic") {
objectStoreKind = cds.env.requires?.attachments?.objectStore?.kind
separateObjectStore =
this.isMultiTenancyEnabled && this.objectStoreKind === "separate"
// Skip inline hash computation in put() — the malware scanner already
// returns SHA-256, avoiding a redundant remote file download from object store.
_skipInlineHash = true

init() {
LOG.debug(`${this.constructor.name} initialization`, {
Expand Down
Loading
Loading