mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
666 lines
20 KiB
TypeScript
666 lines
20 KiB
TypeScript
import React from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import type {
|
||
HealthScore,
|
||
PostureSummary,
|
||
BacklogStats,
|
||
ConfidenceDistribution,
|
||
ScannerQuality,
|
||
HotSink,
|
||
OwaspBucket,
|
||
LanguageHealth,
|
||
SuppressionHygiene,
|
||
BaselineInfo,
|
||
WeightedFile,
|
||
OverviewCount,
|
||
} from '../../api/types';
|
||
import { truncPath } from '../../utils/truncPath';
|
||
|
||
// ── HealthScoreCard ─────────────────────────────────────────────────────────
|
||
|
||
export function HealthScoreCard({
|
||
health,
|
||
posture,
|
||
}: {
|
||
health: HealthScore;
|
||
posture?: PostureSummary;
|
||
}) {
|
||
const gradeClass = `grade-${health.grade.toLowerCase()}`;
|
||
const gradeAccent =
|
||
health.grade === 'A' || health.grade === 'B'
|
||
? 'var(--green)'
|
||
: health.grade === 'C'
|
||
? 'var(--amber)'
|
||
: 'var(--red)';
|
||
return (
|
||
<div
|
||
className="card health-card"
|
||
style={{ '--health-accent': gradeAccent } as React.CSSProperties}
|
||
>
|
||
<div className="health-eyebrow">Health Score</div>
|
||
<div className="health-headline">
|
||
<div className={`health-grade-block ${gradeClass}`}>
|
||
<span className="health-grade-letter">{health.grade}</span>
|
||
</div>
|
||
<div className="health-headline-text">
|
||
<div className="health-summary">
|
||
<span className="health-number">{health.score}</span>
|
||
<span className="health-of">/ 100</span>
|
||
</div>
|
||
{posture && (
|
||
<div className={`health-posture posture-${posture.severity}`}>
|
||
{posture.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="health-components">
|
||
{health.components.map((c) => {
|
||
const barColor =
|
||
c.score >= 70
|
||
? 'var(--green)'
|
||
: c.score >= 40
|
||
? 'var(--amber)'
|
||
: 'var(--red)';
|
||
return (
|
||
<div className="health-component" key={c.label} title={c.detail}>
|
||
<div className="health-component-label">{c.label}</div>
|
||
<div className="health-component-bar-track">
|
||
<div
|
||
className="health-component-fill"
|
||
style={{ width: `${c.score}%`, background: barColor }}
|
||
/>
|
||
</div>
|
||
<div className="health-component-score">{c.score}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── PostureBanner ──────────────────────────────────────────────────────────
|
||
|
||
export function PostureBanner({ posture }: { posture: PostureSummary }) {
|
||
return (
|
||
<div className={`posture-banner posture-${posture.severity}`}>
|
||
<span className="posture-dot" aria-hidden />
|
||
<span className="posture-message">{posture.message}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── BacklogCard ────────────────────────────────────────────────────────────
|
||
|
||
export function BacklogCard({ backlog }: { backlog: BacklogStats }) {
|
||
const total = backlog.age_buckets.reduce((s, b) => s + b.count, 0);
|
||
const noHistory =
|
||
backlog.oldest_open_days == null && backlog.age_buckets.length === 0;
|
||
if (noHistory) {
|
||
return null;
|
||
}
|
||
return (
|
||
<div className="card backlog-card">
|
||
<div className="card-header">Backlog Age</div>
|
||
<div className="backlog-body">
|
||
<div className="backlog-stat">
|
||
<div className="backlog-stat-value">
|
||
{backlog.oldest_open_days != null
|
||
? `${backlog.oldest_open_days}d`
|
||
: '–'}
|
||
</div>
|
||
<div className="backlog-stat-label">Oldest open</div>
|
||
</div>
|
||
<div className="backlog-stat">
|
||
<div className="backlog-stat-value">
|
||
{backlog.median_age_days != null
|
||
? `${backlog.median_age_days}d`
|
||
: '–'}
|
||
</div>
|
||
<div className="backlog-stat-label">Median age</div>
|
||
</div>
|
||
<div className="backlog-stat">
|
||
<div className="backlog-stat-value">{backlog.stale_count}</div>
|
||
<div className="backlog-stat-label">Older than 30 days</div>
|
||
</div>
|
||
{total > 0 && (
|
||
<div className="backlog-bucket">
|
||
<BucketBar buckets={backlog.age_buckets} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BucketBar({ buckets }: { buckets: OverviewCount[] }) {
|
||
const total = buckets.reduce((s, b) => s + b.count, 0);
|
||
if (total === 0) return null;
|
||
const colors = [
|
||
'var(--accent)',
|
||
'var(--green)',
|
||
'var(--amber)',
|
||
'var(--red)',
|
||
'var(--muted)',
|
||
];
|
||
return (
|
||
<div
|
||
className="bucket-bar"
|
||
title={buckets.map((b) => `${b.name}: ${b.count}`).join(' · ')}
|
||
>
|
||
{buckets.map((b, i) => (
|
||
<div
|
||
key={b.name}
|
||
className="bucket-segment"
|
||
style={{
|
||
width: `${(b.count / total) * 100}%`,
|
||
background: colors[i] || 'var(--accent)',
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── ConfidenceDistributionChart ────────────────────────────────────────────
|
||
|
||
export function ConfidenceDistributionChart({
|
||
dist,
|
||
}: {
|
||
dist: ConfidenceDistribution;
|
||
}) {
|
||
const total = dist.high + dist.medium + dist.low + dist.none;
|
||
if (total === 0) {
|
||
return (
|
||
<div className="empty-state">
|
||
<p>No data</p>
|
||
</div>
|
||
);
|
||
}
|
||
const segments = [
|
||
{ label: 'High', value: dist.high, color: 'var(--green)' },
|
||
{ label: 'Medium', value: dist.medium, color: 'var(--amber)' },
|
||
{ label: 'Low', value: dist.low, color: 'var(--muted)' },
|
||
{ label: 'None', value: dist.none, color: 'var(--subtle)' },
|
||
];
|
||
return (
|
||
<div className="confidence-dist">
|
||
<div className="confidence-bar">
|
||
{segments.map((s) =>
|
||
s.value > 0 ? (
|
||
<div
|
||
key={s.label}
|
||
className="confidence-segment"
|
||
style={{
|
||
width: `${(s.value / total) * 100}%`,
|
||
background: s.color,
|
||
}}
|
||
title={`${s.label}: ${s.value}`}
|
||
/>
|
||
) : null,
|
||
)}
|
||
</div>
|
||
<div className="confidence-legend">
|
||
{segments.map((s) => (
|
||
<div key={s.label} className="confidence-legend-item">
|
||
<span
|
||
className="confidence-swatch"
|
||
style={{ background: s.color }}
|
||
/>
|
||
<span>{s.label}</span>
|
||
<span className="confidence-count">{s.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── ScannerQualityPanel ────────────────────────────────────────────────────
|
||
|
||
export function ScannerQualityPanel({
|
||
quality,
|
||
crossFileRatio,
|
||
}: {
|
||
quality: ScannerQuality;
|
||
crossFileRatio?: number;
|
||
}) {
|
||
const symexAttempted = Object.entries(quality.symex_breakdown || {})
|
||
.filter(([k]) => k !== 'not_attempted')
|
||
.reduce((s, [, v]) => s + v, 0);
|
||
const symexTotal = Object.values(quality.symex_breakdown || {}).reduce(
|
||
(s, v) => s + v,
|
||
0,
|
||
);
|
||
const totalFiles = quality.files_scanned + quality.files_skipped;
|
||
const filesValue = totalFiles.toLocaleString();
|
||
const filesDetail =
|
||
quality.files_skipped > 0
|
||
? `${quality.files_scanned.toLocaleString()} fresh · ${quality.files_skipped.toLocaleString()} from cache`
|
||
: quality.files_scanned > 0
|
||
? `${quality.files_scanned.toLocaleString()} freshly indexed`
|
||
: undefined;
|
||
const dynamic = quality.dynamic_verification ?? {
|
||
total: 0,
|
||
confirmed: 0,
|
||
partially_confirmed: 0,
|
||
not_confirmed: 0,
|
||
inconclusive: 0,
|
||
unsupported: 0,
|
||
};
|
||
const dynamicDetail =
|
||
dynamic.total > 0
|
||
? `${dynamic.total.toLocaleString()} verdicts · ${dynamic.partially_confirmed.toLocaleString()} partially confirmed · ${dynamic.not_confirmed.toLocaleString()} not confirmed · ${dynamic.inconclusive.toLocaleString()} inconclusive · ${dynamic.unsupported.toLocaleString()} unsupported`
|
||
: 'no dynamic verdicts in latest scan';
|
||
|
||
const rows: Array<{
|
||
label: string;
|
||
hint: string;
|
||
value: string;
|
||
detail?: string;
|
||
}> = [
|
||
{
|
||
label: 'Files',
|
||
hint: 'Files the scanner saw on this run.',
|
||
value: filesValue,
|
||
detail: filesDetail,
|
||
},
|
||
{
|
||
label: 'Functions analyzed',
|
||
hint: 'Function bodies the call graph saw.',
|
||
value: quality.functions_analyzed.toLocaleString(),
|
||
},
|
||
{
|
||
label: 'Call edges resolved',
|
||
hint: 'Share of call sites that the scanner resolved to a known callee. The remainder are typically external/library calls.',
|
||
value: `${(quality.call_resolution_rate * 100).toFixed(1)}%`,
|
||
detail:
|
||
quality.unresolved_calls > 0
|
||
? `${quality.unresolved_calls.toLocaleString()} unresolved`
|
||
: undefined,
|
||
},
|
||
{
|
||
label: 'Cross-file flows',
|
||
hint: 'Findings whose taint path crosses a file boundary.',
|
||
value:
|
||
crossFileRatio != null ? `${(crossFileRatio * 100).toFixed(1)}%` : '0%',
|
||
detail: 'of findings',
|
||
},
|
||
{
|
||
label: 'Symbolic verification',
|
||
hint: 'Taint findings the symbolic engine attempted to verify (confirmed, infeasible, or inconclusive).',
|
||
value:
|
||
symexTotal > 0
|
||
? `${(quality.symex_verified_rate * 100).toFixed(1)}%`
|
||
: 'n/a',
|
||
detail:
|
||
symexTotal > 0
|
||
? `${symexAttempted} of ${symexTotal} taint findings`
|
||
: 'no taint findings',
|
||
},
|
||
{
|
||
label: 'Dynamic verification',
|
||
hint: 'Findings re-run in generated harnesses against the dynamic payload corpus.',
|
||
value:
|
||
dynamic.total > 0
|
||
? `${dynamic.confirmed.toLocaleString()} confirmed`
|
||
: 'not run',
|
||
detail: dynamicDetail,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<dl className="kv-list">
|
||
{rows.map((r) => (
|
||
<div className="kv-row" key={r.label}>
|
||
<dt className="kv-label" title={r.hint}>
|
||
{r.label}
|
||
</dt>
|
||
<dd className="kv-value">
|
||
<div className="kv-number">{r.value}</div>
|
||
{r.detail && <div className="kv-detail">{r.detail}</div>}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
);
|
||
}
|
||
|
||
// ── HotSinksList ───────────────────────────────────────────────────────────
|
||
|
||
export function HotSinksList({ sinks }: { sinks: HotSink[] }) {
|
||
if (!sinks.length) {
|
||
return (
|
||
<div className="empty-state">
|
||
<p>No data</p>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Sink</th>
|
||
<th>Findings</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sinks.map((s) => (
|
||
<tr key={s.callee} title={s.callee}>
|
||
<td className="font-mono">{s.callee}</td>
|
||
<td>{s.count}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
// ── OwaspChart ─────────────────────────────────────────────────────────────
|
||
|
||
export function OwaspChart({ buckets }: { buckets: OwaspBucket[] }) {
|
||
if (!buckets.length) {
|
||
return (
|
||
<div className="empty-state">
|
||
<p>No data</p>
|
||
</div>
|
||
);
|
||
}
|
||
const max = Math.max(...buckets.map((b) => b.count), 1);
|
||
return (
|
||
<ul className="owasp-list">
|
||
{buckets.map((b) => (
|
||
<li key={b.code} className="owasp-row" title={b.label}>
|
||
<span className="owasp-code">{b.code}</span>
|
||
<span className="owasp-label">{b.label}</span>
|
||
<div className="owasp-bar">
|
||
<div
|
||
className="owasp-fill"
|
||
style={{ width: `${(b.count / max) * 100}%` }}
|
||
/>
|
||
</div>
|
||
<span className="owasp-count">{b.count}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
// ── WeightedTopFiles ───────────────────────────────────────────────────────
|
||
|
||
export function WeightedTopFiles({
|
||
files,
|
||
onRowClick,
|
||
}: {
|
||
files: WeightedFile[];
|
||
onRowClick?: (name: string) => void;
|
||
}) {
|
||
if (!files.length) {
|
||
return (
|
||
<div className="empty-state">
|
||
<p>No data</p>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>File</th>
|
||
<th>Severity</th>
|
||
<th>Score</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{files.map((f) => (
|
||
<tr
|
||
key={f.name}
|
||
title={f.name}
|
||
className={onRowClick ? 'clickable' : undefined}
|
||
onClick={onRowClick ? () => onRowClick(f.name) : undefined}
|
||
>
|
||
<td>{truncPath(f.name, 45)}</td>
|
||
<td>
|
||
<SeverityStack high={f.high} medium={f.medium} low={f.low} />
|
||
</td>
|
||
<td className="font-mono">{f.score}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function SeverityStack({
|
||
high,
|
||
medium,
|
||
low,
|
||
}: {
|
||
high: number;
|
||
medium: number;
|
||
low: number;
|
||
}) {
|
||
const total = high + medium + low;
|
||
if (total === 0) return null;
|
||
return (
|
||
<div
|
||
className="severity-stack"
|
||
title={`${high} High · ${medium} Medium · ${low} Low`}
|
||
>
|
||
{high > 0 && (
|
||
<div
|
||
className="sev-segment sev-high"
|
||
style={{ width: `${(high / total) * 100}%` }}
|
||
>
|
||
{high}
|
||
</div>
|
||
)}
|
||
{medium > 0 && (
|
||
<div
|
||
className="sev-segment sev-medium"
|
||
style={{ width: `${(medium / total) * 100}%` }}
|
||
>
|
||
{medium}
|
||
</div>
|
||
)}
|
||
{low > 0 && (
|
||
<div
|
||
className="sev-segment sev-low"
|
||
style={{ width: `${(low / total) * 100}%` }}
|
||
>
|
||
{low}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── LanguageHealthTable ────────────────────────────────────────────────────
|
||
|
||
export function LanguageHealthTable({ rows }: { rows: LanguageHealth[] }) {
|
||
if (!rows.length) {
|
||
return (
|
||
<div className="empty-state">
|
||
<p>No data</p>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Language</th>
|
||
<th>Findings</th>
|
||
<th>Severity</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.language}>
|
||
<td>{r.language}</td>
|
||
<td>{r.findings}</td>
|
||
<td>
|
||
<SeverityStack high={r.high} medium={r.medium} low={r.low} />
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
// ── SuppressionHygieneCard ─────────────────────────────────────────────────
|
||
|
||
export function SuppressionHygieneCard({
|
||
hygiene,
|
||
}: {
|
||
hygiene: SuppressionHygiene;
|
||
}) {
|
||
const total =
|
||
hygiene.fingerprint_level +
|
||
hygiene.rule_level +
|
||
hygiene.file_level +
|
||
hygiene.rule_in_file_level;
|
||
const blanket =
|
||
hygiene.rule_level + hygiene.file_level + hygiene.rule_in_file_level;
|
||
const blanketDisplay =
|
||
total > 0 ? `${(hygiene.blanket_rate * 100).toFixed(0)}%` : 'n/a';
|
||
const blanketDetail =
|
||
total > 0
|
||
? `${blanket} of ${total} suppressions are not pinned to a specific finding`
|
||
: 'No suppressions yet';
|
||
return (
|
||
<dl className="kv-list">
|
||
<div className="kv-row kv-row-emphasis">
|
||
<dt
|
||
className="kv-label"
|
||
title="Share of suppressions that are not pinned to a specific finding fingerprint. Lower is better because triage is decisive rather than blanket-silencing whole rules or files."
|
||
>
|
||
Blanket rate
|
||
<span className="kv-hint">Lower is better</span>
|
||
</dt>
|
||
<dd className="kv-value">
|
||
<div className="kv-number">{blanketDisplay}</div>
|
||
<div className="kv-detail">{blanketDetail}</div>
|
||
</dd>
|
||
</div>
|
||
<div className="kv-row">
|
||
<dt
|
||
className="kv-label"
|
||
title="Suppressions that target one specific finding by its fingerprint. Most precise."
|
||
>
|
||
By fingerprint
|
||
<span className="kv-hint">Most specific</span>
|
||
</dt>
|
||
<dd className="kv-value">
|
||
<div className="kv-number">{hygiene.fingerprint_level}</div>
|
||
</dd>
|
||
</div>
|
||
<div className="kv-row">
|
||
<dt
|
||
className="kv-label"
|
||
title="Suppressions that silence a rule only inside a specific file."
|
||
>
|
||
By rule in a file
|
||
</dt>
|
||
<dd className="kv-value">
|
||
<div className="kv-number">{hygiene.rule_in_file_level}</div>
|
||
</dd>
|
||
</div>
|
||
<div className="kv-row">
|
||
<dt
|
||
className="kv-label"
|
||
title="Suppressions that silence an entire rule across the project."
|
||
>
|
||
By rule
|
||
</dt>
|
||
<dd className="kv-value">
|
||
<div className="kv-number">{hygiene.rule_level}</div>
|
||
</dd>
|
||
</div>
|
||
<div className="kv-row">
|
||
<dt
|
||
className="kv-label"
|
||
title="Suppressions that silence everything in a file."
|
||
>
|
||
By file
|
||
<span className="kv-hint">Least specific</span>
|
||
</dt>
|
||
<dd className="kv-value">
|
||
<div className="kv-number">{hygiene.file_level}</div>
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
);
|
||
}
|
||
|
||
// ── BaselinePinControl ─────────────────────────────────────────────────────
|
||
|
||
interface BaselinePinControlProps {
|
||
baseline?: BaselineInfo;
|
||
latestScanId?: string;
|
||
onPin: (scanId: string) => void;
|
||
onUnpin: () => void;
|
||
isPending: boolean;
|
||
}
|
||
|
||
export function BaselinePinControl({
|
||
baseline,
|
||
latestScanId,
|
||
onPin,
|
||
onUnpin,
|
||
isPending,
|
||
}: BaselinePinControlProps) {
|
||
const navigate = useNavigate();
|
||
if (baseline) {
|
||
const net = baseline.drift_new - baseline.drift_fixed;
|
||
const driftClass =
|
||
net > 0
|
||
? 'baseline-drift-bad'
|
||
: net < 0
|
||
? 'baseline-drift-good'
|
||
: 'baseline-drift-flat';
|
||
return (
|
||
<div className="baseline-strip">
|
||
<span className="baseline-label">Baseline:</span>
|
||
<button
|
||
type="button"
|
||
className="baseline-link"
|
||
onClick={() => navigate(`/scans/${baseline.scan_id}`)}
|
||
>
|
||
{baseline.started_at
|
||
? new Date(baseline.started_at).toLocaleDateString()
|
||
: baseline.scan_id.slice(0, 8)}
|
||
</button>
|
||
<span className={driftClass}>
|
||
drift: +{baseline.drift_new} new / -{baseline.drift_fixed} fixed (
|
||
{net >= 0 ? '+' : ''}
|
||
{net})
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="baseline-action"
|
||
onClick={onUnpin}
|
||
disabled={isPending}
|
||
>
|
||
Unpin
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
if (!latestScanId) return null;
|
||
return (
|
||
<div className="baseline-strip baseline-strip-empty">
|
||
<span className="baseline-label">No baseline pinned.</span>
|
||
<button
|
||
type="button"
|
||
className="baseline-action"
|
||
onClick={() => onPin(latestScanId)}
|
||
disabled={isPending}
|
||
>
|
||
Pin latest scan as baseline
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|