Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
853 changes: 311 additions & 542 deletions Cargo.lock

Large diffs are not rendered by default.

32 changes: 24 additions & 8 deletions frontend/app/api/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ const SUPPORTED_SOURCE_EXTENSIONS = new Set([".rs"]);
const MAX_FILE_SIZE_BYTES = 250 * 1024;
const EXECUTION_TIMEOUT_MS = 30000;

function getSettingsFromHeaders(request: NextRequest): {
binPath?: string;
customRulesPath?: string;
} {
const binPath = request.headers.get("x-sanctifier-bin-path") || undefined;
const customRulesPath =
request.headers.get("x-sanctifier-custom-rules") || undefined;
return { binPath, customRulesPath };
}

const rateLimitMap = new Map<string, { count: number; resetTime: number }>();

function getClientIP(request: NextRequest): string {
Expand Down Expand Up @@ -61,16 +71,21 @@ type ProcessResult = {
exitCode: number | null;
};

function runAnalyzeCommand(contractPath: string, timeoutMs: number): Promise<ProcessResult> {
function runAnalyzeCommand(
contractPath: string,
timeoutMs: number,
settings?: { binPath?: string; customRulesPath?: string }
): Promise<ProcessResult> {
return new Promise((resolve, reject) => {
const cliProcess = spawn(
SANCTIFIER_BIN,
["analyze", "--format", "json", contractPath],
{
const bin = settings?.binPath || SANCTIFIER_BIN;
const args = ["analyze", "--format", "json", contractPath];
if (settings?.customRulesPath) {
args.push("--custom-rules", settings.customRulesPath);
}
const cliProcess = spawn(bin, args, {
cwd: REPO_ROOT,
env: { ...process.env, FORCE_COLOR: "0" },
}
);
});
let stdout = "";
let stderr = "";
let timeoutId: NodeJS.Timeout | null = null;
Expand Down Expand Up @@ -260,6 +275,7 @@ export async function POST(request: NextRequest) {
);
}

const settingsFromHeaders = getSettingsFromHeaders(request);
const tempDir = await mkdtemp(path.join(os.tmpdir(), "sanctifier-contract-"));
try {
const contentType = request.headers.get("content-type") ?? "";
Expand Down Expand Up @@ -308,7 +324,7 @@ export async function POST(request: NextRequest) {
const contractPath = path.join(tempDir, sourcePayload.fileName);
await writeFile(contractPath, sourcePayload.source, "utf8");

const { stdout, stderr, exitCode } = await runAnalyzeCommand(contractPath, EXECUTION_TIMEOUT_MS);
const { stdout, stderr, exitCode } = await runAnalyzeCommand(contractPath, EXECUTION_TIMEOUT_MS, settingsFromHeaders);
const report = parseJsonResponse(stdout);

if (report) {
Expand Down
149 changes: 128 additions & 21 deletions frontend/app/components/CodeSnippet.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,140 @@
"use client";

import { useMemo } from "react";

interface CodeSnippetProps {
code: string;
highlightLine?: number;
highlightEndLine?: number;
language?: string;
maxHeight?: string;
}

const RUST_KEYWORDS = new Set([
"as", "break", "const", "continue", "crate", "else", "enum",
"extern", "false", "fn", "for", "if", "impl", "in", "let",
"loop", "match", "mod", "move", "mut", "pub", "ref", "return",
"self", "Self", "static", "struct", "super", "trait", "true",
"type", "unsafe", "use", "where", "while", "async", "await",
"dyn", "abstract", "become", "box", "do", "final", "macro",
"override", "priv", "typeof", "unsized", "virtual", "yield",
"try",
]);

const SOROBAN_TYPES = new Set([
"Address", "Bytes", "BytesN", "String", "Symbol", "Vec", "Map",
"Option", "Result", "Env", "Val", "RawVal", "Bool", "Void",
"I128", "U128", "I256", "U256", "i128", "u128", "u32", "i32",
"u64", "i64", "u8", "i8",
]);

function tokenizeLine(line: string): { text: string; className: string }[] {
const tokens: { text: string; className: string }[] = [];
const regex = /(\/\/.*)|("(?:[^"\\]|\\.)*")|('(?:[^'\\]|\\.)*')|(\b\d[\d_]*(?:\.[\d_]+)?(?:[eE][+-]?\d+)?\b)|(\b[a-zA-Z_]\w*\b)|([{}()\[\];,.:<>!=+\-*/%&|^~@#]|\s+)/g;
let match: RegExpExecArray | null;

while ((match = regex.exec(line)) !== null) {
if (match[1]) {
tokens.push({ text: match[1], className: "text-emerald-500 italic" });
} else if (match[2]) {
tokens.push({ text: match[2], className: "text-amber-400" });
} else if (match[3]) {
tokens.push({ text: match[3], className: "text-amber-400" });
} else if (match[4]) {
tokens.push({ text: match[4], className: "text-cyan-400" });
} else if (match[5]) {
const word = match[5];
if (RUST_KEYWORDS.has(word)) {
tokens.push({ text: word, className: "text-purple-400 font-semibold" });
} else if (SOROBAN_TYPES.has(word)) {
tokens.push({ text: word, className: "text-blue-400" });
} else if (word === word.toUpperCase() && word.length > 1 && word.includes("_")) {
tokens.push({ text: word, className: "text-orange-300" });
} else if (word.startsWith("DataKey") || word.endsWith("Key")) {
tokens.push({ text: word, className: "text-orange-300" });
} else {
tokens.push({ text: word, className: "" });
}
} else if (match[6]) {
const punct = match[6];
if (punct.trim() === "") {
tokens.push({ text: punct, className: "" });
} else {
tokens.push({ text: punct, className: "text-zinc-400" });
}
}
}

if (tokens.length === 0) {
tokens.push({ text: line, className: "" });
}

return tokens;
}

export function CodeSnippet({ code, highlightLine }: CodeSnippetProps) {
const lines = code.split("\n");
export function CodeSnippet({
code,
highlightLine,
highlightEndLine,
language = "rust",
maxHeight,
}: CodeSnippetProps) {
const lines = useMemo(() => code.split("\n"), [code]);
const tokenizedLines = useMemo(
() => lines.map((line) => tokenizeLine(line)),
[lines]
);

const isInRange = (lineNum: number) => {
if (highlightLine === undefined) return false;
if (highlightEndLine !== undefined) {
return lineNum >= highlightLine && lineNum <= highlightEndLine;
}
return lineNum === highlightLine;
};

return (
<pre className="overflow-x-auto rounded-lg bg-zinc-900 dark:bg-zinc-950 p-4 text-sm font-mono text-zinc-100">
<code>
{lines.map((line, i) => (
<div
key={i}
className={`px-2 py-0.5 -mx-2 ${
highlightLine === i + 1
? "bg-amber-500/20 border-l-2 border-amber-500"
: ""
}`}
>
<span className="select-none text-zinc-500 w-8 inline-block mr-4">
{i + 1}
</span>
{line || " "}
</div>
))}
</code>
</pre>
<div
className="overflow-x-auto rounded-lg bg-zinc-900 dark:bg-zinc-950 text-sm font-mono text-zinc-100"
style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}
>
<table className="border-collapse w-full">
<tbody>
{tokenizedLines.map((tokens, i) => {
const lineNum = i + 1;
const highlighted = isInRange(lineNum);
return (
<tr
key={i}
className={
highlighted
? "bg-amber-500/20 border-l-2 border-amber-500"
: ""
}
>
<td className="select-none text-zinc-500 text-right w-10 pr-4 pl-2 py-0.5 align-top">
{lineNum}
</td>
<td className="py-0.5 whitespace-pre">
{tokens.length === 1 && tokens[0].text === "" ? (
<span>&nbsp;</span>
) : (
tokens.map((t, j) =>
t.className ? (
<span key={j} className={t.className}>
{t.text}
</span>
) : (
<span key={j}>{t.text}</span>
)
)
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
97 changes: 66 additions & 31 deletions frontend/app/components/FindingsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { FixedSizeList, type ListChildComponentProps } from "react-window";
import type { Finding, Severity } from "../types";
import { CodeSnippet } from "./CodeSnippet";
import { Sparkles } from "lucide-react";
import { SourceViewer } from "./SourceViewer";
import { Sparkles, FileCode } from "lucide-react";
import { AiFixPanel } from "./AiFixPanel";
import { filterFindings } from "../lib/finding-filters";

interface FindingsListProps {
findings: Finding[];
severityFilter: Severity | "all";
codeFilter?: string;
getSource?: (finding: Finding) => string | undefined;
getFileName?: (finding: Finding) => string | undefined;
}

/** Height reserved for each finding row in the virtual list (px). */
Expand All @@ -38,9 +41,10 @@ const severityLabels: Record<Severity, string> = {
interface FindingCardProps {
finding: Finding;
onSelectAiFix: (finding: Finding) => void;
onViewSource?: (finding: Finding) => void;
}

function FindingCard({ finding, onSelectAiFix }: FindingCardProps) {
function FindingCard({ finding, onSelectAiFix, onViewSource }: FindingCardProps) {
return (
<div className={`rounded-lg border p-4 ${severityColors[finding.severity]}`}>
<div className="flex items-start justify-between gap-4">
Expand All @@ -65,6 +69,17 @@ function FindingCard({ finding, onSelectAiFix }: FindingCardProps) {
)}
</div>
<div className="shrink-0 flex items-center gap-2">
{onViewSource && (
<button
onClick={() => onViewSource(finding)}
aria-label="View full source file"
className="flex items-center gap-1 px-2 py-1 rounded-md bg-zinc-500/10 text-zinc-400 dark:text-zinc-400 text-[10px] font-bold border border-zinc-500/20 hover:bg-zinc-500/20 transition-colors"
title="View full source file"
>
<FileCode size={10} />
SOURCE
</button>
)}
<span
className={`rounded px-2 py-1 text-xs font-medium border ${severityColors[finding.severity]}`}
aria-label={severityLabels[finding.severity]}
Expand All @@ -85,8 +100,9 @@ function FindingCard({ finding, onSelectAiFix }: FindingCardProps) {
);
}

export function FindingsList({ findings, severityFilter, codeFilter = "" }: FindingsListProps) {
export function FindingsList({ findings, severityFilter, codeFilter = "", getSource, getFileName }: FindingsListProps) {
const [selectedFinding, setSelectedFinding] = useState<Finding | null>(null);
const [sourceViewerFinding, setSourceViewerFinding] = useState<Finding | null>(null);
const listRef = useRef<FixedSizeList>(null);

const filtered = useMemo(() => {
Expand All @@ -98,16 +114,25 @@ export function FindingsList({ findings, severityFilter, codeFilter = "" }: Find
listRef.current?.scrollToItem(0);
}, [severityFilter, codeFilter]);

const handleViewSource = useCallback((finding: Finding) => {
setSourceViewerFinding(finding);
}, []);

const handleCloseSource = useCallback(() => {
setSourceViewerFinding(null);
}, []);

const Row = useCallback(
({ index, style }: ListChildComponentProps) => (
<div style={{ ...style, paddingBottom: 16 }}>
<FindingCard
finding={filtered[index]}
onSelectAiFix={(f) => setSelectedFinding(f)}
onViewSource={getSource ? handleViewSource : undefined}
/>
</div>
),
[filtered],
[filtered, getSource, handleViewSource],
);

if (filtered.length === 0) {
Expand All @@ -118,37 +143,47 @@ export function FindingsList({ findings, severityFilter, codeFilter = "" }: Find
);
}

// For small lists render items directly — no virtualisation overhead.
if (filtered.length < VIRTUALISE_THRESHOLD) {
return (
<div className="space-y-4">
<AiFixPanel finding={selectedFinding} onClose={() => setSelectedFinding(null)} />
{filtered.map((f) => (
<FindingCard
key={f.id}
finding={f}
onSelectAiFix={(finding) => setSelectedFinding(finding)}
/>
))}
</div>
);
}

// For large lists (1000+) use a fixed-size virtual window.
const listHeight = Math.min(filtered.length * ITEM_HEIGHT, MAX_LIST_HEIGHT);
const sourceViewerSource = sourceViewerFinding && getSource ? getSource(sourceViewerFinding) : undefined;

return (
<div className="relative">
{sourceViewerSource && sourceViewerFinding && (
<SourceViewer
source={sourceViewerSource}
fileName={getFileName?.(sourceViewerFinding) ?? "contract.rs"}
highlightLine={sourceViewerFinding.line}
highlightEndLine={sourceViewerFinding.line}
onClose={handleCloseSource}
/>
)}
<AiFixPanel finding={selectedFinding} onClose={() => setSelectedFinding(null)} />
<FixedSizeList
height={listHeight}
itemCount={filtered.length}
itemSize={ITEM_HEIGHT}
width="100%"
ref={listRef}
>
{Row}
</FixedSizeList>

{/* For small lists render items directly — no virtualisation overhead. */}
{filtered.length < VIRTUALISE_THRESHOLD ? (
<div className="space-y-4">
{filtered.map((f) => (
<FindingCard
key={f.id}
finding={f}
onSelectAiFix={(finding) => setSelectedFinding(finding)}
onViewSource={getSource ? handleViewSource : undefined}
/>
))}
</div>
) : (
<>
{/* For large lists (1000+) use a fixed-size virtual window. */}
<FixedSizeList
height={Math.min(filtered.length * ITEM_HEIGHT, MAX_LIST_HEIGHT)}
itemCount={filtered.length}
itemSize={ITEM_HEIGHT}
width="100%"
ref={listRef}
>
{Row}
</FixedSizeList>
</>
)}
</div>
);
}
Loading
Loading