mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-06 19:35:13 +02:00
* feat: Add const_bound_vars tracking to prevent false positives in ownership checks
* feat: Introduce field interner and typed bounded vars for enhanced type tracking
* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking
* feat: Centralize method name extraction with bare_method_name helper
* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch
* feat: Enhance C++ taint tracking with additional container operations and inline method resolution
* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking
* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis
* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations
* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details
* test: Add comprehensive tests for lattice algebra laws and SSA edge cases
* feat: Add destructured session user handling and safe user ID access patterns
* feat: Implement row-population reverse-walk for enhanced authorization checks
* feat: Enhance authorization checks with local alias chain for self-actor types
* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction
* feat: Implement chained method call inner-gate rebinding for SSRF prevention
* feat: Add observability and error modules, enhance debug functionality, and implement theme context
* feat: Remove Auth Analysis page and update navigation to redirect to Explorer
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity
* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build
The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(closure-capture): flip JS/TS fixtures to required-finding
The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.
Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".
Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis
* feat: Introduce health module and enhance health score computation with calibration tests
* feat: Add expectations configuration and cleanup .gitignore for log files
* feat: Implement theme selection and enhance settings panel for triage sync
* feat: Suppress false positives for strcpy calls with literal sources in AST
* feat: Update analyse_function_ssa to return body CFG for accurate analysis
* feat: Add bug report and feature request templates for improved issue tracking
* feat: removed dev scripts
* feat: update README.md for clarity and consistency in fixture descriptions
* feat: removed dev docs
* feat: clean up error handling and UI elements for improved user experience
* feat: adjust button sizes in HeaderBar for better UI consistency
* feat: enhance taint analysis with additional context for sanitizer and taint findings
* cargo fmt
* prettier
* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts
* feat: add script to frame PNG screenshots with brand gradient
* feat: add fuzzing support with new targets and CI workflows
* refactor: streamline match expressions and improve formatting in CLI and output handling
* feat: enhance configuration display with detailed output options
* feat: stage demo configuration for improved CLI screenshot output
* feat: expose merge_configs function for user-configurable settings
* refactor: simplify code structure and improve readability in config handling
* refactor: improve descriptions for vulnerability patterns in various languages
* feat: update MIT License section with additional usage details and copyright information
* feat: update screenshots
* refactor: update build process and paths for frontend assets
* feat: add cross-file taint fuzzing target and supporting dictionary
* refactor: clean up formatting and comments in fuzz configuration and example files
* refactor: remove outdated comments and clean up CI configuration files
* chore: update changelog dates and improve formatting in documentation
* refactor: update Cargo.toml and CI configuration for improved packaging and build process
* refactor: enhance quote-stripping logic to prevent panics and add regression tests
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
977 lines
30 KiB
TypeScript
977 lines
30 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import {
|
|
useExplorerSymbols,
|
|
useExplorerFindings,
|
|
} from '../api/queries/explorer';
|
|
import { useFinding } from '../api/queries/findings';
|
|
import { useDebugFunctions } from '../api/queries/debug';
|
|
import { ApiError } from '../api/client';
|
|
import { FileTree } from '../components/data-display/FileTree';
|
|
import { CodeViewer } from '../components/data-display/CodeViewer';
|
|
import { LoadingState } from '../components/ui/LoadingState';
|
|
import { usePageTitle } from '../hooks/usePageTitle';
|
|
import { EmptyState } from '../components/ui/EmptyState';
|
|
import { ExplorerIcon } from '../components/icons/Icons';
|
|
import { useFileTree } from '../hooks/useFileTree';
|
|
import { FunctionSelector } from './debug/FunctionSelector';
|
|
import { CfgAnalysisPanel } from './debug/CfgViewerPage';
|
|
import { SsaAnalysisPanel } from './debug/SsaViewerPage';
|
|
import { TaintAnalysisPanel } from './debug/TaintViewerPage';
|
|
import { SummaryAnalysisPanel } from './debug/SummaryExplorerPage';
|
|
import { AbstractInterpAnalysisPanel } from './debug/AbstractInterpPage';
|
|
import { SymexAnalysisPanel } from './debug/SymexPage';
|
|
import { PointerAnalysisPanel } from './debug/PointerViewerPage';
|
|
import { TypeFactsAnalysisPanel } from './debug/TypeFactsPage';
|
|
import { AuthAnalysisPanel } from './debug/AuthAnalysisPage';
|
|
import type { TreeEntry, FlowStep, FindingView } from '../api/types';
|
|
|
|
type ExplorerMode = 'tree' | 'symbols' | 'hotspots';
|
|
type ExplorerView =
|
|
| 'code'
|
|
| 'cfg'
|
|
| 'ssa'
|
|
| 'taint'
|
|
| 'summaries'
|
|
| 'abstract-interp'
|
|
| 'symex'
|
|
| 'pointer'
|
|
| 'type-facts'
|
|
| 'auth';
|
|
|
|
const FLOW_KIND_COLORS: Record<string, string> = {
|
|
source: 'var(--success)',
|
|
assignment: 'var(--accent)',
|
|
call: 'var(--sev-medium)',
|
|
phi: 'var(--text-tertiary)',
|
|
sink: 'var(--sev-high)',
|
|
};
|
|
|
|
const FLOW_KIND_LABELS: Record<string, string> = {
|
|
source: 'Source',
|
|
assignment: 'Assign',
|
|
call: 'Call',
|
|
phi: 'Phi',
|
|
sink: 'Sink',
|
|
};
|
|
|
|
const VIEW_CONFIG: Array<{
|
|
id: ExplorerView;
|
|
label: string;
|
|
requiresFunction?: boolean;
|
|
supportsFunction?: boolean;
|
|
}> = [
|
|
{ id: 'code', label: 'Code' },
|
|
{ id: 'cfg', label: 'CFG', requiresFunction: true, supportsFunction: true },
|
|
{ id: 'ssa', label: 'SSA', requiresFunction: true, supportsFunction: true },
|
|
{
|
|
id: 'taint',
|
|
label: 'Taint',
|
|
requiresFunction: true,
|
|
supportsFunction: true,
|
|
},
|
|
{ id: 'summaries', label: 'Summaries', supportsFunction: true },
|
|
{
|
|
id: 'abstract-interp',
|
|
label: 'Abstract Interp',
|
|
requiresFunction: true,
|
|
supportsFunction: true,
|
|
},
|
|
{
|
|
id: 'symex',
|
|
label: 'Symex',
|
|
requiresFunction: true,
|
|
supportsFunction: true,
|
|
},
|
|
{
|
|
id: 'pointer',
|
|
label: 'Pointer',
|
|
requiresFunction: true,
|
|
supportsFunction: true,
|
|
},
|
|
{
|
|
id: 'type-facts',
|
|
label: 'Type Facts',
|
|
requiresFunction: true,
|
|
supportsFunction: true,
|
|
},
|
|
{ id: 'auth', label: 'Auth' },
|
|
];
|
|
|
|
const VIEW_CONFIG_BY_ID = new Map(VIEW_CONFIG.map((view) => [view.id, view]));
|
|
|
|
export function ExplorerPage() {
|
|
usePageTitle('Explorer');
|
|
const [params, setParams] = useSearchParams();
|
|
const [explorerMode, setExplorerMode] = useState<ExplorerMode>('tree');
|
|
const [showClosures, setShowClosures] = useState(false);
|
|
const [highlightLine, setHighlightLine] = useState<number | undefined>();
|
|
const [selectedFindingIndex, setSelectedFindingIndex] = useState<
|
|
number | null
|
|
>(null);
|
|
const [invalidFunctionNotice, setInvalidFunctionNotice] = useState<
|
|
string | null
|
|
>(null);
|
|
const codeScrollPositionsRef = useRef<Record<string, number>>({});
|
|
|
|
const rawView = params.get('view');
|
|
const rawFile = params.get('file') || null;
|
|
const rawFunction = params.get('function') || null;
|
|
const currentView: ExplorerView = isExplorerView(rawView) ? rawView : 'code';
|
|
const currentViewConfig = VIEW_CONFIG_BY_ID.get(currentView)!;
|
|
const isCodeView = currentView === 'code';
|
|
|
|
const updateExplorerParams = useCallback(
|
|
(
|
|
updates: Partial<Record<'file' | 'view' | 'function', string | null>>,
|
|
replace = false,
|
|
) => {
|
|
setParams(
|
|
(prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (value) {
|
|
next.set(key, value);
|
|
} else {
|
|
next.delete(key);
|
|
}
|
|
}
|
|
return next;
|
|
},
|
|
{ replace },
|
|
);
|
|
},
|
|
[setParams],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (rawView !== currentView) {
|
|
updateExplorerParams({ view: currentView }, true);
|
|
}
|
|
}, [currentView, rawView, updateExplorerParams]);
|
|
|
|
const { data: symbolEntries, error: symbolsError } =
|
|
useExplorerSymbols(rawFile);
|
|
|
|
const closureSymbolCount = useMemo(
|
|
() => symbolEntries?.filter((s) => s.func_kind === 'closure').length ?? 0,
|
|
[symbolEntries],
|
|
);
|
|
|
|
const visibleSymbolEntries = useMemo(() => {
|
|
if (!symbolEntries) return symbolEntries;
|
|
return showClosures
|
|
? symbolEntries
|
|
: symbolEntries.filter((s) => s.func_kind !== 'closure');
|
|
}, [symbolEntries, showClosures]);
|
|
const hasInvalidFile = Boolean(
|
|
rawFile && isPathResolutionError(symbolsError),
|
|
);
|
|
const hasFileLookupError = Boolean(
|
|
rawFile && symbolsError && !hasInvalidFile,
|
|
);
|
|
const selectedFile = rawFile && !hasInvalidFile ? rawFile : null;
|
|
|
|
const handleFileSelect = useCallback(
|
|
(path: string) => {
|
|
setHighlightLine(undefined);
|
|
setSelectedFindingIndex(null);
|
|
setInvalidFunctionNotice(null);
|
|
updateExplorerParams({ file: path, function: null });
|
|
},
|
|
[updateExplorerParams],
|
|
);
|
|
|
|
const {
|
|
rootEntries,
|
|
isLoading: treeLoading,
|
|
expandedPaths,
|
|
loadedChildren,
|
|
selectedPath,
|
|
handleToggleExpand,
|
|
handleSelectFile,
|
|
} = useFileTree(selectedFile, handleFileSelect);
|
|
|
|
const { data: functions, isLoading: functionsLoading } =
|
|
useDebugFunctions(selectedFile);
|
|
const selectedFunction =
|
|
rawFunction && functions?.some((fn) => fn.name === rawFunction)
|
|
? rawFunction
|
|
: null;
|
|
const hasFunctionOptions = (functions?.length ?? 0) > 0;
|
|
|
|
useEffect(() => {
|
|
if (!rawFunction) {
|
|
return;
|
|
}
|
|
|
|
if (!selectedFile) {
|
|
setInvalidFunctionNotice(
|
|
`Function "${rawFunction}" was cleared because no valid file is selected.`,
|
|
);
|
|
updateExplorerParams({ function: null }, true);
|
|
return;
|
|
}
|
|
|
|
if (!functions) {
|
|
return;
|
|
}
|
|
|
|
if (!functions.some((fn) => fn.name === rawFunction)) {
|
|
setInvalidFunctionNotice(
|
|
`Function "${rawFunction}" was not found in ${selectedFile}.`,
|
|
);
|
|
updateExplorerParams({ function: null }, true);
|
|
}
|
|
}, [functions, rawFunction, selectedFile, updateExplorerParams]);
|
|
|
|
const { data: findings } = useExplorerFindings(selectedFile);
|
|
const { data: fullFinding } = useFinding(selectedFindingIndex ?? '');
|
|
|
|
const handleSelectFinding = useCallback((index: number, line: number) => {
|
|
setSelectedFindingIndex(index);
|
|
setHighlightLine(line);
|
|
}, []);
|
|
|
|
const handleViewSelect = useCallback(
|
|
(view: ExplorerView) => {
|
|
updateExplorerParams({ view });
|
|
},
|
|
[updateExplorerParams],
|
|
);
|
|
|
|
const handleFunctionChange = useCallback(
|
|
(fnName: string | null) => {
|
|
setInvalidFunctionNotice(null);
|
|
updateExplorerParams({ function: fnName });
|
|
},
|
|
[updateExplorerParams],
|
|
);
|
|
|
|
const selectedEntry = findEntry(rootEntries, loadedChildren, selectedFile);
|
|
const language = selectedEntry?.language || '';
|
|
const hotspotFiles = useMemo(
|
|
() => buildHotspotList(rootEntries, loadedChildren),
|
|
[loadedChildren, rootEntries],
|
|
);
|
|
|
|
const sevBreakdown = findings
|
|
? findings.reduce(
|
|
(acc, finding) => {
|
|
const key = finding.severity.toUpperCase();
|
|
acc[key] = (acc[key] || 0) + 1;
|
|
return acc;
|
|
},
|
|
{} as Record<string, number>,
|
|
)
|
|
: {};
|
|
|
|
const evidence = fullFinding?.evidence;
|
|
const flowSteps = evidence?.flow_steps;
|
|
const hasFlow = flowSteps && flowSteps.length > 0;
|
|
const hasStateEvidence =
|
|
fullFinding?.rule_id.startsWith('state-') && evidence?.state;
|
|
|
|
const codeHighlights =
|
|
selectedFindingIndex != null && evidence
|
|
? {
|
|
sourceLine: evidence.source?.line,
|
|
sinkLine: evidence.sink?.line,
|
|
findingLine: fullFinding?.line,
|
|
}
|
|
: undefined;
|
|
|
|
const flowLineSet = new Set<number>();
|
|
if (hasFlow) {
|
|
for (const step of flowSteps) {
|
|
if (step.line) {
|
|
flowLineSet.add(step.line);
|
|
}
|
|
}
|
|
}
|
|
|
|
const analysisContent = renderAnalysisContent({
|
|
currentView,
|
|
currentViewLabel: currentViewConfig.label,
|
|
selectedFile,
|
|
selectedFunction,
|
|
functions,
|
|
functionsLoading,
|
|
onBrowseFiles: () => handleViewSelect('code'),
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className={`explorer-page ${isCodeView ? 'explorer-page-code' : 'explorer-page-analysis'}`}
|
|
>
|
|
<div className="explorer-left">
|
|
<div className="explorer-left-header">
|
|
<div className="explorer-mode-toggle">
|
|
{(['tree', 'symbols', 'hotspots'] as ExplorerMode[]).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
className={`mode-btn${explorerMode === mode ? ' active' : ''}`}
|
|
onClick={() => setExplorerMode(mode)}
|
|
>
|
|
{mode === 'tree'
|
|
? 'Files'
|
|
: mode === 'symbols'
|
|
? 'Symbols'
|
|
: 'Hotspots'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="explorer-left-body">
|
|
{explorerMode === 'tree' && (
|
|
<>
|
|
{treeLoading && <LoadingState message="Loading files..." />}
|
|
{rootEntries && (
|
|
<FileTree
|
|
entries={rootEntries}
|
|
expandedPaths={expandedPaths}
|
|
selectedPath={selectedPath}
|
|
onToggleExpand={handleToggleExpand}
|
|
onSelectFile={handleSelectFile}
|
|
loadedChildren={loadedChildren}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{explorerMode === 'symbols' && (
|
|
<div className="explorer-symbol-list">
|
|
{!selectedFile && (
|
|
<div className="explorer-hint">
|
|
Select a file to view symbols
|
|
</div>
|
|
)}
|
|
{selectedFile && symbolEntries && symbolEntries.length === 0 && (
|
|
<div className="explorer-hint">No symbols found</div>
|
|
)}
|
|
{selectedFile && closureSymbolCount > 0 && (
|
|
<label className="explorer-symbol-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={showClosures}
|
|
onChange={(e) => setShowClosures(e.target.checked)}
|
|
/>
|
|
<span>
|
|
Show {closureSymbolCount} anonymous closure
|
|
{closureSymbolCount === 1 ? '' : 's'}
|
|
</span>
|
|
</label>
|
|
)}
|
|
{selectedFile &&
|
|
visibleSymbolEntries?.map((sym, index) => (
|
|
<div
|
|
key={`${sym.name}-${index}`}
|
|
className="explorer-symbol-item"
|
|
>
|
|
<span className={`symbol-kind symbol-kind-${sym.kind}`}>
|
|
{sym.kind === 'function' ? 'ƒ' : 'm'}
|
|
</span>
|
|
<span className="symbol-name">{sym.name}</span>
|
|
{sym.arity !== undefined && sym.arity !== null && (
|
|
<span className="symbol-arity">({sym.arity})</span>
|
|
)}
|
|
{sym.func_kind === 'closure' && (
|
|
<span
|
|
className="text-secondary"
|
|
style={{ marginLeft: 6, fontSize: '0.85em' }}
|
|
>
|
|
{sym.container
|
|
? `[closure in ${sym.container}]`
|
|
: '[closure]'}
|
|
</span>
|
|
)}
|
|
{sym.finding_count > 0 && (
|
|
<span className="tree-node-badge">
|
|
{sym.finding_count}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{explorerMode === 'hotspots' && (
|
|
<div className="explorer-hotspot-list">
|
|
{hotspotFiles.length === 0 && (
|
|
<div className="explorer-hint">
|
|
No findings in scanned files
|
|
</div>
|
|
)}
|
|
{hotspotFiles.map((entry) => (
|
|
<div
|
|
key={entry.path}
|
|
className={`hotspot-item${selectedFile === entry.path ? ' selected' : ''}`}
|
|
onClick={() => handleSelectFile(entry.path)}
|
|
>
|
|
<span className="hotspot-name" title={entry.path}>
|
|
{entry.name}
|
|
</span>
|
|
<span className="hotspot-count">
|
|
<span
|
|
className={`badge badge-sev badge-sev-${(entry.severity_max || 'low').toLowerCase()}`}
|
|
>
|
|
{entry.finding_count}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="explorer-main-shell">
|
|
<div className="explorer-file-header">
|
|
<div className="explorer-file-header-top">
|
|
<div className="explorer-file-header-copy">
|
|
<span className="explorer-file-label">File</span>
|
|
<span className="explorer-file-path">
|
|
{selectedFile || 'Select a file in Explorer'}
|
|
</span>
|
|
</div>
|
|
{selectedFile && currentViewConfig.supportsFunction && (
|
|
<FunctionSelector
|
|
file={selectedFile}
|
|
selectedFunction={selectedFunction}
|
|
onFunctionChange={handleFunctionChange}
|
|
showFilePath={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="explorer-view-tabs"
|
|
role="tablist"
|
|
aria-label="File views"
|
|
>
|
|
{VIEW_CONFIG.map((view) => (
|
|
<button
|
|
key={view.id}
|
|
className={`explorer-view-tab${currentView === view.id ? ' active' : ''}`}
|
|
onClick={() => handleViewSelect(view.id)}
|
|
type="button"
|
|
>
|
|
{view.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{hasInvalidFile && rawFile && (
|
|
<div className="explorer-inline-notice">
|
|
The requested file <code>{rawFile}</code> could not be found.
|
|
Choose another file in Explorer.
|
|
</div>
|
|
)}
|
|
{hasFileLookupError && (
|
|
<div className="explorer-inline-notice explorer-inline-notice-warning">
|
|
Explorer could not validate the selected file right now.
|
|
</div>
|
|
)}
|
|
{invalidFunctionNotice && (
|
|
<div className="explorer-inline-notice">
|
|
{invalidFunctionNotice}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="explorer-main-body">
|
|
{isCodeView ? (
|
|
<>
|
|
{!selectedFile && (
|
|
<EmptyState
|
|
icon={<ExplorerIcon size={48} />}
|
|
message={
|
|
hasInvalidFile
|
|
? 'Choose a file from the Explorer to continue.'
|
|
: 'Select a file from the tree to view its contents.'
|
|
}
|
|
/>
|
|
)}
|
|
{selectedFile && (
|
|
<CodeViewer
|
|
filePath={selectedFile}
|
|
findings={findings || undefined}
|
|
highlights={codeHighlights}
|
|
highlightLine={highlightLine}
|
|
flowLines={flowLineSet.size > 0 ? flowLineSet : undefined}
|
|
language={language}
|
|
initialScrollTop={
|
|
codeScrollPositionsRef.current[selectedFile]
|
|
}
|
|
onScrollPositionChange={(scrollTop) => {
|
|
codeScrollPositionsRef.current[selectedFile] = scrollTop;
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
analysisContent
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isCodeView && (
|
|
<div className="explorer-right">
|
|
{!selectedFile && (
|
|
<div className="explorer-right-section">
|
|
<div className="explorer-hint">
|
|
Select a file to view analysis details
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedFile && (
|
|
<>
|
|
<div className="explorer-right-section">
|
|
<h3>File Summary</h3>
|
|
<div className="explorer-file-meta">
|
|
{language && <span className="badge">{language}</span>}
|
|
<span className="meta-text">
|
|
{findings ? findings.length : 0} finding
|
|
{findings?.length !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
{findings && findings.length > 0 && (
|
|
<div className="explorer-sev-breakdown">
|
|
{Object.entries(sevBreakdown)
|
|
.sort(([a], [b]) => sevOrder(a) - sevOrder(b))
|
|
.map(([sev, count]) => (
|
|
<span
|
|
key={sev}
|
|
className={`badge badge-sev badge-sev-${sev.toLowerCase()}`}
|
|
>
|
|
{sev}: {count}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="explorer-right-section">
|
|
<h3>Symbols</h3>
|
|
{symbolEntries && symbolEntries.length === 0 && (
|
|
<div className="explorer-hint">No symbols found</div>
|
|
)}
|
|
{visibleSymbolEntries?.map((sym, index) => (
|
|
<div
|
|
key={`${sym.name}-${index}`}
|
|
className="explorer-symbol-item compact"
|
|
>
|
|
<span className={`symbol-kind symbol-kind-${sym.kind}`}>
|
|
{sym.kind === 'function' ? 'ƒ' : 'm'}
|
|
</span>
|
|
<span className="symbol-name">{sym.name}</span>
|
|
{sym.func_kind === 'closure' && (
|
|
<span
|
|
className="text-secondary"
|
|
style={{ marginLeft: 6, fontSize: '0.85em' }}
|
|
>
|
|
[closure]
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
{!showClosures && closureSymbolCount > 0 && (
|
|
<button
|
|
className="explorer-symbol-toggle-link"
|
|
type="button"
|
|
onClick={() => setShowClosures(true)}
|
|
>
|
|
Show {closureSymbolCount} closure
|
|
{closureSymbolCount === 1 ? '' : 's'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="explorer-right-section">
|
|
<h3>Findings</h3>
|
|
{findings && findings.length === 0 && (
|
|
<div className="explorer-hint">No findings in this file</div>
|
|
)}
|
|
<div className="explorer-findings-list">
|
|
{findings?.map((finding) => (
|
|
<div
|
|
key={`${finding.line}-${finding.rule_id}`}
|
|
className={`explorer-finding-item${selectedFindingIndex === finding.index ? ' active' : ''}`}
|
|
onClick={() =>
|
|
handleSelectFinding(finding.index, finding.line)
|
|
}
|
|
>
|
|
<span
|
|
className={`finding-sev-dot sev-${finding.severity.toLowerCase()}`}
|
|
/>
|
|
<span className="finding-line">L{finding.line}</span>
|
|
<span className="finding-rule">{finding.rule_id}</span>
|
|
{finding.message && (
|
|
<span className="finding-msg" title={finding.message}>
|
|
{finding.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{hasFlow && (
|
|
<div className="explorer-right-section">
|
|
<h3>Taint Flow</h3>
|
|
<ExplorerFlowTimeline
|
|
steps={flowSteps}
|
|
onStepClick={(line) => setHighlightLine(line)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{hasStateEvidence && fullFinding && (
|
|
<ExplorerStateDetail finding={fullFinding} />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderAnalysisContent({
|
|
currentView,
|
|
currentViewLabel,
|
|
selectedFile,
|
|
selectedFunction,
|
|
functions,
|
|
functionsLoading,
|
|
onBrowseFiles,
|
|
}: {
|
|
currentView: ExplorerView;
|
|
currentViewLabel: string;
|
|
selectedFile: string | null;
|
|
selectedFunction: string | null;
|
|
functions: Array<{ name: string }> | undefined;
|
|
functionsLoading: boolean;
|
|
onBrowseFiles: () => void;
|
|
}) {
|
|
if (!selectedFile) {
|
|
return (
|
|
<EmptyState
|
|
icon={<ExplorerIcon size={48} />}
|
|
message="Select a file from the tree to view its contents."
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (currentView === 'summaries') {
|
|
return (
|
|
<div className="explorer-analysis-content">
|
|
<SummaryAnalysisPanel
|
|
file={selectedFile}
|
|
functionName={selectedFunction}
|
|
scope="file"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (currentView === 'auth') {
|
|
return (
|
|
<div className="explorer-analysis-content">
|
|
<AuthAnalysisPanel file={selectedFile} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (functionsLoading) {
|
|
return <LoadingState message="Loading functions..." />;
|
|
}
|
|
|
|
if ((functions?.length ?? 0) === 0) {
|
|
return (
|
|
<AnalysisEmptyState
|
|
title="No functions found"
|
|
message="This file does not expose any functions for function-scoped analysis."
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (!selectedFunction) {
|
|
return (
|
|
<AnalysisEmptyState
|
|
title={`Select a function to inspect ${currentViewLabel}`}
|
|
message={`Choose a function in the header to view ${currentViewLabel.toLowerCase()} for this file.`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
switch (currentView) {
|
|
case 'cfg':
|
|
return (
|
|
<CfgAnalysisPanel file={selectedFile} functionName={selectedFunction} />
|
|
);
|
|
case 'ssa':
|
|
return (
|
|
<div className="explorer-analysis-content">
|
|
<SsaAnalysisPanel
|
|
file={selectedFile}
|
|
functionName={selectedFunction}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'taint':
|
|
return (
|
|
<div className="explorer-analysis-content">
|
|
<TaintAnalysisPanel
|
|
file={selectedFile}
|
|
functionName={selectedFunction}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'abstract-interp':
|
|
return (
|
|
<div className="explorer-analysis-content">
|
|
<AbstractInterpAnalysisPanel
|
|
file={selectedFile}
|
|
functionName={selectedFunction}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'symex':
|
|
return (
|
|
<div className="explorer-analysis-content">
|
|
<SymexAnalysisPanel
|
|
file={selectedFile}
|
|
functionName={selectedFunction}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'pointer':
|
|
return (
|
|
<div className="explorer-analysis-content">
|
|
<PointerAnalysisPanel
|
|
file={selectedFile}
|
|
functionName={selectedFunction}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'type-facts':
|
|
return (
|
|
<div className="explorer-analysis-content">
|
|
<TypeFactsAnalysisPanel
|
|
file={selectedFile}
|
|
functionName={selectedFunction}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'code':
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function AnalysisEmptyState({
|
|
title,
|
|
message,
|
|
onBrowseFiles,
|
|
}: {
|
|
title: string;
|
|
message: string;
|
|
onBrowseFiles?: () => void;
|
|
}) {
|
|
return (
|
|
<EmptyState>
|
|
<h3>{title}</h3>
|
|
<p>{message}</p>
|
|
{onBrowseFiles && (
|
|
<button className="btn btn-primary btn-sm" onClick={onBrowseFiles}>
|
|
Browse Files
|
|
</button>
|
|
)}
|
|
</EmptyState>
|
|
);
|
|
}
|
|
|
|
function ExplorerFlowTimeline({
|
|
steps,
|
|
onStepClick,
|
|
}: {
|
|
steps: FlowStep[];
|
|
onStepClick: (line: number) => void;
|
|
}) {
|
|
return (
|
|
<div className="flow-timeline explorer-flow">
|
|
{steps.map((step, index) => {
|
|
const color = FLOW_KIND_COLORS[step.kind] || 'var(--text-secondary)';
|
|
const label = FLOW_KIND_LABELS[step.kind] || step.kind;
|
|
const isLast = index === steps.length - 1;
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={`flow-step${step.is_cross_file ? ' flow-step-cross-file' : ''}`}
|
|
onClick={() => step.line && onStepClick(step.line)}
|
|
>
|
|
<div className="flow-step-connector">
|
|
<div className="flow-step-dot" style={{ background: color }} />
|
|
{!isLast && <div className="flow-step-line" />}
|
|
</div>
|
|
<div className="flow-step-card">
|
|
<div className="flow-step-header">
|
|
<span className="flow-step-badge" style={{ color }}>
|
|
{label}
|
|
</span>
|
|
{step.variable && (
|
|
<span className="flow-step-var">{step.variable}</span>
|
|
)}
|
|
{step.callee && (
|
|
<span className="flow-step-callee">{step.callee}</span>
|
|
)}
|
|
</div>
|
|
<div className="flow-step-loc">
|
|
L{step.line}:{step.col}
|
|
{step.function ? ` in ${step.function}` : ''}
|
|
</div>
|
|
{step.snippet && (
|
|
<div className="flow-step-snippet">{step.snippet}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const STATE_REMEDIATION_HINTS: Record<string, string> = {
|
|
'state-use-after-close':
|
|
'Ensure the resource is not accessed after calling close/free.',
|
|
'state-double-close':
|
|
'Remove the duplicate close call, or guard with a null/closed check.',
|
|
'state-resource-leak':
|
|
'Add a close/free call before the function exits, or use defer/with/try-with-resources/RAII.',
|
|
'state-resource-leak-possible':
|
|
'Ensure the resource is closed on all code paths, including error/early-return paths.',
|
|
'state-unauthed-access':
|
|
'Add an authentication check before this operation, or move it behind auth middleware.',
|
|
};
|
|
|
|
function ExplorerStateDetail({ finding }: { finding: FindingView }) {
|
|
const state = finding.evidence?.state;
|
|
if (!state) {
|
|
return null;
|
|
}
|
|
|
|
const isAuth = state.machine === 'auth';
|
|
const machineLabel = isAuth ? 'Authentication State' : 'Resource Lifecycle';
|
|
const hint = STATE_REMEDIATION_HINTS[finding.rule_id];
|
|
const acquireLocation =
|
|
finding.rule_id.includes('leak') && finding.evidence?.sink
|
|
? `L${finding.evidence.sink.line}:${finding.evidence.sink.col}`
|
|
: null;
|
|
|
|
return (
|
|
<div className="explorer-right-section">
|
|
<h3>State Analysis</h3>
|
|
<div className="state-transition-card">
|
|
<div className="state-machine-label">{machineLabel}</div>
|
|
{state.subject && (
|
|
<div className="state-subject">
|
|
<span className="state-subject-label">Variable:</span>
|
|
<code className="state-subject-name">{state.subject}</code>
|
|
</div>
|
|
)}
|
|
<div className="state-transition-visual">
|
|
<span className="state-from">{state.from_state}</span>
|
|
<span className="state-arrow">→</span>
|
|
<span className="state-to">{state.to_state}</span>
|
|
</div>
|
|
{acquireLocation && (
|
|
<div className="state-acquire-location">
|
|
Acquired at: {acquireLocation}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{hint && (
|
|
<div className="state-remediation">
|
|
<div className="state-remediation-label">Remediation</div>
|
|
{hint}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function findEntry(
|
|
rootEntries: TreeEntry[] | undefined,
|
|
loadedChildren: Map<string, TreeEntry[]>,
|
|
path: string | null,
|
|
): TreeEntry | undefined {
|
|
if (!path) {
|
|
return undefined;
|
|
}
|
|
|
|
if (rootEntries) {
|
|
const found = rootEntries.find((entry) => entry.path === path);
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
|
|
for (const children of loadedChildren.values()) {
|
|
const found = children.find((entry) => entry.path === path);
|
|
if (found) {
|
|
return found;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function buildHotspotList(
|
|
rootEntries: TreeEntry[] | undefined,
|
|
loadedChildren: Map<string, TreeEntry[]>,
|
|
): TreeEntry[] {
|
|
const files: TreeEntry[] = [];
|
|
|
|
function collect(entries: TreeEntry[]) {
|
|
for (const entry of entries) {
|
|
if (entry.entry_type === 'file' && entry.finding_count > 0) {
|
|
files.push(entry);
|
|
}
|
|
if (entry.entry_type === 'dir') {
|
|
const children = loadedChildren.get(entry.path);
|
|
if (children) {
|
|
collect(children);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (rootEntries) {
|
|
collect(rootEntries);
|
|
}
|
|
files.sort((a, b) => b.finding_count - a.finding_count);
|
|
return files;
|
|
}
|
|
|
|
function sevOrder(sev: string): number {
|
|
switch (sev) {
|
|
case 'HIGH':
|
|
return 0;
|
|
case 'MEDIUM':
|
|
return 1;
|
|
case 'LOW':
|
|
return 2;
|
|
default:
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
function isExplorerView(value: string | null): value is ExplorerView {
|
|
return VIEW_CONFIG_BY_ID.has(value as ExplorerView);
|
|
}
|
|
|
|
function isPathResolutionError(error: unknown): boolean {
|
|
return (
|
|
error instanceof ApiError && (error.status === 403 || error.status === 404)
|
|
);
|
|
}
|