diff --git a/.gitignore b/.gitignore index 54c0a53..8b94348 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/engines/ghidra/Dockerfile b/engines/ghidra/Dockerfile new file mode 100644 index 0000000..a27f54f --- /dev/null +++ b/engines/ghidra/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:latest +ENTRYPOINT ["/bin/sh", "-c", "echo 'Ghidra engine not yet implemented' && exit 1"] diff --git a/engines/ghidra/entrypoint.sh b/engines/ghidra/entrypoint.sh new file mode 100644 index 0000000..c4c7c95 --- /dev/null +++ b/engines/ghidra/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo "[ERROR] Ghidra engine is not yet implemented." +exit 1 diff --git a/engines/ida5/Dockerfile.ida5 b/engines/ida5/Dockerfile.ida5 new file mode 100644 index 0000000..4b41752 --- /dev/null +++ b/engines/ida5/Dockerfile.ida5 @@ -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"] diff --git a/engines/ida5/entrypoint.sh b/engines/ida5/entrypoint.sh new file mode 100644 index 0000000..1f7afd2 --- /dev/null +++ b/engines/ida5/entrypoint.sh @@ -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 diff --git a/engines/ida5/extract.idc b/engines/ida5/extract.idc new file mode 100644 index 0000000..c09e824 --- /dev/null +++ b/engines/ida5/extract.idc @@ -0,0 +1,93 @@ +#include + +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); +} diff --git a/engines/ida5/ida_licenca.reg b/engines/ida5/ida_licenca.reg new file mode 100644 index 0000000..25ac1b4 --- /dev/null +++ b/engines/ida5/ida_licenca.reg @@ -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 diff --git a/src/main/java/ai/decompile/analysis/controller/AnalysisController.java b/src/main/java/ai/decompile/analysis/controller/AnalysisController.java new file mode 100644 index 0000000..3d2b85b --- /dev/null +++ b/src/main/java/ai/decompile/analysis/controller/AnalysisController.java @@ -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 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 listFunctions( + @PathVariable UUID analysisId, Pageable pageable) { + return analysisService.listFunctions(analysisId, pageable); + } +} diff --git a/src/main/java/ai/decompile/analysis/controller/FunctionController.java b/src/main/java/ai/decompile/analysis/controller/FunctionController.java new file mode 100644 index 0000000..5e54e7e --- /dev/null +++ b/src/main/java/ai/decompile/analysis/controller/FunctionController.java @@ -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 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 getCallees(@PathVariable UUID functionId) { + return analysisService.getCallees(functionId); + } +} diff --git a/src/main/java/ai/decompile/analysis/controller/package-info.java b/src/main/java/ai/decompile/analysis/controller/package-info.java new file mode 100644 index 0000000..a614c9d --- /dev/null +++ b/src/main/java/ai/decompile/analysis/controller/package-info.java @@ -0,0 +1 @@ +package ai.decompile.analysis.controller; diff --git a/src/main/java/ai/decompile/analysis/event/StaticAnalysisRequestedEvent.java b/src/main/java/ai/decompile/analysis/event/StaticAnalysisRequestedEvent.java new file mode 100644 index 0000000..857b1cf --- /dev/null +++ b/src/main/java/ai/decompile/analysis/event/StaticAnalysisRequestedEvent.java @@ -0,0 +1,6 @@ +package ai.decompile.analysis.event; + +import java.util.UUID; + +public record StaticAnalysisRequestedEvent( + UUID binaryId, UUID projectId, UUID workspaceId, String engineName) {} diff --git a/src/main/java/ai/decompile/analysis/event/package-info.java b/src/main/java/ai/decompile/analysis/event/package-info.java new file mode 100644 index 0000000..e839483 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/event/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.NamedInterface("events") +package ai.decompile.analysis.event; diff --git a/src/main/java/ai/decompile/analysis/model/dto/AnalysisResponse.java b/src/main/java/ai/decompile/analysis/model/dto/AnalysisResponse.java new file mode 100644 index 0000000..7e498c6 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/dto/AnalysisResponse.java @@ -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()); + } +} diff --git a/src/main/java/ai/decompile/analysis/model/dto/FunctionDetailResponse.java b/src/main/java/ai/decompile/analysis/model/dto/FunctionDetailResponse.java new file mode 100644 index 0000000..3192e11 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/dto/FunctionDetailResponse.java @@ -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 labels) { + public static FunctionDetailResponse from( + StaticFunction f, int callerCount, int calleeCount, List labels) { + return new FunctionDetailResponse( + f.getId(), + f.getAnalysis().getId(), + f.getName(), + f.getAddress(), + f.getAssembly(), + f.getDecompiledCode(), + f.getSignature(), + callerCount, + calleeCount, + labels); + } +} diff --git a/src/main/java/ai/decompile/analysis/model/dto/FunctionSummaryResponse.java b/src/main/java/ai/decompile/analysis/model/dto/FunctionSummaryResponse.java new file mode 100644 index 0000000..7864588 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/dto/FunctionSummaryResponse.java @@ -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()); + } +} diff --git a/src/main/java/ai/decompile/analysis/model/dto/LabelResponse.java b/src/main/java/ai/decompile/analysis/model/dto/LabelResponse.java new file mode 100644 index 0000000..9482295 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/dto/LabelResponse.java @@ -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()); + } +} diff --git a/src/main/java/ai/decompile/analysis/model/dto/XrefResponse.java b/src/main/java/ai/decompile/analysis/model/dto/XrefResponse.java new file mode 100644 index 0000000..aa4375e --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/dto/XrefResponse.java @@ -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()); + } +} diff --git a/src/main/java/ai/decompile/analysis/model/dto/package-info.java b/src/main/java/ai/decompile/analysis/model/dto/package-info.java new file mode 100644 index 0000000..19adf4c --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/dto/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.NamedInterface("dto") +package ai.decompile.analysis.model.dto; diff --git a/src/main/java/ai/decompile/analysis/model/entity/StaticAnalysis.java b/src/main/java/ai/decompile/analysis/model/entity/StaticAnalysis.java new file mode 100644 index 0000000..f3e08a7 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/entity/StaticAnalysis.java @@ -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 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; +} diff --git a/src/main/java/ai/decompile/analysis/model/entity/StaticFunction.java b/src/main/java/ai/decompile/analysis/model/entity/StaticFunction.java new file mode 100644 index 0000000..67e963b --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/entity/StaticFunction.java @@ -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 calleeXrefs = new ArrayList<>(); + + @OneToMany( + mappedBy = "callee", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + @Builder.Default + private List callerXrefs = new ArrayList<>(); + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; +} diff --git a/src/main/java/ai/decompile/analysis/model/entity/StaticLabel.java b/src/main/java/ai/decompile/analysis/model/entity/StaticLabel.java new file mode 100644 index 0000000..4fb8598 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/entity/StaticLabel.java @@ -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; +} diff --git a/src/main/java/ai/decompile/analysis/model/entity/StaticXref.java b/src/main/java/ai/decompile/analysis/model/entity/StaticXref.java new file mode 100644 index 0000000..845f966 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/entity/StaticXref.java @@ -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; +} diff --git a/src/main/java/ai/decompile/analysis/model/entity/package-info.java b/src/main/java/ai/decompile/analysis/model/entity/package-info.java new file mode 100644 index 0000000..01f792d --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/entity/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.NamedInterface("entities") +package ai.decompile.analysis.model.entity; diff --git a/src/main/java/ai/decompile/analysis/model/repository/StaticAnalysisRepository.java b/src/main/java/ai/decompile/analysis/model/repository/StaticAnalysisRepository.java new file mode 100644 index 0000000..9904a4e --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/repository/StaticAnalysisRepository.java @@ -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 { + List findByBinaryIdOrderByCreatedAtDesc(UUID binaryId); + + Optional findByBinaryIdAndEngine(UUID binaryId, String engine); +} diff --git a/src/main/java/ai/decompile/analysis/model/repository/StaticFunctionRepository.java b/src/main/java/ai/decompile/analysis/model/repository/StaticFunctionRepository.java new file mode 100644 index 0000000..1b49110 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/repository/StaticFunctionRepository.java @@ -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 { + Page findByAnalysisId(UUID analysisId, Pageable pageable); +} diff --git a/src/main/java/ai/decompile/analysis/model/repository/StaticLabelRepository.java b/src/main/java/ai/decompile/analysis/model/repository/StaticLabelRepository.java new file mode 100644 index 0000000..ccdb7e4 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/repository/StaticLabelRepository.java @@ -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 { + List findByFunctionId(UUID functionId); +} diff --git a/src/main/java/ai/decompile/analysis/model/repository/StaticXrefRepository.java b/src/main/java/ai/decompile/analysis/model/repository/StaticXrefRepository.java new file mode 100644 index 0000000..9d4999a --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/repository/StaticXrefRepository.java @@ -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 { + List findByCallerId(UUID callerId); + + List findByCalleeId(UUID calleeId); + + int countByCallerId(UUID callerId); + + int countByCalleeId(UUID calleeId); +} diff --git a/src/main/java/ai/decompile/analysis/model/repository/package-info.java b/src/main/java/ai/decompile/analysis/model/repository/package-info.java new file mode 100644 index 0000000..e2eafba --- /dev/null +++ b/src/main/java/ai/decompile/analysis/model/repository/package-info.java @@ -0,0 +1 @@ +package ai.decompile.analysis.model.repository; diff --git a/src/main/java/ai/decompile/analysis/package-info.java b/src/main/java/ai/decompile/analysis/package-info.java new file mode 100644 index 0000000..4db2c2c --- /dev/null +++ b/src/main/java/ai/decompile/analysis/package-info.java @@ -0,0 +1,4 @@ +@org.springframework.modulith.ApplicationModule( + displayName = "Analysis", + allowedDependencies = {"workspace::entities", "engine::model", "common"}) +package ai.decompile.analysis; diff --git a/src/main/java/ai/decompile/analysis/service/AnalysisService.java b/src/main/java/ai/decompile/analysis/service/AnalysisService.java new file mode 100644 index 0000000..4aef91b --- /dev/null +++ b/src/main/java/ai/decompile/analysis/service/AnalysisService.java @@ -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 listAnalyses(UUID binaryId) { + return analysisRepository.findByBinaryIdOrderByCreatedAtDesc(binaryId).stream() + .map(AnalysisResponse::from) + .toList(); + } + + public AnalysisResponse getAnalysis(UUID analysisId) { + return AnalysisResponse.from(findAnalysis(analysisId)); + } + + public Page 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 getCallers(UUID functionId) { + return listXrefs(functionId, xrefRepository::findByCalleeId); + } + + public List 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 saveFunctions(StaticAnalysis analysis, AnalysisResult result) { + var nameToId = new HashMap(); + 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 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 listXrefs(UUID functionId, Function> 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")); + } +} diff --git a/src/main/java/ai/decompile/analysis/service/package-info.java b/src/main/java/ai/decompile/analysis/service/package-info.java new file mode 100644 index 0000000..5fe7792 --- /dev/null +++ b/src/main/java/ai/decompile/analysis/service/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.NamedInterface("services") +package ai.decompile.analysis.service; diff --git a/src/main/java/ai/decompile/engine/config/EngineProperties.java b/src/main/java/ai/decompile/engine/config/EngineProperties.java new file mode 100644 index 0000000..0f86e76 --- /dev/null +++ b/src/main/java/ai/decompile/engine/config/EngineProperties.java @@ -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 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; + } +} diff --git a/src/main/java/ai/decompile/engine/config/package-info.java b/src/main/java/ai/decompile/engine/config/package-info.java new file mode 100644 index 0000000..3e56e3b --- /dev/null +++ b/src/main/java/ai/decompile/engine/config/package-info.java @@ -0,0 +1 @@ +package ai.decompile.engine.config; diff --git a/src/main/java/ai/decompile/engine/model/AnalysisResult.java b/src/main/java/ai/decompile/engine/model/AnalysisResult.java new file mode 100644 index 0000000..9dd943f --- /dev/null +++ b/src/main/java/ai/decompile/engine/model/AnalysisResult.java @@ -0,0 +1,5 @@ +package ai.decompile.engine.model; + +import java.util.List; + +public record AnalysisResult(List functions) {} diff --git a/src/main/java/ai/decompile/engine/model/EngineType.java b/src/main/java/ai/decompile/engine/model/EngineType.java new file mode 100644 index 0000000..956a3e3 --- /dev/null +++ b/src/main/java/ai/decompile/engine/model/EngineType.java @@ -0,0 +1,6 @@ +package ai.decompile.engine.model; + +public enum EngineType { + IDA5, + GHIDRA +} diff --git a/src/main/java/ai/decompile/engine/model/FunctionResult.java b/src/main/java/ai/decompile/engine/model/FunctionResult.java new file mode 100644 index 0000000..8c9d953 --- /dev/null +++ b/src/main/java/ai/decompile/engine/model/FunctionResult.java @@ -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 xrefNames, + List labels) {} diff --git a/src/main/java/ai/decompile/engine/model/LabelEntry.java b/src/main/java/ai/decompile/engine/model/LabelEntry.java new file mode 100644 index 0000000..dc37691 --- /dev/null +++ b/src/main/java/ai/decompile/engine/model/LabelEntry.java @@ -0,0 +1,3 @@ +package ai.decompile.engine.model; + +public record LabelEntry(String name, String address) {} diff --git a/src/main/java/ai/decompile/engine/model/package-info.java b/src/main/java/ai/decompile/engine/model/package-info.java new file mode 100644 index 0000000..54998ec --- /dev/null +++ b/src/main/java/ai/decompile/engine/model/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.NamedInterface("model") +package ai.decompile.engine.model; diff --git a/src/main/java/ai/decompile/engine/package-info.java b/src/main/java/ai/decompile/engine/package-info.java new file mode 100644 index 0000000..a9bd402 --- /dev/null +++ b/src/main/java/ai/decompile/engine/package-info.java @@ -0,0 +1,4 @@ +@org.springframework.modulith.ApplicationModule( + displayName = "Engine", + allowedDependencies = {"docker", "common"}) +package ai.decompile.engine; diff --git a/src/main/java/ai/decompile/engine/service/EngineService.java b/src/main/java/ai/decompile/engine/service/EngineService.java new file mode 100644 index 0000000..2971a9b --- /dev/null +++ b/src/main/java/ai/decompile/engine/service/EngineService.java @@ -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(); +} diff --git a/src/main/java/ai/decompile/engine/service/GhidraEngineService.java b/src/main/java/ai/decompile/engine/service/GhidraEngineService.java new file mode 100644 index 0000000..a1e0a46 --- /dev/null +++ b/src/main/java/ai/decompile/engine/service/GhidraEngineService.java @@ -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; + } +} diff --git a/src/main/java/ai/decompile/engine/service/Ida5EngineService.java b/src/main/java/ai/decompile/engine/service/Ida5EngineService.java new file mode 100644 index 0000000..597f004 --- /dev/null +++ b/src/main/java/ai/decompile/engine/service/Ida5EngineService.java @@ -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(); + 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.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 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)); + } +} diff --git a/src/main/java/ai/decompile/engine/service/package-info.java b/src/main/java/ai/decompile/engine/service/package-info.java new file mode 100644 index 0000000..c120cc4 --- /dev/null +++ b/src/main/java/ai/decompile/engine/service/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.modulith.NamedInterface("services") +package ai.decompile.engine.service; diff --git a/src/main/java/ai/decompile/job/model/enums/JobType.java b/src/main/java/ai/decompile/job/model/enums/JobType.java index bbe610c..50dce22 100644 --- a/src/main/java/ai/decompile/job/model/enums/JobType.java +++ b/src/main/java/ai/decompile/job/model/enums/JobType.java @@ -1,5 +1,6 @@ package ai.decompile.job.model.enums; public enum JobType { - ANALYZE_FILE + ANALYZE_FILE, + STATIC_ANALYSIS } diff --git a/src/main/java/ai/decompile/job/package-info.java b/src/main/java/ai/decompile/job/package-info.java index df0dadb..fdcebc1 100644 --- a/src/main/java/ai/decompile/job/package-info.java +++ b/src/main/java/ai/decompile/job/package-info.java @@ -6,6 +6,10 @@ "workspace::services", "die::service", "die::model", + "analysis::events", + "analysis::services", + "analysis::dto", + "engine::services", "common" }) package ai.decompile.job; diff --git a/src/main/java/ai/decompile/job/service/JobService.java b/src/main/java/ai/decompile/job/service/JobService.java index 44b2c84..26026a5 100644 --- a/src/main/java/ai/decompile/job/service/JobService.java +++ b/src/main/java/ai/decompile/job/service/JobService.java @@ -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) diff --git a/src/main/java/ai/decompile/job/service/StaticAnalysisEventSubscriber.java b/src/main/java/ai/decompile/job/service/StaticAnalysisEventSubscriber.java new file mode 100644 index 0000000..18132ce --- /dev/null +++ b/src/main/java/ai/decompile/job/service/StaticAnalysisEventSubscriber.java @@ -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()); + } +} diff --git a/src/main/java/ai/decompile/job/service/handler/StaticAnalysisHandler.java b/src/main/java/ai/decompile/job/service/handler/StaticAnalysisHandler.java new file mode 100644 index 0000000..dd34085 --- /dev/null +++ b/src/main/java/ai/decompile/job/service/handler/StaticAnalysisHandler.java @@ -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 engineServices; + private final AnalysisService analysisService; + private final BinaryService binaryService; + private final ObjectMapper objectMapper; + + public StaticAnalysisHandler( + List 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; + } + } +} diff --git a/src/main/java/ai/decompile/workspace/controller/BinaryController.java b/src/main/java/ai/decompile/workspace/controller/BinaryController.java index 88492d4..4791370 100644 --- a/src/main/java/ai/decompile/workspace/controller/BinaryController.java +++ b/src/main/java/ai/decompile/workspace/controller/BinaryController.java @@ -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> 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( diff --git a/src/main/java/ai/decompile/workspace/model/dto/BinaryResponse.java b/src/main/java/ai/decompile/workspace/model/dto/BinaryResponse.java index 9564b9a..79de31f 100644 --- a/src/main/java/ai/decompile/workspace/model/dto/BinaryResponse.java +++ b/src/main/java/ai/decompile/workspace/model/dto/BinaryResponse.java @@ -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()); } } diff --git a/src/main/java/ai/decompile/workspace/model/entity/Binary.java b/src/main/java/ai/decompile/workspace/model/entity/Binary.java index 93e3996..41ee165 100644 --- a/src/main/java/ai/decompile/workspace/model/entity/Binary.java +++ b/src/main/java/ai/decompile/workspace/model/entity/Binary.java @@ -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 staticAnalyses = new ArrayList<>(); + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private Instant createdAt; diff --git a/src/main/java/ai/decompile/workspace/package-info.java b/src/main/java/ai/decompile/workspace/package-info.java index d2d8a52..c25e795 100644 --- a/src/main/java/ai/decompile/workspace/package-info.java +++ b/src/main/java/ai/decompile/workspace/package-info.java @@ -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; diff --git a/src/main/java/ai/decompile/workspace/service/BinaryService.java b/src/main/java/ai/decompile/workspace/service/BinaryService.java index e2946d1..948cde3 100644 --- a/src/main/java/ai/decompile/workspace/service/BinaryService.java +++ b/src/main/java/ai/decompile/workspace/service/BinaryService.java @@ -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")); diff --git a/src/main/java/ai/decompile/workspace/service/storage/FileStorage.java b/src/main/java/ai/decompile/workspace/service/storage/FileStorage.java index 27e3562..ba78e5b 100644 --- a/src/main/java/ai/decompile/workspace/service/storage/FileStorage.java +++ b/src/main/java/ai/decompile/workspace/service/storage/FileStorage.java @@ -9,4 +9,6 @@ public interface FileStorage { void delete(String path) throws IOException; InputStream read(String path) throws IOException; + + String resolvePath(String path); } diff --git a/src/main/java/ai/decompile/workspace/service/storage/LocalFileStorage.java b/src/main/java/ai/decompile/workspace/service/storage/LocalFileStorage.java index 7cd320d..ddeeec9 100644 --- a/src/main/java/ai/decompile/workspace/service/storage/LocalFileStorage.java +++ b/src/main/java/ai/decompile/workspace/service/storage/LocalFileStorage.java @@ -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(); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9e07e23..d30a2be 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/db/migration/V006__create_static_analysis_tables.sql b/src/main/resources/db/migration/V006__create_static_analysis_tables.sql new file mode 100644 index 0000000..0e74b25 --- /dev/null +++ b/src/main/resources/db/migration/V006__create_static_analysis_tables.sql @@ -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; diff --git a/src/main/resources/db/migration/V007__create_static_label.sql b/src/main/resources/db/migration/V007__create_static_label.sql new file mode 100644 index 0000000..9456647 --- /dev/null +++ b/src/main/resources/db/migration/V007__create_static_label.sql @@ -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); diff --git a/src/test/java/ai/decompile/workspace/controller/BinaryControllerTest.java b/src/test/java/ai/decompile/workspace/controller/BinaryControllerTest.java index 4bea2d8..7c37e44 100644 --- a/src/test/java/ai/decompile/workspace/controller/BinaryControllerTest.java +++ b/src/test/java/ai/decompile/workspace/controller/BinaryControllerTest.java @@ -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)) diff --git a/src/test/java/ai/decompile/workspace/service/BinaryServiceTest.java b/src/test/java/ai/decompile/workspace/service/BinaryServiceTest.java index e47f039..d82a1bc 100644 --- a/src/test/java/ai/decompile/workspace/service/BinaryServiceTest.java +++ b/src/test/java/ai/decompile/workspace/service/BinaryServiceTest.java @@ -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()); }