Skip to content

feat: Rust port (oxidation) — full campaign engine, planner, MCP, and timeline UI#18

Merged
Magier merged 206 commits into
mainfrom
oxidation
May 29, 2026
Merged

feat: Rust port (oxidation) — full campaign engine, planner, MCP, and timeline UI#18
Magier merged 206 commits into
mainfrom
oxidation

Conversation

@Magier

@Magier Magier commented May 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Core Rust rewrite: New crates/ workspace with api, app, armory, c2, campaign, cli, domain, graph, k8s, and planner crates, replacing the Go implementation with a full Rust campaign engine, entity store, inference engine, and knowledge graph
  • Campaign engine: Output parsers, analyzers, grounding, effects, C2 channel chaining (kubectl/kubelet exec, reverse shell, interactive sessions), cleanup-after-emulation, and entity dedup via PendingView
  • Planner crate: Fuzzy plan execution with dependency DAG validation, entity ID resolver (regex + select strategies), campaign export to plan YAML, and REST endpoints
  • Armory expansions: k8s_request procedure abstraction, CopyFail CVE-2026-31431 escape-to-host TTP, new Discovery/LateralMovement/Tools TTPs, tool YAML slotting
  • Embedded MCP server: Streamable HTTP MCP server via rmcp exposed from the API crate
  • Operation Timeline UI: Replaces ActionLogDrawer — TimelineStore with ActionGroup effect grouping, single-pass effectCounts, SSE entity-discovered events, Svelte 5 reactivity fixes
  • ELK graph layout: Replaces fCoSE with ELK layered layout, layer assignment builder, GraphLayoutPlayground
  • mdBook documentation: Full book covering armory, atomic testing, campaign model, extending, and agent API/MCP chapters
  • CI/CD: Rust build + lint pipeline added alongside existing Go pipeline

Test Plan

  • cargo test — all suites pass
  • vitest run — 22 frontend tests pass
  • Smoke-test campaign against a live cluster: initial access → discovery → lateral movement
  • Verify OperationTimeline groups actions correctly and toggle works
  • Verify planner /api/plan/execute endpoint with a sample YAML plan
  • Verify MCP server responds to tool calls via Streamable HTTP

Magier and others added 30 commits March 28, 2026 20:17
ttp_rbac_satisfied and ttp_exists_satisfied inspect campaign state and
belong in the campaign crate, not the api crate. Moved both functions
and their tests to a new campaign::ttp_applicability module; the api
handler now imports them from there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the nested 'system' object from the frontend payload by adding
#[serde(flatten)] to the system field in Pod and K8sNode. SystemInfo
fields (binaries, env_vars, files, ips, missing_files, mounts, processes,
access_level) are now serialized directly into the entity map.

Update state_conversions.rs accordingly: add_ui_system_field_aliases now
reads env_vars and access_level from the top-level map, and
prune_entity_payload_for_ui prunes access_level directly. Remove the
now-unused prune_null_entries_json_map helper.
- output_parsers: add sys.has-binary(inner) pure parser
  - single-arg literal path: derives binary name from path basename
  - two-arg form: explicit-name, path (quoted names supported)
  - ${OUTPUT} sentinel: extracts absolute paths from stdout
  - routes through existing orchestrator merge path (no campaign mutation)
- external_parser: add binaries field to SystemFieldUpdates + merge
- execution: infer tool binary presence after successful execution
  (mirrors Go analyzeToolSuccessfullyUsedInTTP; only writes when Unknown)
- domain: add listeners field to C2Server for exists:Listener TTP checks
- api: ttp_exists_satisfied checks C2Server.listeners; unknown kinds fail safe
- api: ttp_rbac_satisfied checks any captured SA (no SA = fail, not pass)
- AGENTS.md: note examples/ is test/playground material only
- Parser (`rawServiceAccountToken` effect): decodes the JWT payload from
  stdout, supports both projected (kubernetes.io claim) and legacy flat
  claim formats, and produces Namespace, ServiceAccount (with token),
  Pod, and K8sNode entities plus Contains/Uses/RunsOn relations.
  Uses the new SuccessWithFacts ParserOutput variant so entity-producing
  parsers don't need a system target.

- ServiceAccountTokenAnalyzer: for every new ServiceAccount whose token
  carries pod_name claims, ensures the Pod entity exists with the correct
  service_account_name set and wires the Uses relation — the token-driven
  counterpart of ServiceAccountAnalyzer.

- Rename PodServiceAccountAnalyzer → ServiceAccountAnalyzer throughout.

- Add #[derive(Debug)] to FactsUpdate (required by new ParserOutput variant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a TTP targets a service account (ns/.../sa/...) rather than a pod,
resolve_exec_channel now traverses the `uses` relation (pod→SA) to find
a backing pod and returns it as exec_target_id on ExecChannel.
prepare_action uses exec_target_id as the ExecTtp.target_id so the
builtin C2 kubectl-execs into the pod, not the SA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A K8sNode always belongs to the cluster it was discovered in. Added
NodeClusterAnalyzer (mirrors NamespaceClusterAnalyzer) to emit a
contains relation from the campaign cluster to every new K8sNode,
regardless of how it was discovered (SA token, RBAC get nodes, etc.).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
K8sNodes are always part of the cluster they are discovered in.
Added ManagesNode relation type (domain) and NodeClusterRule (campaign)
so that every new K8sNode — regardless of discovery path (SA token,
RBAC get nodes, kubelet mounts, etc.) — gets a manages-node edge from
the campaign cluster. The graph renderer treats manages-node as a
high-priority compound-node parent, placing nodes inside the cluster.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added is<T>() and downcast<T>() methods on dyn Relation (all Send+Sync
variants) so callers can check relation types structurally rather than
comparing relation_name() strings.

Updated all relation type checks in analyzers, rules, and output_parsers
tests to use r.is::<Contains>() / r.is::<RunsOn>() etc. String-based
checks on RelationSummary (campaign.relations) are unchanged since type
information is not preserved in summaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Parse SelfSubjectRulesReview output (JSON and kubectl tabular) into
  RbacPermission entries on the target ServiceAccount
- Resolve SA via pod→SA indirection: exec target_id is the pod, not the
  SA; follow pod.service_account_name to find the correct SA
- Convert entitlements to camelCase `can` field in API state conversions
  so the frontend EntitlementInfo component receives the expected shape
- Remove stale entityInfo.svelte block using old Go-style verbs/resourceTypes
- Add drop_ran-ws.yaml TTP and defaults for drop_fileless_binary params
- Fix read_environment_variables procedure key; add vite HMR config for
  Rust proxy dev workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a TTP fails with exit code 127, record the procedure's binary as
BinaryPresence::Absent on the exec system so the procedure selector can
automatically fall back to an alternative tool next time.

- Add "exit code 127" string pattern to CommandNotFoundFailureAnalyzer
  to catch cases where kube embeds the exit code in fail_reason without
  propagating it to event.exit_code
- Add procedure_binary_name() helper: tries procedure.tool, then bare
  procedure.id, then first word of command
- On CommandNotFound failure, write Absent via the existing
  apply_system_update path (empty-path convention, "only if Unknown"
  guard preserves confirmed Present entries)
- Add tests covering Absent marking and Present-not-overwritten invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parses reverse DNS lookup CSV output (ip,ptr format) into Pod entities.
Mirrors the Go parseReverseDnsLookup + analyzeDnsEntries logic: only
processes cluster.local entries, identifies pod IPs by matching the
first DNS label to the IP in kebab-case, and derives pod name/namespace
from the DNS label count (4/5/6/6+ label patterns).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exposes Ran's knowledge graph, armory, and execution engine as 15 MCP
tools so that LLM agents can drive adversary emulation campaigns.

Transport: Streamable HTTP at /mcp (same port as the API/SSE server).

Tools:
  Discovery  : get_graph, get_entity, get_attack_surface, resolve_workload
  Campaign   : get_campaign_state, get_attack_flow
  Armory     : list_ttps, get_applicable_ttps, get_ttp_detail
  Execution  : execute_action, wait_for_result
  Goal eval  : check_rbac_goal, check_access_level
  Extension  : list_parse_audits, add_parser

Changes:
- crates/api/Cargo.toml  : add rmcp 1.x with streamable-http-server feature
- crates/api/src/mcp.rs  : RanMcpHandler<S: ApiService> + McpConfig + mcp_router()
- crates/api/src/lib.rs  : pub mod mcp, McpConfig re-export, router_with_sse_and_mcp()
- crates/api/src/api_handlers.rs : widen ttp_is_applicable_for_target_kind to pub(crate)
- crates/cli/src/main.rs : construct McpConfig and switch to router_with_sse_and_mcp
Magier and others added 28 commits May 23, 2026 23:18
Introduces TimelineStore with a discriminated union type (TtpActionEntry | EntityEntry) supporting ttp-action, discovery, credential, and access-gained entries. Replaces the flat ActionLogStore with richer state: pendingCount, open toggle, deduplication by entityId, and resolveTtpAction. All 13 tests pass.
Replaces the single-kind ActionLogDrawer with a multi-kind OperationTimeline
that handles ttp-action, credential, access-gained, and discovery entries.
… correlation

Replace ActionLogDrawer/actionLog with OperationTimeline/timeline store.
Make sendAction and onExecuteTTP async so cmd_id from ExecuteAction response
is used for per-command correlation instead of TTP definition id. Add
entity-discovered SSE handler to feed timeline discovery events.
…bel branch

Read cmdId (camelCase) from ExecuteAction response to match the serde rename in ExecuteActionAck, so TTP timeline entries resolve from pending instead of always falling back to randomUUID. Also remove the redundant K8sCredential branch in entryLabel that returned the same string as the fallback.
@Magier Magier merged commit e3360e8 into main May 29, 2026
2 checks passed
Magier added a commit that referenced this pull request May 30, 2026
feat: Rust port (oxidation) — full campaign engine, planner, MCP, and timeline UI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant