nyx/frontend/src/pages/ScanDetailPage.tsx
Eli Peter 82f18184b1
Prerelease cleanup (#46)
* 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>
2026-04-29 00:58:38 -04:00

469 lines
15 KiB
TypeScript

import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
useScan,
useScans,
useScanFindings,
useScanLogs,
useScanMetrics,
} from '../api/queries/scans';
import { LoadingState } from '../components/ui/LoadingState';
import { ErrorState } from '../components/ui/ErrorState';
import { usePageTitle } from '../hooks/usePageTitle';
import type { ScanView, ScanLogEntry, ScanMetricsSnapshot } from '../api/types';
function truncPath(p?: string, max = 50): string {
if (!p) return '';
if (p.length <= max) return p;
return '...' + p.slice(p.length - max + 3);
}
function fmtDate(iso?: string): string {
return iso ? new Date(iso).toLocaleString() : '-';
}
function fmtNum(n?: number | null): string {
return n != null ? n.toLocaleString() : '-';
}
// ── Summary Tab ──────────────────────────────────────────────────────────────
function SummaryTab({ scan }: { scan: ScanView }) {
const duration =
scan.duration_secs != null ? scan.duration_secs.toFixed(2) + 's' : '-';
const langs = (scan.languages || []).join(', ') || '-';
const timing = scan.timing;
let total = 0;
if (timing) {
total =
timing.walk_ms +
timing.pass1_ms +
timing.call_graph_ms +
timing.pass2_ms +
timing.post_process_ms;
}
const pct = (ms: number) => ((ms / total) * 100).toFixed(1);
return (
<>
<div className="scan-stat-grid">
<div className="scan-stat-card">
<div className="scan-stat-label">Files Scanned</div>
<div className="scan-stat-value">{scan.files_scanned ?? '-'}</div>
</div>
<div className="scan-stat-card">
<div className="scan-stat-label">Findings</div>
<div className="scan-stat-value">{scan.finding_count ?? '-'}</div>
</div>
<div className="scan-stat-card">
<div className="scan-stat-label">Duration</div>
<div className="scan-stat-value">{duration}</div>
</div>
<div className="scan-stat-card">
<div className="scan-stat-label">Languages</div>
<div
className="scan-stat-value"
style={{ fontSize: 'var(--text-base)' }}
>
{langs}
</div>
</div>
</div>
<div className="card">
<div className="card-header">Details</div>
<table>
<tbody>
<tr>
<td style={{ color: 'var(--text-secondary)', width: 140 }}>
Scan ID
</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-xs)',
}}
>
{scan.id}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Root</td>
<td
style={{
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-sm)',
}}
>
{scan.scan_root}
</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Engine</td>
<td>{scan.engine_version || '-'}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Started</td>
<td>{fmtDate(scan.started_at)}</td>
</tr>
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Finished</td>
<td>{fmtDate(scan.finished_at)}</td>
</tr>
{scan.error && (
<tr>
<td style={{ color: 'var(--text-secondary)' }}>Error</td>
<td style={{ color: 'var(--sev-high)' }}>{scan.error}</td>
</tr>
)}
</tbody>
</table>
</div>
{timing && total > 0 && (
<div className="card" style={{ marginTop: 'var(--space-4)' }}>
<div className="card-header">Timing Breakdown</div>
<div className="timing-bar">
<div
className="timing-bar-segment walk"
style={{ width: `${pct(timing.walk_ms)}%` }}
title={`Walk: ${timing.walk_ms}ms`}
></div>
<div
className="timing-bar-segment pass1"
style={{ width: `${pct(timing.pass1_ms)}%` }}
title={`Pass 1: ${timing.pass1_ms}ms`}
></div>
<div
className="timing-bar-segment callgraph"
style={{ width: `${pct(timing.call_graph_ms)}%` }}
title={`Call Graph: ${timing.call_graph_ms}ms`}
></div>
<div
className="timing-bar-segment pass2"
style={{ width: `${pct(timing.pass2_ms)}%` }}
title={`Pass 2: ${timing.pass2_ms}ms`}
></div>
<div
className="timing-bar-segment postprocess"
style={{ width: `${pct(timing.post_process_ms)}%` }}
title={`Post-process: ${timing.post_process_ms}ms`}
></div>
</div>
<div className="timing-legend">
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-low)' }}
></span>{' '}
Walk {timing.walk_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--accent)' }}
></span>{' '}
Pass 1 {timing.pass1_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--sev-medium)' }}
></span>{' '}
Call Graph {timing.call_graph_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--success)' }}
></span>{' '}
Pass 2 {timing.pass2_ms}ms
</span>
<span className="timing-legend-item">
<span
className="timing-legend-dot"
style={{ background: 'var(--text-tertiary)' }}
></span>{' '}
Post {timing.post_process_ms}ms
</span>
</div>
</div>
)}
</>
);
}
// ── Findings Tab ─────────────────────────────────────────────────────────────
function FindingsTab({ scanId }: { scanId: string }) {
const navigate = useNavigate();
const { data, isLoading, error } = useScanFindings(scanId);
if (isLoading) return <LoadingState message="Loading findings..." />;
if (error) return <ErrorState message={error.message} />;
if (!data?.findings || data.findings.length === 0) {
return (
<div className="empty-state">
<h3>No findings</h3>
<p>This scan produced no findings.</p>
</div>
);
}
return (
<>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Severity</th>
<th>Rule</th>
<th>File</th>
<th>Line</th>
<th>Confidence</th>
</tr>
</thead>
<tbody>
{data.findings.map((f) => (
<tr
key={f.index}
className="clickable"
onClick={() => navigate(`/findings/${f.index}`)}
>
<td>
<span className={`badge badge-${f.severity.toLowerCase()}`}>
{f.severity}
</span>
</td>
<td>{f.rule_id}</td>
<td className="cell-path" title={f.path}>
{truncPath(f.path)}
</td>
<td>{f.line}</td>
<td>
{f.confidence ? (
<span
className={`badge badge-conf-${f.confidence.toLowerCase()}`}
>
{f.confidence}
</span>
) : (
'-'
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div
style={{
marginTop: 'var(--space-2)',
fontSize: 'var(--text-sm)',
color: 'var(--text-secondary)',
}}
>
Showing {data.findings.length} of {data.total} findings
</div>
</>
);
}
// ── Logs Tab ─────────────────────────────────────────────────────────────────
function LogsTab({ scanId }: { scanId: string }) {
const [levelFilter, setLevelFilter] = useState<string | undefined>(undefined);
const { data: logs, isLoading, error } = useScanLogs(scanId, levelFilter);
if (isLoading) return <LoadingState message="Loading logs..." />;
if (error) return <ErrorState message={error.message} />;
const levels: Array<{ value: string | undefined; label: string }> = [
{ value: undefined, label: 'All' },
{ value: 'info', label: 'Info' },
{ value: 'warn', label: 'Warn' },
{ value: 'error', label: 'Error' },
];
return (
<>
<div className="log-filters">
{levels.map((l) => (
<button
key={l.label}
className={`log-filter-btn ${levelFilter === l.value ? 'active' : ''}`}
onClick={() => setLevelFilter(l.value)}
>
{l.label}
</button>
))}
</div>
{!logs || logs.length === 0 ? (
<div className="empty-state">
<p>No log entries</p>
</div>
) : (
<div className="log-viewer">
{logs.map((l: ScanLogEntry, i: number) => (
<div key={i} className={`log-entry log-${l.level}`}>
<span className={`log-level ${l.level}`}>{l.level}</span>
<span className="log-time">
{new Date(l.timestamp).toLocaleTimeString()}
</span>
<span className="log-message">
{l.message}
{l.file_path && (
<span style={{ color: 'var(--text-tertiary)' }}>
{' '}
{l.file_path}
</span>
)}
</span>
</div>
))}
</div>
)}
</>
);
}
// ── Metrics Tab ──────────────────────────────────────────────────────────────
function MetricsTab({ scanId, scan }: { scanId: string; scan: ScanView }) {
const { data: fetchedMetrics } = useScanMetrics(scanId);
const metrics: ScanMetricsSnapshot | undefined =
scan.metrics || fetchedMetrics || undefined;
if (!metrics) {
return (
<div className="empty-state">
<p>No metrics available for this scan.</p>
</div>
);
}
return (
<div className="metric-grid">
<div className="metric-card">
<div className="metric-card-label">CFG Nodes</div>
<div className="metric-card-value">{fmtNum(metrics.cfg_nodes)}</div>
</div>
<div className="metric-card">
<div className="metric-card-label">Call Edges</div>
<div className="metric-card-value">{fmtNum(metrics.call_edges)}</div>
</div>
<div className="metric-card">
<div className="metric-card-label">Functions Analyzed</div>
<div className="metric-card-value">
{fmtNum(metrics.functions_analyzed)}
</div>
</div>
<div className="metric-card">
<div className="metric-card-label">Summaries Reused</div>
<div className="metric-card-value">
{fmtNum(metrics.summaries_reused)}
</div>
</div>
<div className="metric-card">
<div className="metric-card-label">Unresolved Calls</div>
<div className="metric-card-value">
{fmtNum(metrics.unresolved_calls)}
</div>
</div>
</div>
);
}
// ── Scan Detail Page ─────────────────────────────────────────────────────────
type TabId = 'summary' | 'findings' | 'logs' | 'metrics';
export function ScanDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: scan, isLoading, error } = useScan(id || '');
const { data: allScans } = useScans();
const [activeTab, setActiveTab] = useState<TabId>('summary');
usePageTitle(scan ? `Scan ${scan.id.slice(0, 8)}` : 'Scan');
const prevScanId = useMemo(() => {
if (!scan || scan.status !== 'completed' || !allScans) return null;
const completed = allScans
.filter((s) => s.status === 'completed' && s.started_at)
.sort((a, b) => (a.started_at || '').localeCompare(b.started_at || ''));
const myIdx = completed.findIndex((s) => s.id === id);
if (myIdx > 0) return completed[myIdx - 1].id;
return null;
}, [scan, allScans, id]);
if (isLoading) return <LoadingState message="Loading scan..." />;
if (error || !scan) {
return (
<ErrorState
title="Scan not found"
message={error?.message || 'Not found'}
/>
);
}
const tabs: { id: TabId; label: string }[] = [
{ id: 'summary', label: 'Summary' },
{ id: 'findings', label: 'Findings' },
{ id: 'logs', label: 'Logs' },
{ id: 'metrics', label: 'Metrics' },
];
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
marginBottom: 'var(--space-4)',
}}
>
<button className="btn btn-sm" onClick={() => navigate('/scans')}>
Back to Scans
</button>
{prevScanId && (
<button
className="btn btn-sm"
style={{ marginLeft: 'auto' }}
onClick={() => navigate(`/scans/compare/${prevScanId}/${id}`)}
>
Compare with Previous
</button>
)}
</div>
<div className="page-header">
<h2>Scan Detail</h2>
<span className={`status-badge ${scan.status}`}>
<span className={`status-dot ${scan.status}`}></span>
{scan.status}
</span>
</div>
<div className="scan-detail-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`scan-detail-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div id="scan-tab-content">
{activeTab === 'summary' && <SummaryTab scan={scan} />}
{activeTab === 'findings' && <FindingsTab scanId={id!} />}
{activeTab === 'logs' && <LogsTab scanId={id!} />}
{activeTab === 'metrics' && <MetricsTab scanId={id!} scan={scan} />}
</div>
</>
);
}