Skip to content

Commit 04f1bcf

Browse files
ChrisEdwardsclaude
andcommitted
Fix null pointer exceptions in session metadata filtering and scan results
Addressed two NPE issues identified by codex autonomous agent: 1. **AssessService** (mcp-d7h): Fixed NullPointerException in search_app_vulnerabilities when sessionMetadataValue or metadataItem.getValue() is null. - Treat null sessionMetadataValue as wildcard (match on name only) - Add null check for metadataItem.getValue() before equalsIgnoreCase - Added 2 unit tests covering both null scenarios 2. **SastService** (mcp-dvf): Fixed NullPointerException in get_scan_results when project.lastScanId() is null (common for projects with no completed scans). - Check if lastScanId is null before calling scans.get() - Check if scan is null after retrieval - Return descriptive IOException with clear user-facing message - Updated existing test + added new test for null scan scenario All 272 unit tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 37b79a0 commit 04f1bcf

File tree

4 files changed

+222
-4
lines changed

4 files changed

+222
-4
lines changed

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -789,8 +789,18 @@ public PaginatedResponse<VulnLight> searchAppVulnerabilities(
789789
if (vuln.sessionMetadata() != null) {
790790
for (SessionMetadata sm : vuln.sessionMetadata()) {
791791
for (MetadataItem metadataItem : sm.getMetadata()) {
792-
if (metadataItem.getDisplayLabel().equalsIgnoreCase(sessionMetadataName)
793-
&& metadataItem.getValue().equalsIgnoreCase(sessionMetadataValue)) {
792+
// Match on display label (required)
793+
var nameMatches =
794+
metadataItem.getDisplayLabel().equalsIgnoreCase(sessionMetadataName);
795+
796+
// If sessionMetadataValue is null, treat as wildcard (match name only)
797+
// Otherwise, both name and value must match
798+
var valueMatches =
799+
sessionMetadataValue == null
800+
|| (metadataItem.getValue() != null
801+
&& metadataItem.getValue().equalsIgnoreCase(sessionMetadataValue));
802+
803+
if (nameMatches && valueMatches) {
794804
filteredVulns.add(vuln);
795805
log.debug(
796806
"Found matching vulnerability with ID: {} for session metadata {}={}",

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,29 @@ public String getLatestScanResult(String projectName) throws IOException {
9393
.orElseThrow(() -> new IOException("Project not found"));
9494
log.debug("Found project with id: {}", project.id());
9595

96+
// Check if project has any completed scans
97+
if (project.lastScanId() == null) {
98+
var errorMsg =
99+
String.format(
100+
"No scan results available for project: %s. Project exists but has no completed"
101+
+ " scans.",
102+
projectName);
103+
log.warn(errorMsg);
104+
throw new IOException(errorMsg);
105+
}
106+
96107
var scans = contrastSDK.scan(orgID).scans(project.id());
97108
log.debug("Retrieved scans for project, last scan id: {}", project.lastScanId());
98109

99110
var scan = scans.get(project.lastScanId());
111+
if (scan == null) {
112+
var errorMsg =
113+
String.format(
114+
"No scan results available for project: %s. Scan ID %s not found.",
115+
projectName, project.lastScanId());
116+
log.warn(errorMsg);
117+
throw new IOException(errorMsg);
118+
}
100119
log.debug("Retrieved scan with id: {}", project.lastScanId());
101120

102121
try (InputStream sarifStream = scan.sarif();

src/test/java/com/contrast/labs/ai/mcp/contrast/AssessServiceTest.java

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1625,6 +1625,164 @@ void searchAppVulnerabilities_should_filter_by_session_metadata_case_insensitive
16251625
verify(mockContrastSDK).getTraces(eq(TEST_ORG_ID), eq(TEST_APP_ID), any(), any());
16261626
}
16271627

1628+
@Test
1629+
void searchAppVulnerabilities_should_treat_null_sessionMetadataValue_as_wildcard_match_any_value()
1630+
throws Exception {
1631+
// Given - 3 traces with different values for same metadata name
1632+
var mockTraces = mock(Traces.class);
1633+
var traces = new ArrayList<com.contrastsecurity.models.Trace>();
1634+
1635+
// Trace 1: has "branch" metadata with value "main"
1636+
Trace trace1 = mock();
1637+
when(trace1.getTitle()).thenReturn("SQL Injection vulnerability");
1638+
when(trace1.getRule()).thenReturn("sql-injection");
1639+
when(trace1.getUuid()).thenReturn("uuid-1");
1640+
when(trace1.getSeverity()).thenReturn("HIGH");
1641+
when(trace1.getLastTimeSeen()).thenReturn(System.currentTimeMillis());
1642+
1643+
var sessionMetadata1 = mock(com.contrastsecurity.models.SessionMetadata.class);
1644+
var metadataItem1 = mock(com.contrastsecurity.models.MetadataItem.class);
1645+
when(metadataItem1.getDisplayLabel()).thenReturn("branch");
1646+
// No getValue() stub needed - wildcard test doesn't check values
1647+
when(sessionMetadata1.getMetadata()).thenReturn(List.of(metadataItem1));
1648+
when(trace1.getSessionMetadata()).thenReturn(List.of(sessionMetadata1));
1649+
traces.add(trace1);
1650+
1651+
// Trace 2: has "branch" metadata with value "develop"
1652+
Trace trace2 = mock();
1653+
when(trace2.getTitle()).thenReturn("XSS vulnerability");
1654+
when(trace2.getRule()).thenReturn("xss");
1655+
when(trace2.getUuid()).thenReturn("uuid-2");
1656+
when(trace2.getSeverity()).thenReturn("MEDIUM");
1657+
when(trace2.getLastTimeSeen()).thenReturn(System.currentTimeMillis());
1658+
1659+
var sessionMetadata2 = mock(com.contrastsecurity.models.SessionMetadata.class);
1660+
var metadataItem2 = mock(com.contrastsecurity.models.MetadataItem.class);
1661+
when(metadataItem2.getDisplayLabel()).thenReturn("branch");
1662+
// No getValue() stub needed - wildcard test doesn't check values
1663+
when(sessionMetadata2.getMetadata()).thenReturn(List.of(metadataItem2));
1664+
when(trace2.getSessionMetadata()).thenReturn(List.of(sessionMetadata2));
1665+
traces.add(trace2);
1666+
1667+
// Trace 3: has "environment" metadata (different name, should not match)
1668+
Trace trace3 = mock();
1669+
when(trace3.getTitle()).thenReturn("Command Injection vulnerability");
1670+
when(trace3.getRule()).thenReturn("cmd-injection");
1671+
when(trace3.getUuid()).thenReturn("uuid-3");
1672+
when(trace3.getSeverity()).thenReturn("CRITICAL");
1673+
when(trace3.getLastTimeSeen()).thenReturn(System.currentTimeMillis());
1674+
1675+
var sessionMetadata3 = mock(com.contrastsecurity.models.SessionMetadata.class);
1676+
var metadataItem3 = mock(com.contrastsecurity.models.MetadataItem.class);
1677+
when(metadataItem3.getDisplayLabel()).thenReturn("environment");
1678+
// No getValue() stub needed - name doesn't match so value never checked
1679+
when(sessionMetadata3.getMetadata()).thenReturn(List.of(metadataItem3));
1680+
when(trace3.getSessionMetadata()).thenReturn(List.of(sessionMetadata3));
1681+
traces.add(trace3);
1682+
1683+
when(mockTraces.getTraces()).thenReturn(traces);
1684+
1685+
// Mock SDK to return all 3 traces
1686+
when(mockContrastSDK.getTraces(eq(TEST_ORG_ID), eq(TEST_APP_ID), any(), any()))
1687+
.thenReturn(mockTraces);
1688+
1689+
// When - search with sessionMetadataName but NULL sessionMetadataValue (wildcard)
1690+
var result =
1691+
assessService.searchAppVulnerabilities(
1692+
TEST_APP_ID,
1693+
1, // page
1694+
50, // pageSize
1695+
null,
1696+
null,
1697+
null,
1698+
null,
1699+
null,
1700+
null,
1701+
null,
1702+
"branch", // sessionMetadataName
1703+
null, // sessionMetadataValue = null (wildcard, match any value)
1704+
null);
1705+
1706+
// Then - should return traces 1 and 2 (both have "branch" metadata, regardless of value)
1707+
assertThat(result.items()).hasSize(2);
1708+
assertThat(result.items().get(0).vulnID()).isEqualTo("uuid-1");
1709+
assertThat(result.items().get(1).vulnID()).isEqualTo("uuid-2");
1710+
assertThat(result.totalItems()).isEqualTo(2);
1711+
1712+
// Verify SDK was called
1713+
verify(mockContrastSDK).getTraces(eq(TEST_ORG_ID), eq(TEST_APP_ID), any(), any());
1714+
}
1715+
1716+
@Test
1717+
void searchAppVulnerabilities_should_handle_metadata_item_with_null_value() throws Exception {
1718+
// Given - traces with metadata items that have null values
1719+
var mockTraces = mock(Traces.class);
1720+
var traces = new ArrayList<com.contrastsecurity.models.Trace>();
1721+
1722+
// Trace 1: has "branch" metadata with NULL value
1723+
Trace trace1 = mock();
1724+
when(trace1.getTitle()).thenReturn("SQL Injection vulnerability");
1725+
when(trace1.getRule()).thenReturn("sql-injection");
1726+
when(trace1.getUuid()).thenReturn("uuid-1");
1727+
when(trace1.getSeverity()).thenReturn("HIGH");
1728+
when(trace1.getLastTimeSeen()).thenReturn(System.currentTimeMillis());
1729+
1730+
var sessionMetadata1 = mock(com.contrastsecurity.models.SessionMetadata.class);
1731+
var metadataItem1 = mock(com.contrastsecurity.models.MetadataItem.class);
1732+
when(metadataItem1.getDisplayLabel()).thenReturn("branch");
1733+
when(metadataItem1.getValue()).thenReturn(null); // NULL value
1734+
when(sessionMetadata1.getMetadata()).thenReturn(List.of(metadataItem1));
1735+
when(trace1.getSessionMetadata()).thenReturn(List.of(sessionMetadata1));
1736+
traces.add(trace1);
1737+
1738+
// Trace 2: has "branch" metadata with actual value "main"
1739+
Trace trace2 = mock();
1740+
when(trace2.getTitle()).thenReturn("XSS vulnerability");
1741+
when(trace2.getRule()).thenReturn("xss");
1742+
when(trace2.getUuid()).thenReturn("uuid-2");
1743+
when(trace2.getSeverity()).thenReturn("MEDIUM");
1744+
when(trace2.getLastTimeSeen()).thenReturn(System.currentTimeMillis());
1745+
1746+
var sessionMetadata2 = mock(com.contrastsecurity.models.SessionMetadata.class);
1747+
var metadataItem2 = mock(com.contrastsecurity.models.MetadataItem.class);
1748+
when(metadataItem2.getDisplayLabel()).thenReturn("branch");
1749+
when(metadataItem2.getValue()).thenReturn("main");
1750+
when(sessionMetadata2.getMetadata()).thenReturn(List.of(metadataItem2));
1751+
when(trace2.getSessionMetadata()).thenReturn(List.of(sessionMetadata2));
1752+
traces.add(trace2);
1753+
1754+
when(mockTraces.getTraces()).thenReturn(traces);
1755+
1756+
// Mock SDK to return both traces
1757+
when(mockContrastSDK.getTraces(eq(TEST_ORG_ID), eq(TEST_APP_ID), any(), any()))
1758+
.thenReturn(mockTraces);
1759+
1760+
// When - search with specific value "main"
1761+
var result =
1762+
assessService.searchAppVulnerabilities(
1763+
TEST_APP_ID,
1764+
1, // page
1765+
50, // pageSize
1766+
null,
1767+
null,
1768+
null,
1769+
null,
1770+
null,
1771+
null,
1772+
null,
1773+
"branch", // sessionMetadataName
1774+
"main", // sessionMetadataValue = "main"
1775+
null);
1776+
1777+
// Then - should return only trace 2 (trace 1 has null value, doesn't match "main")
1778+
assertThat(result.items()).hasSize(1);
1779+
assertThat(result.items().get(0).vulnID()).isEqualTo("uuid-2");
1780+
assertThat(result.totalItems()).isEqualTo(1);
1781+
1782+
// Verify SDK was called and no NullPointerException occurred
1783+
verify(mockContrastSDK).getTraces(eq(TEST_ORG_ID), eq(TEST_APP_ID), any(), any());
1784+
}
1785+
16281786
@Test
16291787
void searchAppVulnerabilities_should_pass_all_standard_filters_to_SDK() throws Exception {
16301788
// Given

src/test/java/com/contrast/labs/ai/mcp/contrast/SastServiceTest.java

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ void getLatestScanResult_should_throw_IOException_when_project_not_found() throw
185185
}
186186

187187
@Test
188-
void getLatestScanResult_should_throw_exception_when_lastScanId_is_null() throws IOException {
188+
void getLatestScanResult_should_throw_IOException_when_lastScanId_is_null() throws IOException {
189189
// Arrange
190190
var projectName = "project-without-scans";
191191
var mockProject = mock(Project.class);
@@ -205,7 +205,38 @@ void getLatestScanResult_should_throw_exception_when_lastScanId_is_null() throws
205205

206206
// Act & Assert
207207
assertThatThrownBy(() -> sastService.getLatestScanResult(projectName))
208-
.isInstanceOf(NullPointerException.class);
208+
.isInstanceOf(IOException.class)
209+
.hasMessageContaining("No scan results available")
210+
.hasMessageContaining("has no completed scans");
211+
}
212+
}
213+
214+
@Test
215+
void getLatestScanResult_should_throw_IOException_when_scan_is_null() throws IOException {
216+
// Arrange
217+
var projectName = "test-project";
218+
var mockProject = mock(Project.class);
219+
var scanId = "scan-123";
220+
221+
when(mockProject.name()).thenReturn(projectName);
222+
when(mockProject.id()).thenReturn("project-123");
223+
when(mockProject.lastScanId()).thenReturn(scanId);
224+
225+
try (MockedStatic<SDKHelper> sdkHelper = mockStatic(SDKHelper.class)) {
226+
sdkHelper
227+
.when(() -> SDKHelper.getSDK(any(), any(), any(), any(), any(), any()))
228+
.thenReturn(contrastSDK);
229+
when(contrastSDK.scan(any())).thenReturn(scanManager);
230+
when(scanManager.projects()).thenReturn(projects);
231+
when(scanManager.scans(any())).thenReturn(scans);
232+
when(projects.findByName(projectName)).thenReturn(Optional.of(mockProject));
233+
when(scans.get(scanId)).thenReturn(null);
234+
235+
// Act & Assert
236+
assertThatThrownBy(() -> sastService.getLatestScanResult(projectName))
237+
.isInstanceOf(IOException.class)
238+
.hasMessageContaining("No scan results available")
239+
.hasMessageContaining("Scan ID " + scanId + " not found");
209240
}
210241
}
211242

0 commit comments

Comments
 (0)