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: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1 +1 @@
* @ChrisEdwards @Contrast-Security-OSS/aiml-developers
* @ChrisEdwards @Contrast-Security-OSS/platform-developers
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,6 @@ public class App {
@SerializedName("app_id")
private String appId;

@SerializedName("last_seen")
private long lastSeen;

@SerializedName("last_reset")
private Object lastReset;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ public class ListApplicationsByCveTool extends SingleTool<ListApplicationsByCveP
per-application class usage. classUsage 0 or absent means no classes from the vulnerable
library were seen loaded, so exploitation is unlikely; prioritize applications with
classUsage above 0. Use list_application_libraries for the reverse direction, all
libraries of one application. lastSeen and server status reflect last-known agent
reports and can lag live state; search_servers is fresher for current server state.
libraries of one application. Use search_applications for application lastSeenAt and
search_servers for current server state.
""")
public SingleToolResponse<CveData> listApplicationsByCve(
@ToolParam(description = "CVE identifier (e.g., CVE-2021-44228)") String cveId,
Expand Down Expand Up @@ -117,8 +117,6 @@ protected CveData doExecute(ListApplicationsByCveParams params, NoticeCollector
return cveData;
}

noticeNeverObservedApps(apps, collector);

log.debug(
"Found {} applications vulnerable to {}, enriching with class usage data",
apps.size(),
Expand All @@ -141,19 +139,6 @@ protected CveData doExecute(ListApplicationsByCveParams params, NoticeCollector
return cveData;
}

// TeamServer sends last_seen 0 for applications that have never reported agent activity, and
// App.lastSeen is a primitive long, so the zero sentinel always serializes (Jira AIML-1331).
private static void noticeNeverObservedApps(List<App> apps, NoticeCollector collector) {
var neverObserved =
apps.stream().filter(app -> app.getLastSeen() == 0).map(App::getName).toList();
if (!neverObserved.isEmpty()) {
collector.notice(
"lastSeen of 0 means the application has never been observed running, typically a"
+ " static or SCA-only upload: "
+ String.join(", ", neverObserved));
}
}

private static void applyPreferredCvssSummary(Cve cve) {
if (cve == null) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.util.json.JsonParser;

class ListApplicationsByCveToolTest {

Expand All @@ -50,10 +51,6 @@ class ListApplicationsByCveToolTest {
private static final String SECRET_BODY = "token=raw-token-value&apiKey=secret";
private static final String CVSS_V2_NOTICE =
"score is omitted for CVEs with only CVSS v2 data; use severity and the cvssv2 metrics.";
private static final long LAST_SEEN_MILLIS = 1721000000000L;
private static final String NEVER_OBSERVED_NOTICE_PREFIX =
"lastSeen of 0 means the application has never been observed running, typically a static or"
+ " SCA-only upload: ";
private static final String CVSS_V3_CVE_RESPONSE =
"""
{
Expand Down Expand Up @@ -258,6 +255,9 @@ void listApplicationsByCve_should_forward_tool_context_and_preserve_tool_name()
assertThat(result.isSuccess()).isTrue();
assertThat(capturedContext.get()).isSameAs(toolContext);
assertThat(method.getAnnotation(Tool.class).name()).isEqualTo("list_applications_by_cve");
assertThat(method.getAnnotation(Tool.class).description())
.contains("search_applications", "search_servers")
.doesNotContain("lastSeen and server status");
}

@Test
Expand Down Expand Up @@ -305,29 +305,19 @@ void listApplicationsByCve_should_warn_for_empty_apps_list() throws Exception {
}

@Test
void listApplicationsByCve_should_notice_never_observed_apps_when_lastSeen_is_zero()
throws Exception {
var neverObserved = app("StaticUpload", "app-static");
neverObserved.setLastSeen(0);
var running = app("Orders", APP_ID);
var cveData = new CveData();
cveData.setApps(List.of(neverObserved, running));
cveData.setLibraries(List.of(vulnerableLibrary(LIBRARY_HASH)));

when(contrastApiClient.getApplicationsByCve(eq(CVE_ID))).thenReturn(cveData);
when(contrastApiClient.getAllLibraries(eq("app-static"))).thenReturn(List.of());
when(contrastApiClient.getAllLibraries(eq(APP_ID))).thenReturn(List.of());

var result = tool.listApplicationsByCve(CVE_ID, null);

assertThat(result.isSuccess()).isTrue();
assertThat(result.notices()).contains(NEVER_OBSERVED_NOTICE_PREFIX + "StaticUpload");
}

@Test
void listApplicationsByCve_should_not_notice_never_observed_apps_when_lastSeen_is_populated()
throws Exception {
var cveData = cveData(app("Orders", APP_ID), vulnerableLibrary(LIBRARY_HASH));
void
listApplicationsByCve_should_omit_unreliable_last_seen_and_never_observed_notice_when_teamserver_sends_last_seen_zero()
throws Exception {
var cveData =
GsonFactory.create()
.fromJson(
"""
{
"apps": [{"name": "Orders", "app_id": "app-123", "last_seen": 0}],
"libraries": []
}
""",
CveData.class);

when(contrastApiClient.getApplicationsByCve(eq(CVE_ID))).thenReturn(cveData);
when(contrastApiClient.getAllLibraries(eq(APP_ID))).thenReturn(List.of());
Expand All @@ -336,6 +326,7 @@ void listApplicationsByCve_should_not_notice_never_observed_apps_when_lastSeen_i

assertThat(result.isSuccess()).isTrue();
assertThat(result.notices()).noneMatch(n -> n.contains("never been observed running"));
assertThat(JsonParser.toJson(result)).doesNotContain("lastSeen", "last_seen");
}

@Test
Expand Down Expand Up @@ -519,7 +510,6 @@ private static App app(String name, String appId) {
var app = new App();
app.setName(name);
app.setAppId(appId);
app.setLastSeen(LAST_SEEN_MILLIS);
return app;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.util.json.JsonParser;

@ExtendWith(MockitoExtension.class)
class ListApplicationsByCveLocalParityTest {
Expand Down Expand Up @@ -94,6 +95,8 @@ void listApplicationsByCve_should_return_cve_data_on_success() throws IOExceptio
assertThat(result.data()).isNotNull();
assertThat(result.data().getApps()).isNotEmpty();
assertThat(result.errors()).isEmpty();
assertThat(result.notices()).noneMatch(n -> n.contains("never been observed running"));
assertThat(JsonParser.toJson(result)).doesNotContain("lastSeen", "last_seen");
verify(sdkExtension).getAppsForCVE(eq(TEST_ORG_ID), eq(TEST_CVE_ID));
mockedSDKHelper.verify(
() -> SDKHelper.getLibsForID(eq(TEST_APP_ID), eq(TEST_ORG_ID), eq(sdkExtension)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.util.json.JsonParser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
Expand Down Expand Up @@ -283,12 +284,8 @@ void listApplicationsByCve_should_populate_app_fields() {
.as("known-vulnerable CVE must return at least one impacted app")
.isNotEmpty();

// Every returned App must populate identity plus non-negative seen-timestamps. Enrichment
// counters are validated separately in the class-usage test.
//
// lastSeen==0 is a documented sentinel for "app has never been observed running" (common
// for SCA-only apps that are catalogued but have no runtime activity). Only enforce
// lastSeen >= firstSeen when lastSeen is populated.
// Every returned App must populate identity plus a non-negative first-seen timestamp.
// Enrichment counters are validated separately in the class-usage test.
assertThat(result.data().getApps())
.as("every impacted app must populate identity and timestamp fields")
.allSatisfy(
Expand All @@ -298,14 +295,6 @@ void listApplicationsByCve_should_populate_app_fields() {
assertThat(app.getFirstSeen())
.as("%s.firstSeen must be non-negative", app.getAppId())
.isNotNegative();
assertThat(app.getLastSeen())
.as("%s.lastSeen must be non-negative", app.getAppId())
.isNotNegative();
if (app.getLastSeen() > 0) {
assertThat(app.getLastSeen())
.as("%s.lastSeen must not precede firstSeen when populated", app.getAppId())
.isGreaterThanOrEqualTo(app.getFirstSeen());
}
});

// Identity must actually be populated for every app — a real test of the payload, not just
Expand All @@ -314,6 +303,8 @@ void listApplicationsByCve_should_populate_app_fields() {
assertThat(result.data().getApps())
.as("at least one impacted app must carry a populated firstSeen timestamp")
.anyMatch(app -> app.getFirstSeen() > 0);
assertThat(result.notices()).noneMatch(n -> n.contains("never been observed running"));
assertThat(JsonParser.toJson(result)).doesNotContain("lastSeen", "last_seen");
}

@Test
Expand Down
Loading