soc-openenv / web /src /components /HomePage.tsx
Jayanth4577's picture
Add tests for graders and environment configuration
6964e37
Raw
History Blame Contribute Delete
28.7 kB
import { useEffect, useMemo, useState } from "react";
type TaskId = "alert_triage" | "attack_chain_reconstruction" | "constrained_incident_response";
type Difficulty = "easy" | "medium" | "hard";
type TaskDefinition = {
id: TaskId;
name: string;
difficulty: Difficulty;
max_steps: number;
description: string;
};
type AlertRecord = {
alert_id?: string;
severity?: string;
host_id?: string;
user_id?: string | null;
rule_name?: string;
mitre_tactic?: string | null;
status?: string;
enrichment?: unknown;
indicators?: Array<{
type?: string;
value?: string;
reputation?: string;
}>;
};
type HostRecord = {
host_id?: string;
hostname?: string;
status?: string;
role?: string;
is_critical?: boolean;
is_vip?: boolean;
};
type ConstraintRecord = {
host_id?: string | null;
user_id?: string | null;
constraint_type?: string;
severity?: string;
reason?: string;
};
type NoteRecord = {
step?: number;
action_taken?: string;
finding?: string;
timestamp?: string;
};
type Observation = {
step: number;
task_id: string;
task_description: string;
active_alerts: AlertRecord[];
acknowledged_alerts?: AlertRecord[];
hosts: HostRecord[];
business_constraints: ConstraintRecord[];
notes: NoteRecord[];
elapsed_minutes: number;
steps_remaining: number;
last_action_result?: string | null;
last_action_success?: boolean;
};
type EnvState = {
task_id: string;
step: number;
done: boolean;
cumulative_reward: number;
observation: Observation;
attack_chain?: Record<string, unknown> | null;
identified_stages?: string[];
isolated_hosts?: string[];
disabled_accounts?: string[];
forensics_collected?: Record<string, string[]>;
escalated?: boolean;
ticket_created?: boolean;
};
type StepReward = {
total?: number;
classification_delta?: number;
chain_coverage_delta?: number;
containment_delta?: number;
dwell_penalty?: number;
fp_penalty?: number;
compliance_delta?: number;
info?: string;
};
type StepResponse = {
observation?: Observation;
reward?: StepReward;
done?: boolean;
info?: Record<string, unknown>;
};
type GradeResponse = {
task_id?: string;
score?: number;
breakdown?: Record<string, unknown>;
};
type RunTrace = {
id: string;
kind: "reset" | "step" | "grade" | "error" | "info";
title: string;
detail: string;
reward?: number;
done?: boolean;
actionType?: string;
createdAt: string;
};
type TaskSeed = {
action_type: string;
alert_id?: string;
alert_ids?: string[];
host_id?: string;
user_id?: string;
artifact_types?: string[];
source?: string;
priority?: "P1" | "P2" | "P3";
summary?: string;
correlation_hypothesis?: string;
};
const FALLBACK_TASKS: TaskDefinition[] = [
{
id: "alert_triage",
name: "Alert triage",
difficulty: "easy",
max_steps: 10,
description: "Classify noisy SIEM alerts, enrich the useful ones, and contain the true positives."
},
{
id: "attack_chain_reconstruction",
name: "Attack chain reconstruction",
difficulty: "medium",
max_steps: 25,
description: "Correlate alerts across hosts, identify the ATT&CK chain, and stop lateral movement."
},
{
id: "constrained_incident_response",
name: "Constrained incident response",
difficulty: "hard",
max_steps: 40,
description:
"Respond to an active breach while respecting legal hold, customer-facing, and hard-block constraints."
}
];
const API_ROOT = import.meta.env.VITE_API_BASE_URL ?? window.location.origin;
const createSuggestedAction = (taskId: TaskId, observation: Observation | null): TaskSeed => {
const activeAlerts = observation?.active_alerts ?? [];
const hosts = observation?.hosts ?? [];
const constraints = observation?.business_constraints ?? [];
const firstAlertId = typeof activeAlerts[0]?.alert_id === "string" ? activeAlerts[0].alert_id : "ALT-001";
const secondAlertId = typeof activeAlerts[1]?.alert_id === "string" ? activeAlerts[1].alert_id : firstAlertId;
const criticalHost = hosts.find((host) => host.is_critical && typeof host.host_id === "string")?.host_id;
const legalHoldHost = constraints.find(
(constraint) => constraint.constraint_type === "legal_hold" && typeof constraint.host_id === "string"
)?.host_id;
const preferredHost = criticalHost ?? legalHoldHost ?? hosts[0]?.host_id ?? "HOST-001";
if (taskId === "alert_triage") {
return {
action_type: "enrich_alert",
alert_id: firstAlertId,
source: "threat_intel"
};
}
if (taskId === "attack_chain_reconstruction") {
return {
action_type: "correlate_alerts",
alert_ids: [firstAlertId, secondAlertId],
correlation_hypothesis: "Linked intrusion activity across multiple hosts"
};
}
if (constraints.some((constraint) => constraint.constraint_type === "legal_hold")) {
return {
action_type: "collect_forensics",
host_id: String(legalHoldHost ?? preferredHost),
artifact_types: ["memory_dump", "event_logs"]
};
}
return {
action_type: "create_ticket",
priority: "P1",
summary: "Critical incident requires immediate senior analyst review"
};
};
const buildGuidedScript = (taskId: TaskId, observation: Observation | null): TaskSeed[] => {
const activeAlerts = observation?.active_alerts ?? [];
const hosts = observation?.hosts ?? [];
const constraints = observation?.business_constraints ?? [];
const firstAlertId = typeof activeAlerts[0]?.alert_id === "string" ? activeAlerts[0].alert_id : "ALT-001";
const secondAlertId = typeof activeAlerts[1]?.alert_id === "string" ? activeAlerts[1].alert_id : firstAlertId;
const criticalHost = hosts.find((host) => host.is_critical && typeof host.host_id === "string")?.host_id;
const legalHoldHost = constraints.find(
(constraint) => constraint.constraint_type === "legal_hold" && typeof constraint.host_id === "string"
)?.host_id;
const customerFacingHost = constraints.find(
(constraint) => constraint.constraint_type === "customer_facing" && typeof constraint.host_id === "string"
)?.host_id;
const hostTarget = criticalHost ?? legalHoldHost ?? customerFacingHost ?? hosts[0]?.host_id ?? "HOST-001";
if (taskId === "alert_triage") {
return [
{
action_type: "enrich_alert",
alert_id: firstAlertId,
source: "threat_intel"
},
{
action_type: "create_ticket",
priority: "P2",
summary: "Escalate the triage outcome and document containment evidence"
}
];
}
if (taskId === "attack_chain_reconstruction") {
return [
{
action_type: "enrich_alert",
alert_id: firstAlertId,
source: "asset_db"
},
{
action_type: "correlate_alerts",
alert_ids: [firstAlertId, secondAlertId],
correlation_hypothesis: "Single intrusion spanning multiple hosts and stages"
},
{
action_type: "isolate_endpoint",
host_id: String(hostTarget)
},
{
action_type: "create_ticket",
priority: "P1",
summary: "Attack chain identified and containment actions executed"
}
];
}
return [
{
action_type: "collect_forensics",
host_id: String(legalHoldHost ?? hostTarget),
artifact_types: ["memory_dump", "event_logs", "network_capture"]
},
{
action_type: "escalate_to_tier2",
summary: "Active breach with legal and business constraints requires senior review"
},
{
action_type: "create_ticket",
priority: "P1",
summary: "Open incident record and preserve evidence chain"
}
];
};
const formatJson = (value: unknown) => JSON.stringify(value, null, 2);
function HomePage() {
const [tasks, setTasks] = useState<TaskDefinition[]>(FALLBACK_TASKS);
const [selectedTaskId, setSelectedTaskId] = useState<TaskId>("alert_triage");
const [busy, setBusy] = useState<string | null>(null);
const [statusText, setStatusText] = useState<string>("Ready to reset an episode.");
const [draftAction, setDraftAction] = useState<string>(formatJson(createSuggestedAction("alert_triage", null)));
const [observation, setObservation] = useState<Observation | null>(null);
const [stateData, setStateData] = useState<EnvState | null>(null);
const [gradeData, setGradeData] = useState<GradeResponse | null>(null);
const [trace, setTrace] = useState<RunTrace[]>([]);
const [backendError, setBackendError] = useState<string>("");
const selectedTask = tasks.find((task) => task.id === selectedTaskId) ?? tasks[0];
const activeAlerts = observation?.active_alerts ?? [];
const acknowledgedAlerts = observation?.acknowledged_alerts ?? [];
const hosts = observation?.hosts ?? [];
const constraints = observation?.business_constraints ?? [];
const notes = observation?.notes ?? [];
const counts = useMemo(() => {
return {
alerts: activeAlerts.length,
acknowledged: acknowledgedAlerts.length,
hosts: hosts.length,
constraints: constraints.length,
notes: notes.length
};
}, [activeAlerts.length, acknowledgedAlerts.length, hosts.length, constraints.length, notes.length]);
useEffect(() => {
const loadTasks = async () => {
try {
const response = await fetch(`${API_ROOT}/api/tasks`);
if (!response.ok) {
throw new Error(`Task list request failed with status ${response.status}`);
}
const payload = (await response.json()) as { tasks?: TaskDefinition[] };
if (Array.isArray(payload.tasks) && payload.tasks.length > 0) {
setTasks(payload.tasks);
if (!payload.tasks.some((task) => task.id === selectedTaskId) && payload.tasks[0]) {
setSelectedTaskId(payload.tasks[0].id);
}
}
setBackendError("");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load tasks";
setBackendError(message);
}
};
void loadTasks();
}, []);
useEffect(() => {
setDraftAction(formatJson(createSuggestedAction(selectedTaskId, observation)));
}, [selectedTaskId]);
useEffect(() => {
if (!tasks.some((task) => task.id === selectedTaskId) && tasks[0]) {
setSelectedTaskId(tasks[0].id);
}
}, [tasks, selectedTaskId]);
const pushTrace = (
entry: Omit<RunTrace, "id" | "createdAt">
) => {
setTrace((current) => [
{
...entry,
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
createdAt: new Date().toISOString()
},
...current
].slice(0, 32));
};
const syncState = async (taskId: TaskId) => {
const response = await fetch(`${API_ROOT}/state?task_id=${taskId}`);
if (!response.ok) {
throw new Error(`State request failed with status ${response.status}`);
}
const payload = (await response.json()) as EnvState;
setStateData(payload);
return payload;
};
const resetEpisode = async (taskId: TaskId = selectedTaskId) => {
setBusy("reset");
setStatusText(`Resetting ${taskId}...`);
try {
const response = await fetch(`${API_ROOT}/reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_id: taskId, seed: 42 })
});
if (!response.ok) {
const details = await response.text();
throw new Error(`Reset failed (${response.status}): ${details}`);
}
const payload = (await response.json()) as Observation;
setObservation(payload);
const state = await syncState(taskId);
setGradeData(null);
setBackendError("");
setDraftAction(formatJson(createSuggestedAction(taskId, payload)));
setStatusText(`Episode reset for ${taskId}. Ready for the next action.`);
pushTrace({
kind: "reset",
title: "Episode reset",
detail: `Loaded ${taskId} with ${payload.active_alerts.length} active alerts and ${payload.hosts.length} hosts.`
});
return { observation: payload, state };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown reset error";
setBackendError(message);
setStatusText("Reset failed.");
pushTrace({
kind: "error",
title: "Reset error",
detail: message
});
throw error;
} finally {
setBusy(null);
}
};
const parseDraft = () => {
const parsed = JSON.parse(draftAction) as TaskSeed;
if (!parsed || typeof parsed !== "object" || typeof parsed.action_type !== "string") {
throw new Error("Action JSON must include action_type.");
}
return parsed;
};
const sendStep = async (action: TaskSeed, label: string) => {
setBusy("step");
setStatusText(`${label}...`);
try {
const response = await fetch(`${API_ROOT}/step`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_id: selectedTaskId, action })
});
if (!response.ok) {
const details = await response.text();
throw new Error(`Step failed (${response.status}): ${details}`);
}
const payload = (await response.json()) as StepResponse;
const nextObservation = payload.observation ?? null;
setObservation(nextObservation);
const state = await syncState(selectedTaskId);
setBackendError("");
const reward = payload.reward?.total ?? 0;
const done = Boolean(payload.done);
const actionType = action.action_type;
setStatusText(`${label} complete. Reward ${reward.toFixed(3)}. Done: ${done ? "yes" : "no"}.`);
pushTrace({
kind: "step",
title: label,
detail: payload.reward?.info ?? `Action ${actionType} executed.`,
reward,
done,
actionType
});
return { payload, state };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown step error";
setBackendError(message);
setStatusText("Action failed.");
pushTrace({
kind: "error",
title: "Step error",
detail: message
});
throw error;
} finally {
setBusy(null);
}
};
const gradeEpisode = async () => {
setBusy("grade");
setStatusText(`Grading ${selectedTaskId}...`);
try {
const response = await fetch(`${API_ROOT}/grade?task_id=${selectedTaskId}`, {
method: "POST"
});
if (!response.ok) {
const details = await response.text();
throw new Error(`Grade failed (${response.status}): ${details}`);
}
const payload = (await response.json()) as GradeResponse;
setGradeData(payload);
setBackendError("");
const score = typeof payload.score === "number" ? payload.score : 0;
setStatusText(`Grade complete. Score ${score.toFixed(3)}.`);
pushTrace({
kind: "grade",
title: "Episode graded",
detail: `Task ${selectedTaskId} scored ${score.toFixed(3)}.`,
reward: score
});
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown grade error";
setBackendError(message);
setStatusText("Grade failed.");
pushTrace({
kind: "error",
title: "Grade error",
detail: message
});
throw error;
} finally {
setBusy(null);
}
};
const runGuidedDemo = async () => {
setBusy("demo");
setBackendError("");
try {
const resetResult = await resetEpisode(selectedTaskId);
const script = buildGuidedScript(selectedTaskId, resetResult.observation);
for (const [index, action] of script.entries()) {
const response = await sendStep(action, `Guided step ${index + 1}`);
if (response.payload.done) {
break;
}
}
await gradeEpisode();
setStatusText(`Guided demo completed for ${selectedTaskId}.`);
} catch {
// errors already surfaced in trace and status
} finally {
setBusy(null);
}
};
const loadSuggestedAction = () => {
setDraftAction(formatJson(createSuggestedAction(selectedTaskId, observation)));
};
const submitDraftAction = async () => {
const action = parseDraft();
await sendStep(action, `Manual step for ${selectedTaskId}`);
};
return (
<section className="page page-home">
<div className="hero-card hero-console reveal">
<div className="hero-copy">
<p className="section-label">OpenEnv hackathon demo</p>
<h2>Security Operations Center simulation with live reset, step, state, and grade flows.</h2>
<p>
The environment is the simulator. The model is only the policy that emits JSON actions. This
console shows both sides clearly so judges can inspect the episode, the response, the reward, and
the final score without reading code.
</p>
</div>
<div className="hero-stats" aria-label="Environment summary">
<div className="stat-card">
<span>Tasks</span>
<strong>{tasks.length}</strong>
<small>easy → hard</small>
</div>
<div className="stat-card">
<span>Backend</span>
<strong>FastAPI</strong>
<small>/reset /step /state /grade</small>
</div>
<div className="stat-card">
<span>Baseline</span>
<strong>HF router</strong>
<small>OpenAI client compatible</small>
</div>
</div>
</div>
<div className="console-layout">
<aside className="console-sidebar">
<article className="panel reveal">
<p className="section-label">Tasks</p>
<h3>Available scenarios</h3>
<div className="task-list">
{tasks.map((task, index) => (
<button
key={task.id}
type="button"
className={`task-card ${selectedTaskId === task.id ? "active" : ""} reveal`}
style={{ animationDelay: `${index * 0.08}s` }}
onClick={() => setSelectedTaskId(task.id)}
>
<div className="task-card-head">
<strong>{task.name}</strong>
<span>{task.difficulty.toUpperCase()}</span>
</div>
<p>{task.description}</p>
<small>{task.max_steps} max steps</small>
</button>
))}
</div>
</article>
<article className="panel reveal" style={{ animationDelay: "0.08s" }}>
<p className="section-label">Controls</p>
<h3>Episode actions</h3>
<div className="control-stack">
<button type="button" className="primary-button" onClick={() => void resetEpisode()} disabled={busy !== null}>
{busy === "reset" ? "Resetting..." : "Reset episode"}
</button>
<button type="button" className="primary-button ghost" onClick={loadSuggestedAction} disabled={busy !== null}>
Load suggested action
</button>
<button type="button" className="primary-button" onClick={() => void submitDraftAction()} disabled={busy !== null}>
{busy === "step" ? "Sending action..." : "Run draft action"}
</button>
<button type="button" className="primary-button ghost" onClick={() => void gradeEpisode()} disabled={busy !== null}>
{busy === "grade" ? "Grading..." : "Grade current episode"}
</button>
<button type="button" className="primary-button accent" onClick={() => void runGuidedDemo()} disabled={busy !== null}>
{busy === "demo" ? "Running demo..." : "Run guided demo"}
</button>
</div>
<div className="status-block">
<span>Status</span>
<strong>{statusText}</strong>
<small>{backendError || `Connected to ${API_ROOT}`}</small>
</div>
</article>
</aside>
<section className="console-main">
<article className="panel reveal">
<div className="panel-head">
<div>
<p className="section-label">Active episode</p>
<h3>{selectedTask?.name ?? selectedTaskId}</h3>
</div>
<div className="score-pill-row">
<span className="score-pill">Reward {stateData?.cumulative_reward?.toFixed(3) ?? "0.000"}</span>
<span className="score-pill">Step {stateData?.step ?? observation?.step ?? 0}</span>
<span className="score-pill">Done {stateData?.done ? "yes" : "no"}</span>
</div>
</div>
<p className="panel-note">
{selectedTask?.description ?? "Select a task to inspect the current incident."}
</p>
<div className="metric-grid">
<div className="metric-card">
<span>Active alerts</span>
<strong>{counts.alerts}</strong>
</div>
<div className="metric-card">
<span>Acknowledged</span>
<strong>{counts.acknowledged}</strong>
</div>
<div className="metric-card">
<span>Hosts</span>
<strong>{counts.hosts}</strong>
</div>
<div className="metric-card">
<span>Constraints</span>
<strong>{counts.constraints}</strong>
</div>
</div>
<div className="json-grid">
<div className="json-card">
<div className="json-card-head">
<h4>Current observation</h4>
<small>Visible to the agent</small>
</div>
<pre>{formatJson(observation ?? { note: "Reset an episode to load data." })}</pre>
</div>
<div className="json-card">
<div className="json-card-head">
<h4>Backend state</h4>
<small>Includes hidden grading state</small>
</div>
<pre>{formatJson(stateData ?? { note: "State appears after the first reset." })}</pre>
</div>
</div>
</article>
<article className="panel reveal" style={{ animationDelay: "0.08s" }}>
<div className="panel-head">
<div>
<p className="section-label">Action draft</p>
<h3>JSON action submitted to /step</h3>
</div>
<div className="score-pill-row">
<span className="score-pill">HF client</span>
<span className="score-pill">Deterministic grader</span>
</div>
</div>
<textarea
className="draft-editor"
value={draftAction}
onChange={(event) => setDraftAction(event.target.value)}
spellCheck={false}
/>
<div className="panel-actions">
<button type="button" className="secondary-button" onClick={loadSuggestedAction} disabled={busy !== null}>
Load suggested JSON
</button>
<button type="button" className="secondary-button accent" onClick={() => void submitDraftAction()} disabled={busy !== null}>
Execute draft action
</button>
</div>
{gradeData && (
<div className="grade-card">
<strong>Final score: {typeof gradeData.score === "number" ? gradeData.score.toFixed(4) : "n/a"}</strong>
<pre>{formatJson(gradeData.breakdown ?? {})}</pre>
</div>
)}
</article>
<article className="panel reveal" style={{ animationDelay: "0.16s" }}>
<div className="panel-head">
<div>
<p className="section-label">Trace</p>
<h3>Episode timeline</h3>
</div>
<div className="score-pill-row">
<span className="score-pill">Max {selectedTask?.max_steps ?? 0} steps</span>
<span className="score-pill">{trace.length} events</span>
</div>
</div>
<div className="trace-list">
{trace.length === 0 && <p className="empty-state">Run reset, step, or grade to populate the trace.</p>}
{trace.map((entry) => (
<div className={`trace-item ${entry.kind}`} key={entry.id}>
<div>
<strong>{entry.title}</strong>
<p>{entry.detail}</p>
<small>{new Date(entry.createdAt).toLocaleTimeString()}</small>
</div>
<div className="trace-meta">
{entry.actionType && <span>{entry.actionType}</span>}
{typeof entry.reward === "number" && <span>reward {entry.reward.toFixed(3)}</span>}
{typeof entry.done === "boolean" && <span>done {entry.done ? "yes" : "no"}</span>}
</div>
</div>
))}
</div>
</article>
<article className="panel reveal" style={{ animationDelay: "0.24s" }}>
<div className="panel-head">
<div>
<p className="section-label">Incident snapshot</p>
<h3>Alerts, hosts, constraints, and notes</h3>
</div>
</div>
<div className="detail-grid">
<div className="detail-column">
<h4>Active alerts</h4>
{activeAlerts.length === 0 && <p className="empty-state">No active alerts yet.</p>}
{activeAlerts.map((alert, index) => (
<div className="detail-card" key={alert.alert_id ?? index}>
<strong>{alert.alert_id ?? `Alert ${index + 1}`}</strong>
<p>{alert.rule_name ?? "No rule name"}</p>
<small>
Host {alert.host_id ?? "n/a"} | Severity {alert.severity ?? "n/a"} | Tactic {alert.mitre_tactic ?? "n/a"}
</small>
</div>
))}
</div>
<div className="detail-column">
<h4>Hosts</h4>
{hosts.length === 0 && <p className="empty-state">No hosts loaded yet.</p>}
{hosts.map((host, index) => (
<div className="detail-card" key={host.host_id ?? index}>
<strong>{host.hostname ?? host.host_id ?? `Host ${index + 1}`}</strong>
<p>{host.role ?? "Unknown role"}</p>
<small>
{host.host_id ?? "n/a"} | {host.status ?? "n/a"} | critical {host.is_critical ? "yes" : "no"} | vip {host.is_vip ? "yes" : "no"}
</small>
</div>
))}
</div>
<div className="detail-column">
<h4>Constraints</h4>
{constraints.length === 0 && <p className="empty-state">No business constraints loaded yet.</p>}
{constraints.map((constraint, index) => (
<div className="detail-card" key={`${constraint.constraint_type ?? "constraint"}-${index}`}>
<strong>{constraint.constraint_type ?? "Constraint"}</strong>
<p>{constraint.reason ?? "No reason provided"}</p>
<small>
Severity {constraint.severity ?? "n/a"} | host {constraint.host_id ?? "n/a"} | user {constraint.user_id ?? "n/a"}
</small>
</div>
))}
</div>
<div className="detail-column">
<h4>Recent notes</h4>
{notes.length === 0 && <p className="empty-state">No investigation notes yet.</p>}
{notes.map((note, index) => (
<div className="detail-card" key={`${note.step ?? index}-${index}`}>
<strong>Step {note.step ?? index + 1}</strong>
<p>{note.action_taken ?? "No action"}</p>
<small>{note.finding ?? "No finding"}</small>
</div>
))}
</div>
</div>
</article>
</section>
</div>
</section>
);
}
export default HomePage;