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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@ test-plan-results/
# MCP local config
.mcp.json
test-plans-archive/
.tldr/
.tldrignore
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,9 @@ Human review is the bottleneck in AI-assisted development. Creating exceptional
- Manual testing performed
- Edge cases covered

**Goal**: Make reviewing effortless by providing all information the reviewer needs to understand and evaluate the changes with confidence and speed. The reviewer should not need to ask clarifying questions.
**Goal**: Make reviewing effortless by providing all information the reviewer needs to understand and evaluate the changes with confidence and speed. The reviewer should not need to ask clarifying questions.

**IMPORTANT**: Ensure the text of the PR clearly explains all the changes and leads the developer through the logical progression of the chain of thought that produced it.

### Moving to Review

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright 2025 Contrast Security
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.contrast.labs.ai.mcp.contrast.data;

import java.util.List;

/**
* Lightweight route coverage response containing essential route data and aggregate statistics.
* Uses RouteLight instead of full Route objects for reduced payload size.
*
* @param success Whether the API request succeeded
* @param messages Any API messages (warnings, info)
* @param totalRoutes Total number of routes in the response
* @param exercisedCount Number of routes with EXERCISED status
* @param discoveredCount Number of routes with DISCOVERED status (not yet exercised)
* @param coveragePercent Percentage of routes that are exercised (0.0 to 100.0)
* @param totalVulnerabilities Sum of vulnerabilities across all routes
* @param totalCriticalVulnerabilities Sum of critical vulnerabilities across all routes
* @param routes List of lightweight route objects
*/
public record RouteCoverageResponseLight(
boolean success,
List<String> messages,
int totalRoutes,
int exercisedCount,
int discoveredCount,
double coveragePercent,
int totalVulnerabilities,
int totalCriticalVulnerabilities,
List<RouteLight> routes) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2025 Contrast Security
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.contrast.labs.ai.mcp.contrast.data;

import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Observation;
import java.util.Collections;
import java.util.List;

/**
* Lightweight route record for route coverage responses. Contains essential route information
* without redundant application data or full server details.
*
* @param signature Code-level route identifier (e.g., Java method signature)
* @param environments Distinct environments where route has been observed (DEVELOPMENT, QA,
* PRODUCTION)
* @param status Route status (DISCOVERED, EXERCISED)
* @param routeHash Unique route identifier hash
* @param vulnerabilities Total vulnerability count for this route
* @param criticalVulnerabilities Count of vulnerabilities with critical severity
* @param exercised Timestamp when route was last exercised (0 if never)
* @param discovered Timestamp when route was first discovered (immutable)
* @param serversTotal Count of distinct enabled servers where route has been observed
* @param observations List of observed HTTP interactions (verb + url)
* @param totalObservations Total observation count
*/
public record RouteLight(
String signature,
List<String> environments,
String status,
String routeHash,
int vulnerabilities,
int criticalVulnerabilities,
long exercised,
long discovered,
int serversTotal,
List<Observation> observations,
Long totalObservations) {

/** Compact constructor ensures observations is never null. */
public RouteLight {
observations = observations != null ? observations : Collections.emptyList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright 2025 Contrast Security
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.contrast.labs.ai.mcp.contrast.mapper;

import com.contrast.labs.ai.mcp.contrast.data.RouteCoverageResponseLight;
import com.contrast.labs.ai.mcp.contrast.data.RouteLight;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.Route;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse;
import java.util.Collections;
import java.util.Optional;
import org.springframework.stereotype.Component;

/**
* Mapper for transforming Route objects into lightweight RouteLight representations. Eliminates
* redundant fields (app, servers, routeHashString) while preserving essential route coverage data.
*/
@Component
public class RouteMapper {

/**
* Transform a Route object into a lightweight representation. Removes redundant app data, full
* server list, and legacy fields.
*
* @param route The full route object from SDK
* @return RouteLight with essential fields only
*/
public RouteLight toRouteLight(Route route) {
return new RouteLight(
route.getSignature(),
Optional.ofNullable(route.getEnvironments()).orElse(Collections.emptyList()),
route.getStatus(),
route.getRouteHash(),
route.getVulnerabilities(),
route.getCriticalVulnerabilities(),
route.getExercised(),
route.getDiscovered(),
route.getServersTotal(),
Optional.ofNullable(route.getObservations()).orElse(Collections.emptyList()),
route.getTotalObservations());
}

/**
* Transform full RouteCoverageResponse to lightweight version with aggregate statistics. Uses
* single-pass computation for efficiency with large route lists.
*
* @param response Full route coverage response from SDK
* @return Lightweight response with RouteLight objects and computed aggregates
*/
public RouteCoverageResponseLight toResponseLight(RouteCoverageResponse response) {
var routes = Optional.ofNullable(response.getRoutes()).orElse(Collections.emptyList());

var lightRoutes = routes.stream().map(this::toRouteLight).toList();

// Single-pass computation for all aggregates
int exercisedCount = 0;
int discoveredCount = 0;
int totalVulnerabilities = 0;
int totalCriticalVulnerabilities = 0;

for (var route : routes) {
if ("EXERCISED".equalsIgnoreCase(route.getStatus())) {
exercisedCount++;
} else if ("DISCOVERED".equalsIgnoreCase(route.getStatus())) {
discoveredCount++;
}
totalVulnerabilities += route.getVulnerabilities();
totalCriticalVulnerabilities += route.getCriticalVulnerabilities();
}

int totalRoutes = routes.size();
double coveragePercent =
totalRoutes > 0 ? Math.round((exercisedCount * 100.0) / totalRoutes * 100.0) / 100.0 : 0.0;

return new RouteCoverageResponseLight(
response.isSuccess(),
response.getMessages(),
totalRoutes,
exercisedCount,
discoveredCount,
coveragePercent,
totalVulnerabilities,
totalCriticalVulnerabilities,
lightRoutes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.adr.AttacksResponse;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.application.ApplicationsResponse;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteCoverageResponse;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.routecoverage.RouteDetailsResponse;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sca.LibraryObservation;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sca.LibraryObservationsResponse;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.sessionmetadata.SessionMetadataResponse;
Expand Down Expand Up @@ -175,72 +174,41 @@ private String getLibraryObservationsUrl(
organizationId, applicationId, libraryId, offset, limit);
}

/**
* Retrieves the detailed observations for a specific route.
*
* @param organizationId The organization ID
* @param applicationId The application ID
* @param routeHash The unique hash identifying the route
* @return RouteDetailsResponse containing observations for the route
* @throws IOException If an I/O error occurs
* @throws UnauthorizedException If the request is not authorized
*/
public RouteDetailsResponse getRouteDetails(
String organizationId, String applicationId, String routeHash)
throws IOException, UnauthorizedException {
var url = getRouteDetailsUrl(organizationId, applicationId, routeHash);

try (InputStream is = contrastSDK.makeRequest(HttpMethod.GET, url);
Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
return gson.fromJson(reader, RouteDetailsResponse.class);
}
}

/**
* Retrieves route coverage information for an application.
*
* <p>Uses GET with expand=observations for unfiltered requests, POST for filtered requests. Both
* include observations inline, eliminating N+1 queries for route details.
*
* @param organizationId The organization ID
* @param appId The application ID
* @param metadata Optional metadata request for filtering (can be null)
* @return RouteCoverageResponse containing route coverage information
* @param metadata Optional metadata request for filtering (can be null for unfiltered)
* @return RouteCoverageResponse containing route coverage information with observations
* @throws IOException If an I/O error occurs
* @throws UnauthorizedException If the request is not authorized
*/
public RouteCoverageResponse getRouteCoverage(
String organizationId, String appId, RouteCoverageBySessionIDAndMetadataRequest metadata)
throws IOException, UnauthorizedException {

InputStream is = null;

try {
if (metadata == null) {
is =
contrastSDK.makeRequest(
HttpMethod.GET, urlBuilder.getRouteCoverageUrl(organizationId, appId));
} else {
is =
contrastSDK.makeRequestWithBody(
HttpMethod.POST,
urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId),
gson.toJson(metadata),
MediaType.JSON);
}
InputStream is;

try (Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
return gson.fromJson(reader, RouteCoverageResponse.class);
}
} finally {
if (is != null) {
is.close();
}
if (metadata == null) {
// GET for unfiltered - add expand=observations to include observations inline
var url = urlBuilder.getRouteCoverageUrl(organizationId, appId) + "&expand=observations";
is = contrastSDK.makeRequest(HttpMethod.GET, url);
} else {
// POST for filtered - URL already includes expand=observations
var url = urlBuilder.getRouteCoverageWithMetadataUrl(organizationId, appId);
is =
contrastSDK.makeRequestWithBody(
HttpMethod.POST, url, gson.toJson(metadata), MediaType.JSON);
}
}

/** Builds URL for retrieving route details observations */
private String getRouteDetailsUrl(String organizationId, String applicationId, String routeHash) {
return String.format(
"/ng/%s/applications/%s/route/%s/observations?expand=skip_links,session_metadata",
organizationId, applicationId, routeHash);
try (is;
Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
return gson.fromJson(reader, RouteCoverageResponse.class);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ public class Route {
private long discovered;
private String status;

private RouteDetailsResponse routeDetailsResponse;

@SerializedName("route_hash")
private String routeHash;

Expand All @@ -45,4 +43,10 @@ public class Route {

@SerializedName("critical_vulnerabilities")
private int criticalVulnerabilities;

@SerializedName("observations")
private List<Observation> observations;

@SerializedName("total_observations")
private Long totalObservations;
}

This file was deleted.

Loading