feat(binary): add static analysis engine support

This commit is contained in:
Rodrigo Verdiani 2026-06-07 21:00:04 -03:00
parent 011dbe9321
commit 199345d497
60 changed files with 1475 additions and 13 deletions

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ target/
# Uploaded binaries
uploads/
engines/ida5/ida5_files/**
### Intellij ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider

View File

@ -0,0 +1,2 @@
FROM alpine:latest
ENTRYPOINT ["/bin/sh", "-c", "echo 'Ghidra engine not yet implemented' && exit 1"]

View File

@ -0,0 +1,3 @@
#!/bin/sh
echo "[ERROR] Ghidra engine is not yet implemented."
exit 1

View File

@ -0,0 +1,43 @@
FROM debian:bullseye-slim
ENV DEBIAN_FRONTEND=noninteractive
# Install 32-bit Wine architecture and minimal virtual framebuffer (Xvfb)
RUN dpkg --add-architecture i386 && \
apt-get update && \
apt-get install -y --no-install-recommends \
wine \
wine32 \
xvfb \
xauth && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Configure Wine environment variables
ENV WINEPREFIX=/wine/prefix
ENV WINEARCH=win32
ENV WINEDLLOVERRIDES="mscoree,mshtml="
ENV PATH="/usr/lib/wine:${PATH}"
RUN mkdir -p /wine/prefix
# Initialize the default Wine prefix in background
RUN xvfb-run -a wine wineboot -u
# Inject the validated DataRescue IDA 5 license key into the Wine registry
COPY ida_licenca.reg /tmp/ida_licenca.reg
RUN xvfb-run -a wine regedit /tmp/ida_licenca.reg
# Set up application directories
RUN mkdir -p /app/ida /input /output
COPY ./ida5_files /app/ida
WORKDIR /app/ida
# Embed the analysis script into the image
COPY extract.idc /app/ida/extract.idc
# Configure the entrypoint script
COPY entrypoint.sh /app/ida/entrypoint.sh
RUN chmod +x /app/ida/entrypoint.sh
ENTRYPOINT ["/app/ida/entrypoint.sh"]

View File

@ -0,0 +1,43 @@
#!/bin/bash
export WINEPREFIX="/wine/prefix"
export WINEARCH=win32
export WINEDEBUG=-all
# Accept target filename as first argument
TARGET_FILENAME="${1:-target.exe}"
# Ensure required input files exist in the mounted volumes
if [ ! -f "/input/${TARGET_FILENAME}" ]; then
echo "[ERROR] Missing /input/${TARGET_FILENAME}"
exit 1
fi
if [ ! -f "/app/ida/extract.idc" ]; then
echo "[ERROR] Missing /app/ida/extract.idc"
exit 1
fi
echo "[INFO] Preparing internal analysis environment..."
cp "/input/${TARGET_FILENAME}" "/app/ida/target.exe"
# Force IDA to suppress all initial compiler dialogs and welcome screens
echo -e "ASK_COMPILER = NO\nASK_EXIT = NO\nDISPLAY_WELCOME = NO" >> /app/ida/ida.cfg
echo "[INFO] Launching IDA Pro 5 in headless batch mode..."
xvfb-run -a wine idag.exe -A -c -S"extract.idc" "target.exe"
echo "[INFO] Verifying generated artifacts..."
if [ -f "/app/ida/output.json" ]; then
cp /app/ida/output.json /output/output.json
echo "[SUCCESS] Static analysis completed. output.json is ready!"
RET_CODE=0
else
echo "[ERROR] IDC script failed to generate output.json"
RET_CODE=1
fi
# Clean up temporary files inside the container before exiting
rm -f /app/ida/target.exe /app/ida/target.idb /app/ida/output.json
exit $RET_CODE

93
engines/ida5/extract.idc Normal file
View File

@ -0,0 +1,93 @@
#include <idc.idc>
static main() {
auto ea, func_end, curr_inst, func_name, disasm, file;
auto last_inst, xref, ref, label_name;
Message("Aguardando fim da analise...\n");
Wait();
file = fopen("output.json", "w");
if (file == 0) {
Message("Erro ao criar arquivo de output!\n");
Exit(1);
}
fprintf(file, "[\n");
ea = NextFunction(0);
while (ea != BADADDR) {
func_name = GetFunctionName(ea);
fprintf(file, " {\n");
fprintf(file, " \"function\": \"%s\",\n", func_name);
fprintf(file, " \"address\": \"0x%08X\",\n", ea);
// --- XREFS TO (Simplificado e direto para o IDA 5) ---
fprintf(file, " \"xrefs\": \"");
xref = RfirstB(ea);
while (xref != BADADDR) {
if (GetFunctionName(xref) != "") {
fprintf(file, "%s ", GetFunctionName(xref));
}
xref = RnextB(ea, xref);
}
fprintf(file, "\",\n");
// --- ASSEMBLY BRUTO (com offset) ---
fprintf(file, " \"assembly\": \"");
func_end = FindFuncEnd(ea);
curr_inst = ea;
last_inst = 0;
while (curr_inst < func_end) {
if (curr_inst == last_inst) {
break;
}
last_inst = curr_inst;
disasm = GetDisasm(curr_inst);
if (disasm != "") {
fprintf(file, "0x%08X %s\\n", curr_inst, disasm);
}
curr_inst = curr_inst + ItemSize(curr_inst);
}
fprintf(file, "\",\n");
// --- LABELS (code references from instructions) ---
fprintf(file, " \"labels\": \"");
curr_inst = ea;
last_inst = 0;
while (curr_inst < func_end) {
if (curr_inst == last_inst) {
break;
}
last_inst = curr_inst;
ref = Rfirst(curr_inst);
while (ref != BADADDR) {
label_name = Name(ref);
if (label_name != "") {
fprintf(file, "%s:0x%08X ", label_name, ref);
}
ref = Rnext(curr_inst, ref);
}
curr_inst = curr_inst + ItemSize(curr_inst);
}
fprintf(file, "\"\n }");
ea = NextFunction(ea);
if (ea != BADADDR) {
fprintf(file, ",\n");
}
}
fprintf(file, "\n]\n");
fclose(file);
Message("Extracao concluida com sucesso!\n");
Exit(0);
}

View File

@ -0,0 +1,12 @@
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Hex-Rays\IDA]
"LicenseIda5"="I AGREE"
[HKEY_CURRENT_USER\Software\DataRescue\IDA Pro]
"License"="I AGREE"
[HKEY_CURRENT_USER\Software\DataRescue]
[HKEY_CURRENT_USER\Software\DataRescue\IDA]
"License Mach EDV Dienstleistungen, Jan Mach, 1 user, adv, 11_2007"=dword:00000001

View File

@ -0,0 +1,49 @@
package ai.decompile.analysis.controller;
import ai.decompile.analysis.model.dto.AnalysisResponse;
import ai.decompile.analysis.model.dto.FunctionSummaryResponse;
import ai.decompile.analysis.service.AnalysisService;
import io.swagger.v3.oas.annotations.Operation;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
public class AnalysisController {
private final AnalysisService analysisService;
@Operation(
summary = "List static analyses for a binary",
description = "Retrieve all static analyses performed on a binary.",
tags = {"Analysis"},
operationId = "listAnalyses")
@GetMapping("/binaries/{binaryId}/analysis")
public List<AnalysisResponse> listAnalyses(@PathVariable UUID binaryId) {
return analysisService.listAnalyses(binaryId);
}
@Operation(
summary = "Get static analysis details",
description = "Retrieve details of a specific static analysis.",
tags = {"Analysis"},
operationId = "getAnalysis")
@GetMapping("/analysis/{analysisId}")
public AnalysisResponse getAnalysis(@PathVariable UUID analysisId) {
return analysisService.getAnalysis(analysisId);
}
@Operation(
summary = "List functions in an analysis",
description = "Retrieve paginated list of functions extracted during a static analysis.",
tags = {"Analysis"},
operationId = "listFunctions")
@GetMapping("/analysis/{analysisId}/functions")
public Page<FunctionSummaryResponse> listFunctions(
@PathVariable UUID analysisId, Pageable pageable) {
return analysisService.listFunctions(analysisId, pageable);
}
}

View File

@ -0,0 +1,49 @@
package ai.decompile.analysis.controller;
import ai.decompile.analysis.model.dto.FunctionDetailResponse;
import ai.decompile.analysis.model.dto.XrefResponse;
import ai.decompile.analysis.service.AnalysisService;
import io.swagger.v3.oas.annotations.Operation;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class FunctionController {
private final AnalysisService analysisService;
@Operation(
summary = "Get function details",
description =
"Retrieve full details of a function including assembly, decompiled code, and xref counts.",
tags = {"Function"},
operationId = "getFunction")
@GetMapping("/functions/{functionId}")
public FunctionDetailResponse getFunction(@PathVariable UUID functionId) {
return analysisService.getFunction(functionId);
}
@Operation(
summary = "List callers of a function",
description = "Retrieve functions that call this function (incoming references).",
tags = {"Function"},
operationId = "getCallers")
@GetMapping("/functions/{functionId}/callers")
public List<XrefResponse> getCallers(@PathVariable UUID functionId) {
return analysisService.getCallers(functionId);
}
@Operation(
summary = "List callees of a function",
description = "Retrieve functions that this function calls (outgoing references).",
tags = {"Function"},
operationId = "getCallees")
@GetMapping("/functions/{functionId}/callees")
public List<XrefResponse> getCallees(@PathVariable UUID functionId) {
return analysisService.getCallees(functionId);
}
}

View File

@ -0,0 +1 @@
package ai.decompile.analysis.controller;

View File

@ -0,0 +1,6 @@
package ai.decompile.analysis.event;
import java.util.UUID;
public record StaticAnalysisRequestedEvent(
UUID binaryId, UUID projectId, UUID workspaceId, String engineName) {}

View File

@ -0,0 +1,2 @@
@org.springframework.modulith.NamedInterface("events")
package ai.decompile.analysis.event;

View File

@ -0,0 +1,27 @@
package ai.decompile.analysis.model.dto;
import ai.decompile.analysis.model.entity.StaticAnalysis;
import java.time.Instant;
import java.util.UUID;
public record AnalysisResponse(
UUID id,
UUID binaryId,
UUID jobId,
String engine,
String status,
int functionCount,
Instant createdAt,
Instant updatedAt) {
public static AnalysisResponse from(StaticAnalysis a) {
return new AnalysisResponse(
a.getId(),
a.getBinary().getId(),
a.getJobId(),
a.getEngine(),
a.getStatus(),
a.getFunctionCount(),
a.getCreatedAt(),
a.getUpdatedAt());
}
}

View File

@ -0,0 +1,32 @@
package ai.decompile.analysis.model.dto;
import ai.decompile.analysis.model.entity.StaticFunction;
import java.util.List;
import java.util.UUID;
public record FunctionDetailResponse(
UUID id,
UUID analysisId,
String name,
String address,
String assembly,
String decompiledCode,
String signature,
int callerCount,
int calleeCount,
List<LabelResponse> labels) {
public static FunctionDetailResponse from(
StaticFunction f, int callerCount, int calleeCount, List<LabelResponse> labels) {
return new FunctionDetailResponse(
f.getId(),
f.getAnalysis().getId(),
f.getName(),
f.getAddress(),
f.getAssembly(),
f.getDecompiledCode(),
f.getSignature(),
callerCount,
calleeCount,
labels);
}
}

View File

@ -0,0 +1,11 @@
package ai.decompile.analysis.model.dto;
import ai.decompile.analysis.model.entity.StaticFunction;
import java.util.UUID;
public record FunctionSummaryResponse(UUID id, UUID analysisId, String name, String address) {
public static FunctionSummaryResponse from(StaticFunction f) {
return new FunctionSummaryResponse(
f.getId(), f.getAnalysis().getId(), f.getName(), f.getAddress());
}
}

View File

@ -0,0 +1,9 @@
package ai.decompile.analysis.model.dto;
import ai.decompile.analysis.model.entity.StaticLabel;
public record LabelResponse(String name, String address) {
public static LabelResponse from(StaticLabel l) {
return new LabelResponse(l.getName(), l.getAddress());
}
}

View File

@ -0,0 +1,24 @@
package ai.decompile.analysis.model.dto;
import ai.decompile.analysis.model.entity.StaticXref;
import java.util.UUID;
public record XrefResponse(
UUID id,
UUID callerId,
String callerName,
UUID calleeId,
String calleeName,
String type,
String address) {
public static XrefResponse from(StaticXref xref) {
return new XrefResponse(
xref.getId(),
xref.getCaller().getId(),
xref.getCaller().getName(),
xref.getCallee().getId(),
xref.getCallee().getName(),
xref.getType(),
xref.getAddress());
}
}

View File

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

View File

@ -0,0 +1,57 @@
package ai.decompile.analysis.model.entity;
import ai.decompile.workspace.model.entity.Binary;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
@Entity
@Table(name = "static_analysis")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class StaticAnalysis {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "binary_id", nullable = false)
private Binary binary;
@Column(name = "job_id")
private UUID jobId;
@Column(nullable = false, length = 20)
private String engine;
@Column(nullable = false, length = 50)
private String status;
@Column(name = "function_count", nullable = false)
@Builder.Default
private int functionCount = 0;
@OneToMany(
mappedBy = "analysis",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
@Builder.Default
private List<StaticFunction> functions = new ArrayList<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
}

View File

@ -0,0 +1,61 @@
package ai.decompile.analysis.model.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
@Entity
@Table(name = "static_function")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class StaticFunction {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "analysis_id", nullable = false)
private StaticAnalysis analysis;
@Column(nullable = false, length = 500)
private String name;
@Column(nullable = false, length = 20)
private String address;
@Column(columnDefinition = "TEXT")
private String assembly;
@Column(name = "decompiled_code", columnDefinition = "TEXT")
private String decompiledCode;
@Column(length = 1000)
private String signature;
@OneToMany(
mappedBy = "caller",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
@Builder.Default
private List<StaticXref> calleeXrefs = new ArrayList<>();
@OneToMany(
mappedBy = "callee",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
@Builder.Default
private List<StaticXref> callerXrefs = new ArrayList<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
}

View File

@ -0,0 +1,28 @@
package ai.decompile.analysis.model.entity;
import jakarta.persistence.*;
import java.util.UUID;
import lombok.*;
@Entity
@Table(name = "static_label")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class StaticLabel {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "function_id", nullable = false)
private StaticFunction function;
@Column(nullable = false, length = 500)
private String name;
@Column(nullable = false, length = 20)
private String address;
}

View File

@ -0,0 +1,33 @@
package ai.decompile.analysis.model.entity;
import jakarta.persistence.*;
import java.util.UUID;
import lombok.*;
@Entity
@Table(name = "static_xref")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class StaticXref {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "caller_id", nullable = false)
private StaticFunction caller;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "callee_id", nullable = false)
private StaticFunction callee;
@Column(nullable = false, length = 20)
@Builder.Default
private String type = "CALL";
@Column(length = 20)
private String address;
}

View File

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

View File

@ -0,0 +1,13 @@
package ai.decompile.analysis.model.repository;
import ai.decompile.analysis.model.entity.StaticAnalysis;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StaticAnalysisRepository extends JpaRepository<StaticAnalysis, UUID> {
List<StaticAnalysis> findByBinaryIdOrderByCreatedAtDesc(UUID binaryId);
Optional<StaticAnalysis> findByBinaryIdAndEngine(UUID binaryId, String engine);
}

View File

@ -0,0 +1,11 @@
package ai.decompile.analysis.model.repository;
import ai.decompile.analysis.model.entity.StaticFunction;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StaticFunctionRepository extends JpaRepository<StaticFunction, UUID> {
Page<StaticFunction> findByAnalysisId(UUID analysisId, Pageable pageable);
}

View File

@ -0,0 +1,10 @@
package ai.decompile.analysis.model.repository;
import ai.decompile.analysis.model.entity.StaticLabel;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StaticLabelRepository extends JpaRepository<StaticLabel, UUID> {
List<StaticLabel> findByFunctionId(UUID functionId);
}

View File

@ -0,0 +1,16 @@
package ai.decompile.analysis.model.repository;
import ai.decompile.analysis.model.entity.StaticXref;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StaticXrefRepository extends JpaRepository<StaticXref, UUID> {
List<StaticXref> findByCallerId(UUID callerId);
List<StaticXref> findByCalleeId(UUID calleeId);
int countByCallerId(UUID callerId);
int countByCalleeId(UUID calleeId);
}

View File

@ -0,0 +1 @@
package ai.decompile.analysis.model.repository;

View File

@ -0,0 +1,4 @@
@org.springframework.modulith.ApplicationModule(
displayName = "Analysis",
allowedDependencies = {"workspace::entities", "engine::model", "common"})
package ai.decompile.analysis;

View File

@ -0,0 +1,191 @@
package ai.decompile.analysis.service;
import ai.decompile.analysis.model.dto.AnalysisResponse;
import ai.decompile.analysis.model.dto.FunctionDetailResponse;
import ai.decompile.analysis.model.dto.FunctionSummaryResponse;
import ai.decompile.analysis.model.dto.LabelResponse;
import ai.decompile.analysis.model.dto.XrefResponse;
import ai.decompile.analysis.model.entity.StaticAnalysis;
import ai.decompile.analysis.model.entity.StaticFunction;
import ai.decompile.analysis.model.entity.StaticLabel;
import ai.decompile.analysis.model.entity.StaticXref;
import ai.decompile.analysis.model.repository.StaticAnalysisRepository;
import ai.decompile.analysis.model.repository.StaticFunctionRepository;
import ai.decompile.analysis.model.repository.StaticLabelRepository;
import ai.decompile.analysis.model.repository.StaticXrefRepository;
import ai.decompile.common.exception.NotFoundException;
import ai.decompile.engine.model.AnalysisResult;
import ai.decompile.workspace.model.entity.Binary;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Log4j2
@Service
@RequiredArgsConstructor
public class AnalysisService {
private final StaticAnalysisRepository analysisRepository;
private final StaticFunctionRepository functionRepository;
private final StaticXrefRepository xrefRepository;
private final StaticLabelRepository labelRepository;
@Transactional
public AnalysisResponse saveResult(
Binary binary, UUID jobId, String engine, AnalysisResult result) {
replaceExistingAnalysis(binary, engine);
var analysis = createAnalysis(binary, jobId, engine, result);
var nameToId = saveFunctions(analysis, result);
saveXrefs(result, nameToId);
return AnalysisResponse.from(analysis);
}
public List<AnalysisResponse> listAnalyses(UUID binaryId) {
return analysisRepository.findByBinaryIdOrderByCreatedAtDesc(binaryId).stream()
.map(AnalysisResponse::from)
.toList();
}
public AnalysisResponse getAnalysis(UUID analysisId) {
return AnalysisResponse.from(findAnalysis(analysisId));
}
public Page<FunctionSummaryResponse> listFunctions(UUID analysisId, Pageable pageable) {
assertAnalysisExists(analysisId);
return functionRepository
.findByAnalysisId(analysisId, pageable)
.map(FunctionSummaryResponse::from);
}
public FunctionDetailResponse getFunction(UUID functionId) {
var func = findFunction(functionId);
int calleeCount = xrefRepository.countByCallerId(functionId);
int callerCount = xrefRepository.countByCalleeId(functionId);
var labels =
labelRepository.findByFunctionId(functionId).stream().map(LabelResponse::from).toList();
return FunctionDetailResponse.from(func, callerCount, calleeCount, labels);
}
public List<XrefResponse> getCallers(UUID functionId) {
return listXrefs(functionId, xrefRepository::findByCalleeId);
}
public List<XrefResponse> getCallees(UUID functionId) {
return listXrefs(functionId, xrefRepository::findByCallerId);
}
private void replaceExistingAnalysis(Binary binary, String engine) {
analysisRepository
.findByBinaryIdAndEngine(binary.getId(), engine)
.ifPresent(
existing -> {
log.info(
"Deleting existing {} analysis for binary {} (id={})",
engine,
binary.getId(),
existing.getId());
analysisRepository.delete(existing);
analysisRepository.flush();
});
}
private StaticAnalysis createAnalysis(
Binary binary, UUID jobId, String engine, AnalysisResult result) {
return analysisRepository.save(
StaticAnalysis.builder()
.binary(binary)
.jobId(jobId)
.engine(engine)
.status("COMPLETED")
.functionCount(result.functions().size())
.build());
}
private Map<String, UUID> saveFunctions(StaticAnalysis analysis, AnalysisResult result) {
var nameToId = new HashMap<String, UUID>();
for (var funcResult : result.functions()) {
var func =
functionRepository.save(
StaticFunction.builder()
.analysis(analysis)
.name(funcResult.name())
.address(funcResult.address())
.assembly(funcResult.assembly())
.decompiledCode(funcResult.decompiledCode())
.signature(funcResult.signature())
.build());
nameToId.put(funcResult.name(), func.getId());
for (var label : funcResult.labels()) {
labelRepository.save(
StaticLabel.builder()
.function(func)
.name(label.name())
.address(label.address())
.build());
}
}
return nameToId;
}
private void saveXrefs(AnalysisResult result, Map<String, UUID> nameToId) {
for (var funcResult : result.functions()) {
var callerId = nameToId.get(funcResult.name());
if (callerId == null) {
continue;
}
for (var calleeName : funcResult.xrefNames()) {
var calleeId = nameToId.get(calleeName);
if (calleeId == null) {
log.debug(
"Skipping xref from '{}' to unknown function '{}' (likely external/lib)",
funcResult.name(),
calleeName);
continue;
}
xrefRepository.save(
StaticXref.builder()
.caller(functionRepository.getReferenceById(callerId))
.callee(functionRepository.getReferenceById(calleeId))
.type("CALL")
.build());
}
}
}
private List<XrefResponse> listXrefs(UUID functionId, Function<UUID, List<StaticXref>> finder) {
findFunction(functionId);
return finder.apply(functionId).stream().map(XrefResponse::from).toList();
}
private void assertAnalysisExists(UUID analysisId) {
if (!analysisRepository.existsById(analysisId)) {
throw new NotFoundException("Static analysis with id '" + analysisId + "' not found");
}
}
private StaticAnalysis findAnalysis(UUID analysisId) {
return analysisRepository
.findById(analysisId)
.orElseThrow(
() -> new NotFoundException("Static analysis with id '" + analysisId + "' not found"));
}
private StaticFunction findFunction(UUID functionId) {
return functionRepository
.findById(functionId)
.orElseThrow(
() -> new NotFoundException("Function with id '" + functionId + "' not found"));
}
}

View File

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

View File

@ -0,0 +1,29 @@
package ai.decompile.engine.config;
import java.util.HashMap;
import java.util.Map;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@Configuration
@ConfigurationProperties(prefix = "app")
public class EngineProperties {
private Map<String, EngineConfig> engines = new HashMap<>();
public EngineConfig getEngineConfig(String engineName) {
var config = engines.get(engineName);
if (config == null) {
throw new IllegalArgumentException("No configuration found for engine '" + engineName + "'");
}
return config;
}
@Data
public static class EngineConfig {
private String image;
private long timeoutSeconds = 300;
}
}

View File

@ -0,0 +1 @@
package ai.decompile.engine.config;

View File

@ -0,0 +1,5 @@
package ai.decompile.engine.model;
import java.util.List;
public record AnalysisResult(List<FunctionResult> functions) {}

View File

@ -0,0 +1,6 @@
package ai.decompile.engine.model;
public enum EngineType {
IDA5,
GHIDRA
}

View File

@ -0,0 +1,12 @@
package ai.decompile.engine.model;
import java.util.List;
public record FunctionResult(
String name,
String address,
String assembly,
String decompiledCode,
String signature,
List<String> xrefNames,
List<LabelEntry> labels) {}

View File

@ -0,0 +1,3 @@
package ai.decompile.engine.model;
public record LabelEntry(String name, String address) {}

View File

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

View File

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

View File

@ -0,0 +1,10 @@
package ai.decompile.engine.service;
import ai.decompile.engine.model.AnalysisResult;
import ai.decompile.engine.model.EngineType;
public interface EngineService {
AnalysisResult analyze(String filePath);
EngineType supportedEngine();
}

View File

@ -0,0 +1,18 @@
package ai.decompile.engine.service;
import ai.decompile.engine.model.AnalysisResult;
import ai.decompile.engine.model.EngineType;
import org.springframework.stereotype.Component;
@Component
public class GhidraEngineService implements EngineService {
@Override
public AnalysisResult analyze(String filePath) {
throw new UnsupportedOperationException("Ghidra engine is not yet implemented.");
}
@Override
public EngineType supportedEngine() {
return EngineType.GHIDRA;
}
}

View File

@ -0,0 +1,222 @@
package ai.decompile.engine.service;
import ai.decompile.docker.service.ContainerService;
import ai.decompile.engine.config.EngineProperties;
import ai.decompile.engine.model.AnalysisResult;
import ai.decompile.engine.model.EngineType;
import ai.decompile.engine.model.FunctionResult;
import ai.decompile.engine.model.LabelEntry;
import com.github.dockerjava.api.model.Bind;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
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 Ida5EngineService implements EngineService {
private static final String DOCKERFILE_PATH = "engines/ida5/Dockerfile.ida5";
private static final String KEY_ADDRESS = "\",\n \"address\": \"";
private static final String KEY_XREFS = "\",\n \"xrefs\": \"";
private static final String KEY_ASSEMBLY = "\",\n \"assembly\": \"";
private static final String KEY_LABELS = "\",\n \"labels\": \"";
private static final String KEY_ENTRY_END = "\"\n }";
private final ContainerService containers;
private final EngineProperties engineProperties;
@Override
public AnalysisResult analyze(String filePath) {
var config = engineProperties.getEngineConfig("ida5");
var absolutePath = Path.of(filePath).toAbsolutePath().normalize();
var filename = absolutePath.getFileName().toString();
containers.ensureImage(config.getImage(), new File(DOCKERFILE_PATH));
Path outputDir = createTempDir();
var containerId = createContainer(config.getImage(), filename, absolutePath, outputDir);
try {
var exitCode = containers.waitForExit(containerId, config.getTimeoutSeconds());
var logs = containers.captureLogs(containerId, 2);
validateExitCode(exitCode, logs);
var outputFile = validateOutputFile(outputDir, logs);
return parseOutput(readStringTolerant(outputFile));
} catch (Exception e) {
log.error("IDA5 analysis failed for file {}: {}", filePath, e.getMessage());
throw new RuntimeException("IDA5 analysis failed: " + e.getMessage(), e);
} finally {
containers.stopAndRemove(containerId);
cleanupTempDir(outputDir);
}
}
@Override
public EngineType supportedEngine() {
return EngineType.IDA5;
}
private static Path createTempDir() {
try {
return Files.createTempDirectory("ida5-output-");
} catch (IOException e) {
throw new RuntimeException("Failed to create temporary output directory", e);
}
}
private String createContainer(String image, String filename, Path absolutePath, Path outputDir) {
var inputBind = Bind.parse(absolutePath.getParent() + ":/input:ro");
var outputBind = Bind.parse(outputDir.toAbsolutePath() + ":/output:rw");
var containerId =
containers.createContainer(image, new String[] {filename}, inputBind, outputBind);
containers.startContainer(containerId);
return containerId;
}
private static void validateExitCode(int exitCode, String logs) {
if (exitCode != 0) {
log.error("IDA5 container exited with code {}: {}", exitCode, logs);
throw new RuntimeException(
"IDA5 analysis failed with exit code " + exitCode + ". Output: " + logs);
}
}
private static Path validateOutputFile(Path outputDir, String logs) {
var outputFile = outputDir.resolve("output.json");
if (!Files.exists(outputFile)) {
throw new RuntimeException(
"IDA5 analysis did not produce output.json. Container logs: " + logs);
}
return outputFile;
}
private static void cleanupTempDir(Path dir) {
try (var walk = Files.walk(dir)) {
walk.sorted(Comparator.reverseOrder()).forEach(Ida5EngineService::deleteSilently);
} catch (IOException ignored) {
}
}
private static void deleteSilently(Path path) {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
}
private static String readStringTolerant(Path path) throws IOException {
var decoder =
StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPLACE)
.onUnmappableCharacter(CodingErrorAction.REPLACE);
return decoder.decode(ByteBuffer.wrap(Files.readAllBytes(path))).toString();
}
private static AnalysisResult parseOutput(String raw) {
var functions = new ArrayList<FunctionResult>();
var text = raw.replace("\r\n", "\n");
var sections = text.split("\"function\"\\s*:\\s*\"");
for (int i = 1; i < sections.length; i++) {
var section = sections[i];
var name = extractField(section, KEY_ADDRESS);
if (name == null) {
continue;
}
var remainder = after(section, KEY_ADDRESS);
if (remainder == null) {
continue;
}
var address = extractField(remainder, KEY_XREFS);
if (address == null) {
continue;
}
remainder = after(remainder, KEY_XREFS);
if (remainder == null) {
continue;
}
var xrefsRaw = extractField(remainder, KEY_ASSEMBLY);
if (xrefsRaw == null) {
continue;
}
remainder = after(remainder, KEY_ASSEMBLY);
if (remainder == null) {
continue;
}
var assembly = extractField(remainder, KEY_LABELS);
if (assembly == null) {
continue;
}
remainder = after(remainder, KEY_LABELS);
if (remainder == null) {
continue;
}
var labelsRaw = extractField(remainder, KEY_ENTRY_END);
if (labelsRaw == null) {
continue;
}
var xrefNames =
xrefsRaw.isBlank() ? List.<String>of() : Arrays.asList(xrefsRaw.trim().split("\\s+"));
var labels = parseLabels(labelsRaw.trim());
functions.add(new FunctionResult(name, address, assembly, null, null, xrefNames, labels));
}
if (functions.isEmpty()) {
log.warn(
"IDA5 produced no parsable function entries. Raw output (first 500 chars): {}",
raw.length() > 500 ? raw.substring(0, 500) : raw);
}
return new AnalysisResult(functions);
}
private static String extractField(String text, String endDelim) {
int end = text.indexOf(endDelim);
return end < 0 ? null : text.substring(0, end);
}
private static String after(String text, String delim) {
int idx = text.indexOf(delim);
return idx < 0 ? null : text.substring(idx + delim.length());
}
private static List<LabelEntry> parseLabels(String raw) {
if (raw == null || raw.isBlank()) {
return List.of();
}
return Arrays.stream(raw.trim().split("\\s+"))
.distinct()
.map(Ida5EngineService::parseLabelEntry)
.toList();
}
private static LabelEntry parseLabelEntry(String token) {
int colon = token.lastIndexOf(':');
if (colon < 0) {
return new LabelEntry(token, "");
}
return new LabelEntry(token.substring(0, colon), token.substring(colon + 1));
}
}

View File

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

View File

@ -1,5 +1,6 @@
package ai.decompile.job.model.enums;
public enum JobType {
ANALYZE_FILE
ANALYZE_FILE,
STATIC_ANALYSIS
}

View File

@ -6,6 +6,10 @@
"workspace::services",
"die::service",
"die::model",
"analysis::events",
"analysis::services",
"analysis::dto",
"engine::services",
"common"
})
package ai.decompile.job;

View File

@ -13,11 +13,13 @@ import ai.decompile.job.model.specification.JobSpecification;
import ai.decompile.workspace.event.BinaryUploadedEvent;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Log4j2
@Service
@RequiredArgsConstructor
public class JobService {
@ -66,8 +68,13 @@ public class JobService {
@ApplicationModuleListener
void onBinaryUploaded(BinaryUploadedEvent event) {
log.info(
"Received BinaryUploadedEvent for binary={} project={}",
event.binaryId(),
event.projectId());
var job = createJob(event);
jobMessagePublisher.publish(job.getId());
log.info("Published ANALYZE_FILE job {} to RabbitMQ", job.getId());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)

View File

@ -0,0 +1,50 @@
package ai.decompile.job.service;
import ai.decompile.analysis.event.StaticAnalysisRequestedEvent;
import ai.decompile.job.messaging.JobMessagePublisher;
import ai.decompile.job.model.entity.Job;
import ai.decompile.job.model.enums.JobStatus;
import ai.decompile.job.model.enums.JobType;
import ai.decompile.job.model.repository.JobRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.modulith.events.ApplicationModuleListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Log4j2
@Component
@RequiredArgsConstructor
public class StaticAnalysisEventSubscriber {
private final JobRepository jobRepository;
private final JobMessagePublisher jobMessagePublisher;
@ApplicationModuleListener
public void on(StaticAnalysisRequestedEvent event) {
log.info(
">>> StaticAnalysisEventSubscriber: Received StaticAnalysisRequestedEvent binary={} engine={}",
event.binaryId(),
event.engineName());
var job = createJob(event);
log.info(
">>> StaticAnalysisEventSubscriber: Saved job {} - publishing to RabbitMQ", job.getId());
jobMessagePublisher.publish(job.getId());
log.info(">>> StaticAnalysisEventSubscriber: Published job {} to RabbitMQ", job.getId());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
Job createJob(StaticAnalysisRequestedEvent event) {
return jobRepository.save(
Job.builder()
.type(JobType.STATIC_ANALYSIS)
.status(JobStatus.ENQUEUED)
.projectId(event.projectId())
.binaryId(event.binaryId())
.payload("{\"engine\":\"" + event.engineName() + "\"}")
.retryCount(0)
.build());
}
}

View File

@ -0,0 +1,98 @@
package ai.decompile.job.service.handler;
import ai.decompile.analysis.service.AnalysisService;
import ai.decompile.engine.service.EngineService;
import ai.decompile.job.model.entity.Job;
import ai.decompile.job.model.enums.JobStatus;
import ai.decompile.job.model.enums.JobType;
import ai.decompile.workspace.service.BinaryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.extern.log4j.Log4j2;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@Log4j2
@Component
@ConditionalOnProperty(name = "app.docker.enabled", havingValue = "true")
public class StaticAnalysisHandler implements JobHandler {
private final Map<String, EngineService> engineServices;
private final AnalysisService analysisService;
private final BinaryService binaryService;
private final ObjectMapper objectMapper;
public StaticAnalysisHandler(
List<EngineService> engineServiceList,
AnalysisService analysisService,
BinaryService binaryService,
ObjectMapper objectMapper) {
this.engineServices =
engineServiceList.stream()
.collect(Collectors.toMap(e -> e.supportedEngine().name(), Function.identity()));
this.analysisService = analysisService;
this.binaryService = binaryService;
this.objectMapper = objectMapper;
}
@Override
public void process(Job job) {
var engineName = readEngineFromPayload(job.getPayload());
log.info(
"Processing STATIC_ANALYSIS job {} for binary {} in project {} with engine {}",
job.getId(),
job.getBinaryId(),
job.getProjectId(),
engineName);
var engineService = engineServices.get(engineName);
if (engineService == null) {
job.setStatus(JobStatus.FAILED);
job.setErrorMessage(
"Unknown engine '" + engineName + "'. Available: " + engineServices.keySet());
return;
}
var filePath = binaryService.getBinaryFilePath(job.getBinaryId());
var result = engineService.analyze(filePath);
var binary = binaryService.getBinary(job.getBinaryId());
var analysisResponse = analysisService.saveResult(binary, job.getId(), engineName, result);
binaryService.markStaticAnalysisDone(job.getBinaryId());
job.setResult(toJson(Map.of("analysisId", analysisResponse.id().toString())));
job.setStatus(JobStatus.COMPLETED);
log.info(
"Job {} completed. engine={}, functionCount={}",
job.getId(),
engineName,
analysisResponse.functionCount());
}
@Override
public JobType supportedType() {
return JobType.STATIC_ANALYSIS;
}
private String readEngineFromPayload(String payload) {
try {
var node = objectMapper.readTree(payload);
return node.get("engine").asText();
} catch (Exception e) {
throw new RuntimeException("Failed to read engine from job payload", e);
}
}
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

@ -5,6 +5,7 @@ import ai.decompile.workspace.service.BinaryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.InputStreamResource;
@ -38,7 +39,8 @@ public class BinaryController {
@Operation(
summary = "Upload a binary",
description = "Upload a binary file to a project.",
description =
"Upload a binary file to a project. Optionally specify an engine for static analysis.",
tags = {"Binary"},
operationId = "uploadBinary")
@PostMapping(
@ -46,8 +48,28 @@ public class BinaryController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseStatus(HttpStatus.CREATED)
public BinaryResponse uploadBinary(
@PathVariable UUID projectId, @RequestPart MultipartFile file) {
return binaryService.uploadBinary(projectId, file);
@PathVariable UUID projectId,
@RequestPart MultipartFile file,
@Parameter(description = "Static analysis engine to use (IDA5, GHIDRA)")
@RequestParam(required = false)
String engine) {
return binaryService.uploadBinary(projectId, file, engine);
}
@Operation(
summary = "Trigger static analysis on a binary",
description =
"Trigger or re-trigger static analysis on an existing binary with the specified engine.",
tags = {"Binary"},
operationId = "analyzeBinary")
@PostMapping("/binaries/{binaryId}/analyze")
public ResponseEntity<Map<String, String>> analyzeBinary(
@PathVariable UUID binaryId,
@Parameter(description = "Static analysis engine to use (IDA5, GHIDRA)") @RequestParam
String engine) {
binaryService.requestStaticAnalysis(binaryId, engine);
return ResponseEntity.accepted()
.body(Map.of("message", "Static analysis requested with engine " + engine));
}
@Operation(

View File

@ -15,6 +15,7 @@ public record BinaryResponse(
String architecture,
String compiler,
@NotBlank String filePath,
boolean staticAnalysisDone,
@NotNull Instant updatedAt) {
public static BinaryResponse from(Binary binary) {
return new BinaryResponse(
@ -26,6 +27,7 @@ public record BinaryResponse(
binary.getArchitecture(),
binary.getCompiler(),
binary.getFilePath(),
binary.isStaticAnalysisDone(),
binary.getUpdatedAt());
}
}

View File

@ -1,7 +1,10 @@
package ai.decompile.workspace.model.entity;
import ai.decompile.analysis.model.entity.StaticAnalysis;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
@ -41,6 +44,18 @@ public class Binary {
@JoinColumn(name = "project_id", nullable = false)
private Project project;
@Column(name = "static_analysis_done", nullable = false)
@Builder.Default
private boolean staticAnalysisDone = false;
@OneToMany(
mappedBy = "binary",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
@Builder.Default
private List<StaticAnalysis> staticAnalyses = new ArrayList<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

View File

@ -1,2 +1,4 @@
@org.springframework.modulith.ApplicationModule(displayName = "Workspace")
@org.springframework.modulith.ApplicationModule(
displayName = "Workspace",
allowedDependencies = {"analysis::events", "common", "analysis :: entities"})
package ai.decompile.workspace;

View File

@ -1,5 +1,6 @@
package ai.decompile.workspace.service;
import ai.decompile.analysis.event.StaticAnalysisRequestedEvent;
import ai.decompile.common.exception.NotFoundException;
import ai.decompile.workspace.event.BinaryUploadedEvent;
import ai.decompile.workspace.model.dto.BinaryResponse;
@ -43,7 +44,7 @@ public class BinaryService {
}
@Transactional
public BinaryResponse uploadBinary(UUID projectId, MultipartFile file) {
public BinaryResponse uploadBinary(UUID projectId, MultipartFile file, String engine) {
var project = projectService.getProject(projectId);
String storedPath;
@ -68,9 +69,29 @@ public class BinaryService {
binary.getProject().getId(),
binary.getProject().getWorkspace().getId()));
if (engine != null && !engine.isBlank()) {
eventPublisher.publishEvent(
new StaticAnalysisRequestedEvent(
binary.getId(),
binary.getProject().getId(),
binary.getProject().getWorkspace().getId(),
engine.strip().toUpperCase()));
}
return BinaryResponse.from(binary);
}
@Transactional
public void requestStaticAnalysis(UUID binaryId, String engine) {
var binary = getBinary(binaryId);
eventPublisher.publishEvent(
new StaticAnalysisRequestedEvent(
binary.getId(),
binary.getProject().getId(),
binary.getProject().getWorkspace().getId(),
engine.strip().toUpperCase()));
}
@Transactional
public void updateAnalysisInfo(
UUID binaryId, String format, String architecture, String compiler) {
@ -104,11 +125,19 @@ public class BinaryService {
}
}
public String getBinaryFilePath(UUID binaryId) {
return getBinary(binaryId).getFilePath();
@Transactional
public void markStaticAnalysisDone(UUID binaryId) {
var binary = getBinary(binaryId);
binary.setStaticAnalysisDone(true);
binaryRepository.save(binary);
}
private Binary getBinary(UUID binaryId) {
public String getBinaryFilePath(UUID binaryId) {
var relativePath = getBinary(binaryId).getFilePath();
return fileStorage.resolvePath(relativePath);
}
public Binary getBinary(UUID binaryId) {
return binaryRepository
.findById(binaryId)
.orElseThrow(() -> new NotFoundException("Binary with id '" + binaryId + "' not found"));

View File

@ -9,4 +9,6 @@ public interface FileStorage {
void delete(String path) throws IOException;
InputStream read(String path) throws IOException;
String resolvePath(String path);
}

View File

@ -41,4 +41,9 @@ public class LocalFileStorage implements FileStorage {
var filePath = baseDir.resolve(path);
return Files.newInputStream(filePath);
}
@Override
public String resolvePath(String path) {
return baseDir.resolve(path).toAbsolutePath().normalize().toString();
}
}

View File

@ -36,6 +36,13 @@ app:
image: decompile-ai/diec:latest
timeout-seconds: 60
docker-host: unix:///var/run/docker.sock
engines:
ida5:
image: decompile-ai/ida5:latest
timeout-seconds: 300
ghidra:
image: decompile-ai/ghidra:latest
timeout-seconds: 600
logging:
level:

View File

@ -0,0 +1,43 @@
CREATE TABLE static_analysis
(
id UUID PRIMARY KEY,
binary_id UUID NOT NULL REFERENCES binaries (id) ON DELETE CASCADE,
job_id UUID REFERENCES jobs (id) ON DELETE SET NULL,
engine VARCHAR(20) NOT NULL,
status VARCHAR(50) NOT NULL,
function_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE INDEX idx_static_analysis_binary_id ON static_analysis (binary_id);
CREATE TABLE static_function
(
id UUID PRIMARY KEY,
analysis_id UUID NOT NULL REFERENCES static_analysis (id) ON DELETE CASCADE,
name VARCHAR(500) NOT NULL,
address VARCHAR(20) NOT NULL,
assembly TEXT,
decompiled_code TEXT,
signature VARCHAR(1000),
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE INDEX idx_static_function_analysis_id ON static_function (analysis_id);
CREATE INDEX idx_static_function_name ON static_function (name);
CREATE TABLE static_xref
(
id UUID PRIMARY KEY,
caller_id UUID NOT NULL REFERENCES static_function (id) ON DELETE CASCADE,
callee_id UUID NOT NULL REFERENCES static_function (id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL DEFAULT 'CALL',
address VARCHAR(20)
);
CREATE INDEX idx_static_xref_caller_id ON static_xref (caller_id);
CREATE INDEX idx_static_xref_callee_id ON static_xref (callee_id);
ALTER TABLE binaries
ADD COLUMN static_analysis_done BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -0,0 +1,9 @@
CREATE TABLE static_label
(
id UUID PRIMARY KEY,
function_id UUID NOT NULL REFERENCES static_function (id) ON DELETE CASCADE,
name VARCHAR(500) NOT NULL,
address VARCHAR(20) NOT NULL
);
CREATE INDEX idx_static_label_function_id ON static_label (function_id);

View File

@ -18,6 +18,7 @@ import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
@ -31,6 +32,8 @@ class BinaryControllerTest {
@MockitoBean private BinaryService binaryService;
@MockitoBean private ApplicationEventPublisher eventPublisher;
@Test
void getBinariesShouldReturn200WithList() throws Exception {
var projectId = UUID.randomUUID();
@ -45,6 +48,7 @@ class BinaryControllerTest {
null,
null,
"abc_test.exe",
false,
Instant.now());
when(binaryService.getBinaries(eq(projectId), isNull())).thenReturn(List.of(response));
@ -82,6 +86,7 @@ class BinaryControllerTest {
null,
null,
"abc_filtered.exe",
false,
Instant.now());
when(binaryService.getBinaries(eq(projectId), eq("filter"))).thenReturn(List.of(response));
@ -111,8 +116,17 @@ class BinaryControllerTest {
new MockMultipartFile("file", "test.exe", "application/octet-stream", new byte[] {1, 2, 3});
var response =
new BinaryResponse(
binaryId, projectId, "test.exe", 3L, null, null, null, "abc_test.exe", Instant.now());
when(binaryService.uploadBinary(eq(projectId), any())).thenReturn(response);
binaryId,
projectId,
"test.exe",
3L,
null,
null,
null,
"abc_test.exe",
false,
Instant.now());
when(binaryService.uploadBinary(eq(projectId), any(), isNull())).thenReturn(response);
mockMvc
.perform(multipart("/projects/{projectId}/binaries", projectId).file(file))

View File

@ -133,7 +133,7 @@ class BinaryServiceTest {
when(fileStorage.store(any(), eq("test.exe"))).thenReturn("stored_test.exe");
when(binaryRepository.save(any(Binary.class))).thenReturn(binary);
BinaryResponse result = binaryService.uploadBinary(projectId, file);
BinaryResponse result = binaryService.uploadBinary(projectId, file, null);
assertEquals(binaryId, result.id());
assertEquals("test.bin", result.filename());
@ -149,7 +149,7 @@ class BinaryServiceTest {
when(projectService.getProject(projectId)).thenReturn(project);
when(fileStorage.store(any(), eq("test.exe"))).thenThrow(new IOException("disk full"));
assertThrows(RuntimeException.class, () -> binaryService.uploadBinary(projectId, file));
assertThrows(RuntimeException.class, () -> binaryService.uploadBinary(projectId, file, null));
verify(binaryRepository, never()).save(any());
verify(eventPublisher, never()).publishEvent(any());
}