feat(analysis): add stack frame, comments, and imports

This commit is contained in:
Rodrigo Verdiani 2026-06-10 08:57:39 -03:00
parent 908d57c3c4
commit a2c3c1ced9
6 changed files with 383 additions and 47 deletions

View File

@ -19,6 +19,7 @@ import type {
import type {
AnalysisResponse,
ImportResponse,
ListFunctionsParams,
PageFunctionSummaryResponse,
SegmentResponse,
@ -469,6 +470,116 @@ export function useListSegments<TData = Awaited<ReturnType<typeof listSegments>>
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<typeof customInstance>,
signal?: AbortSignal
) => {
return customInstance<ImportResponse[]>(
{ 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<ReturnType<typeof listImports>>,
TError = unknown,
>(
analysisId: string,
options?: {
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof listImports>>, TError, TData>>;
request?: SecondParameter<typeof customInstance>;
}
) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListImportsQueryKey(analysisId);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listImports>>> = ({ signal }) =>
listImports(analysisId, requestOptions, signal);
return {
queryKey,
queryFn,
enabled: analysisId !== null && analysisId !== undefined,
...queryOptions,
} as UseQueryOptions<Awaited<ReturnType<typeof listImports>>, TError, TData> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
};
export type ListImportsQueryResult = NonNullable<Awaited<ReturnType<typeof listImports>>>;
export type ListImportsQueryError = unknown;
export function useListImports<TData = Awaited<ReturnType<typeof listImports>>, TError = unknown>(
analysisId: string,
options: {
query: Partial<UseQueryOptions<Awaited<ReturnType<typeof listImports>>, TError, TData>> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listImports>>,
TError,
Awaited<ReturnType<typeof listImports>>
>,
"initialData"
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
export function useListImports<TData = Awaited<ReturnType<typeof listImports>>, TError = unknown>(
analysisId: string,
options?: {
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof listImports>>, TError, TData>> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listImports>>,
TError,
Awaited<ReturnType<typeof listImports>>
>,
"initialData"
>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
export function useListImports<TData = Awaited<ReturnType<typeof listImports>>, TError = unknown>(
analysisId: string,
options?: {
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof listImports>>, TError, TData>>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
/**
* @summary List imports in an analysis
*/
export function useListImports<TData = Awaited<ReturnType<typeof listImports>>, TError = unknown>(
analysisId: string,
options?: {
query?: Partial<UseQueryOptions<Awaited<ReturnType<typeof listImports>>, TError, TData>>;
request?: SecondParameter<typeof customInstance>;
},
queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListImportsQueryOptions(analysisId, options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Retrieve paginated list of functions extracted during a static analysis.
* @summary List functions in an analysis

View File

@ -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;
}

View File

@ -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<string, string>;
}
const FLAG_COLORS: Record<string, string> = {
@ -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<HTMLTableElement>(null);
const pseudoRef = useRef<HTMLTableElement>(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<string, CommentResponse[]> = {};
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 (
<Tabs defaultValue="assembly" className="flex h-full flex-col">
<div className="border-border flex shrink-0 items-center justify-between border-b px-4 py-2">
@ -129,6 +152,32 @@ export function CodeViewer({ func, isLoading }: CodeViewerProps) {
</div>
</div>
{hasStackFrame && (
<div className="border-border shrink-0 border-b px-4 py-1">
<details>
<summary className="text-muted-foreground cursor-pointer text-xs select-none">
Stack Frame ({stackFrame.length} member{stackFrame.length !== 1 ? "s" : ""})
</summary>
<div className="mt-1 flex flex-col">
<div className="text-muted-foreground flex shrink-0 text-xs">
<span className="w-[60px]">Offset</span>
<span className="w-[50px]">Size</span>
<span>Name</span>
</div>
<div className="max-h-40 overflow-y-auto">
{stackFrame.map((m, i) => (
<div key={i} className="even:bg-muted/30 flex font-mono text-xs">
<span className="w-[60px] shrink-0">{m.offset}</span>
<span className="w-[50px] shrink-0">{m.size}</span>
<span className="truncate">{m.name}</span>
</div>
))}
</div>
</div>
</details>
</div>
)}
<TabsContent value="assembly" className="mt-0 flex-1 overflow-hidden">
{hasData ? (
<ScrollArea className="h-full w-full">
@ -143,6 +192,9 @@ export function CodeViewer({ func, isLoading }: CodeViewerProps) {
addressToLabel={addressToLabel}
labelToAddress={labelToAddress}
addressToLabelType={addressToLabelType}
commentsByAddress={commentsByAddress}
onSelectFunction={onSelectFunction}
functionNameToId={functionNameToId}
/>
))}
</tbody>
@ -214,12 +266,18 @@ function AssemblyRow({
addressToLabel,
labelToAddress,
addressToLabelType,
commentsByAddress,
onSelectFunction,
functionNameToId,
}: {
line: string;
lineNumber: number;
addressToLabel: Record<string, string>;
labelToAddress: Record<string, string>;
addressToLabelType: Record<string, string>;
commentsByAddress: Record<string, CommentResponse[]>;
onSelectFunction: (id: string) => void;
functionNameToId: Record<string, string>;
}) {
const trimmed = line.trim();
@ -229,7 +287,7 @@ function AssemblyRow({
<td className="w-12 pr-4 text-right text-[var(--code-line-number)] select-none">
{lineNumber}
</td>
<td colSpan={3}>&nbsp;</td>
<td colSpan={4}>&nbsp;</td>
</tr>
);
}
@ -250,7 +308,7 @@ function AssemblyRow({
<td className="w-[14ch] whitespace-nowrap">
<span className="text-[var(--code-function)]">{labelName}:</span>
</td>
<td colSpan={2}>&nbsp;</td>
<td colSpan={3}>&nbsp;</td>
</tr>
) : null;
@ -260,6 +318,22 @@ function AssemblyRow({
</td>
);
// 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 ? (
<td className="text-xs text-[var(--code-comment)]">
{backendComments.map((c, ci) => {
const body = (c.text ?? "").replace(/^;\s*/, "");
return (
<span key={`ida-cmt-${ci}`}>
{ci > 0 && " "}; {body}
</span>
);
})}
</td>
) : 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}
</td>
{addrCell}
<td className="text-[var(--code-function)]" colSpan={2}>
{rest}
</td>
<td className="text-[var(--code-function)]">{rest}</td>
{commentsCell}
</tr>
</>
);
@ -289,9 +362,8 @@ function AssemblyRow({
{lineNumber}
</td>
{addrCell}
<td className="text-[var(--code-comment)]" colSpan={2}>
{rest}
</td>
<td className="text-[var(--code-comment)]">{rest}</td>
{commentsCell}
</tr>
</>
);
@ -319,8 +391,11 @@ function AssemblyRow({
operands={operands}
labelToAddress={labelToAddress}
labelTypeMap={addressToLabelType}
onSelectFunction={onSelectFunction}
functionNameToId={functionNameToId}
/>
</td>
{commentsCell}
</tr>
</>
);
@ -335,9 +410,8 @@ function AssemblyRow({
{lineNumber}
</td>
{addrCell}
<td className="text-foreground" colSpan={2}>
{rest}
</td>
<td className="text-foreground">{rest}</td>
{commentsCell}
</tr>
</>
);
@ -347,10 +421,14 @@ function HighlightedAssembly({
operands,
labelToAddress,
labelTypeMap,
onSelectFunction,
functionNameToId,
}: {
operands: string;
labelToAddress: Record<string, string>;
labelTypeMap: Record<string, string>;
onSelectFunction: (id: string) => void;
functionNameToId: Record<string, string>;
}) {
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 (
<span>
@ -373,9 +470,18 @@ function HighlightedAssembly({
</span>
);
}
if (/^(loc_\w+|sub_\w+)$/.test(part)) {
const addr = labelToAddress[part];
const ltype = addr ? labelTypeMap[addr] : undefined;
if (/^[a-zA-Z_]\w*$/.test(part)) {
if (KEYWORDS.has(part)) {
return (
<span key={i} className="text-[var(--code-keyword)]">
{part}
</span>
);
}
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)]"
@ -386,19 +492,16 @@ function HighlightedAssembly({
<span
key={i}
className={`cursor-pointer hover:underline ${lcolor}`}
title={addr ? `${addr}${ltype ? ` (${ltype})` : ""}` : undefined}
onClick={() => jumpToLabel(part)}
title={
isLocal ? `${labelToAddress[part]}${ltype ? ` (${ltype})` : ""}` : `${part}`
}
onClick={() => handleLabelClick(part)}
>
{part}
</span>
);
}
if (/^(short|byte|word|dword|ptr|near|far)$/.test(part)) {
return (
<span key={i} className="text-[var(--code-keyword)]">
{part}
</span>
);
return <span key={i}>{part}</span>;
}
return <span key={i}>{part}</span>;
})}

View File

@ -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 (
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-border shrink-0 border-b p-3">
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
<Input
value={dllFilter}
onChange={(e) => setDllFilter(e.target.value)}
placeholder="Filter by DLL..."
className="bg-muted h-8 border-transparent pl-9"
/>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[130px]">DLL</TableHead>
<TableHead>Function</TableHead>
<TableHead className="w-[110px]">Address</TableHead>
<TableHead className="w-[90px] text-right">Ordinal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((imp) => (
<TableRow key={imp.id}>
<TableCell className="text-xs">{imp.dll}</TableCell>
<TableCell className="font-mono text-xs">{imp.name}</TableCell>
<TableCell className="font-mono text-xs">{imp.address}</TableCell>
<TableCell className="text-right text-xs">{imp.ordinal}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length === 0 && (
<div className="text-muted-foreground py-4 text-center text-xs">
{dllFilter ? "No matching imports" : "No imports found"}
</div>
)}
</ScrollArea>
<div className="border-border text-muted-foreground border-t p-3 text-xs">
{dllFilter
? `${filtered.length} of ${imports.length} imports`
: `${imports.length} imports`}
</div>
</div>
);
}

View File

@ -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,

View File

@ -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 = () => {
<TabsList className="mx-3 mt-2 shrink-0">
<TabsTrigger value="functions">Functions</TabsTrigger>
<TabsTrigger value="strings">Strings</TabsTrigger>
{imports && imports.length > 0 && (
<TabsTrigger value="imports">Imports</TabsTrigger>
)}
</TabsList>
<TabsContent
value="functions"
@ -163,6 +168,14 @@ const Binary = () => {
highlightedAddress={highlightedStringAddress}
/>
</TabsContent>
{imports && imports.length > 0 && (
<TabsContent
value="imports"
className="mt-1 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
>
<ImportTable imports={imports} />
</TabsContent>
)}
</Tabs>
</div>
</ResizablePanel>
@ -196,7 +209,12 @@ const Binary = () => {
<ResizablePanel id="code-panel" defaultSize="40%" minSize="30%">
<div className="relative flex h-full flex-col overflow-hidden bg-(--code-bg)">
<CodeViewer func={selectedFunction} isLoading={selectedFunctionLoading} />
<CodeViewer
func={selectedFunction}
isLoading={selectedFunctionLoading}
onSelectFunction={setSelectedFunctionId}
functionNameToId={functionNameToId}
/>
</div>
</ResizablePanel>