From cd208b51fc1e61a7a4f074a097259239c8aacf08 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Tue, 9 Jun 2026 20:25:14 -0300 Subject: [PATCH 1/6] feat(binary): add engine selection for binary analysis --- src/api/generated/api.schemas.ts | 6 + src/api/generated/engine/engine.ts | 120 +++++++++++++++++++ src/components/EngineSelect.tsx | 57 +++++++++ src/components/ui/select.tsx | 2 +- src/features/binary/AnalyzeDialog.tsx | 96 +++++++++++++++ src/features/binary/hooks/useBinaryPage.ts | 31 ++--- src/features/project/FileUpload.tsx | 12 +- src/features/project/UploadBinaryDialog.tsx | 69 ++++++++++- src/features/project/hooks/useProjectPage.ts | 9 +- src/pages/Binary.tsx | 31 ++--- src/pages/Project.tsx | 5 + 11 files changed, 400 insertions(+), 38 deletions(-) create mode 100644 src/api/generated/engine/engine.ts create mode 100644 src/components/EngineSelect.tsx create mode 100644 src/features/binary/AnalyzeDialog.tsx diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index 205038c..55e7255 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -4,6 +4,12 @@ * OpenAPI definition * OpenAPI spec version: v0 */ +export interface EngineResponse { + name: string; + label: string; + available: boolean; +} + export interface WorkspaceRequest { /** @minLength 1 */ name: string; diff --git a/src/api/generated/engine/engine.ts b/src/api/generated/engine/engine.ts new file mode 100644 index 0000000..6b76587 --- /dev/null +++ b/src/api/generated/engine/engine.ts @@ -0,0 +1,120 @@ +/** + * Generated by orval v8.15.0 🍺 + * Do not edit manually. + * OpenAPI definition + * OpenAPI spec version: v0 + */ +import { useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { EngineResponse } from "../api.schemas"; + +import { customInstance } from "../../api"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * Retrieve all registered static analysis engines with their availability status. + * @summary List available analysis engines + */ +export const listEngines = ( + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance({ url: `/engines`, method: "GET", signal }, options); +}; + +export const getListEnginesQueryKey = () => { + return [`/engines`] as const; +}; + +export const getListEnginesQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListEnginesQueryKey(); + + const queryFn: QueryFunction>> = ({ signal }) => + listEngines(requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ListEnginesQueryResult = NonNullable>>; +export type ListEnginesQueryError = unknown; + +export function useListEngines>, TError = unknown>( + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useListEngines>, TError = unknown>( + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useListEngines>, TError = unknown>( + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List available analysis engines + */ + +export function useListEngines>, TError = unknown>( + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getListEnginesQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/src/components/EngineSelect.tsx b/src/components/EngineSelect.tsx new file mode 100644 index 0000000..253a8ab --- /dev/null +++ b/src/components/EngineSelect.tsx @@ -0,0 +1,57 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Loader2 } from "lucide-react"; +import type { EngineResponse } from "@/api/generated/api.schemas"; +import { useMemo } from "react"; + +interface EngineSelectProps { + engines: EngineResponse[]; + value?: string; + onChange: (engine: string) => void; + placeholder?: string; + isLoading?: boolean; + disabled?: boolean; +} + +export const EngineSelect = ({ + engines, + value, + onChange, + placeholder = "Select engine", + isLoading = false, + disabled = false, +}: EngineSelectProps) => { + const available = useMemo(() => engines.filter((e) => e.available), [engines]); + + return ( + + ); +}; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index e41f2b1..20513ff 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -109,7 +109,7 @@ const SelectItem = React.forwardRef< void; + isReanalyze: boolean; + engines: EngineResponse[]; + enginesLoading: boolean; + onConfirm: (engine: string) => void; +} + +export const AnalyzeDialog = ({ + open, + onOpenChange, + isReanalyze, + engines, + enginesLoading, + onConfirm, +}: AnalyzeDialogProps) => { + const [selectedEngine, setSelectedEngine] = useState(); + + const handleConfirm = () => { + if (!selectedEngine) return; + onConfirm(selectedEngine); + onOpenChange(false); + }; + + return ( + + + + {isReanalyze && ( +
+
+ +
+
+ Re-analyze binary + + This will replace all current analysis data. Function names, comments, and + decompiled code will be lost. + +
+
+ )} + {!isReanalyze && ( +
+ Analyze binary + + Select the analysis engine to use. + +
+ )} +
+ +
+
+ + +
+
+ + + + + +
+
+ ); +}; diff --git a/src/features/binary/hooks/useBinaryPage.ts b/src/features/binary/hooks/useBinaryPage.ts index 434fee8..23bdfb6 100644 --- a/src/features/binary/hooks/useBinaryPage.ts +++ b/src/features/binary/hooks/useBinaryPage.ts @@ -6,6 +6,7 @@ import { useGetProject } from "@/api/generated/project/project.ts"; import { useGetWorkspace } from "@/api/generated/workspace/workspace.ts"; import { useListAnalyses, useListSegments } from "@/api/generated/analysis/analysis.ts"; import { useGetFunction } from "@/api/generated/function/function.ts"; +import { useListEngines } from "@/api/generated/engine/engine.ts"; import { customInstance } from "@/api/api.ts"; import type { FunctionSummaryResponse, @@ -42,7 +43,7 @@ export function useBinaryPage() { const queryClient = useQueryClient(); const [selectedFunctionId, setSelectedFunctionId] = useState(); - const [confirmReanalyzeOpen, setConfirmReanalyzeOpen] = useState(false); + const [analyzeDialogOpen, setAnalyzeDialogOpen] = useState(false); const [selectedSegmentId, setSelectedSegmentId] = useState(); const [selectedFlags, setSelectedFlags] = useState>(new Set()); @@ -96,6 +97,9 @@ export function useBinaryPage() { { query: { enabled: !!selectedFunctionId } } ); + // Fetch available engines + const { data: engines, isLoading: enginesLoading } = useListEngines(); + // Analyze / re-analyze mutation const analyzeMutation = useAnalyzeBinary({ mutation: { @@ -107,17 +111,13 @@ export function useBinaryPage() { }, }); - const handleAnalyze = () => { - analyzeMutation.mutate({ binaryId, params: { engine: "IDA5" } }); + const handleOpenAnalyzeDialog = () => { + setAnalyzeDialogOpen(true); }; - const handleReanalyzeRequest = () => { - setConfirmReanalyzeOpen(true); - }; - - const handleReanalyzeConfirm = () => { - setConfirmReanalyzeOpen(false); - handleAnalyze(); + const handleAnalyzeConfirm = (engine: string) => { + setAnalyzeDialogOpen(false); + analyzeMutation.mutate({ binaryId, params: { engine } }); }; // Flatten all pages into a single array @@ -217,10 +217,11 @@ export function useBinaryPage() { selectedFunction, selectedFunctionLoading, analyzeMutation, - confirmReanalyzeOpen, - setConfirmReanalyzeOpen, - handleAnalyze, - handleReanalyzeRequest, - handleReanalyzeConfirm, + analyzeDialogOpen, + setAnalyzeDialogOpen, + handleOpenAnalyzeDialog, + handleAnalyzeConfirm, + engines: engines ?? [], + enginesLoading, }; } diff --git a/src/features/project/FileUpload.tsx b/src/features/project/FileUpload.tsx index db3ae28..a9e670d 100644 --- a/src/features/project/FileUpload.tsx +++ b/src/features/project/FileUpload.tsx @@ -13,6 +13,8 @@ interface FileUploadProps { accept?: string; maxSize?: number; className?: string; + autoUpload?: boolean; + onFileSelect?: (file: File) => void; } export const FileUpload = ({ @@ -23,6 +25,8 @@ export const FileUpload = ({ accept = ".exe,.com,.dll,.bin", maxSize = 50 * 1024 * 1024, className, + autoUpload = true, + onFileSelect, }: FileUploadProps) => { const [isDragging, setIsDragging] = useState(false); const [file, setFile] = useState(null); @@ -49,9 +53,13 @@ export const FileUpload = ({ setValidationError(null); setFile(file); - onUpload(file); + if (autoUpload) { + onUpload(file); + } else { + onFileSelect?.(file); + } }, - [onUpload, maxSize] + [onUpload, maxSize, autoUpload, onFileSelect] ); const handleDrop = useCallback( diff --git a/src/features/project/UploadBinaryDialog.tsx b/src/features/project/UploadBinaryDialog.tsx index b08ae4b..435ba07 100644 --- a/src/features/project/UploadBinaryDialog.tsx +++ b/src/features/project/UploadBinaryDialog.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Dialog, DialogContent, @@ -8,14 +9,19 @@ import { } from "@/components/ui/dialog.tsx"; import { Button } from "@/components/ui/button.tsx"; import { FileUpload } from "@/features/project/FileUpload.tsx"; +import { EngineSelect } from "@/components/EngineSelect"; +import { Upload } from "lucide-react"; +import type { EngineResponse } from "@/api/generated/api.schemas"; interface UploadBinaryDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onUpload: (file: File) => void; + onUpload: (file: File, engine?: string) => void; isUploading: boolean; uploadError: string | null; onReset: () => void; + engines: EngineResponse[]; + enginesLoading: boolean; } export const UploadBinaryDialog = ({ @@ -25,7 +31,31 @@ export const UploadBinaryDialog = ({ isUploading, uploadError, onReset, + engines, + enginesLoading, }: UploadBinaryDialogProps) => { + const [selectedFile, setSelectedFile] = useState(null); + const [selectedEngine, setSelectedEngine] = useState(); + + const handleFileSelect = (file: File) => { + setSelectedFile(file); + }; + + const handleEngineChange = (engine: string) => { + setSelectedEngine(engine || undefined); + }; + + const handleUpload = () => { + if (!selectedFile) return; + onUpload(selectedFile, selectedEngine); + }; + + const handleReset = () => { + setSelectedFile(null); + setSelectedEngine(undefined); + onReset(); + }; + return ( @@ -45,19 +75,52 @@ export const UploadBinaryDialog = ({ Select Binary File {}} + onFileSelect={handleFileSelect} isUploading={isUploading} error={uploadError} - onReset={onReset} + onReset={handleReset} accept=".exe,.com,.dll,.bin,.elf" maxSize={100 * 1024 * 1024} + autoUpload={false} /> + {selectedFile && ( +
+
+
+ 2 +
+ Analysis Engine + (optional) +
+ +
+ )} + + {selectedFile && ( + + )}
diff --git a/src/features/project/hooks/useProjectPage.ts b/src/features/project/hooks/useProjectPage.ts index e92b1b5..ea30270 100644 --- a/src/features/project/hooks/useProjectPage.ts +++ b/src/features/project/hooks/useProjectPage.ts @@ -4,6 +4,7 @@ import { useGetProject } from "@/api/generated/project/project.ts"; import { useGetWorkspace } from "@/api/generated/workspace/workspace.ts"; import { useDeleteBinary, useGetBinaries, useUploadBinary } from "@/api/generated/binary/binary.ts"; import { downloadBinary } from "@/api/generated/binary/binary.ts"; +import { useListEngines } from "@/api/generated/engine/engine.ts"; import { useQueryClient } from "@tanstack/react-query"; import type { BinaryResponse } from "@/api/generated/api.schemas.ts"; @@ -28,6 +29,8 @@ export function useProjectPage(projectId: string) { { query: { enabled: !!projectId } } ); + const { data: engines, isLoading: enginesLoading } = useListEngines(); + const { mutate: mutateDeleteBinary } = useDeleteBinary({ mutation: { onSuccess: () => { @@ -69,8 +72,8 @@ export function useProjectPage(projectId: string) { setBinaryToDelete(null); }; - const handleUploadBinary = (file: File) => { - uploadMutation.mutate({ projectId, data: { file } }); + const handleUploadBinary = (file: File, engine?: string) => { + uploadMutation.mutate({ projectId, data: { file }, params: engine ? { engine } : undefined }); }; const handleDownloadBinary = async (binary: BinaryResponse) => { @@ -104,5 +107,7 @@ export function useProjectPage(projectId: string) { uploadError, handleUploadBinary, handleDownloadBinary, + engines: engines ?? [], + enginesLoading, }; } diff --git a/src/pages/Binary.tsx b/src/pages/Binary.tsx index 3173328..744655e 100644 --- a/src/pages/Binary.tsx +++ b/src/pages/Binary.tsx @@ -1,7 +1,6 @@ import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/EmptyState"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; import { Breadcrumbs } from "@/components/Breadcrumbs.tsx"; import { BinaryHeader } from "@/features/binary/BinaryHeader.tsx"; import { FunctionTree } from "@/features/binary/FunctionTree"; @@ -14,6 +13,7 @@ import { import { useBinaryPage } from "@/features/binary/hooks/useBinaryPage"; import { CodeViewer } from "@/features/binary/CodeViewer"; import { AIChatPanel } from "@/features/binary/AIChatPanel"; +import { AnalyzeDialog } from "@/features/binary/AnalyzeDialog"; const Binary = () => { const { @@ -39,11 +39,12 @@ const Binary = () => { selectedFunction, selectedFunctionLoading, analyzeMutation, - confirmReanalyzeOpen, - setConfirmReanalyzeOpen, - handleAnalyze, - handleReanalyzeRequest, - handleReanalyzeConfirm, + analyzeDialogOpen, + setAnalyzeDialogOpen, + handleOpenAnalyzeDialog, + handleAnalyzeConfirm, + engines, + enginesLoading, } = useBinaryPage(); const navigateToEntryPoint = () => { @@ -91,7 +92,7 @@ const Binary = () => { binary={binary} hasAnalysis={binary.staticAnalysisDone} isAnalyzing={analyzeMutation.isPending} - onAnalyze={binary.staticAnalysisDone ? handleReanalyzeRequest : handleAnalyze} + onAnalyze={handleOpenAnalyzeDialog} entryPoint={analysis?.entryPoint} onNavigateToEntryPoint={navigateToEntryPoint} /> @@ -165,14 +166,14 @@ const Binary = () => { - ); diff --git a/src/pages/Project.tsx b/src/pages/Project.tsx index c030190..dd5693a 100644 --- a/src/pages/Project.tsx +++ b/src/pages/Project.tsx @@ -32,6 +32,8 @@ const Project = () => { uploadError, handleUploadBinary, handleDownloadBinary, + engines, + enginesLoading, } = useProjectPage(projectId!); if (!project || !workspace) { @@ -111,12 +113,15 @@ const Project = () => { )} Date: Tue, 9 Jun 2026 22:16:23 -0300 Subject: [PATCH 2/6] feat(analysis): add string listing and filtering functionality --- src/api/generated/analysis/analysis.ts | 111 +++++++++++++++++++++ src/api/generated/api.schemas.ts | 26 +++-- src/components/ConfirmDialog.tsx | 2 +- src/features/binary/AnalyzeDialog.tsx | 2 +- src/features/binary/StringTable.tsx | 110 ++++++++++++++++++++ src/features/binary/XrefPanel.tsx | 56 ++++++++++- src/features/binary/hooks/useBinaryPage.ts | 33 +++++- src/pages/Binary.tsx | 80 +++++++++++---- 8 files changed, 387 insertions(+), 33 deletions(-) create mode 100644 src/features/binary/StringTable.tsx diff --git a/src/api/generated/analysis/analysis.ts b/src/api/generated/analysis/analysis.ts index 3dff5de..af5b8e2 100644 --- a/src/api/generated/analysis/analysis.ts +++ b/src/api/generated/analysis/analysis.ts @@ -22,6 +22,7 @@ import type { ListFunctionsParams, PageFunctionSummaryResponse, SegmentResponse, + StringResponse, } from "../api.schemas"; import { customInstance } from "../../api"; @@ -248,6 +249,116 @@ export function useGetAnalysis>, return { ...query, queryKey: queryOptions.queryKey }; } +/** + * Retrieve all strings extracted during a static analysis. + * @summary List strings in an analysis + */ +export const listStrings = ( + analysisId: string, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { url: `/analysis/${encodeURIComponent(String(analysisId))}/strings`, method: "GET", signal }, + options + ); +}; + +export const getListStringsQueryKey = (analysisId: string) => { + return [`/analysis/${analysisId}/strings`] as const; +}; + +export const getListStringsQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + } +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListStringsQueryKey(analysisId); + + const queryFn: QueryFunction>> = ({ signal }) => + listStrings(analysisId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: analysisId !== null && analysisId !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type ListStringsQueryResult = NonNullable>>; +export type ListStringsQueryError = unknown; + +export function useListStrings>, TError = unknown>( + analysisId: string, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useListStrings>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useListStrings>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List strings in an analysis + */ + +export function useListStrings>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getListStringsQueryOptions(analysisId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * Retrieve memory segments (code, data, etc.) identified during a static analysis. * @summary List segments in an analysis diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index 55e7255..02be0cf 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -4,12 +4,6 @@ * OpenAPI definition * OpenAPI spec version: v0 */ -export interface EngineResponse { - name: string; - label: string; - available: boolean; -} - export interface WorkspaceRequest { /** @minLength 1 */ name: string; @@ -141,6 +135,12 @@ export interface XrefResponse { address?: string; } +export interface EngineResponse { + name?: string; + label?: string; + available?: boolean; +} + export interface MessageResponse { id?: string; sessionId?: string; @@ -162,6 +162,14 @@ export interface AnalysisResponse { updatedAt?: string; } +export interface StringResponse { + id?: string; + address?: string; + value?: string; + encoding?: string; + referencedBy?: string; +} + export interface SegmentResponse { id?: string; name?: string; @@ -194,11 +202,11 @@ export interface SortObject { export interface PageableObject { offset?: number; - paged?: boolean; pageNumber?: number; pageSize?: number; - sort?: SortObject; + paged?: boolean; unpaged?: boolean; + sort?: SortObject; } export interface PageFunctionSummaryResponse { @@ -208,10 +216,10 @@ export interface PageFunctionSummaryResponse { content?: FunctionSummaryResponse[]; number?: number; pageable?: PageableObject; + numberOfElements?: number; sort?: SortObject; first?: boolean; last?: boolean; - numberOfElements?: number; empty?: boolean; } diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 2a223dc..2fe090d 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -36,7 +36,7 @@ export const ConfirmDialog = ({
{variant === "destructive" && ( -
+
)} diff --git a/src/features/binary/AnalyzeDialog.tsx b/src/features/binary/AnalyzeDialog.tsx index 41b5a65..a0d3d31 100644 --- a/src/features/binary/AnalyzeDialog.tsx +++ b/src/features/binary/AnalyzeDialog.tsx @@ -43,7 +43,7 @@ export const AnalyzeDialog = ({ {isReanalyze && (
-
+
diff --git a/src/features/binary/StringTable.tsx b/src/features/binary/StringTable.tsx new file mode 100644 index 0000000..844a219 --- /dev/null +++ b/src/features/binary/StringTable.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; +import { Search } from "lucide-react"; +import type { StringResponse } from "@/api/generated/api.schemas"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface StringTableProps { + strings: StringResponse[]; + functionNameToId: Record; + onSelectFunction: (functionId: string) => void; + highlightedAddress?: string; +} + +export function StringTable({ + strings, + functionNameToId, + onSelectFunction, + highlightedAddress, +}: StringTableProps) { + const [search, setSearch] = useState(""); + + const filtered = strings.filter((s) => s.value?.toLowerCase().includes(search.toLowerCase())); + + return ( +
+
+
+ + setSearch(e.target.value)} + placeholder="Filter strings..." + className="bg-muted h-8 border-transparent pl-9" + /> +
+
+ + + + + + Address + Value + Encoding + Referenced by + + + + {filtered.map((s) => ( + + {s.address} + + {s.value} + + {s.encoding} + + {s.referencedBy + ?.split(/\s+/) + .filter(Boolean) + .map((name, i) => { + const funcId = functionNameToId[name]; + return ( + + {i > 0 && " "} + {funcId ? ( + + ) : ( + name + )} + + ); + })} + + + ))} + +
+ {filtered.length === 0 && ( +
+ {search ? "No matching strings" : "No strings found"} +
+ )} +
+ +
+ {search ? `${filtered.length} of ${strings.length} strings` : `${strings.length} strings`} +
+
+ ); +} diff --git a/src/features/binary/XrefPanel.tsx b/src/features/binary/XrefPanel.tsx index 2f20ab4..bdf305c 100644 --- a/src/features/binary/XrefPanel.tsx +++ b/src/features/binary/XrefPanel.tsx @@ -1,14 +1,24 @@ "use client"; -import { ArrowUpRight, ArrowDownRight, Link, Loader2, GitFork, GripHorizontal } from "lucide-react"; +import { + ArrowUpRight, + ArrowDownRight, + Link, + Loader2, + GitFork, + GripHorizontal, + Text, +} from "lucide-react"; import { useGetCallers, useGetCallees } from "@/api/generated/function/function.ts"; -import type { XrefResponse } from "@/api/generated/api.schemas"; +import type { XrefResponse, StringResponse } from "@/api/generated/api.schemas"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; interface XrefPanelProps { functionId?: string; onSelectFunction: (functionId: string) => void; + strings?: StringResponse[]; + onSelectString?: (address: string) => void; } function XrefTypeBadge({ type }: { type?: string }) { @@ -108,7 +118,12 @@ function XrefList({ ); } -export function XrefPanel({ functionId, onSelectFunction }: XrefPanelProps) { +export function XrefPanel({ + functionId, + onSelectFunction, + strings, + onSelectString, +}: XrefPanelProps) { const { data: callers, isLoading: callersLoading } = useGetCallers(functionId ?? "", { query: { enabled: !!functionId }, }); @@ -146,6 +161,41 @@ export function XrefPanel({ functionId, onSelectFunction }: XrefPanelProps) { onSelectFunction={onSelectFunction} isLoading={calleesLoading} /> + + {strings && ( + <> +
+
+

+ + Strings + {strings.length} +

+ {strings.length === 0 ? ( +

None

+ ) : ( +
+ {strings.map((s, i) => ( + + ))} +
+ )} +
+ + )} )}
diff --git a/src/features/binary/hooks/useBinaryPage.ts b/src/features/binary/hooks/useBinaryPage.ts index 23bdfb6..1c481be 100644 --- a/src/features/binary/hooks/useBinaryPage.ts +++ b/src/features/binary/hooks/useBinaryPage.ts @@ -4,7 +4,11 @@ import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useGetBinary, useAnalyzeBinary } from "@/api/generated/binary/binary.ts"; import { useGetProject } from "@/api/generated/project/project.ts"; import { useGetWorkspace } from "@/api/generated/workspace/workspace.ts"; -import { useListAnalyses, useListSegments } from "@/api/generated/analysis/analysis.ts"; +import { + useListAnalyses, + useListSegments, + useListStrings, +} from "@/api/generated/analysis/analysis.ts"; import { useGetFunction } from "@/api/generated/function/function.ts"; import { useListEngines } from "@/api/generated/engine/engine.ts"; import { customInstance } from "@/api/api.ts"; @@ -76,6 +80,11 @@ export function useBinaryPage() { query: { enabled: !!analysisId }, }); + // Fetch strings for the analysis + const { data: strings } = useListStrings(analysisId ?? "", { + query: { enabled: !!analysisId }, + }); + // Infinite query for functions pagination — single page with all functions const functionsQuery = useInfiniteQuery({ queryKey: ["functions", analysisId], @@ -138,6 +147,25 @@ export function useBinaryPage() { return map; }, [allFunctions, segmentsList]); + // Build map: function name → id (for resolving referencedBy in strings) + const functionNameToId = useMemo(() => { + const map: Record = {}; + for (const f of allFunctions) { + if (f.name && f.id) map[f.name] = f.id; + } + return map; + }, [allFunctions]); + + // Strings referenced by the currently selected function + const stringsList = useMemo(() => strings ?? [], [strings]); + const functionStrings = useMemo(() => { + if (!selectedFunction?.name) return []; + return stringsList.filter((s) => { + if (!s.referencedBy) return false; + return s.referencedBy.split(/\s+/).includes(selectedFunction.name!); + }); + }, [stringsList, selectedFunction]); + // Filter functions by segment, flags, and text search (text search is in FunctionTree) const filteredFunctions = useMemo(() => { let result = allFunctions; @@ -214,6 +242,9 @@ export function useBinaryPage() { isFetchingNextPage: functionsQuery.isFetchingNextPage, functionsLoading: functionsQuery.isLoading, functionsTotal: functionsQuery.data?.pages[0]?.totalElements ?? 0, + strings: stringsList, + functionNameToId, + functionStrings, selectedFunction, selectedFunctionLoading, analyzeMutation, diff --git a/src/pages/Binary.tsx b/src/pages/Binary.tsx index 744655e..9554ca4 100644 --- a/src/pages/Binary.tsx +++ b/src/pages/Binary.tsx @@ -1,9 +1,11 @@ +import { useState } from "react"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/EmptyState"; import { Breadcrumbs } from "@/components/Breadcrumbs.tsx"; import { BinaryHeader } from "@/features/binary/BinaryHeader.tsx"; import { FunctionTree } from "@/features/binary/FunctionTree"; +import { StringTable } from "@/features/binary/StringTable"; import { XrefPanel } from "@/features/binary/XrefPanel"; import { ResizableHandle, @@ -14,6 +16,7 @@ import { useBinaryPage } from "@/features/binary/hooks/useBinaryPage"; import { CodeViewer } from "@/features/binary/CodeViewer"; import { AIChatPanel } from "@/features/binary/AIChatPanel"; import { AnalyzeDialog } from "@/features/binary/AnalyzeDialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const Binary = () => { const { @@ -36,6 +39,9 @@ const Binary = () => { hasNextPage, isFetchingNextPage, functionsTotal, + strings, + functionNameToId, + functionStrings, selectedFunction, selectedFunctionLoading, analyzeMutation, @@ -53,6 +59,19 @@ const Binary = () => { if (match) selectFunctionByAddress(match[1]); }; + const [activeTab, setActiveTab] = useState("functions"); + const [highlightedStringAddress, setHighlightedStringAddress] = useState(); + + const handleSelectStringInXref = (address: string) => { + setActiveTab("strings"); + setHighlightedStringAddress(address); + }; + + const handleTabChange = (value: string) => { + setActiveTab(value); + setHighlightedStringAddress(undefined); + }; + if (binaryLoading) return null; if (!binary) { @@ -104,24 +123,47 @@ const Binary = () => {
-
- Functions -
- setSelectedFunctionId(f.id)} - onLoadMore={fetchNextPage} - hasMore={!!hasNextPage} - isLoading={isFetchingNextPage} - totalCount={functionsTotal} - segments={segments} - selectedSegmentId={selectedSegmentId} - onSelectSegment={setSelectedSegmentId} - segmentByAddress={segmentByAddress} - selectedFlags={selectedFlags} - onToggleFlag={toggleFlag} - /> + + + Functions + Strings + + + setSelectedFunctionId(f.id)} + onLoadMore={fetchNextPage} + hasMore={!!hasNextPage} + isLoading={isFetchingNextPage} + totalCount={functionsTotal} + segments={segments} + selectedSegmentId={selectedSegmentId} + onSelectSegment={setSelectedSegmentId} + segmentByAddress={segmentByAddress} + selectedFlags={selectedFlags} + onToggleFlag={toggleFlag} + /> + + + + +
@@ -141,6 +183,8 @@ const Binary = () => {
-- 2.49.1 From a2c3c1ced91c0ad57b86edc0efa16b027cf0e9a8 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Wed, 10 Jun 2026 08:57:39 -0300 Subject: [PATCH 3/6] feat(analysis): add stack frame, comments, and imports --- src/api/generated/analysis/analysis.ts | 111 ++++++++++++ src/api/generated/api.schemas.ts | 28 ++- src/features/binary/CodeViewer.tsx | 189 ++++++++++++++++----- src/features/binary/ImportTable.tsx | 75 ++++++++ src/features/binary/hooks/useBinaryPage.ts | 7 + src/pages/Binary.tsx | 20 ++- 6 files changed, 383 insertions(+), 47 deletions(-) create mode 100644 src/features/binary/ImportTable.tsx diff --git a/src/api/generated/analysis/analysis.ts b/src/api/generated/analysis/analysis.ts index af5b8e2..146449b 100644 --- a/src/api/generated/analysis/analysis.ts +++ b/src/api/generated/analysis/analysis.ts @@ -19,6 +19,7 @@ import type { import type { AnalysisResponse, + ImportResponse, ListFunctionsParams, PageFunctionSummaryResponse, SegmentResponse, @@ -469,6 +470,116 @@ export function useListSegments> return { ...query, queryKey: queryOptions.queryKey }; } +/** + * Retrieve all imported DLL functions identified during a static analysis. + * @summary List imports in an analysis + */ +export const listImports = ( + analysisId: string, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { url: `/analysis/${encodeURIComponent(String(analysisId))}/imports`, method: "GET", signal }, + options + ); +}; + +export const getListImportsQueryKey = (analysisId: string) => { + return [`/analysis/${analysisId}/imports`] as const; +}; + +export const getListImportsQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + } +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListImportsQueryKey(analysisId); + + const queryFn: QueryFunction>> = ({ signal }) => + listImports(analysisId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: analysisId !== null && analysisId !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type ListImportsQueryResult = NonNullable>>; +export type ListImportsQueryError = unknown; + +export function useListImports>, TError = unknown>( + analysisId: string, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useListImports>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useListImports>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List imports in an analysis + */ + +export function useListImports>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getListImportsQueryOptions(analysisId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * Retrieve paginated list of functions extracted during a static analysis. * @summary List functions in an analysis diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index 02be0cf..8cbd264 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -104,6 +104,18 @@ export interface JobListResponse { failed?: number; } +export interface CommentResponse { + address?: string; + text?: string; + repeatable?: boolean; +} + +export interface FrameMemberResponse { + offset?: number; + size?: number; + name?: string; +} + export interface LabelResponse { name?: string; address?: string; @@ -123,6 +135,8 @@ export interface FunctionDetailResponse { callerCount?: number; calleeCount?: number; labels?: LabelResponse[]; + stackFrame?: FrameMemberResponse[]; + comments?: CommentResponse[]; } export interface XrefResponse { @@ -177,6 +191,14 @@ export interface SegmentResponse { endAddress?: string; } +export interface ImportResponse { + id?: string; + dll?: string; + name?: string; + address?: string; + ordinal?: number; +} + export interface Pageable { /** @minimum 0 */ page?: number; @@ -202,11 +224,11 @@ export interface SortObject { export interface PageableObject { offset?: number; + paged?: boolean; pageNumber?: number; pageSize?: number; - paged?: boolean; - unpaged?: boolean; sort?: SortObject; + unpaged?: boolean; } export interface PageFunctionSummaryResponse { @@ -216,10 +238,10 @@ export interface PageFunctionSummaryResponse { content?: FunctionSummaryResponse[]; number?: number; pageable?: PageableObject; - numberOfElements?: number; sort?: SortObject; first?: boolean; last?: boolean; + numberOfElements?: number; empty?: boolean; } diff --git a/src/features/binary/CodeViewer.tsx b/src/features/binary/CodeViewer.tsx index a870738..e07d9a5 100644 --- a/src/features/binary/CodeViewer.tsx +++ b/src/features/binary/CodeViewer.tsx @@ -1,8 +1,8 @@ "use client"; -import { useRef, useCallback } from "react"; +import { useRef, useCallback, useMemo } from "react"; import { Copy, Code, ArrowUpRight, ArrowDownRight, Link } from "lucide-react"; -import type { FunctionDetailResponse } from "@/api/generated/api.schemas"; +import type { FunctionDetailResponse, CommentResponse } from "@/api/generated/api.schemas"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; @@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button"; interface CodeViewerProps { func: FunctionDetailResponse | undefined; isLoading?: boolean; + onSelectFunction: (id: string) => void; + functionNameToId: Record; } const FLAG_COLORS: Record = { @@ -50,7 +52,12 @@ function LabelTypeIcon({ type }: { type: string | undefined }) { } } -export function CodeViewer({ func, isLoading }: CodeViewerProps) { +export function CodeViewer({ + func, + isLoading, + onSelectFunction, + functionNameToId, +}: CodeViewerProps) { const assemblyRef = useRef(null); const pseudoRef = useRef(null); @@ -85,6 +92,22 @@ export function CodeViewer({ func, isLoading }: CodeViewerProps) { addressToLabel[func.address] = func.name; } + // Build comments by address for inline rendering + const commentsByAddress = useMemo(() => { + const map: Record = {}; + for (const c of func?.comments ?? []) { + if (!c.address) continue; + const key = c.address.toLowerCase(); + if (!map[key]) map[key] = []; + map[key].push(c); + } + return map; + }, [func?.comments]); + + // Stack frame table + const stackFrame = func?.stackFrame ?? []; + const hasStackFrame = stackFrame.length > 0; + return (
@@ -129,6 +152,32 @@ export function CodeViewer({ func, isLoading }: CodeViewerProps) {
+ {hasStackFrame && ( +
+
+ + Stack Frame ({stackFrame.length} member{stackFrame.length !== 1 ? "s" : ""}) + +
+
+ Offset + Size + Name +
+
+ {stackFrame.map((m, i) => ( +
+ {m.offset} + {m.size} + {m.name} +
+ ))} +
+
+
+
+ )} + {hasData ? ( @@ -143,6 +192,9 @@ export function CodeViewer({ func, isLoading }: CodeViewerProps) { addressToLabel={addressToLabel} labelToAddress={labelToAddress} addressToLabelType={addressToLabelType} + commentsByAddress={commentsByAddress} + onSelectFunction={onSelectFunction} + functionNameToId={functionNameToId} /> ))} @@ -214,12 +266,18 @@ function AssemblyRow({ addressToLabel, labelToAddress, addressToLabelType, + commentsByAddress, + onSelectFunction, + functionNameToId, }: { line: string; lineNumber: number; addressToLabel: Record; labelToAddress: Record; addressToLabelType: Record; + commentsByAddress: Record; + onSelectFunction: (id: string) => void; + functionNameToId: Record; }) { const trimmed = line.trim(); @@ -229,7 +287,7 @@ function AssemblyRow({ {lineNumber} -   +   ); } @@ -250,7 +308,7 @@ function AssemblyRow({ {labelName}: -   +   ) : null; @@ -260,6 +318,22 @@ function AssemblyRow({ ); + // Inject backend comments anchored to this address — rendered in a dedicated column + const backendComments = address ? commentsByAddress[address.toLowerCase()] : undefined; + const commentsCell = + backendComments && backendComments.length > 0 ? ( + + {backendComments.map((c, ci) => { + const body = (c.text ?? "").replace(/^;\s*/, ""); + return ( + + {ci > 0 && " "}; {body} + + ); + })} + + ) : null; + // Label-only line (ends with colon, no mnemonic) const labelMatch = rest.match(/^([a-zA-Z_]\w*):$/); if (labelMatch) { @@ -271,9 +345,8 @@ function AssemblyRow({ {lineNumber} {addrCell} - - {rest} - + {rest} + {commentsCell} ); @@ -289,9 +362,8 @@ function AssemblyRow({ {lineNumber} {addrCell} - - {rest} - + {rest} + {commentsCell} ); @@ -319,8 +391,11 @@ function AssemblyRow({ operands={operands} labelToAddress={labelToAddress} labelTypeMap={addressToLabelType} + onSelectFunction={onSelectFunction} + functionNameToId={functionNameToId} /> + {commentsCell} ); @@ -335,9 +410,8 @@ function AssemblyRow({ {lineNumber} {addrCell} - - {rest} - + {rest} + {commentsCell} ); @@ -347,10 +421,14 @@ function HighlightedAssembly({ operands, labelToAddress, labelTypeMap, + onSelectFunction, + functionNameToId, }: { operands: string; labelToAddress: Record; labelTypeMap: Record; + onSelectFunction: (id: string) => void; + functionNameToId: Record; }) { const jumpToLabel = (name: string) => { document.getElementById(`asm-label-${name}`)?.scrollIntoView({ @@ -359,9 +437,28 @@ function HighlightedAssembly({ }); }; - const parts = operands.split( - /(\b0x[0-9a-fA-F]+\b|\bloc_\w+\b|\bsub_\w+\b|\bshort\b|\bbyte\b|\bword\b|\bdword\b|\bptr\b|\bnear\b|\bfar\b)/g - ); + const handleLabelClick = (label: string) => { + const addr = labelToAddress[label]; + if (addr) { + const ltype = labelTypeMap[addr]; + // CALL / JUMP labels are cross-references to external functions — prefer navigation + if (ltype === "CALL" || ltype === "JUMP") { + const funcId = functionNameToId[label]; + if (funcId) { + onSelectFunction(funcId); + return; + } + } + jumpToLabel(label); + return; + } + const funcId = functionNameToId[label]; + if (funcId) onSelectFunction(funcId); + }; + + const KEYWORDS = new Set(["short", "byte", "word", "dword", "ptr", "near", "far"]); + + const parts = operands.split(/(\b0x[0-9a-fA-F]+\b|\b[a-zA-Z_]\w*\b)/g); return ( @@ -373,32 +470,38 @@ function HighlightedAssembly({ ); } - if (/^(loc_\w+|sub_\w+)$/.test(part)) { - const addr = labelToAddress[part]; - const ltype = addr ? labelTypeMap[addr] : undefined; - const lcolor = - ltype === "CALL" - ? "text-[var(--code-function)]" - : ltype === "JUMP" - ? "text-[var(--chart-3)]" - : "text-[var(--code-function)]"; - return ( - jumpToLabel(part)} - > - {part} - - ); - } - if (/^(short|byte|word|dword|ptr|near|far)$/.test(part)) { - return ( - - {part} - - ); + if (/^[a-zA-Z_]\w*$/.test(part)) { + if (KEYWORDS.has(part)) { + return ( + + {part} + + ); + } + const isLocal = labelToAddress[part]; + const isFunc = functionNameToId[part]; + if (isLocal || isFunc) { + const ltype = isLocal ? labelTypeMap[labelToAddress[part]] : undefined; + const lcolor = + ltype === "CALL" + ? "text-[var(--code-function)]" + : ltype === "JUMP" + ? "text-[var(--chart-3)]" + : "text-[var(--code-function)]"; + return ( + handleLabelClick(part)} + > + {part} + + ); + } + return {part}; } return {part}; })} diff --git a/src/features/binary/ImportTable.tsx b/src/features/binary/ImportTable.tsx new file mode 100644 index 0000000..a7c5a32 --- /dev/null +++ b/src/features/binary/ImportTable.tsx @@ -0,0 +1,75 @@ +import { useState } from "react"; +import { Search } from "lucide-react"; +import type { ImportResponse } from "@/api/generated/api.schemas"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface ImportTableProps { + imports: ImportResponse[]; +} + +export function ImportTable({ imports }: ImportTableProps) { + const [dllFilter, setDllFilter] = useState(""); + + const filtered = imports.filter( + (i) => !dllFilter || i.dll?.toLowerCase().includes(dllFilter.toLowerCase()) + ); + + return ( +
+
+
+ + setDllFilter(e.target.value)} + placeholder="Filter by DLL..." + className="bg-muted h-8 border-transparent pl-9" + /> +
+
+ + + + + + DLL + Function + Address + Ordinal + + + + {filtered.map((imp) => ( + + {imp.dll} + {imp.name} + {imp.address} + {imp.ordinal} + + ))} + +
+ {filtered.length === 0 && ( +
+ {dllFilter ? "No matching imports" : "No imports found"} +
+ )} +
+ +
+ {dllFilter + ? `${filtered.length} of ${imports.length} imports` + : `${imports.length} imports`} +
+
+ ); +} diff --git a/src/features/binary/hooks/useBinaryPage.ts b/src/features/binary/hooks/useBinaryPage.ts index 1c481be..43e2249 100644 --- a/src/features/binary/hooks/useBinaryPage.ts +++ b/src/features/binary/hooks/useBinaryPage.ts @@ -8,6 +8,7 @@ import { useListAnalyses, useListSegments, useListStrings, + useListImports, } from "@/api/generated/analysis/analysis.ts"; import { useGetFunction } from "@/api/generated/function/function.ts"; import { useListEngines } from "@/api/generated/engine/engine.ts"; @@ -85,6 +86,11 @@ export function useBinaryPage() { query: { enabled: !!analysisId }, }); + // Fetch imports for the analysis + const { data: imports } = useListImports(analysisId ?? "", { + query: { enabled: !!analysisId }, + }); + // Infinite query for functions pagination — single page with all functions const functionsQuery = useInfiniteQuery({ queryKey: ["functions", analysisId], @@ -243,6 +249,7 @@ export function useBinaryPage() { functionsLoading: functionsQuery.isLoading, functionsTotal: functionsQuery.data?.pages[0]?.totalElements ?? 0, strings: stringsList, + imports: imports ?? [], functionNameToId, functionStrings, selectedFunction, diff --git a/src/pages/Binary.tsx b/src/pages/Binary.tsx index 9554ca4..39e2e61 100644 --- a/src/pages/Binary.tsx +++ b/src/pages/Binary.tsx @@ -6,6 +6,7 @@ import { Breadcrumbs } from "@/components/Breadcrumbs.tsx"; import { BinaryHeader } from "@/features/binary/BinaryHeader.tsx"; import { FunctionTree } from "@/features/binary/FunctionTree"; import { StringTable } from "@/features/binary/StringTable"; +import { ImportTable } from "@/features/binary/ImportTable"; import { XrefPanel } from "@/features/binary/XrefPanel"; import { ResizableHandle, @@ -51,6 +52,7 @@ const Binary = () => { handleAnalyzeConfirm, engines, enginesLoading, + imports, } = useBinaryPage(); const navigateToEntryPoint = () => { @@ -131,6 +133,9 @@ const Binary = () => { Functions Strings + {imports && imports.length > 0 && ( + Imports + )} { highlightedAddress={highlightedStringAddress} /> + {imports && imports.length > 0 && ( + + + + )}
@@ -196,7 +209,12 @@ const Binary = () => {
- +
-- 2.49.1 From 56cbfb794de3c6f357146412c8bccc1aa2fb1064 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Wed, 10 Jun 2026 09:46:47 -0300 Subject: [PATCH 4/6] feat(analysis): add global variables listing and table --- src/api/generated/analysis/analysis.ts | 127 +++++++++++++++++ src/api/generated/api.schemas.ts | 15 ++ src/features/binary/CodeViewer.tsx | 6 + src/features/binary/GlobalVarTable.tsx | 158 +++++++++++++++++++++ src/features/binary/hooks/useBinaryPage.ts | 7 + src/pages/Binary.tsx | 17 +++ 6 files changed, 330 insertions(+) create mode 100644 src/features/binary/GlobalVarTable.tsx diff --git a/src/api/generated/analysis/analysis.ts b/src/api/generated/analysis/analysis.ts index 146449b..93ebf40 100644 --- a/src/api/generated/analysis/analysis.ts +++ b/src/api/generated/analysis/analysis.ts @@ -19,6 +19,7 @@ import type { import type { AnalysisResponse, + DataLabelResponse, ImportResponse, ListFunctionsParams, PageFunctionSummaryResponse, @@ -580,6 +581,132 @@ export function useListImports>, return { ...query, queryKey: queryOptions.queryKey }; } +/** + * Retrieve all named data labels (global variables) identified during a static analysis. + * @summary List global variables in an analysis + */ +export const listGlobalVars = ( + analysisId: string, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { + url: `/analysis/${encodeURIComponent(String(analysisId))}/global-vars`, + method: "GET", + signal, + }, + options + ); +}; + +export const getListGlobalVarsQueryKey = (analysisId: string) => { + return [`/analysis/${analysisId}/global-vars`] as const; +}; + +export const getListGlobalVarsQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + } +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListGlobalVarsQueryKey(analysisId); + + const queryFn: QueryFunction>> = ({ signal }) => + listGlobalVars(analysisId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: analysisId !== null && analysisId !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type ListGlobalVarsQueryResult = NonNullable>>; +export type ListGlobalVarsQueryError = unknown; + +export function useListGlobalVars< + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useListGlobalVars< + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useListGlobalVars< + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List global variables in an analysis + */ + +export function useListGlobalVars< + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getListGlobalVarsQueryOptions(analysisId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * Retrieve paginated list of functions extracted during a static analysis. * @summary List functions in an analysis diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index 8cbd264..2671270 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -199,6 +199,21 @@ export interface ImportResponse { ordinal?: number; } +export interface DataXrefResponse { + function?: string; + address?: string; + type?: string; +} + +export interface DataLabelResponse { + id?: string; + name?: string; + address?: string; + segment?: string; + size?: number; + dataXrefs?: DataXrefResponse[]; +} + export interface Pageable { /** @minimum 0 */ page?: number; diff --git a/src/features/binary/CodeViewer.tsx b/src/features/binary/CodeViewer.tsx index e07d9a5..bf80dd1 100644 --- a/src/features/binary/CodeViewer.tsx +++ b/src/features/binary/CodeViewer.tsx @@ -152,6 +152,12 @@ export function CodeViewer({
+ {func?.signature && ( +
+ {func.signature} +
+ )} + {hasStackFrame && (
diff --git a/src/features/binary/GlobalVarTable.tsx b/src/features/binary/GlobalVarTable.tsx new file mode 100644 index 0000000..a25066c --- /dev/null +++ b/src/features/binary/GlobalVarTable.tsx @@ -0,0 +1,158 @@ +import { useState, Fragment } from "react"; +import { Search, ChevronRight, ChevronDown } from "lucide-react"; +import type { DataLabelResponse } from "@/api/generated/api.schemas"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface GlobalVarTableProps { + globalVars: DataLabelResponse[]; + functionNameToId: Record; + onSelectFunction: (functionId: string) => void; +} + +export function GlobalVarTable({ + globalVars, + functionNameToId, + onSelectFunction, +}: GlobalVarTableProps) { + const [segmentFilter, setSegmentFilter] = useState(""); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const filtered = globalVars.filter( + (gv) => !segmentFilter || gv.segment?.toLowerCase().includes(segmentFilter.toLowerCase()) + ); + + const toggleExpanded = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+
+ + setSegmentFilter(e.target.value)} + placeholder="Filter by segment..." + className="bg-muted h-8 border-transparent pl-9" + /> +
+
+ + + + + + + Name + Address + Segment + Size + + + + {filtered.map((gv) => { + const hasXrefs = gv.dataXrefs && gv.dataXrefs.length > 0; + const expanded = !!(gv.id && expandedIds.has(gv.id)); + + return ( + + gv.id && hasXrefs && toggleExpanded(gv.id)} + > + + {hasXrefs && + (expanded ? ( + + ) : ( + + ))} + + {gv.name} + {gv.address} + {gv.segment} + {gv.size} + + {expanded && hasXrefs && ( + + +
+
+ + + Function + Address + Type + + + + {gv.dataXrefs!.map((xref, xi) => { + const funcId = xref.function + ? functionNameToId[xref.function] + : undefined; + return ( + + + {funcId ? ( + + ) : ( + (xref.function ?? "") + )} + + + {xref.address} + + {xref.type} + + ); + })} + +
+
+ + + )} + + ); + })} + + + {filtered.length === 0 && ( +
+ {segmentFilter ? "No matching global vars" : "No global vars found"} +
+ )} + + +
+ {segmentFilter + ? `${filtered.length} of ${globalVars.length} global vars` + : `${globalVars.length} global vars`} +
+
+ ); +} diff --git a/src/features/binary/hooks/useBinaryPage.ts b/src/features/binary/hooks/useBinaryPage.ts index 43e2249..b003b32 100644 --- a/src/features/binary/hooks/useBinaryPage.ts +++ b/src/features/binary/hooks/useBinaryPage.ts @@ -9,6 +9,7 @@ import { useListSegments, useListStrings, useListImports, + useListGlobalVars, } from "@/api/generated/analysis/analysis.ts"; import { useGetFunction } from "@/api/generated/function/function.ts"; import { useListEngines } from "@/api/generated/engine/engine.ts"; @@ -91,6 +92,11 @@ export function useBinaryPage() { query: { enabled: !!analysisId }, }); + // Fetch global vars for the analysis + const { data: globalVars } = useListGlobalVars(analysisId ?? "", { + query: { enabled: !!analysisId }, + }); + // Infinite query for functions pagination — single page with all functions const functionsQuery = useInfiniteQuery({ queryKey: ["functions", analysisId], @@ -250,6 +256,7 @@ export function useBinaryPage() { functionsTotal: functionsQuery.data?.pages[0]?.totalElements ?? 0, strings: stringsList, imports: imports ?? [], + globalVars: globalVars ?? [], functionNameToId, functionStrings, selectedFunction, diff --git a/src/pages/Binary.tsx b/src/pages/Binary.tsx index 39e2e61..d9e43ea 100644 --- a/src/pages/Binary.tsx +++ b/src/pages/Binary.tsx @@ -7,6 +7,7 @@ import { BinaryHeader } from "@/features/binary/BinaryHeader.tsx"; import { FunctionTree } from "@/features/binary/FunctionTree"; import { StringTable } from "@/features/binary/StringTable"; import { ImportTable } from "@/features/binary/ImportTable"; +import { GlobalVarTable } from "@/features/binary/GlobalVarTable"; import { XrefPanel } from "@/features/binary/XrefPanel"; import { ResizableHandle, @@ -53,6 +54,7 @@ const Binary = () => { engines, enginesLoading, imports, + globalVars, } = useBinaryPage(); const navigateToEntryPoint = () => { @@ -136,6 +138,9 @@ const Binary = () => { {imports && imports.length > 0 && ( Imports )} + {globalVars && globalVars.length > 0 && ( + Global Vars + )} { )} + {globalVars && globalVars.length > 0 && ( + + + + )} -- 2.49.1 From fa464e68082d9f1bf236881bfe394d9057407f85 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Wed, 10 Jun 2026 10:45:53 -0300 Subject: [PATCH 5/6] feat(cfg): implement control flow graph visualization --- package-lock.json | 16 ++ package.json | 1 + src/api/generated/api.schemas.ts | 10 + src/features/binary/CfgGraph.tsx | 354 +++++++++++++++++++++++++++++ src/features/binary/CodeViewer.tsx | 129 ++++++++--- 5 files changed, 475 insertions(+), 35 deletions(-) create mode 100644 src/features/binary/CfgGraph.tsx diff --git a/package-lock.json b/package-lock.json index bdc09ef..60a9d15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "decompile-ai", "version": "0.0.0", "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@radix-ui/react-dialog": "^1.1.15", @@ -317,6 +318,21 @@ "commander": "~14.0.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", + "integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.1" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz", + "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", + "license": "MIT" + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", diff --git a/package.json b/package.json index dfa2522..d69c425 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "preview": "vite preview" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index 2671270..663ffd9 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -104,6 +104,15 @@ export interface JobListResponse { failed?: number; } +export interface CfgBlockResponse { + id?: number; + start?: string; + end?: string; + type?: number; + succs?: number[]; + preds?: number[]; +} + export interface CommentResponse { address?: string; text?: string; @@ -137,6 +146,7 @@ export interface FunctionDetailResponse { labels?: LabelResponse[]; stackFrame?: FrameMemberResponse[]; comments?: CommentResponse[]; + cfgBlocks?: CfgBlockResponse[]; } export interface XrefResponse { diff --git a/src/features/binary/CfgGraph.tsx b/src/features/binary/CfgGraph.tsx new file mode 100644 index 0000000..d66b4d9 --- /dev/null +++ b/src/features/binary/CfgGraph.tsx @@ -0,0 +1,354 @@ +import { useMemo, useRef, useState, useCallback, useEffect } from "react"; +import { Graph } from "@dagrejs/graphlib"; +import { layout } from "@dagrejs/dagre"; +import type { CfgBlockResponse } from "@/api/generated/api.schemas"; + +interface CfgGraphProps { + blocks: CfgBlockResponse[]; + assembly?: string; + onHighlightRange: (start: string, end: string) => void; +} + +const NODE_W = 220; +const HEADER_H = 18; +const LINE_H = 13; +const FOOTER_H = 11; +const PAD_V = 12; +const GRAPH_PAD = 32; + +const BLOCK_DEFS: Record = { + 0: { stroke: "#2563eb", fill: "#bfdbfe", label: "Normal" }, + 1: { stroke: "#ea580c", fill: "#fed7aa", label: "Indirect Jump" }, + 2: { stroke: "#dc2626", fill: "#fecaca", label: "Return" }, + 3: { stroke: "#dc2626", fill: "#fecaca", label: "Cond Return" }, + 4: { stroke: "#4b5563", fill: "#e5e7eb", label: "No-return" }, + 5: { stroke: "#4b5563", fill: "#e5e7eb", label: "Ext No-return" }, + 6: { stroke: "#7c3aed", fill: "#ddd6fe", label: "External" }, + 7: { stroke: "#dc2626", fill: "#fecaca", label: "Error" }, +}; + +const DEFAULT_DEF = { stroke: "#6b7280", fill: "#f3f4f6", label: "Unknown" }; + +interface AsmLine { + address: string; + text: string; +} + +function parseAssembly(assembly?: string): AsmLine[] { + if (!assembly) return []; + return assembly + .split(/\\n/) + .map((line) => { + const m = line.trim().match(/^(0x[0-9a-fA-F]+)\s{2,}(.+)?$/); + return m ? { address: m[1], text: m[2]?.trim() ?? "" } : null; + }) + .filter((l): l is AsmLine => l !== null); +} + +function addressNum(addr: string) { + return parseInt(addr, 16); +} + +export function CfgGraph({ blocks, assembly, onHighlightRange }: CfgGraphProps) { + const svgRef = useRef(null); + const isDragging = useRef(false); + const hasDragged = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + const dragLast = useRef({ x: 0, y: 0 }); + const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 }); + + const { svgWidth, svgHeight, nodes, edges } = useMemo(() => { + const allLines = parseAssembly(assembly); + + const blockLines = new Map(); + for (const b of blocks) { + const matched = allLines.filter((l) => { + const a = addressNum(l.address); + const s = addressNum(b.start ?? ""); + const e = addressNum(b.end ?? ""); + if (isNaN(a) || isNaN(s) || isNaN(e)) return false; + return a >= s && a <= e; + }); + blockLines.set(b.id ?? -1, matched); + } + + const g = new Graph(); + g.setGraph({ rankdir: "TB", nodesep: 30, ranksep: 50 }); + g.setDefaultEdgeLabel(() => ({})); + + for (const b of blocks) { + const lines = blockLines.get(b.id ?? -1) ?? []; + const h = HEADER_H + lines.length * LINE_H + FOOTER_H + PAD_V; + g.setNode(String(b.id), { width: NODE_W, height: h }); + } + for (const b of blocks) { + for (const t of b.succs ?? []) { + g.setEdge(String(b.id), String(t)); + } + } + + layout(g); + + const nodeList: Array<{ + block: CfgBlockResponse; + lines: AsmLine[]; + x: number; + y: number; + width: number; + height: number; + }> = []; + + for (const b of blocks) { + const n = g.node(String(b.id)); + if (n) { + nodeList.push({ + block: b, + lines: blockLines.get(b.id ?? -1) ?? [], + x: n.x, + y: n.y, + width: n.width, + height: n.height, + }); + } + } + + const edgeList: Array<{ + points: { x: number; y: number }[]; + }> = []; + + for (const b of blocks) { + for (const t of b.succs ?? []) { + const e = g.edge(String(b.id), String(t)) as unknown as { + points?: { x: number; y: number }[]; + }; + if (e?.points?.length) { + edgeList.push({ points: e.points }); + } + } + } + + let maxX = 0; + let maxY = 0; + for (const n of nodeList) { + maxX = Math.max(maxX, n.x + n.width / 2); + maxY = Math.max(maxY, n.y + n.height / 2); + } + + return { + svgWidth: maxX + GRAPH_PAD, + svgHeight: maxY + GRAPH_PAD, + nodes: nodeList, + edges: edgeList, + }; + }, [blocks, assembly]); + + // Center the graph in the container on mount / resize + const centerGraph = useCallback(() => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + setTransform({ + x: (rect.width - svgWidth) / 2, + y: (rect.height - svgHeight) / 2, + scale: 1, + }); + }, [svgWidth, svgHeight]); + + useEffect(() => { + centerGraph(); + }, [centerGraph]); + + // Also center when the window resizes + useEffect(() => { + window.addEventListener("resize", centerGraph); + return () => window.removeEventListener("resize", centerGraph); + }, [centerGraph]); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + const delta = e.deltaY > 0 ? 0.9 : 1.1; + setTransform((prev) => { + const newScale = Math.max(0.3, Math.min(3, prev.scale * delta)); + const newX = cx - (cx - prev.x) * (newScale / prev.scale); + const newY = cy - (cy - prev.y) * (newScale / prev.scale); + return { x: newX, y: newY, scale: newScale }; + }); + }, []); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + hasDragged.current = false; + isDragging.current = true; + dragStart.current = { x: e.clientX, y: e.clientY }; + dragLast.current = { x: e.clientX, y: e.clientY }; + (e.target as Element).setPointerCapture?.(e.pointerId); + }, []); + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!isDragging.current) return; + const dx = e.clientX - dragLast.current.x; + const dy = e.clientY - dragLast.current.y; + const totalDx = e.clientX - dragStart.current.x; + const totalDy = e.clientY - dragStart.current.y; + if (Math.abs(totalDx) > 3 || Math.abs(totalDy) > 3) { + hasDragged.current = true; + } + dragLast.current = { x: e.clientX, y: e.clientY }; + setTransform((prev) => ({ + ...prev, + x: prev.x + dx, + y: prev.y + dy, + })); + }, []); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + isDragging.current = false; + (e.target as Element).releasePointerCapture?.(e.pointerId); + if (hasDragged.current || e.button !== 0) return; + const nodeEl = (e.target as Element).closest("[data-cfg-node]") as HTMLElement | null; + if (nodeEl) { + const id = nodeEl.getAttribute("data-cfg-node"); + const block = blocks.find((b) => String(b.id) === id); + if (block?.start && block?.end) { + onHighlightRange(block.start, block.end); + } + } + }, + [blocks, onHighlightRange] + ); + + if (blocks.length === 0) { + return ( +
No control flow blocks
+ ); + } + + return ( +
+
+ e.preventDefault()} + onWheel={handleWheel} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerLeave={handlePointerUp} + > + + + + + + + + {/* Padding background (invisible, just to show padding area) */} + + + {/* Edges */} + {edges.map((edge, ei) => { + const d = edge.points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" "); + return ( + + ); + })} + + {/* Nodes */} + {nodes.map(({ block, lines, x, y, width, height }) => { + const def = BLOCK_DEFS[block.type ?? -1] ?? DEFAULT_DEF; + const rx = x - width / 2; + const ry = y - height / 2; + return ( + + + + {block.start}-{block.end} + + {lines.map((l, li) => ( + + {l.text} + + ))} + + {def.label} + + + ); + })} + + +
+ + {/* Legend */} +
+ {Object.entries(BLOCK_DEFS).map(([type, def]) => ( +
+ + {def.label} +
+ ))} +
+
+ ); +} diff --git a/src/features/binary/CodeViewer.tsx b/src/features/binary/CodeViewer.tsx index bf80dd1..5470462 100644 --- a/src/features/binary/CodeViewer.tsx +++ b/src/features/binary/CodeViewer.tsx @@ -1,12 +1,13 @@ "use client"; -import { useRef, useCallback, useMemo } from "react"; +import { useRef, useCallback, useMemo, useState } from "react"; import { Copy, Code, ArrowUpRight, ArrowDownRight, Link } from "lucide-react"; import type { FunctionDetailResponse, CommentResponse } from "@/api/generated/api.schemas"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { CfgGraph } from "@/features/binary/CfgGraph"; interface CodeViewerProps { func: FunctionDetailResponse | undefined; @@ -61,6 +62,25 @@ export function CodeViewer({ const assemblyRef = useRef(null); const pseudoRef = useRef(null); + const [activeTab, setActiveTab] = useState("assembly"); + const [highlightedRange, setHighlightedRange] = useState<{ + start: string; + end: string; + } | null>(null); + + const handleCfgHighlight = useCallback((start: string, end: string) => { + setHighlightedRange((prev) => + prev?.start === start && prev?.end === end ? null : { start, end } + ); + setActiveTab("assembly"); + setTimeout(() => { + document.getElementById(`asm-addr-${start}`)?.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }); + }, 50); + }, []); + const copyActiveTab = useCallback(() => { const activeContent = assemblyRef.current?.textContent ?? pseudoRef.current?.textContent ?? ""; navigator.clipboard.writeText(activeContent); @@ -107,17 +127,27 @@ export function CodeViewer({ // Stack frame table const stackFrame = func?.stackFrame ?? []; const hasStackFrame = stackFrame.length > 0; + const cfgBlocks = func?.cfgBlocks ?? []; + const hasCfg = cfgBlocks.length > 0; + const hasPseudocode = !!func?.decompiledCode; return ( - +
Assembly - - Pseudocode - + {hasCfg && ( + + CFG + + )} + {hasPseudocode && ( + + Pseudocode + + )}
@@ -201,6 +231,7 @@ export function CodeViewer({ commentsByAddress={commentsByAddress} onSelectFunction={onSelectFunction} functionNameToId={functionNameToId} + highlightedRange={highlightedRange} /> ))} @@ -222,33 +253,47 @@ export function CodeViewer({ )} - - {hasData ? ( - -
- - - {pseudoLines.map((line, idx) => ( - - ))} - -
-
-
- ) : ( -
-
- -
-

Code Viewer

-

- {isLoading - ? "Loading function..." - : "Select a function from the list to view its assembly and pseudocode."} -

+ {hasCfg && ( + +
+
- )} -
+ + )} + + {hasPseudocode && ( + + {hasData ? ( + +
+ + + {pseudoLines.map((line, idx) => ( + + ))} + +
+
+
+ ) : ( +
+
+ +
+

Code Viewer

+

+ {isLoading + ? "Loading function..." + : "Select a function from the list to view its assembly and pseudocode."} +

+
+ )} +
+ )} ); } @@ -275,6 +320,7 @@ function AssemblyRow({ commentsByAddress, onSelectFunction, functionNameToId, + highlightedRange, }: { line: string; lineNumber: number; @@ -284,6 +330,7 @@ function AssemblyRow({ commentsByAddress: Record; onSelectFunction: (id: string) => void; functionNameToId: Record; + highlightedRange?: { start: string; end: string } | null; }) { const trimmed = line.trim(); @@ -340,13 +387,25 @@ function AssemblyRow({ ) : null; + let isHighlighted = false; + if (highlightedRange && address) { + const a = parseInt(address, 16); + const rs = parseInt(highlightedRange.start, 16); + const re = parseInt(highlightedRange.end, 16); + if (!isNaN(a) && !isNaN(rs) && !isNaN(re)) { + isHighlighted = a >= rs && a <= re; + } + } + const highlightClass = isHighlighted ? "bg-primary/10" : ""; + const rowId = address ? `asm-addr-${address}` : undefined; + // Label-only line (ends with colon, no mnemonic) const labelMatch = rest.match(/^([a-zA-Z_]\w*):$/); if (labelMatch) { return ( <> {labelDeclRow} - + {lineNumber} @@ -363,7 +422,7 @@ function AssemblyRow({ return ( <> {labelDeclRow} - + {lineNumber} @@ -384,7 +443,7 @@ function AssemblyRow({ return ( <> {labelDeclRow} - + {lineNumber} @@ -411,7 +470,7 @@ function AssemblyRow({ return ( <> {labelDeclRow} - + {lineNumber} -- 2.49.1 From 249a6b81b599f82896cf68fb0df2b9875eadc31b Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Wed, 10 Jun 2026 11:45:11 -0300 Subject: [PATCH 6/6] feat(analysis): add structures and enums listing functionality --- src/api/generated/analysis/analysis.ts | 238 +++++++++++++++++++++ src/api/generated/api.schemas.ts | 27 +++ src/features/binary/EnumTable.tsx | 121 +++++++++++ src/features/binary/StructureTable.tsx | 136 ++++++++++++ src/features/binary/hooks/useBinaryPage.ts | 14 ++ src/pages/Binary.tsx | 78 ++++++- 6 files changed, 607 insertions(+), 7 deletions(-) create mode 100644 src/features/binary/EnumTable.tsx create mode 100644 src/features/binary/StructureTable.tsx diff --git a/src/api/generated/analysis/analysis.ts b/src/api/generated/analysis/analysis.ts index 93ebf40..1cff535 100644 --- a/src/api/generated/analysis/analysis.ts +++ b/src/api/generated/analysis/analysis.ts @@ -20,11 +20,13 @@ import type { import type { AnalysisResponse, DataLabelResponse, + EnumResponse, ImportResponse, ListFunctionsParams, PageFunctionSummaryResponse, SegmentResponse, StringResponse, + StructResponse, } from "../api.schemas"; import { customInstance } from "../../api"; @@ -251,6 +253,132 @@ export function useGetAnalysis>, return { ...query, queryKey: queryOptions.queryKey }; } +/** + * Retrieve all struct/union definitions identified during a static analysis. + * @summary List structures in an analysis + */ +export const listStructures = ( + analysisId: string, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { + url: `/analysis/${encodeURIComponent(String(analysisId))}/structures`, + method: "GET", + signal, + }, + options + ); +}; + +export const getListStructuresQueryKey = (analysisId: string) => { + return [`/analysis/${analysisId}/structures`] as const; +}; + +export const getListStructuresQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + } +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListStructuresQueryKey(analysisId); + + const queryFn: QueryFunction>> = ({ signal }) => + listStructures(analysisId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: analysisId !== null && analysisId !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type ListStructuresQueryResult = NonNullable>>; +export type ListStructuresQueryError = unknown; + +export function useListStructures< + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useListStructures< + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useListStructures< + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List structures in an analysis + */ + +export function useListStructures< + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getListStructuresQueryOptions(analysisId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + /** * Retrieve all strings extracted during a static analysis. * @summary List strings in an analysis @@ -839,3 +967,113 @@ export function useListFunctions< return { ...query, queryKey: queryOptions.queryKey }; } + +/** + * Retrieve all enum definitions identified during a static analysis. + * @summary List enums in an analysis + */ +export const listEnums = ( + analysisId: string, + options?: SecondParameter, + signal?: AbortSignal +) => { + return customInstance( + { url: `/analysis/${encodeURIComponent(String(analysisId))}/enums`, method: "GET", signal }, + options + ); +}; + +export const getListEnumsQueryKey = (analysisId: string) => { + return [`/analysis/${analysisId}/enums`] as const; +}; + +export const getListEnumsQueryOptions = < + TData = Awaited>, + TError = unknown, +>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + } +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListEnumsQueryKey(analysisId); + + const queryFn: QueryFunction>> = ({ signal }) => + listEnums(analysisId, requestOptions, signal); + + return { + queryKey, + queryFn, + enabled: analysisId !== null && analysisId !== undefined, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag; + }; +}; + +export type ListEnumsQueryResult = NonNullable>>; +export type ListEnumsQueryError = unknown; + +export function useListEnums>, TError = unknown>( + analysisId: string, + options: { + query: Partial>, TError, TData>> & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useListEnums>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>> & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useListEnums>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary List enums in an analysis + */ + +export function useListEnums>, TError = unknown>( + analysisId: string, + options?: { + query?: Partial>, TError, TData>>; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getListEnumsQueryOptions(analysisId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/src/api/generated/api.schemas.ts b/src/api/generated/api.schemas.ts index 663ffd9..fa4f156 100644 --- a/src/api/generated/api.schemas.ts +++ b/src/api/generated/api.schemas.ts @@ -186,6 +186,21 @@ export interface AnalysisResponse { updatedAt?: string; } +export interface StructMemberResponse { + offset?: number; + size?: number; + name?: string; + type?: string; +} + +export interface StructResponse { + id?: string; + name?: string; + size?: number; + isUnion?: boolean; + members?: StructMemberResponse[]; +} + export interface StringResponse { id?: string; address?: string; @@ -270,6 +285,18 @@ export interface PageFunctionSummaryResponse { empty?: boolean; } +export interface EnumConstResponse { + name?: string; + value?: number; +} + +export interface EnumResponse { + id?: string; + name?: string; + width?: number; + constants?: EnumConstResponse[]; +} + export type GetWorkspacesParams = { /** * Filter workspaces whose name contains this value (case-insensitive) diff --git a/src/features/binary/EnumTable.tsx b/src/features/binary/EnumTable.tsx new file mode 100644 index 0000000..274c09a --- /dev/null +++ b/src/features/binary/EnumTable.tsx @@ -0,0 +1,121 @@ +import { useState, Fragment } from "react"; +import { Search, ChevronRight, ChevronDown } from "lucide-react"; +import type { EnumResponse } from "@/api/generated/api.schemas"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface EnumTableProps { + enums: EnumResponse[]; +} + +export function EnumTable({ enums }: EnumTableProps) { + const [search, setSearch] = useState(""); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const filtered = enums.filter( + (e) => !search || e.name?.toLowerCase().includes(search.toLowerCase()) + ); + + const toggleExpanded = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + placeholder="Filter enums..." + className="bg-muted h-8 border-transparent pl-9" + /> +
+
+ + + + + + + Name + Width + + + + {filtered.map((e) => { + const hasConstants = e.constants && e.constants.length > 0; + const expanded = !!(e.id && expandedIds.has(e.id)); + + return ( + + e.id && hasConstants && toggleExpanded(e.id)} + > + + {hasConstants && + (expanded ? ( + + ) : ( + + ))} + + {e.name} + {e.width} + + {expanded && hasConstants && ( + + +
+
+ Name + Value +
+
+ {e.constants!.map((c, ci) => ( +
+ {c.name} + {c.value} +
+ ))} +
+
+
+
+ )} +
+ ); + })} +
+
+ {filtered.length === 0 && ( +
+ {search ? "No matching enums" : "No enums found"} +
+ )} +
+ +
+ {search ? `${filtered.length} of ${enums.length} enums` : `${enums.length} enums`} +
+
+ ); +} diff --git a/src/features/binary/StructureTable.tsx b/src/features/binary/StructureTable.tsx new file mode 100644 index 0000000..2de2c90 --- /dev/null +++ b/src/features/binary/StructureTable.tsx @@ -0,0 +1,136 @@ +import { useState, Fragment } from "react"; +import { Search, ChevronRight, ChevronDown } from "lucide-react"; +import type { StructResponse } from "@/api/generated/api.schemas"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface StructureTableProps { + structures: StructResponse[]; +} + +export function StructureTable({ structures }: StructureTableProps) { + const [search, setSearch] = useState(""); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const filtered = structures.filter( + (s) => !search || s.name?.toLowerCase().includes(search.toLowerCase()) + ); + + const toggleExpanded = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+
+ + setSearch(e.target.value)} + placeholder="Filter structures..." + className="bg-muted h-8 border-transparent pl-9" + /> +
+
+ + + + + + + Name + Size + Type + + + + {filtered.map((s) => { + const expanded = !!(s.id && expandedIds.has(s.id)); + + return ( + + s.id && toggleExpanded(s.id)}> + + {expanded ? ( + + ) : ( + + )} + + {s.name} + {s.size} + + + {s.isUnion ? "Union" : "Struct"} + + + + {expanded && s.members && s.members.length > 0 && ( + + +
+
+ Offset + Name + Size + Type +
+
+ {s.members.map((m, mi) => ( +
+ {m.offset} + {m.name} + {m.size} + {m.type} +
+ ))} +
+
+
+
+ )} +
+ ); + })} +
+
+ {filtered.length === 0 && ( +
+ {search ? "No matching structures" : "No structures found"} +
+ )} +
+ +
+ {search + ? `${filtered.length} of ${structures.length} structures` + : `${structures.length} structures`} +
+
+ ); +} diff --git a/src/features/binary/hooks/useBinaryPage.ts b/src/features/binary/hooks/useBinaryPage.ts index b003b32..4f9c2e8 100644 --- a/src/features/binary/hooks/useBinaryPage.ts +++ b/src/features/binary/hooks/useBinaryPage.ts @@ -10,6 +10,8 @@ import { useListStrings, useListImports, useListGlobalVars, + useListStructures, + useListEnums, } from "@/api/generated/analysis/analysis.ts"; import { useGetFunction } from "@/api/generated/function/function.ts"; import { useListEngines } from "@/api/generated/engine/engine.ts"; @@ -97,6 +99,16 @@ export function useBinaryPage() { query: { enabled: !!analysisId }, }); + // Fetch structures for the analysis + const { data: structures } = useListStructures(analysisId ?? "", { + query: { enabled: !!analysisId }, + }); + + // Fetch enums for the analysis + const { data: enums } = useListEnums(analysisId ?? "", { + query: { enabled: !!analysisId }, + }); + // Infinite query for functions pagination — single page with all functions const functionsQuery = useInfiniteQuery({ queryKey: ["functions", analysisId], @@ -257,6 +269,8 @@ export function useBinaryPage() { strings: stringsList, imports: imports ?? [], globalVars: globalVars ?? [], + structures: structures ?? [], + enums: enums ?? [], functionNameToId, functionStrings, selectedFunction, diff --git a/src/pages/Binary.tsx b/src/pages/Binary.tsx index d9e43ea..1866dff 100644 --- a/src/pages/Binary.tsx +++ b/src/pages/Binary.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { ArrowLeft } from "lucide-react"; +import { ArrowLeft, ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/EmptyState"; import { Breadcrumbs } from "@/components/Breadcrumbs.tsx"; @@ -8,6 +8,8 @@ import { FunctionTree } from "@/features/binary/FunctionTree"; import { StringTable } from "@/features/binary/StringTable"; import { ImportTable } from "@/features/binary/ImportTable"; import { GlobalVarTable } from "@/features/binary/GlobalVarTable"; +import { StructureTable } from "@/features/binary/StructureTable"; +import { EnumTable } from "@/features/binary/EnumTable"; import { XrefPanel } from "@/features/binary/XrefPanel"; import { ResizableHandle, @@ -19,6 +21,13 @@ import { CodeViewer } from "@/features/binary/CodeViewer"; import { AIChatPanel } from "@/features/binary/AIChatPanel"; import { AnalyzeDialog } from "@/features/binary/AnalyzeDialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; const Binary = () => { const { @@ -55,6 +64,8 @@ const Binary = () => { enginesLoading, imports, globalVars, + structures, + enums, } = useBinaryPage(); const navigateToEntryPoint = () => { @@ -76,6 +87,20 @@ const Binary = () => { setHighlightedStringAddress(undefined); }; + // Build data tabs for the "More" dropdown + const dataTabs = [ + ...(imports && imports.length > 0 ? ([{ value: "imports", label: "Imports" }] as const) : []), + ...(globalVars && globalVars.length > 0 + ? ([{ value: "global-vars", label: "Global Vars" }] as const) + : []), + ...(structures && structures.length > 0 + ? ([{ value: "structures", label: "Structures" }] as const) + : []), + ...(enums && enums.length > 0 ? ([{ value: "enums", label: "Enums" }] as const) : []), + ]; + const hasDataTabs = dataTabs.length > 0; + const isDataTabActive = dataTabs.some((t) => t.value === activeTab); + if (binaryLoading) return null; if (!binary) { @@ -132,14 +157,37 @@ const Binary = () => { onValueChange={handleTabChange} className="flex min-h-0 flex-1 flex-col" > - + Functions Strings - {imports && imports.length > 0 && ( - Imports - )} - {globalVars && globalVars.length > 0 && ( - Global Vars + {hasDataTabs && ( + + + + + + {dataTabs.map((tab) => ( + handleTabChange(tab.value)} + > + + {tab.label} + + + ))} + + )} { /> )} + {structures && structures.length > 0 && ( + + + + )} + {enums && enums.length > 0 && ( + + + + )}
-- 2.49.1