mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-03 20:41:00 +02:00
520 lines
15 KiB
TypeScript
520 lines
15 KiB
TypeScript
|
|
import { useState, useCallback } from 'react';
|
||
|
|
import {
|
||
|
|
useConfig,
|
||
|
|
useSources,
|
||
|
|
useSinks,
|
||
|
|
useSanitizers,
|
||
|
|
useTerminators,
|
||
|
|
useProfiles,
|
||
|
|
} from '../api/queries/config';
|
||
|
|
import {
|
||
|
|
useAddSource,
|
||
|
|
useDeleteSource,
|
||
|
|
useAddSink,
|
||
|
|
useDeleteSink,
|
||
|
|
useAddSanitizer,
|
||
|
|
useDeleteSanitizer,
|
||
|
|
useAddTerminator,
|
||
|
|
useDeleteTerminator,
|
||
|
|
useAddProfile,
|
||
|
|
useDeleteProfile,
|
||
|
|
useActivateProfile,
|
||
|
|
useToggleTriageSync,
|
||
|
|
} from '../api/mutations/config';
|
||
|
|
import { LoadingState } from '../components/ui/LoadingState';
|
||
|
|
import { ErrorState } from '../components/ui/ErrorState';
|
||
|
|
import type { LabelEntryView, TerminatorView, ProfileView } from '../api/types';
|
||
|
|
|
||
|
|
const LANG_OPTIONS = [
|
||
|
|
'javascript',
|
||
|
|
'typescript',
|
||
|
|
'python',
|
||
|
|
'go',
|
||
|
|
'java',
|
||
|
|
'c',
|
||
|
|
'cpp',
|
||
|
|
'php',
|
||
|
|
'ruby',
|
||
|
|
'rust',
|
||
|
|
];
|
||
|
|
|
||
|
|
const CAP_OPTIONS = [
|
||
|
|
'all',
|
||
|
|
'env_var',
|
||
|
|
'html_escape',
|
||
|
|
'shell_escape',
|
||
|
|
'url_encode',
|
||
|
|
'json_parse',
|
||
|
|
'file_io',
|
||
|
|
'sql_query',
|
||
|
|
'deserialize',
|
||
|
|
'ssrf',
|
||
|
|
'code_exec',
|
||
|
|
'crypto',
|
||
|
|
];
|
||
|
|
|
||
|
|
// ── Collapsible Config Section ───────────────────────────────────────────────
|
||
|
|
|
||
|
|
function ConfigSection({
|
||
|
|
title,
|
||
|
|
id,
|
||
|
|
children,
|
||
|
|
}: {
|
||
|
|
title: string;
|
||
|
|
id: string;
|
||
|
|
children: React.ReactNode;
|
||
|
|
}) {
|
||
|
|
const [collapsed, setCollapsed] = useState(false);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="config-section" id={id}>
|
||
|
|
<div
|
||
|
|
className={`config-section-header${collapsed ? ' collapsed' : ''}`}
|
||
|
|
onClick={() => setCollapsed(!collapsed)}
|
||
|
|
>
|
||
|
|
<span
|
||
|
|
className={`config-collapse-arrow${collapsed ? ' collapsed' : ''}`}
|
||
|
|
>
|
||
|
|
▼
|
||
|
|
</span>{' '}
|
||
|
|
<strong>{title}</strong>
|
||
|
|
</div>
|
||
|
|
<div className={`config-section-body${collapsed ? ' collapsed' : ''}`}>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Label Table (Source/Sink/Sanitizer) ──────────────────────────────────────
|
||
|
|
|
||
|
|
function LabelSection({
|
||
|
|
title,
|
||
|
|
id,
|
||
|
|
kind,
|
||
|
|
entries,
|
||
|
|
onAdd,
|
||
|
|
onDelete,
|
||
|
|
}: {
|
||
|
|
title: string;
|
||
|
|
id: string;
|
||
|
|
kind: string;
|
||
|
|
entries: LabelEntryView[];
|
||
|
|
onAdd: (body: { lang: string; matchers: string[]; cap: string }) => void;
|
||
|
|
onDelete: (entry: LabelEntryView) => void;
|
||
|
|
}) {
|
||
|
|
const [lang, setLang] = useState('');
|
||
|
|
const [matcher, setMatcher] = useState('');
|
||
|
|
const [cap, setCap] = useState('all');
|
||
|
|
|
||
|
|
const builtins = entries.filter((e) => e.is_builtin);
|
||
|
|
const custom = entries.filter((e) => !e.is_builtin);
|
||
|
|
|
||
|
|
const handleAdd = useCallback(() => {
|
||
|
|
if (!lang || !matcher) return;
|
||
|
|
onAdd({ lang, matchers: [matcher], cap });
|
||
|
|
setMatcher('');
|
||
|
|
}, [lang, matcher, cap, onAdd]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<ConfigSection title={title} id={id}>
|
||
|
|
<div className="inline-form add-label-form">
|
||
|
|
<div className="form-group">
|
||
|
|
<label>Language</label>
|
||
|
|
<select
|
||
|
|
style={{ width: 140 }}
|
||
|
|
value={lang}
|
||
|
|
onChange={(e) => setLang(e.target.value)}
|
||
|
|
>
|
||
|
|
<option value="">Select...</option>
|
||
|
|
{LANG_OPTIONS.map((l) => (
|
||
|
|
<option key={l} value={l}>
|
||
|
|
{l}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div className="form-group">
|
||
|
|
<label>Matcher</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="functionName"
|
||
|
|
value={matcher}
|
||
|
|
onChange={(e) => setMatcher(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="form-group">
|
||
|
|
<label>Capability</label>
|
||
|
|
<select value={cap} onChange={(e) => setCap(e.target.value)}>
|
||
|
|
{CAP_OPTIONS.map((c) => (
|
||
|
|
<option key={c} value={c}>
|
||
|
|
{c}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<button className="btn btn-primary btn-sm" onClick={handleAdd}>
|
||
|
|
Add {kind}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div className="table-wrap" style={{ marginTop: 8 }}>
|
||
|
|
{entries.length === 0 ? (
|
||
|
|
<div className="empty-state" style={{ padding: 12 }}>
|
||
|
|
<p>No {kind} rules</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<table className="label-table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Language</th>
|
||
|
|
<th>Matchers</th>
|
||
|
|
<th>Cap</th>
|
||
|
|
<th></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{builtins.map((e, i) => (
|
||
|
|
<tr key={`b-${i}`} className="label-builtin">
|
||
|
|
<td>{e.lang}</td>
|
||
|
|
<td style={{ fontFamily: 'var(--font-mono)' }}>
|
||
|
|
{e.matchers.join(', ')}
|
||
|
|
</td>
|
||
|
|
<td>{e.cap}</td>
|
||
|
|
<td>
|
||
|
|
<span className="badge-builtin">built-in</span>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
{custom.map((e, i) => (
|
||
|
|
<tr key={`c-${i}`}>
|
||
|
|
<td>{e.lang}</td>
|
||
|
|
<td style={{ fontFamily: 'var(--font-mono)' }}>
|
||
|
|
{e.matchers.join(', ')}
|
||
|
|
</td>
|
||
|
|
<td>{e.cap}</td>
|
||
|
|
<td>
|
||
|
|
<button
|
||
|
|
className="btn btn-danger btn-sm"
|
||
|
|
onClick={() => onDelete(e)}
|
||
|
|
>
|
||
|
|
Remove
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</ConfigSection>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Config Page ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export function ConfigPage() {
|
||
|
|
const {
|
||
|
|
data: config,
|
||
|
|
isLoading: configLoading,
|
||
|
|
error: configError,
|
||
|
|
} = useConfig();
|
||
|
|
const { data: sources } = useSources();
|
||
|
|
const { data: sinks } = useSinks();
|
||
|
|
const { data: sanitizers } = useSanitizers();
|
||
|
|
const { data: terminators } = useTerminators();
|
||
|
|
const { data: profiles } = useProfiles();
|
||
|
|
|
||
|
|
const addSource = useAddSource();
|
||
|
|
const deleteSource = useDeleteSource();
|
||
|
|
const addSink = useAddSink();
|
||
|
|
const deleteSink = useDeleteSink();
|
||
|
|
const addSanitizer = useAddSanitizer();
|
||
|
|
const deleteSanitizer = useDeleteSanitizer();
|
||
|
|
const addTerminator = useAddTerminator();
|
||
|
|
const deleteTerminator = useDeleteTerminator();
|
||
|
|
const addProfile = useAddProfile();
|
||
|
|
const deleteProfile = useDeleteProfile();
|
||
|
|
const activateProfile = useActivateProfile();
|
||
|
|
const toggleTriageSync = useToggleTriageSync();
|
||
|
|
|
||
|
|
const [termLang, setTermLang] = useState('');
|
||
|
|
const [termName, setTermName] = useState('');
|
||
|
|
const [profileName, setProfileName] = useState('');
|
||
|
|
|
||
|
|
const handleAddTerminator = useCallback(() => {
|
||
|
|
if (!termLang || !termName) return;
|
||
|
|
addTerminator.mutate({ lang: termLang, name: termName });
|
||
|
|
setTermName('');
|
||
|
|
}, [termLang, termName, addTerminator]);
|
||
|
|
|
||
|
|
const handleSaveProfile = useCallback(() => {
|
||
|
|
if (!profileName) return;
|
||
|
|
addProfile.mutate({ name: profileName, settings: {} });
|
||
|
|
setProfileName('');
|
||
|
|
}, [profileName, addProfile]);
|
||
|
|
|
||
|
|
if (configLoading) return <LoadingState message="Loading configuration..." />;
|
||
|
|
if (configError) return <ErrorState message={configError.message} />;
|
||
|
|
|
||
|
|
// Extract config fields (config is typed as unknown since it's the raw NyxConfig)
|
||
|
|
const cfg = config as Record<string, Record<string, unknown>> | undefined;
|
||
|
|
const scanner = cfg?.scanner as Record<string, unknown> | undefined;
|
||
|
|
const output = cfg?.output as Record<string, unknown> | undefined;
|
||
|
|
const server = cfg?.server as Record<string, unknown> | undefined;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div className="page-header">
|
||
|
|
<h2>Config</h2>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* General Section */}
|
||
|
|
<ConfigSection title="General" id="config-general">
|
||
|
|
<div className="detail-meta">
|
||
|
|
<div>
|
||
|
|
<strong>Analysis Mode:</strong> {String(scanner?.mode || 'full')}
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<strong>Min Severity:</strong>{' '}
|
||
|
|
{String(scanner?.min_severity || 'Low')}
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<strong>Max File Size:</strong>{' '}
|
||
|
|
{scanner?.max_file_size_mb
|
||
|
|
? String(scanner.max_file_size_mb) + ' MB'
|
||
|
|
: 'unlimited'}
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<strong>Excluded Dirs:</strong>{' '}
|
||
|
|
{((scanner?.excluded_directories as string[]) || []).join(', ')}
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<strong>Excluded Exts:</strong>{' '}
|
||
|
|
{((scanner?.excluded_extensions as string[]) || []).join(', ')}
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<strong>Attack Surface Ranking:</strong>{' '}
|
||
|
|
{output?.attack_surface_ranking ? 'Enabled' : 'Disabled'}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
marginTop: 'var(--space-4)',
|
||
|
|
paddingTop: 'var(--space-3)',
|
||
|
|
borderTop: '1px solid var(--border)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="toggle-inline">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
id="triage-sync-toggle"
|
||
|
|
checked={!!server?.triage_sync}
|
||
|
|
onChange={(e) =>
|
||
|
|
toggleTriageSync.mutate({ enabled: e.target.checked })
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<label htmlFor="triage-sync-toggle">
|
||
|
|
<strong>Triage Sync</strong> — Auto-sync triage decisions to{' '}
|
||
|
|
<code>.nyx/triage.json</code> for git-based team sharing
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</ConfigSection>
|
||
|
|
|
||
|
|
{/* Sources */}
|
||
|
|
<LabelSection
|
||
|
|
title="Sources"
|
||
|
|
id="config-sources"
|
||
|
|
kind="source"
|
||
|
|
entries={sources || []}
|
||
|
|
onAdd={(body) => addSource.mutate(body)}
|
||
|
|
onDelete={(e) =>
|
||
|
|
deleteSource.mutate({
|
||
|
|
lang: e.lang,
|
||
|
|
matchers: e.matchers,
|
||
|
|
cap: e.cap,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Sinks */}
|
||
|
|
<LabelSection
|
||
|
|
title="Sinks"
|
||
|
|
id="config-sinks"
|
||
|
|
kind="sink"
|
||
|
|
entries={sinks || []}
|
||
|
|
onAdd={(body) => addSink.mutate(body)}
|
||
|
|
onDelete={(e) =>
|
||
|
|
deleteSink.mutate({ lang: e.lang, matchers: e.matchers, cap: e.cap })
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Sanitizers */}
|
||
|
|
<LabelSection
|
||
|
|
title="Sanitizers"
|
||
|
|
id="config-sanitizers"
|
||
|
|
kind="sanitizer"
|
||
|
|
entries={sanitizers || []}
|
||
|
|
onAdd={(body) => addSanitizer.mutate(body)}
|
||
|
|
onDelete={(e) =>
|
||
|
|
deleteSanitizer.mutate({
|
||
|
|
lang: e.lang,
|
||
|
|
matchers: e.matchers,
|
||
|
|
cap: e.cap,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Terminators */}
|
||
|
|
<ConfigSection title="Terminators" id="config-terminators">
|
||
|
|
<div className="inline-form" id="add-term-form">
|
||
|
|
<div className="form-group">
|
||
|
|
<label>Language</label>
|
||
|
|
<select
|
||
|
|
style={{ width: 140 }}
|
||
|
|
value={termLang}
|
||
|
|
onChange={(e) => setTermLang(e.target.value)}
|
||
|
|
>
|
||
|
|
<option value="">Select...</option>
|
||
|
|
{LANG_OPTIONS.map((l) => (
|
||
|
|
<option key={l} value={l}>
|
||
|
|
{l}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div className="form-group">
|
||
|
|
<label>Function Name</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="process.exit"
|
||
|
|
value={termName}
|
||
|
|
onChange={(e) => setTermName(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
className="btn btn-primary btn-sm"
|
||
|
|
onClick={handleAddTerminator}
|
||
|
|
>
|
||
|
|
Add Terminator
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div className="table-wrap">
|
||
|
|
{!terminators || terminators.length === 0 ? (
|
||
|
|
<div className="empty-state" style={{ padding: 12 }}>
|
||
|
|
<p>No terminators configured</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Language</th>
|
||
|
|
<th>Name</th>
|
||
|
|
<th></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{(terminators as TerminatorView[]).map((t, i) => (
|
||
|
|
<tr key={i}>
|
||
|
|
<td>{t.lang}</td>
|
||
|
|
<td style={{ fontFamily: 'var(--font-mono)' }}>{t.name}</td>
|
||
|
|
<td>
|
||
|
|
<button
|
||
|
|
className="btn btn-danger btn-sm"
|
||
|
|
onClick={() => deleteTerminator.mutate(t)}
|
||
|
|
>
|
||
|
|
Remove
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</ConfigSection>
|
||
|
|
|
||
|
|
{/* Profiles */}
|
||
|
|
<ConfigSection title="Profiles" id="config-profiles">
|
||
|
|
<div className="table-wrap">
|
||
|
|
{!profiles || profiles.length === 0 ? (
|
||
|
|
<div className="empty-state" style={{ padding: 12 }}>
|
||
|
|
<p>No profiles configured</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Name</th>
|
||
|
|
<th>Type</th>
|
||
|
|
<th>Settings</th>
|
||
|
|
<th></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{(profiles as ProfileView[]).map((p) => (
|
||
|
|
<tr key={p.name}>
|
||
|
|
<td>
|
||
|
|
<strong>{p.name}</strong>
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
{p.is_builtin ? (
|
||
|
|
<span className="badge-builtin">built-in</span>
|
||
|
|
) : (
|
||
|
|
<span className="badge-custom">custom</span>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
<td
|
||
|
|
style={{
|
||
|
|
fontSize: 'var(--text-xs)',
|
||
|
|
maxWidth: 300,
|
||
|
|
overflow: 'hidden',
|
||
|
|
textOverflow: 'ellipsis',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{JSON.stringify(p.settings)}
|
||
|
|
</td>
|
||
|
|
<td>
|
||
|
|
<button
|
||
|
|
className="btn btn-sm"
|
||
|
|
onClick={() => activateProfile.mutate(p.name)}
|
||
|
|
>
|
||
|
|
Activate
|
||
|
|
</button>
|
||
|
|
{!p.is_builtin && (
|
||
|
|
<button
|
||
|
|
className="btn btn-danger btn-sm"
|
||
|
|
onClick={() => deleteProfile.mutate(p.name)}
|
||
|
|
>
|
||
|
|
Delete
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="inline-form" style={{ marginTop: 12 }}>
|
||
|
|
<div className="form-group">
|
||
|
|
<label>Profile Name</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
placeholder="my_profile"
|
||
|
|
value={profileName}
|
||
|
|
onChange={(e) => setProfileName(e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
className="btn btn-primary btn-sm"
|
||
|
|
onClick={handleSaveProfile}
|
||
|
|
>
|
||
|
|
Save Current as Profile
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</ConfigSection>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|