Skip to content

Commit 4f8fbb3

Browse files
ChrisEdwardsclaude
andcommitted
Fix vulnerability duplication in session metadata filtering (mcp-oj2)
Problem: - When filtering by sessionMetadataName, vulnerabilities appearing in multiple sessions were added to results multiple times - Caused inflated totals, broken pagination, and duplicate entries - Common scenario: vuln found in both "main" and "develop" branches Root Cause: - Nested loop used `break` which only exited innermost loop - Code continued iterating over remaining SessionMetadata objects - Same vulnerability added multiple times if it matched in multiple sessions Solution: - Added labeled break `sessionLoop:` on SessionMetadata loop - Changed `break` to `break sessionLoop` to exit both inner loops - Ensures each vulnerability appears at most once in results - Continues processing other vulnerabilities correctly Test Coverage: - Added test case for vulnerability with 2 matching session metadata objects - Verifies vulnerability appears exactly once (not duplicated) - All existing tests pass (62 tests total) Benefits: - Accurate pagination counts - No duplicate vulnerabilities in results - Slight performance improvement (stops after first match per vuln) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent b37b9d9 commit 4f8fbb3

File tree

5 files changed

+475
-1
lines changed

5 files changed

+475
-1
lines changed

src/main/java/com/contrast/labs/ai/mcp/contrast/AssessService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@ public PaginatedResponse<VulnLight> searchAppVulnerabilities(
788788
var filteredVulns = new ArrayList<VulnLight>();
789789
for (VulnLight vuln : vulnerabilities) {
790790
if (vuln.sessionMetadata() != null) {
791+
sessionLoop:
791792
for (SessionMetadata sm : vuln.sessionMetadata()) {
792793
for (MetadataItem metadataItem : sm.getMetadata()) {
793794
// Match on display label (required)
@@ -808,7 +809,7 @@ public PaginatedResponse<VulnLight> searchAppVulnerabilities(
808809
vuln.vulnID(),
809810
sessionMetadataName,
810811
sessionMetadataValue);
811-
break;
812+
break sessionLoop;
812813
}
813814
}
814815
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package com.contrast.labs.ai.mcp.contrast;
2+
3+
import static org.mockito.Mockito.lenient;
4+
import static org.mockito.Mockito.mock;
5+
6+
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.LibraryExtended;
7+
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.LibraryVulnerabilityExtended;
8+
import com.contrastsecurity.models.Application;
9+
import com.contrastsecurity.models.Server;
10+
import java.util.ArrayList;
11+
import java.util.List;
12+
import java.util.UUID;
13+
14+
/**
15+
* Builder for creating anonymous LibraryExtended mocks with sensible defaults. Only override fields
16+
* that matter for your specific test.
17+
*
18+
* <p>Example usage:
19+
*
20+
* <pre>
21+
* LibraryExtended lib = AnonymousLibraryExtendedBuilder.validLibrary()
22+
* .withFilename("log4j-core-2.14.1.jar")
23+
* .withVersion("2.14.1")
24+
* .withClassCount(100)
25+
* .build();
26+
* </pre>
27+
*/
28+
public class AnonymousLibraryExtendedBuilder {
29+
private final LibraryExtended library;
30+
private String filename = "library-" + UUID.randomUUID().toString().substring(0, 8) + ".jar";
31+
private String version = "1.0." + UUID.randomUUID().toString().substring(0, 4);
32+
private String hash = "hash-" + UUID.randomUUID().toString().substring(0, 16);
33+
private String group = "com.example";
34+
private String grade = "B";
35+
private String manifest = "Manifest-Version: 1.0";
36+
private String fileVersion = "1.0.0";
37+
private String appId = "app-" + UUID.randomUUID().toString().substring(0, 8);
38+
private String appName = "TestApp";
39+
private String appContextPath = "/app";
40+
private String appLanguage = "Java";
41+
private String latestVersion = "1.1.0";
42+
private long libraryId = Math.abs(UUID.randomUUID().getLeastSignificantBits());
43+
private int classCount = 100;
44+
private int classedUsed = 50;
45+
private long releaseDate = System.currentTimeMillis();
46+
private long latestReleaseDate = System.currentTimeMillis();
47+
private int totalVulnerabilities = 0;
48+
private int highVulnerabilities = 0;
49+
private boolean custom = false;
50+
private double libScore = 75.0;
51+
private int monthsOutdated = 0;
52+
private List<Application> applications = new ArrayList<>();
53+
private List<Server> servers = new ArrayList<>();
54+
private List<LibraryVulnerabilityExtended> vulnerabilities = new ArrayList<>();
55+
56+
private AnonymousLibraryExtendedBuilder() {
57+
this.library = mock(LibraryExtended.class);
58+
}
59+
60+
/** Create a builder with valid defaults for all required fields. */
61+
public static AnonymousLibraryExtendedBuilder validLibrary() {
62+
return new AnonymousLibraryExtendedBuilder();
63+
}
64+
65+
public AnonymousLibraryExtendedBuilder withFilename(String filename) {
66+
this.filename = filename;
67+
return this;
68+
}
69+
70+
public AnonymousLibraryExtendedBuilder withVersion(String version) {
71+
this.version = version;
72+
return this;
73+
}
74+
75+
public AnonymousLibraryExtendedBuilder withHash(String hash) {
76+
this.hash = hash;
77+
return this;
78+
}
79+
80+
public AnonymousLibraryExtendedBuilder withGroup(String group) {
81+
this.group = group;
82+
return this;
83+
}
84+
85+
public AnonymousLibraryExtendedBuilder withGrade(String grade) {
86+
this.grade = grade;
87+
return this;
88+
}
89+
90+
public AnonymousLibraryExtendedBuilder withManifest(String manifest) {
91+
this.manifest = manifest;
92+
return this;
93+
}
94+
95+
public AnonymousLibraryExtendedBuilder withFileVersion(String fileVersion) {
96+
this.fileVersion = fileVersion;
97+
return this;
98+
}
99+
100+
public AnonymousLibraryExtendedBuilder withAppId(String appId) {
101+
this.appId = appId;
102+
return this;
103+
}
104+
105+
public AnonymousLibraryExtendedBuilder withAppName(String appName) {
106+
this.appName = appName;
107+
return this;
108+
}
109+
110+
public AnonymousLibraryExtendedBuilder withAppContextPath(String appContextPath) {
111+
this.appContextPath = appContextPath;
112+
return this;
113+
}
114+
115+
public AnonymousLibraryExtendedBuilder withAppLanguage(String appLanguage) {
116+
this.appLanguage = appLanguage;
117+
return this;
118+
}
119+
120+
public AnonymousLibraryExtendedBuilder withLatestVersion(String latestVersion) {
121+
this.latestVersion = latestVersion;
122+
return this;
123+
}
124+
125+
public AnonymousLibraryExtendedBuilder withLibraryId(long libraryId) {
126+
this.libraryId = libraryId;
127+
return this;
128+
}
129+
130+
public AnonymousLibraryExtendedBuilder withClassCount(int classCount) {
131+
this.classCount = classCount;
132+
return this;
133+
}
134+
135+
public AnonymousLibraryExtendedBuilder withClassedUsed(int classedUsed) {
136+
this.classedUsed = classedUsed;
137+
return this;
138+
}
139+
140+
public AnonymousLibraryExtendedBuilder withReleaseDate(long releaseDate) {
141+
this.releaseDate = releaseDate;
142+
return this;
143+
}
144+
145+
public AnonymousLibraryExtendedBuilder withLatestReleaseDate(long latestReleaseDate) {
146+
this.latestReleaseDate = latestReleaseDate;
147+
return this;
148+
}
149+
150+
public AnonymousLibraryExtendedBuilder withTotalVulnerabilities(int totalVulnerabilities) {
151+
this.totalVulnerabilities = totalVulnerabilities;
152+
return this;
153+
}
154+
155+
public AnonymousLibraryExtendedBuilder withHighVulnerabilities(int highVulnerabilities) {
156+
this.highVulnerabilities = highVulnerabilities;
157+
return this;
158+
}
159+
160+
public AnonymousLibraryExtendedBuilder withCustom(boolean custom) {
161+
this.custom = custom;
162+
return this;
163+
}
164+
165+
public AnonymousLibraryExtendedBuilder withLibScore(double libScore) {
166+
this.libScore = libScore;
167+
return this;
168+
}
169+
170+
public AnonymousLibraryExtendedBuilder withMonthsOutdated(int monthsOutdated) {
171+
this.monthsOutdated = monthsOutdated;
172+
return this;
173+
}
174+
175+
public AnonymousLibraryExtendedBuilder withApplications(List<Application> applications) {
176+
this.applications = applications;
177+
return this;
178+
}
179+
180+
public AnonymousLibraryExtendedBuilder withServers(List<Server> servers) {
181+
this.servers = servers;
182+
return this;
183+
}
184+
185+
public AnonymousLibraryExtendedBuilder withVulnerabilities(
186+
List<LibraryVulnerabilityExtended> vulnerabilities) {
187+
this.vulnerabilities = vulnerabilities;
188+
return this;
189+
}
190+
191+
/**
192+
* Build the LibraryExtended mock with all configured values. Uses lenient stubbing to avoid
193+
* UnnecessaryStubbingException for fields not accessed in specific tests.
194+
*/
195+
public LibraryExtended build() {
196+
lenient().when(library.getFilename()).thenReturn(filename);
197+
lenient().when(library.getVersion()).thenReturn(version);
198+
lenient().when(library.getHash()).thenReturn(hash);
199+
lenient().when(library.getGroup()).thenReturn(group);
200+
lenient().when(library.getGrade()).thenReturn(grade);
201+
lenient().when(library.getManifest()).thenReturn(manifest);
202+
lenient().when(library.getFileVersion()).thenReturn(fileVersion);
203+
lenient().when(library.getAppId()).thenReturn(appId);
204+
lenient().when(library.getAppName()).thenReturn(appName);
205+
lenient().when(library.getAppContextPath()).thenReturn(appContextPath);
206+
lenient().when(library.getAppLanguage()).thenReturn(appLanguage);
207+
lenient().when(library.getLatestVersion()).thenReturn(latestVersion);
208+
lenient().when(library.getLibraryId()).thenReturn(libraryId);
209+
lenient().when(library.getClassCount()).thenReturn(classCount);
210+
lenient().when(library.getClassedUsed()).thenReturn(classedUsed);
211+
lenient().when(library.getReleaseDate()).thenReturn(releaseDate);
212+
lenient().when(library.getLatestReleaseDate()).thenReturn(latestReleaseDate);
213+
lenient().when(library.getTotalVulnerabilities()).thenReturn(totalVulnerabilities);
214+
lenient().when(library.getHighVulnerabilities()).thenReturn(highVulnerabilities);
215+
lenient().when(library.isCustom()).thenReturn(custom);
216+
lenient().when(library.getLibScore()).thenReturn(libScore);
217+
lenient().when(library.getMonthsOutdated()).thenReturn(monthsOutdated);
218+
lenient().when(library.getApplications()).thenReturn(applications);
219+
lenient().when(library.getServers()).thenReturn(servers);
220+
lenient().when(library.getVulnerabilities()).thenReturn(vulnerabilities);
221+
return library;
222+
}
223+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.contrast.labs.ai.mcp.contrast;
2+
3+
import static org.mockito.Mockito.lenient;
4+
import static org.mockito.Mockito.mock;
5+
6+
import com.contrastsecurity.sdk.scan.Scan;
7+
import com.contrastsecurity.sdk.scan.ScanStatus;
8+
import java.io.ByteArrayInputStream;
9+
import java.io.InputStream;
10+
import java.nio.charset.StandardCharsets;
11+
import java.util.UUID;
12+
13+
/**
14+
* Builder for creating anonymous Scan mocks with sensible defaults. Only override fields that
15+
* matter for your specific test.
16+
*
17+
* <p>Note: Scan is an interface, so this builder mocks the interface methods (not getters).
18+
*
19+
* <p>Example usage:
20+
*
21+
* <pre>
22+
* Scan scan = AnonymousScanBuilder.validScan()
23+
* .withId("scan-123")
24+
* .withStatus(ScanStatus.COMPLETED)
25+
* .withSarif("{\"version\":\"2.1.0\"}")
26+
* .build();
27+
* </pre>
28+
*/
29+
public class AnonymousScanBuilder {
30+
private final Scan scan;
31+
private String id = "scan-" + UUID.randomUUID().toString().substring(0, 8);
32+
private String projectId = "project-" + UUID.randomUUID().toString().substring(0, 8);
33+
private String organizationId = "org-" + UUID.randomUUID().toString().substring(0, 8);
34+
private ScanStatus status = ScanStatus.COMPLETED;
35+
private String errorMessage = null;
36+
private boolean isFinished = true;
37+
private InputStream sarif =
38+
new ByteArrayInputStream(
39+
"{\"version\":\"2.1.0\",\"runs\":[]}".getBytes(StandardCharsets.UTF_8));
40+
41+
private AnonymousScanBuilder() {
42+
this.scan = mock(Scan.class);
43+
}
44+
45+
/** Create a builder with valid defaults for all required fields. */
46+
public static AnonymousScanBuilder validScan() {
47+
return new AnonymousScanBuilder();
48+
}
49+
50+
public AnonymousScanBuilder withId(String id) {
51+
this.id = id;
52+
return this;
53+
}
54+
55+
public AnonymousScanBuilder withProjectId(String projectId) {
56+
this.projectId = projectId;
57+
return this;
58+
}
59+
60+
public AnonymousScanBuilder withOrganizationId(String organizationId) {
61+
this.organizationId = organizationId;
62+
return this;
63+
}
64+
65+
public AnonymousScanBuilder withStatus(ScanStatus status) {
66+
this.status = status;
67+
return this;
68+
}
69+
70+
public AnonymousScanBuilder withErrorMessage(String errorMessage) {
71+
this.errorMessage = errorMessage;
72+
return this;
73+
}
74+
75+
public AnonymousScanBuilder withIsFinished(boolean isFinished) {
76+
this.isFinished = isFinished;
77+
return this;
78+
}
79+
80+
public AnonymousScanBuilder withSarif(InputStream sarif) {
81+
this.sarif = sarif;
82+
return this;
83+
}
84+
85+
/**
86+
* Convenience method to set SARIF content from a String.
87+
*
88+
* @param sarifContent the SARIF JSON as a string
89+
*/
90+
public AnonymousScanBuilder withSarif(String sarifContent) {
91+
this.sarif = new ByteArrayInputStream(sarifContent.getBytes(StandardCharsets.UTF_8));
92+
return this;
93+
}
94+
95+
/**
96+
* Build the Scan mock with all configured values. Uses lenient stubbing to avoid
97+
* UnnecessaryStubbingException for fields not accessed in specific tests.
98+
*/
99+
public Scan build() throws java.io.IOException {
100+
lenient().when(scan.id()).thenReturn(id);
101+
lenient().when(scan.projectId()).thenReturn(projectId);
102+
lenient().when(scan.organizationId()).thenReturn(organizationId);
103+
lenient().when(scan.status()).thenReturn(status);
104+
lenient().when(scan.errorMessage()).thenReturn(errorMessage);
105+
lenient().when(scan.isFinished()).thenReturn(isFinished);
106+
lenient().when(scan.sarif()).thenReturn(sarif);
107+
return scan;
108+
}
109+
}

0 commit comments

Comments
 (0)