Skip to content

Commit 493ea6b

Browse files
committed
Add clean scan validation and harden ClamAV status parsing
1 parent 5329853 commit 493ea6b

5 files changed

Lines changed: 138 additions & 13 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ jobs:
110110
VALIDATION_BUCKET: ${{ vars.VALIDATION_BUCKET }}
111111
ONLY_TAG_INFECTED: ${{ vars.ONLY_TAG_INFECTED }}
112112
run: |
113+
if [ "${ONLY_TAG_INFECTED}" != "false" ]; then
114+
echo "VALIDATION_BUCKET requires ONLY_TAG_INFECTED=false so clean-file validation can assert CLEAN instead of an absent tag." >&2
115+
exit 1
116+
fi
113117
echo "Waiting 30 seconds to allow Lambda to finish deploying..."
114118
sleep 30
115119
echo "Running Virus Scan Validation Tests..."

integration-test/src/main/java/cloud/cleo/clamav/test/VirusScanValidationTest.java

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public class VirusScanValidationTest {
3636

3737
private static final String BUCKET_NAME = System.getenv("VALIDATION_BUCKET");
3838
private static final String INFECTED_KEY = "eicar.txt";
39+
private static final String CLEAN_KEY = "NotInfectedFile.pdf";
3940
private static final String OVERSIZED_KEY = "large-test-file.zip";
4041

4142
private static final S3Client s3 = S3Client.create();
@@ -48,6 +49,7 @@ static void checkSetup() {
4849

4950
// Ensure all tags are cleared before testing starts
5051
clearTags(INFECTED_KEY);
52+
clearTags(CLEAN_KEY);
5153
clearTags(OVERSIZED_KEY);
5254

5355
//throw new AssertionError("Testing rollback");
@@ -83,20 +85,35 @@ public void validateScanOfKnownInfectedFile() throws InterruptedException {
8385
retriggerScan(INFECTED_KEY);
8486
}
8587
// Need to wait for scan to complete, which sometimes can take over a minute
86-
waitForTagValue(INFECTED_KEY, ScanStatus.INFECTED, Duration.ofMinutes(2));
88+
waitForTerminalTagValue(INFECTED_KEY, ScanStatus.INFECTED, Duration.ofMinutes(2));
8789
}
8890

8991
/**
90-
* When file to scan is over MAX_BYTES, then ensure it is not scanned and immediately tagged FILE_SIZE_EXCEEDED.
92+
* Validate a known clean file tests to be CLEAN. This catches false positives where ClamAV
93+
* fails to start but the pipeline still reports files as infected.
9194
*
9295
* @throws InterruptedException
9396
*/
9497
@Test
9598
@Order(3)
99+
public void validateScanOfKnownCleanFile() throws InterruptedException {
100+
assumeTrue(!ONLY_TAG_INFECTED, "Skipping because clean validation requires ONLY_TAG_INFECTED=false");
101+
102+
retriggerScan(CLEAN_KEY);
103+
waitForTerminalTagValue(CLEAN_KEY, ScanStatus.CLEAN, Duration.ofMinutes(2));
104+
}
105+
106+
/**
107+
* When file to scan is over MAX_BYTES, then ensure it is not scanned and immediately tagged FILE_SIZE_EXCEEDED.
108+
*
109+
* @throws InterruptedException
110+
*/
111+
@Test
112+
@Order(4)
96113
public void validateScanOfOversizedFile() throws InterruptedException {
97114
retriggerScan(OVERSIZED_KEY);
98115
// Should be detected before scanning on the S3 Head operation
99-
waitForTagValue(OVERSIZED_KEY, ScanStatus.FILE_SIZE_EXCEEED, Duration.ofSeconds(30));
116+
waitForTerminalTagValue(OVERSIZED_KEY, ScanStatus.FILE_SIZE_EXCEEED, Duration.ofSeconds(30));
100117
}
101118

102119
/**
@@ -141,6 +158,37 @@ private static void waitForTagValue(String key, ScanStatus expectedValue, Durati
141158
throw new AssertionError("Timed out waiting for scan-status tag: " + expectedValue + " on key: " + key);
142159
}
143160

161+
private static void waitForTerminalTagValue(String key, ScanStatus expectedValue, Duration timeout) throws InterruptedException {
162+
long timeoutMillis = timeout.toMillis();
163+
long sleepMillis = 5000;
164+
long start = System.currentTimeMillis();
165+
166+
while (System.currentTimeMillis() - start <= timeoutMillis) {
167+
List<Tag> tags = getTags(key);
168+
String actual = tags.stream()
169+
.filter(tag -> SCAN_TAG_NAME.equals(tag.key()))
170+
.map(Tag::value)
171+
.findFirst()
172+
.orElse(null);
173+
174+
if (expectedValue.name().equals(actual)) {
175+
assertThat(actual).isEqualTo(expectedValue.name());
176+
return;
177+
}
178+
179+
if (actual != null) {
180+
ScanStatus actualStatus = ScanStatus.valueOf(actual);
181+
if (actualStatus != ScanStatus.SCANNING) {
182+
throw new AssertionError("Expected scan-status " + expectedValue + " but found " + actualStatus + " on key: " + key);
183+
}
184+
}
185+
186+
Thread.sleep(sleepMillis);
187+
}
188+
189+
throw new AssertionError("Timed out waiting for scan-status tag: " + expectedValue + " on key: " + key);
190+
}
191+
144192
/**
145193
* Get all tags on S3 Object.
146194
*

lambda/pom.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@
6969
<artifactId>aws-crt-client</artifactId>
7070
</dependency>
7171

72+
<dependency>
73+
<groupId>org.junit.jupiter</groupId>
74+
<artifactId>junit-jupiter-api</artifactId>
75+
<scope>test</scope>
76+
</dependency>
77+
78+
<dependency>
79+
<groupId>org.junit.jupiter</groupId>
80+
<artifactId>junit-jupiter-engine</artifactId>
81+
<scope>test</scope>
82+
</dependency>
83+
84+
<dependency>
85+
<groupId>org.assertj</groupId>
86+
<artifactId>assertj-core</artifactId>
87+
<scope>test</scope>
88+
</dependency>
89+
7290
</dependencies>
7391

7492
<build>
@@ -78,6 +96,12 @@
7896
<groupId>org.apache.maven.plugins</groupId>
7997
<artifactId>maven-shade-plugin</artifactId>
8098
</plugin>
99+
100+
<plugin>
101+
<groupId>org.apache.maven.plugins</groupId>
102+
<artifactId>maven-surefire-plugin</artifactId>
103+
<version>3.5.4</version>
104+
</plugin>
81105

82106
<plugin>
83107
<groupId>org.apache.maven.plugins</groupId>

lambda/src/main/java/cloud/cleo/clamav/lambda/ScanningLambda.java

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import java.nio.file.Paths;
1717
import java.util.ArrayList;
1818
import java.util.List;
19+
import java.util.regex.Pattern;
1920
import java.util.concurrent.CompletableFuture;
2021
import java.util.concurrent.CompletionException;
2122
import java.util.concurrent.TimeUnit;
@@ -32,6 +33,7 @@
3233
* @author sjensen
3334
*/
3435
public class ScanningLambda implements RequestHandler<S3EventNotification, Void> {
36+
private static final Pattern CLAMAV_FOUND_PATTERN = Pattern.compile("(?m)^.*\\bFOUND$");
3537

3638
// Create an S3 client with CRT Async (better download performance and Async calls)
3739
final static S3AsyncClient s3Client = S3AsyncClient.crtCreate();
@@ -129,19 +131,13 @@ public Void handleRequest(S3EventNotification event, Context context) {
129131
return;
130132
}
131133

134+
String processOutput;
132135
try (final var is = process.getInputStream()) {
133-
log.debug("Process Output: {}", new String(is.readAllBytes()));
136+
processOutput = new String(is.readAllBytes());
137+
log.debug("Process Output: {}", processOutput);
134138
}
135139

136-
// According to ClamAV: 0 means CLEAN, 1 means INFECTED, else ERROR.
137-
status = switch (process.exitValue()) {
138-
case 0 ->
139-
ScanStatus.CLEAN;
140-
case 1 ->
141-
ScanStatus.INFECTED;
142-
default ->
143-
ScanStatus.ERROR;
144-
};
140+
status = determineScanStatus(process.exitValue(), processOutput);
145141
log.info("Scan result for {}: {}", key, status);
146142
} catch (IOException | InterruptedException e) {
147143
log.error("Error running clamscan: ", e);
@@ -228,6 +224,24 @@ static long getClamScanWaitMillis(int remainingMillis) {
228224
return Math.max(0, remainingMillis - 10000L);
229225
}
230226

227+
static ScanStatus determineScanStatus(int exitCode, String processOutput) {
228+
if (exitCode == 0) {
229+
return ScanStatus.CLEAN;
230+
}
231+
232+
if (exitCode == 1) {
233+
if (processOutput != null && CLAMAV_FOUND_PATTERN.matcher(processOutput).find()) {
234+
return ScanStatus.INFECTED;
235+
}
236+
237+
log.error("clamscan exited with code 1 but did not report a FOUND signature. Treating as ERROR. Output: {}",
238+
processOutput);
239+
return ScanStatus.ERROR;
240+
}
241+
242+
return ScanStatus.ERROR;
243+
}
244+
231245
static Path createTempFilePath(String key) {
232246
String baseName = new File(key).getName();
233247
String extension = "";
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package cloud.cleo.clamav.lambda;
2+
3+
import cloud.cleo.clamav.ScanStatus;
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
import org.junit.jupiter.api.Test;
6+
7+
class ScanningLambdaTest {
8+
9+
@Test
10+
void determineScanStatusReturnsInfectedWhenClamavReportsFoundSignature() {
11+
String output = """
12+
/tmp/upload.pdf: Win.Test.EICAR_HDB-1 FOUND
13+
----------- SCAN SUMMARY -----------
14+
Infected files: 1
15+
""";
16+
17+
assertThat(ScanningLambda.determineScanStatus(1, output)).isEqualTo(ScanStatus.INFECTED);
18+
}
19+
20+
@Test
21+
void determineScanStatusTreatsStartupFailureAsErrorInsteadOfInfected() {
22+
String output = """
23+
clamscan: /lib64/libc.so.6: version `GLIBC_2.38' not found (required by clamscan)
24+
clamscan: /lib64/libm.so.6: version `GLIBC_2.38' not found (required by /usr/lib/clamav_libs/libclamav.so.12)
25+
""";
26+
27+
assertThat(ScanningLambda.determineScanStatus(1, output)).isEqualTo(ScanStatus.ERROR);
28+
}
29+
30+
@Test
31+
void determineScanStatusReturnsCleanForExitCodeZero() {
32+
assertThat(ScanningLambda.determineScanStatus(0, "/tmp/upload.pdf: OK"))
33+
.isEqualTo(ScanStatus.CLEAN);
34+
}
35+
}

0 commit comments

Comments
 (0)