Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,18 @@ public void run() {
RouteEntry[] routes = RoutesStore.getRoutesAsList();
List<User> users = UsersStore.getUsersAsList();
List<RuntimePackage> packages = RuntimePackagesStore.getPackagesAsList();
List<JavaArtifact> javaArtifacts = JavaArtifactsStore.getArtifactsAsList();

// Clear data :
StatisticsStore.clear();
HostnamesStore.clear();
RoutesStore.clear();
UsersStore.clear();
RuntimePackagesStore.clear();
JavaArtifactsStore.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium - Artifact hashes are dropped permanently when a heartbeat fails

The new Java artifact inventory is cleared before the heartbeat is sent, so any network error or non-successful heartbeat response discards every collected hash from memory. Those hashes are only queued once per JAR location because observed locations are deduplicated permanently, so the collector will not recreate them after a transient outage. A temporary API failure therefore leaves the backend with a permanently incomplete artifact inventory until the process restarts or a new JAR is loaded.

Show fix

Do not delete JavaArtifactsStore before the heartbeat has been accepted. Instead, drain artifacts atomically only after a successful report, or keep a retry buffer and reinsert unsent artifacts when the heartbeat fails so transient API outages cannot permanently lose the one-time scan results.

More info - Reply on this comment to give feedback or ignore the issue.


// Create and send event :
Heartbeat.HeartbeatEvent event = Heartbeat.get(stats, hostnames, routes, users, packages);
Heartbeat.HeartbeatEvent event = Heartbeat.get(stats, hostnames, routes, users, packages, javaArtifacts);
Optional<APIResponse> res = api.report(event);
res.ifPresent(ServiceConfigStore::updateFromAPIResponse);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import dev.aikido.agent_api.background.cloud.GetManagerInfo;
import dev.aikido.agent_api.storage.Hostnames;
import dev.aikido.agent_api.storage.JavaArtifact;
import dev.aikido.agent_api.storage.RuntimePackage;
import dev.aikido.agent_api.storage.ServiceConfigStore;
import dev.aikido.agent_api.storage.statistics.Statistics;
Expand All @@ -24,16 +25,20 @@ public record HeartbeatEvent(
RouteEntry[] routes,
List<User> users,
List<RuntimePackage> packages,
List<JavaArtifact> javaArtifacts,
boolean middlewareInstalled
) implements APIEvent {}

public static HeartbeatEvent get(
Statistics.StatsRecord stats, Hostnames.HostnameEntry[] hostnames, RouteEntry[] routes,
List<User> users, List<RuntimePackage> packages
List<User> users, List<RuntimePackage> packages, List<JavaArtifact> javaArtifacts
) {
long time = getUnixTimeMS(); // Get current time
GetManagerInfo.ManagerInfo agent = getManagerInfo();
boolean middlewareInstalled = ServiceConfigStore.getConfig().isMiddlewareInstalled();
return new HeartbeatEvent("heartbeat", agent, time, stats, hostnames, routes, users, packages, middlewareInstalled);
return new HeartbeatEvent(
"heartbeat", agent, time, stats, hostnames, routes, users,
packages, javaArtifacts, middlewareInstalled
);
}
}
34 changes: 34 additions & 0 deletions agent_api/src/main/java/dev/aikido/agent_api/helpers/Hashing.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.aikido.agent_api.helpers;

import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public final class Hashing {
private static final char[] HEX = "0123456789abcdef".toCharArray();

private Hashing() {}

public static String sha1(InputStream input) throws IOException {
final MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-1 is not available", impossible);
}
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
byte[] hash = digest.digest();
char[] encoded = new char[hash.length * 2];
for (int i = 0; i < hash.length; i++) {
int value = hash[i] & 0xff;
encoded[i * 2] = HEX[value >>> 4];
encoded[i * 2 + 1] = HEX[value & 0x0f];
}
return new String(encoded);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.aikido.agent_api.helpers.packages;

import dev.aikido.agent_api.helpers.Hashing;
import dev.aikido.agent_api.storage.RuntimePackage;

import java.io.BufferedInputStream;
Expand Down Expand Up @@ -29,28 +30,59 @@ private JarPackageScanner() {}
public static List<RuntimePackage> findMavenPackages(
String classResourceUrl,
long requiredAt
) {
return scan(classResourceUrl, requiredAt).packages();
}

public static JarScanResult scan(
String classResourceUrl,
long requiredAt
) {
try {
JarLocation location = JarLocation.parse(classResourceUrl);
if (location == null) {
return List.of();
return JarScanResult.empty();
}
if (location.nestedEntry() == null) {
List<RuntimePackage> packages;
try (InputStream input = new BufferedInputStream(Files.newInputStream(location.outerJar()))) {
return findMavenPackages(input, requiredAt);
packages = findMavenPackages(input, requiredAt);
}
return result(location.outerJar(), packages);
}
try (JarFile outerJar = new JarFile(location.outerJar().toFile())) {
JarEntry nestedJar = outerJar.getJarEntry(location.nestedEntry());
if (nestedJar == null) {
return List.of();
return JarScanResult.empty();
}
List<RuntimePackage> packages;
try (InputStream input = new BufferedInputStream(outerJar.getInputStream(nestedJar))) {
packages = findMavenPackages(input, requiredAt);
}
if (!packages.isEmpty()) {
return new JarScanResult(packages, null);
}
try (InputStream input = new BufferedInputStream(outerJar.getInputStream(nestedJar))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested JAR is read completely once for package scanning and again for SHA-1 calculation. Compute the digest during the initial pass to avoid doubling I/O for unresolved archives.

Details

✨ AI Reasoning
​The initial scan consumes the entire nested archive while looking for Maven metadata. When no packages are found, the code opens the same archive again and reads it from start to finish for hashing. This doubles disk or archive-stream work for every unresolved nested JAR and can be avoided by computing the digest during the first pass.

🔧 How do I fix it?
Move constant work outside loops. Use StringBuilder instead of string concatenation in loops. Cache compiled regex patterns. Use hash-based lookups instead of nested loops. Batch database operations instead of N+1 queries.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info

return findMavenPackages(input, requiredAt);
return new JarScanResult(packages, Hashing.sha1(input));
}
}
} catch (IOException | RuntimeException ignored) {
return List.of();
return JarScanResult.empty();
}
}

private static JarScanResult result(Path jar, List<RuntimePackage> packages) throws IOException {
if (!packages.isEmpty()) {
return new JarScanResult(packages, null);
}
try (InputStream input = new BufferedInputStream(Files.newInputStream(jar))) {
return new JarScanResult(packages, Hashing.sha1(input));
}
}

public record JarScanResult(List<RuntimePackage> packages, String sha1) {
private static JarScanResult empty() {
return new JarScanResult(List.of(), null);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.aikido.agent_api.helpers.packages;

import dev.aikido.agent_api.storage.JavaArtifact;
import dev.aikido.agent_api.storage.JavaArtifactsStore;
import dev.aikido.agent_api.storage.RuntimePackagesStore;

import java.security.ProtectionDomain;
Expand Down Expand Up @@ -45,7 +47,11 @@ private static void processLocations() {
while (!Thread.currentThread().isInterrupted()) {
try {
ObservedLocation location = PENDING_LOCATIONS.take();
RuntimePackagesStore.addAll(JarPackageScanner.findMavenPackages(location.url(), location.requiredAt()));
JarPackageScanner.JarScanResult result = JarPackageScanner.scan(location.url(), location.requiredAt());
RuntimePackagesStore.addAll(result.packages());
if (result.sha1() != null) {
JavaArtifactsStore.add(new JavaArtifact(result.sha1(), location.requiredAt()));
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
} catch (Throwable ignored) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package dev.aikido.agent_api.storage;

public record JavaArtifact(String sha1, long requiredAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dev.aikido.agent_api.storage;

import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public final class JavaArtifactsStore {
private static final ConcurrentMap<String, JavaArtifact> ARTIFACTS = new ConcurrentHashMap<>();

private JavaArtifactsStore() {}

public static void add(JavaArtifact artifact) {
ARTIFACTS.putIfAbsent(artifact.sha1(), artifact);
}

public static List<JavaArtifact> getArtifactsAsList() {
return ARTIFACTS.values().stream()
.sorted(Comparator.comparing(JavaArtifact::sha1))
.toList();
}

public static void clear() {
ARTIFACTS.clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import dev.aikido.agent_api.background.cloud.GetManagerInfo;
import dev.aikido.agent_api.background.cloud.api.events.Heartbeat;
import dev.aikido.agent_api.storage.Hostnames;
import dev.aikido.agent_api.storage.JavaArtifact;
import dev.aikido.agent_api.storage.statistics.Statistics;
import dev.aikido.agent_api.storage.routes.RouteEntry;
import dev.aikido.agent_api.context.User;
Expand Down Expand Up @@ -50,12 +51,15 @@ public void testGetHeartbeatEvent() {
hostnames.add("aikido.dev", 8080);
RouteEntry[] routes = new RouteEntry[0]; // Replace with actual RouteEntry array if needed
List<User> users = Collections.emptyList(); // Replace with actual User list if needed
List<JavaArtifact> javaArtifacts = List.of(new JavaArtifact("0123456789012345678901234567890123456789", 123L));

mockedGetManagerInfo.when(GetManagerInfo::getManagerInfo).thenReturn(managerInfo);
ServiceConfigStore.setMiddlewareInstalled(false);

// Act
Heartbeat.HeartbeatEvent event = Heartbeat.get(stats, hostnames.asArray(), routes, users, Collections.emptyList());
Heartbeat.HeartbeatEvent event = Heartbeat.get(
stats, hostnames.asArray(), routes, users, Collections.emptyList(), javaArtifacts
);

// Assert
assertEquals("heartbeat", event.type());
Expand All @@ -64,11 +68,14 @@ public void testGetHeartbeatEvent() {
assertArrayEquals(hostnames.asArray(), event.hostnames());
assertEquals(routes, event.routes());
assertEquals(users, event.users());
assertEquals(javaArtifacts, event.javaArtifacts());
assertFalse(event.middlewareInstalled());

// Test middleware installed as well :
ServiceConfigStore.setMiddlewareInstalled(true);
Heartbeat.HeartbeatEvent event2 = Heartbeat.get(stats, hostnames.asArray(), routes, users, Collections.emptyList());
Heartbeat.HeartbeatEvent event2 = Heartbeat.get(
stats, hostnames.asArray(), routes, users, Collections.emptyList(), javaArtifacts
);
assertTrue(event2.middlewareInstalled());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,53 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.List;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

class JarPackageScannerTest {
@Test
void hashesJarWithoutMavenCoordinates() throws Exception {
Path jar = Files.createTempFile("aikido-package", ".jar");
try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar))) {
output.putNextEntry(new JarEntry("org/example/Demo.class"));
output.write(new byte[] {1, 2, 3});
}

JarPackageScanner.JarScanResult result = JarPackageScanner.scan(jar.toUri().toString(), 123L);

assertEquals(List.of(), result.packages());
byte[] hash = MessageDigest.getInstance("SHA-1").digest(Files.readAllBytes(jar));
assertEquals(toHex(hash), result.sha1());
Files.deleteIfExists(jar);
}

@Test
void readsAllMavenCoordinatesFromFlattenedUberJarWithoutHashingIt() throws IOException {
Path jar = Files.createTempFile("aikido-uber-package", ".jar");
try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(jar))) {
output.putNextEntry(new JarEntry("META-INF/maven/org.example/first/pom.properties"));
output.write("groupId=org.example\nartifactId=first\nversion=1.0.0\n".getBytes(StandardCharsets.UTF_8));
output.putNextEntry(new JarEntry("META-INF/maven/org.example/second/pom.properties"));
output.write("groupId=org.example\nartifactId=second\nversion=2.0.0\n".getBytes(StandardCharsets.UTF_8));
}

JarPackageScanner.JarScanResult result = JarPackageScanner.scan(jar.toUri().toString(), 123L);

assertEquals(List.of(
new RuntimePackage("org.example:first", "1.0.0", 123L),
new RuntimePackage("org.example:second", "2.0.0", 123L)
), result.packages());
assertNull(result.sha1());
Files.deleteIfExists(jar);
}

@Test
void readsMavenCoordinates() throws IOException {
Path jar = Files.createTempFile("aikido-package", ".jar");
Expand Down Expand Up @@ -99,4 +137,12 @@ void ignoresUnresolvedMavenProperties() throws IOException {
assertEquals(List.of(), packages);
Files.deleteIfExists(jar);
}

private static String toHex(byte[] bytes) {
StringBuilder result = new StringBuilder(bytes.length * 2);
for (byte value : bytes) {
result.append(String.format("%02x", value));
}
return result.toString();
}
}
Loading