feat(die): integrate DiE analysis and result parsing

This commit is contained in:
Rodrigo Verdiani 2026-06-07 15:15:56 -03:00
parent 3b57f89943
commit ae8f5b87a6
22 changed files with 520 additions and 8 deletions

14
Dockerfile.die Normal file
View File

@ -0,0 +1,14 @@
FROM ubuntu:24.04
ARG DIE_VERSION=3.21
RUN apt-get update \
&& apt-get install -y --no-install-recommends wget ca-certificates \
&& wget -q https://github.com/horsicq/DIE-engine/releases/download/${DIE_VERSION}/die_${DIE_VERSION}_Ubuntu_24.04_amd64.deb \
&& apt-get install -y ./die_${DIE_VERSION}_Ubuntu_24.04_amd64.deb \
&& rm die_${DIE_VERSION}_Ubuntu_24.04_amd64.deb \
&& apt-get purge -y wget \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/diec"]

View File

@ -0,0 +1,13 @@
package ai.decompile.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}

View File

@ -0,0 +1,20 @@
package ai.decompile.die.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record DieResult(@JsonProperty("detects") List<Detect> detects) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Detect(
@JsonProperty("filetype") String filetype, @JsonProperty("values") List<Value> values) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Value(
@JsonProperty("info") String info,
@JsonProperty("string") String string,
@JsonProperty("type") String type,
@JsonProperty("name") String name) {}
}

View File

@ -0,0 +1,124 @@
package ai.decompile.die.model;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
@Component
public class DieResultParser {
private static final Pattern COMPILER_PATTERN =
Pattern.compile("Compiler:\\s*(.+)", Pattern.CASE_INSENSITIVE);
private static final Pattern OS_PATTERN =
Pattern.compile("Operation system:\\s*(.+)", Pattern.CASE_INSENSITIVE);
private static final Pattern ARCH_BITS_PATTERN = Pattern.compile("\\[([^,\\]]+),\\s*(\\d+)-bit");
private static final Map<String, String> FILETYPE_ARCH_HINTS =
Map.ofEntries(
Map.entry("NE", "x86, 16-bit"),
Map.entry("LE", "x86, 32-bit"),
Map.entry("LX", "x86, 32-bit"),
Map.entry("PE", "x86, 32-bit"),
Map.entry("PE32", "x86, 32-bit"),
Map.entry("PE64", "x86-64, 64-bit"),
Map.entry("PE32+", "x86-64, 64-bit"),
Map.entry("MS-DOS", "x86, 16-bit"));
public ParsedInfo parse(DieResult result) {
if (Objects.isNull(result) || Objects.isNull(result.detects()) || result.detects().isEmpty()) {
return new ParsedInfo(null, null, null);
}
String fileType = null;
String architecture = null;
String compiler = null;
for (var detect : result.detects()) {
if (Objects.isNull(fileType) && StringUtils.isNotBlank(detect.filetype())) {
fileType = detect.filetype();
}
if (Objects.nonNull(detect.values())) {
for (var value : detect.values()) {
var text = firstNonBlank(value.string(), value.info(), value.name());
if (Objects.isNull(compiler)) {
compiler = tryExtractCompiler(value, text);
}
if (Objects.isNull(architecture)) {
architecture = tryExtractArchitecture(text);
}
}
}
if (Objects.nonNull(fileType) && Objects.nonNull(architecture) && Objects.nonNull(compiler)) {
break;
}
}
if (Objects.isNull(architecture) && Objects.nonNull(fileType)) {
architecture = FILETYPE_ARCH_HINTS.get(fileType);
}
return new ParsedInfo(fileType, architecture, compiler);
}
private String tryExtractCompiler(DieResult.Value value, String text) {
if (Objects.isNull(text)) {
return null;
}
var matched = extractFirstMatch(text, COMPILER_PATTERN);
if (Objects.nonNull(matched)) {
return matched;
}
if ("compiler".equalsIgnoreCase(value.type()) && StringUtils.isNotBlank(value.name())) {
var version = StringUtils.isNotBlank(value.info()) ? "[" + value.info() + "]" : "";
return value.name() + version;
}
return null;
}
private String tryExtractArchitecture(String text) {
if (Objects.isNull(text)) {
return null;
}
var osInfo = extractFirstMatch(text, OS_PATTERN);
if (Objects.isNull(osInfo)) {
return null;
}
var matcher = ARCH_BITS_PATTERN.matcher(osInfo);
if (matcher.find()) {
return matcher.group(1).trim() + ", " + matcher.group(2) + "-bit";
}
return osInfo.trim();
}
private String extractFirstMatch(String text, Pattern pattern) {
var matcher = pattern.matcher(text);
if (matcher.find()) {
return matcher.group(1).trim();
}
return null;
}
private String firstNonBlank(String... values) {
for (var v : values) {
if (StringUtils.isNotBlank(v)) {
return v;
}
}
return null;
}
public record ParsedInfo(String fileType, String architecture, String compiler) {}
}

View File

@ -0,0 +1,2 @@
@org.springframework.modulith.NamedInterface("model")
package ai.decompile.die.model;

View File

@ -0,0 +1,4 @@
@org.springframework.modulith.ApplicationModule(
displayName = "DiE",
allowedDependencies = {"docker", "common"})
package ai.decompile.die;

View File

@ -0,0 +1,66 @@
package ai.decompile.die.service;
import ai.decompile.die.model.DieResult;
import ai.decompile.docker.service.ContainerService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.dockerjava.api.model.Bind;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.docker.enabled", havingValue = "true")
public class DiecService {
private final ContainerService containers;
private final ObjectMapper objectMapper;
@Value("${app.die.image}")
private String imageName;
@Value("${app.storage.upload-dir}")
private String uploadDir;
@Value("${app.die.timeout-seconds}")
private long timeoutSeconds;
public DieResult analyze(String filePath) {
var absolutePath = Path.of(uploadDir).resolve(filePath).toAbsolutePath().normalize();
var filename = absolutePath.getFileName().toString();
var hostPath = absolutePath.getParent().toString();
var dockerfile = new File("Dockerfile.die");
containers.ensureImage(imageName, dockerfile);
var bind = Bind.parse(hostPath + ":/input:ro");
var containerId =
containers.createContainer(imageName, new String[] {"--json", "/input/" + filename}, bind);
try {
containers.startContainer(containerId);
var exitCode = containers.waitForExit(containerId, timeoutSeconds);
var stdout = containers.captureLogs(containerId, 2);
if (exitCode != 0) {
log.error("DiE container exited with code {}: {}", exitCode, stdout);
throw new RuntimeException(
"DiE container exited with code " + exitCode + ". Output: " + stdout);
}
return objectMapper.readValue(stdout, DieResult.class);
} catch (IOException e) {
log.error("DiE analysis failed for file {}: {}", filePath, e.getMessage());
throw new RuntimeException("DiE analysis failed: " + e.getMessage(), e);
} finally {
containers.stopAndRemove(containerId);
}
}
}

View File

@ -0,0 +1,2 @@
@org.springframework.modulith.NamedInterface("service")
package ai.decompile.die.service;

View File

@ -0,0 +1,22 @@
package ai.decompile.docker.config;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.core.DockerClientBuilder;
import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
import java.net.URI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConditionalOnProperty(name = "app.docker.enabled", havingValue = "true", matchIfMissing = true)
public class DockerClientConfig {
@Bean
public DockerClient dockerClient(@Value("${app.die.docker-host}") String dockerHost) {
var httpClient =
new ApacheDockerHttpClient.Builder().dockerHost(URI.create(dockerHost)).build();
return DockerClientBuilder.getInstance().withDockerHttpClient(httpClient).build();
}
}

View File

@ -0,0 +1,3 @@
@org.springframework.modulith.ApplicationModule(
type = org.springframework.modulith.ApplicationModule.Type.OPEN)
package ai.decompile.docker;

View File

@ -0,0 +1,155 @@
package ai.decompile.docker.service;
import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.command.BuildImageResultCallback;
import com.github.dockerjava.api.command.WaitContainerResultCallback;
import com.github.dockerjava.api.exception.NotFoundException;
import com.github.dockerjava.api.model.Bind;
import com.github.dockerjava.api.model.BuildResponseItem;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.api.model.HostConfig;
import java.io.Closeable;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.docker.enabled", havingValue = "true")
public class ContainerService {
private final DockerClient dockerClient;
public void ensureImage(String imageTag, File dockerfile) {
try {
dockerClient.inspectImageCmd(imageTag).exec();
log.debug("Image '{}' found locally", imageTag);
} catch (NotFoundException e) {
log.info("Image '{}' not found, building from {}", imageTag, dockerfile.getName());
buildImage(imageTag, dockerfile);
}
}
public String createContainer(String image, String[] cmd, Bind... binds) {
return dockerClient
.createContainerCmd(image)
.withCmd(cmd)
.withHostConfig(HostConfig.newHostConfig().withBinds(binds))
.exec()
.getId();
}
public void startContainer(String containerId) {
dockerClient.startContainerCmd(containerId).exec();
}
public int waitForExit(String containerId, long timeoutSeconds) {
return dockerClient
.waitContainerCmd(containerId)
.exec(new WaitContainerResultCallback())
.awaitStatusCode(timeoutSeconds, TimeUnit.SECONDS);
}
public String captureLogs(String containerId, long timeoutSeconds) {
var collector = new FrameCollector();
try (collector) {
dockerClient
.logContainerCmd(containerId)
.withStdOut(true)
.withStdErr(true)
.withFollowStream(true)
.exec(collector);
collector.awaitCompletion(timeoutSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.warn("Failed to capture container logs for {}: {}", containerId, e.getMessage());
}
return collector.toString();
}
public void stopAndRemove(String containerId) {
try {
dockerClient.stopContainerCmd(containerId).withTimeout(10).exec();
} catch (Exception e) {
log.debug(
"Failed to stop container {} (may already be stopped): {}", containerId, e.getMessage());
}
try {
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
} catch (Exception e) {
log.debug(
"Failed to remove container {} (may already be removed): {}",
containerId,
e.getMessage());
}
}
private void buildImage(String imageTag, File dockerfile) {
var absoluteDockerfile = dockerfile.getAbsoluteFile();
if (!absoluteDockerfile.exists()) {
throw new RuntimeException("Dockerfile not found at " + absoluteDockerfile.getAbsolutePath());
}
Set<String> tags = new HashSet<>();
tags.add(imageTag);
var callback =
dockerClient
.buildImageCmd(absoluteDockerfile)
.withTags(tags)
.exec(
new BuildImageResultCallback() {
@Override
public void onNext(BuildResponseItem item) {
if (Objects.nonNull(item.getStream())) {
log.debug("Build: {}", item.getStream().trim());
}
if (item.isErrorIndicated() && Objects.nonNull(item.getErrorDetail())) {
log.error("Build error: {}", item.getErrorDetail().getMessage());
}
super.onNext(item);
}
});
try {
callback.awaitCompletion();
log.info("Image '{}' built successfully", imageTag);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while building image " + imageTag, e);
} catch (Exception e) {
throw new RuntimeException("Failed to build image " + imageTag + ": " + e.getMessage(), e);
}
}
private static class FrameCollector extends ResultCallback.Adapter<Frame> implements Closeable {
private final StringBuilder buffer = new StringBuilder();
@Override
public void onNext(Frame frame) {
buffer.append(new String(frame.getPayload(), StandardCharsets.UTF_8));
}
@Override
public void close() {
// no-op
}
@Override
public String toString() {
return buffer.toString();
}
}
}

View File

@ -1,4 +1,11 @@
@org.springframework.modulith.ApplicationModule( @org.springframework.modulith.ApplicationModule(
displayName = "Job", displayName = "Job",
allowedDependencies = {"workspace::events", "workspace::entities", "common"}) allowedDependencies = {
"workspace::events",
"workspace::entities",
"workspace::services",
"die::service",
"die::model",
"common"
})
package ai.decompile.job; package ai.decompile.job;

View File

@ -1,14 +1,28 @@
package ai.decompile.job.service.handler; package ai.decompile.job.service.handler;
import ai.decompile.die.model.DieResultParser;
import ai.decompile.die.service.DiecService;
import ai.decompile.job.model.entity.Job; import ai.decompile.job.model.entity.Job;
import ai.decompile.job.model.enums.JobStatus; import ai.decompile.job.model.enums.JobStatus;
import ai.decompile.job.model.enums.JobType; import ai.decompile.job.model.enums.JobType;
import ai.decompile.workspace.service.BinaryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Log4j2 @Log4j2
@Component @Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.docker.enabled", havingValue = "true")
public class AnalyzeFileHandler implements JobHandler { public class AnalyzeFileHandler implements JobHandler {
private final DiecService diecService;
private final DieResultParser dieResultParser;
private final BinaryService binaryService;
private final ObjectMapper objectMapper;
@Override @Override
public void process(Job job) { public void process(Job job) {
log.info( log.info(
@ -17,13 +31,35 @@ public class AnalyzeFileHandler implements JobHandler {
job.getBinaryId(), job.getBinaryId(),
job.getProjectId()); job.getProjectId());
var filePath = binaryService.getBinaryFilePath(job.getBinaryId());
var result = diecService.analyze(filePath);
var parsed = dieResultParser.parse(result);
binaryService.updateAnalysisInfo(
job.getBinaryId(), parsed.fileType(), parsed.architecture(), parsed.compiler());
job.setResult(toJson(Map.of("raw", result, "info", parsed)));
job.setStatus(JobStatus.COMPLETED); job.setStatus(JobStatus.COMPLETED);
log.info("Job {} completed", job.getId()); log.info(
"Job {} completed. fileType={}, arch={}, compiler={}",
job.getId(),
parsed.fileType(),
parsed.architecture(),
parsed.compiler());
} }
@Override @Override
public JobType supportedType() { public JobType supportedType() {
return JobType.ANALYZE_FILE; return JobType.ANALYZE_FILE;
} }
private String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
log.warn("Failed to serialize result to JSON", e);
return null;
}
}
} }

View File

@ -13,6 +13,7 @@ public record BinaryResponse(
long fileSize, long fileSize,
String format, String format,
String architecture, String architecture,
String compiler,
@NotBlank String filePath, @NotBlank String filePath,
@NotNull Instant updatedAt) { @NotNull Instant updatedAt) {
public static BinaryResponse from(Binary binary) { public static BinaryResponse from(Binary binary) {
@ -23,6 +24,7 @@ public record BinaryResponse(
binary.getFileSize(), binary.getFileSize(),
binary.getFormat(), binary.getFormat(),
binary.getArchitecture(), binary.getArchitecture(),
binary.getCompiler(),
binary.getFilePath(), binary.getFilePath(),
binary.getUpdatedAt()); binary.getUpdatedAt());
} }

View File

@ -25,12 +25,15 @@ public class Binary {
@Column(name = "file_size", nullable = false) @Column(name = "file_size", nullable = false)
private long fileSize; private long fileSize;
@Column(length = 20) @Column(length = 30)
private String format; private String format;
@Column(length = 20) @Column(length = 30)
private String architecture; private String architecture;
@Column(length = 100)
private String compiler;
@Column(name = "file_path", nullable = false, length = 500) @Column(name = "file_path", nullable = false, length = 500)
private String filePath; private String filePath;

View File

@ -67,6 +67,16 @@ public class BinaryService {
return BinaryResponse.from(binary); return BinaryResponse.from(binary);
} }
@Transactional
public void updateAnalysisInfo(
UUID binaryId, String format, String architecture, String compiler) {
var binary = getBinary(binaryId);
binary.setFormat(format);
binary.setArchitecture(architecture);
binary.setCompiler(compiler);
binaryRepository.save(binary);
}
@Transactional @Transactional
public void deleteBinary(UUID binaryId) { public void deleteBinary(UUID binaryId) {
var binary = getBinary(binaryId); var binary = getBinary(binaryId);
@ -90,6 +100,10 @@ public class BinaryService {
} }
} }
public String getBinaryFilePath(UUID binaryId) {
return getBinary(binaryId).getFilePath();
}
private Binary getBinary(UUID binaryId) { private Binary getBinary(UUID binaryId) {
return binaryRepository return binaryRepository
.findById(binaryId) .findById(binaryId)

View File

@ -0,0 +1,2 @@
@org.springframework.modulith.NamedInterface("services")
package ai.decompile.workspace.service;

View File

@ -1,4 +1,7 @@
spring: spring:
threads:
virtual:
enabled: true
application: application:
name: decompile-ai name: decompile-ai
datasource: datasource:
@ -27,6 +30,12 @@ app:
exchange: decompile.jobs exchange: decompile.jobs
queue: decompile.jobs.queue queue: decompile.jobs.queue
routing-key: decompile.jobs.created routing-key: decompile.jobs.created
docker:
enabled: true
die:
image: decompile-ai/diec:latest
timeout-seconds: 60
docker-host: unix:///var/run/docker.sock
logging: logging:
level: level:

View File

@ -4,8 +4,9 @@ CREATE TABLE binaries
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL, file_size BIGINT NOT NULL,
format VARCHAR(20), format VARCHAR(30),
architecture VARCHAR(20), architecture VARCHAR(30),
compiler VARCHAR(100),
file_path VARCHAR(500) NOT NULL, file_path VARCHAR(500) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL updated_at TIMESTAMP WITH TIME ZONE NOT NULL

View File

@ -2,8 +2,10 @@ package ai.decompile.decompile_ai;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
@SpringBootTest @SpringBootTest
@ActiveProfiles("test")
class DecompileAiApplicationTests { class DecompileAiApplicationTests {
@Test @Test

View File

@ -37,7 +37,15 @@ class BinaryControllerTest {
var binaryId = UUID.randomUUID(); var binaryId = UUID.randomUUID();
var response = var response =
new BinaryResponse( new BinaryResponse(
binaryId, projectId, "test.exe", 1024L, null, null, "abc_test.exe", Instant.now()); binaryId,
projectId,
"test.exe",
1024L,
null,
null,
null,
"abc_test.exe",
Instant.now());
when(binaryService.getBinaries(eq(projectId), isNull())).thenReturn(List.of(response)); when(binaryService.getBinaries(eq(projectId), isNull())).thenReturn(List.of(response));
mockMvc mockMvc
@ -72,6 +80,7 @@ class BinaryControllerTest {
512L, 512L,
null, null,
null, null,
null,
"abc_filtered.exe", "abc_filtered.exe",
Instant.now()); Instant.now());
when(binaryService.getBinaries(eq(projectId), eq("filter"))).thenReturn(List.of(response)); when(binaryService.getBinaries(eq(projectId), eq("filter"))).thenReturn(List.of(response));
@ -102,7 +111,7 @@ class BinaryControllerTest {
new MockMultipartFile("file", "test.exe", "application/octet-stream", new byte[] {1, 2, 3}); new MockMultipartFile("file", "test.exe", "application/octet-stream", new byte[] {1, 2, 3});
var response = var response =
new BinaryResponse( new BinaryResponse(
binaryId, projectId, "test.exe", 3L, null, null, "abc_test.exe", Instant.now()); binaryId, projectId, "test.exe", 3L, null, null, null, "abc_test.exe", Instant.now());
when(binaryService.uploadBinary(eq(projectId), any())).thenReturn(response); when(binaryService.uploadBinary(eq(projectId), any())).thenReturn(response);
mockMvc mockMvc

View File

@ -25,3 +25,5 @@ app:
exchange: decompile.jobs.test exchange: decompile.jobs.test
queue: decompile.jobs.test.queue queue: decompile.jobs.test.queue
routing-key: decompile.jobs.test.created routing-key: decompile.jobs.test.created
docker:
enabled: false