From 8d44e4cf4ae34f2a18c69573303e43f5eea52fe2 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Mon, 14 Sep 2026 01:19:15 -0300 Subject: [PATCH] feat(internal): internal network / AD attack graph with choke-point analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An internal engagement's result is a path, not a list of findings. This models it as one: Asset → Exposure → Weakness → Credential → Privilege → Movement → Crown Jewel, with business impact, detection and remediation hanging off the edges — because what a client fixes is a relationship, not a host. - the credential → identity → permission → machine → new credential loop, distance-bounded so a real chain turns it and an assumption-chain does not run forever - paths() separates what was walked from what is believed; one assumed hop caps the chain at informational instead of laundering it to critical - choke_points(): the single edge whose removal cuts the most value to crown jewels — the question a CVSS-sorted finding list cannot answer - detection_gaps(): unchecked reported as unchecked, never as unmonitored - dangling edges refused; re-adding a node upgrades rather than duplicates - `neurosploit internal --graph g.json --scaffold corp.local --from ` Co-Authored-By: Claude Opus 5 (1M context) --- neurosploit-rs/app/src/main.rs | 90 ++ neurosploit-rs/crates/harness/src/internal.rs | 864 ++++++++++++++++++ neurosploit-rs/crates/harness/src/lib.rs | 1 + 3 files changed, 955 insertions(+) create mode 100644 neurosploit-rs/crates/harness/src/internal.rs diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index e9b4c61..d36fbb7 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -151,6 +151,29 @@ enum Cmd { #[command(subcommand)] cmd: ProvCmd, }, + /// Internal network / AD attack graph: paths to the crown jewels, and the + /// one edge worth fixing first. + Internal { + /// Graph file (JSON: {"nodes": [...], "edges": [...]}). Omit to start + /// from the AD scaffold alone. + #[arg(long = "graph")] + graph: Option, + /// Seed the graph with the structure every domain has. + #[arg(long = "scaffold")] + scaffold: Option, + /// Where the attacker starts — the foothold node's id. + #[arg(long = "from", default_value = "printer")] + from: String, + /// Turn the credential→identity→permission→machine loop this many times. + #[arg(long = "expand", default_value_t = 3)] + expand: usize, + /// Print the Mermaid diagram too. + #[arg(long)] + mermaid: bool, + /// Write the expanded graph back out as JSON. + #[arg(long = "save")] + save: Option, + }, /// White-box: analyse a repository's source code for vulnerabilities. Whitebox { /// Local path, a GitHub URL (https://github.com/owner/repo[.git]) or an @@ -478,6 +501,9 @@ async fn main() -> anyhow::Result<()> { } Cmd::Capability { cmd } => handle_capability(cmd)?, Cmd::Provenance { cmd } => handle_provenance(cmd)?, + Cmd::Internal { graph, scaffold, from, expand, mermaid, save } => { + handle_internal(graph.as_deref(), scaffold.as_deref(), &from, expand, mermaid, save.as_deref())? + } Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, in_scope, environment, policy, budget, token_limit, deep_test_limit, coverage_first, depth_first, sample_per_route, jira, only, verbose } => { let url = if url.starts_with("http") { url } else { format!("https://{url}") }; let mut cfg = RunConfig::new(&url); @@ -1123,6 +1149,70 @@ fn provenance_key() -> Option> { .map(|s| s.into_bytes()) } +fn handle_internal( + graph: Option<&str>, + scaffold: Option<&str>, + from: &str, + expand: usize, + mermaid: bool, + save: Option<&str>, +) -> anyhow::Result<()> { + use harness::internal::InternalGraph; + let mut g = match (graph, scaffold) { + (Some(path), _) => { + let text = std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("cannot read {path}: {e}"))?; + let mut loaded: InternalGraph = serde_json::from_str(&text) + .map_err(|e| anyhow::anyhow!("{path} is not an internal graph: {e}"))?; + // A loaded graph plus the scaffold: the domain's built-in structure + // is true whether or not the operator typed it out. + if let Some(domain) = scaffold { + let base = harness::internal::ad_scaffold(domain); + for n in base.nodes { + loaded.add(n); + } + for e in base.edges { + loaded.link(e); + } + } + loaded + } + (None, Some(domain)) => harness::internal::ad_scaffold(domain), + (None, None) => anyhow::bail!("nothing to work with — pass --graph or --scaffold "), + }; + if !g.has(from) { + anyhow::bail!( + "no node `{from}` in the graph — known nodes: {}", + g.nodes.iter().map(|n| n.id.clone()).collect::>().join(", ") + ); + } + let turns = g.expand(expand); + println!(" \x1b[2mcredential loop turned {turns} time(s)\x1b[0m"); + print!(" {}", g.summary(from)); + + let proven = g.paths(from, true); + let all = g.paths(from, false); + if proven.is_empty() && !all.is_empty() { + println!(" \x1b[33mno path was walked end to end — {} hypothesis(es) to test next:\x1b[0m", all.len()); + for p in all.iter().take(5) { + for a in &p.assumptions { + println!(" · {a}"); + } + } + } + let gaps = g.detection_gaps(); + if !gaps.is_empty() { + println!(" \x1b[2m{} proven hop(s) with no detection answer — untested, not unmonitored\x1b[0m", gaps.len()); + } + if mermaid { + println!("\n{}", g.mermaid()); + } + if let Some(path) = save { + std::fs::write(path, serde_json::to_string_pretty(&g)?)?; + println!(" saved {path}"); + } + Ok(()) +} + fn handle_provenance(cmd: ProvCmd) -> anyhow::Result<()> { use harness::provenance::{Manifest, Provenance, SIGIL}; match cmd { diff --git a/neurosploit-rs/crates/harness/src/internal.rs b/neurosploit-rs/crates/harness/src/internal.rs new file mode 100644 index 0000000..c2cc5d1 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/internal.rs @@ -0,0 +1,864 @@ +//! Internal network & Active Directory — the engagement as a graph. +//! +//! A web engagement is mostly a list: findings against endpoints, each one +//! standing on its own. An internal network is not. There, the interesting +//! result is almost never a single weakness — it is that a printer nobody owns +//! leaks a service account, that account can write to a share a helpdesk +//! machine runs scripts from, and three hops later something is Domain Admin. +//! No individual step is critical. The path is. +//! +//! So the model here is a graph with one loop at its centre, the loop every +//! internal compromise actually runs on: +//! +//! ```text +//! credential ──→ identity ──→ permission ──→ machine +//! ▲ │ +//! └──────────── new credential ────────────┘ +//! ``` +//! +//! Each turn of that loop is cheap; the loop is what gets you the domain. An +//! assessment that reports the four steps separately, at Medium each, has +//! described everything and explained nothing. +//! +//! ## The layers +//! +//! Every node sits in exactly one layer, and edges mostly go left to right: +//! +//! ```text +//! Asset → Exposure → Weakness → Credential → Privilege → Movement → CrownJewel +//! │ +//! BusinessImpact ←──────────────┘ +//! Detection · Remediation +//! ``` +//! +//! The last three are not stages of an attack — they are what the client is +//! buying. `BusinessImpact` says what the crown jewel being reachable costs, +//! `Detection` says whether anything would have noticed, and `Remediation` +//! hangs off **edges** rather than nodes, because what a client fixes is a +//! relationship: a permission, a trust, a reused password. "Patch the printer" +//! is rarely the answer; "that service account should not be able to write +//! there" usually is. +//! +//! ## What makes a path real +//! +//! Every node and edge carries `proven`. An unproven edge is a hypothesis — +//! worth listing, worth testing, never worth reporting as a path. `paths()` +//! returns both kinds and says which is which, and the severity of a path is +//! capped by its weakest link, so one assumed hop cannot launder a chain into +//! a Critical. + +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; + +/// Where a node sits in the taxonomy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Layer { + /// A thing that exists: host, service, share, database, application. + Asset, + /// How it is reachable: an open port, an anonymous bind, a listening SMB. + Exposure, + /// What is wrong with it: a missing patch, signing disabled, a default. + Weakness, + /// Material that authenticates: a hash, a ticket, a password, a token. + Credential, + /// What that material is allowed to do. + Privilege, + /// Getting from one place to another with it. + Movement, + /// What the client actually cares about losing. + CrownJewel, + /// What losing it costs, in the client's terms. + BusinessImpact, + /// Whether anything would have noticed. + Detection, + /// What to change so the edge stops existing. + Remediation, +} + +impl Layer { + pub fn as_str(self) -> &'static str { + match self { + Layer::Asset => "asset", + Layer::Exposure => "exposure", + Layer::Weakness => "weakness", + Layer::Credential => "credential", + Layer::Privilege => "privilege", + Layer::Movement => "movement", + Layer::CrownJewel => "crown-jewel", + Layer::BusinessImpact => "business-impact", + Layer::Detection => "detection", + Layer::Remediation => "remediation", + } + } + /// Ordering used when laying the graph out left to right. + pub fn rank(self) -> usize { + match self { + Layer::Asset => 0, + Layer::Exposure => 1, + Layer::Weakness => 2, + Layer::Credential => 3, + Layer::Privilege => 4, + Layer::Movement => 5, + Layer::CrownJewel => 6, + Layer::BusinessImpact => 7, + Layer::Detection => 8, + Layer::Remediation => 9, + } + } +} + +/// What a node *is*, within its layer. Kept coarse on purpose: the value is in +/// the relationships, and a taxonomy nobody can place a real object into is +/// worse than one with a few broad buckets. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Kind { + Host, + Service, + Share, + Database, + Application, + User, + Computer, + ServiceAccount, + Group, + Acl, + Ticket, + Hash, + Password, + Token, + Session, + Data, + Process, + Control, + Other, +} + +impl Kind { + pub fn as_str(self) -> &'static str { + match self { + Kind::Host => "host", + Kind::Service => "service", + Kind::Share => "share", + Kind::Database => "database", + Kind::Application => "application", + Kind::User => "user", + Kind::Computer => "computer", + Kind::ServiceAccount => "service-account", + Kind::Group => "group", + Kind::Acl => "acl", + Kind::Ticket => "ticket", + Kind::Hash => "hash", + Kind::Password => "password", + Kind::Token => "token", + Kind::Session => "session", + Kind::Data => "data", + Kind::Process => "process", + Kind::Control => "control", + Kind::Other => "other", + } + } + /// Is this credential material — the thing the central loop passes along? + pub fn is_credential(self) -> bool { + matches!(self, Kind::Hash | Kind::Password | Kind::Ticket | Kind::Token | Kind::Session) + } +} + +/// One node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Node { + pub id: String, + pub layer: Layer, + pub kind: Kind, + /// Human label — what an operator would call it. + pub label: String, + /// Which machine or domain it belongs to, when that is meaningful. + #[serde(default)] + pub host: String, + /// Evidence ids backing its existence. Empty means asserted, not observed. + #[serde(default)] + pub evidence: Vec, + /// Did we actually establish this, or is it inferred? + #[serde(default)] + pub proven: bool, + /// Value to the client, 0.0–1.0. Crown jewels sit near 1.0; this is what + /// makes one path worth reporting ahead of another. + #[serde(default)] + pub value: f64, +} + +impl Node { + pub fn new(id: &str, layer: Layer, kind: Kind, label: &str) -> Node { + Node { + id: id.to_string(), + layer, + kind, + label: label.to_string(), + host: String::new(), + evidence: Vec::new(), + proven: false, + value: 0.0, + } + } + pub fn on(mut self, host: &str) -> Node { + self.host = host.to_string(); + self + } + pub fn proven_by(mut self, evidence: &[&str]) -> Node { + self.evidence = evidence.iter().map(|e| e.to_string()).collect(); + self.proven = !self.evidence.is_empty(); + self + } + pub fn worth(mut self, value: f64) -> Node { + self.value = value.clamp(0.0, 1.0); + self + } +} + +/// How one node leads to the next. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Edge { + pub from: String, + pub to: String, + /// What was done, in the operator's words ("pass-the-hash over SMB"). + pub technique: String, + /// ATT&CK id when there is one. + #[serde(default)] + pub mitre: String, + /// Proven means: performed, observed, and reproducible. Not "should work". + #[serde(default)] + pub proven: bool, + #[serde(default)] + pub evidence: Vec, + /// What removes this edge. Remediation lives here, not on nodes, because + /// what gets fixed is a relationship. + #[serde(default)] + pub remediation: String, + /// Would the client have seen it? Empty means nobody checked, which is a + /// different answer from "no" and is reported as such. + #[serde(default)] + pub detection: String, +} + +impl Edge { + pub fn new(from: &str, to: &str, technique: &str) -> Edge { + Edge { + from: from.to_string(), + to: to.to_string(), + technique: technique.to_string(), + mitre: String::new(), + proven: false, + evidence: Vec::new(), + remediation: String::new(), + detection: String::new(), + } + } + pub fn attck(mut self, id: &str) -> Edge { + self.mitre = id.to_string(); + self + } + pub fn proven_by(mut self, evidence: &[&str]) -> Edge { + self.evidence = evidence.iter().map(|e| e.to_string()).collect(); + self.proven = !self.evidence.is_empty(); + self + } + pub fn fix(mut self, remediation: &str) -> Edge { + self.remediation = remediation.to_string(); + self + } + pub fn detected(mut self, detection: &str) -> Edge { + self.detection = detection.to_string(); + self + } + fn key(&self) -> (String, String, String) { + (self.from.clone(), self.to.clone(), self.technique.to_lowercase()) + } +} + +/// A route from a starting position to something worth having. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Path { + pub nodes: Vec, + pub edges: Vec, + /// Every hop was performed and evidenced. + pub proven: bool, + /// Value of the endpoint reached. + pub value: f64, + /// Hops that were assumed rather than performed — what to test next. + pub assumptions: Vec, +} + +impl Path { + /// Severity of the path, capped by its weakest link. + /// + /// A chain with one assumed hop is a hypothesis about a Critical, not a + /// Critical. Reporting it as the latter is how an internal assessment + /// loses the client's trust on the one finding that mattered. + pub fn severity(&self) -> &'static str { + if !self.proven { + return "informational"; + } + match self.value { + v if v >= 0.9 => "critical", + v if v >= 0.7 => "high", + v if v >= 0.4 => "medium", + _ => "low", + } + } + pub fn hops(&self) -> usize { + self.edges.len() + } +} + +/// The graph. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InternalGraph { + pub nodes: Vec, + pub edges: Vec, +} + +impl InternalGraph { + pub fn new() -> Self { + Self::default() + } + + /// Add a node. Re-adding the same id upgrades it rather than duplicating: + /// a node seen twice, once inferred and once proven, is one node that is + /// now proven. + pub fn add(&mut self, node: Node) -> &mut Self { + if let Some(existing) = self.nodes.iter_mut().find(|n| n.id == node.id) { + existing.proven |= node.proven; + existing.value = existing.value.max(node.value); + for e in node.evidence { + if !existing.evidence.contains(&e) { + existing.evidence.push(e); + } + } + if existing.host.is_empty() { + existing.host = node.host; + } + return self; + } + self.nodes.push(node); + self + } + + /// Add an edge. Same upgrade rule, and an edge to or from a node that does + /// not exist is dropped — a dangling edge invents a path that nobody can + /// walk, which is the one failure mode a graph like this must not have. + pub fn link(&mut self, edge: Edge) -> &mut Self { + if !self.has(&edge.from) || !self.has(&edge.to) { + return self; + } + if let Some(existing) = self.edges.iter_mut().find(|e| e.key() == edge.key()) { + existing.proven |= edge.proven; + for ev in edge.evidence { + if !existing.evidence.contains(&ev) { + existing.evidence.push(ev); + } + } + if existing.remediation.is_empty() { + existing.remediation = edge.remediation; + } + if existing.detection.is_empty() { + existing.detection = edge.detection; + } + return self; + } + self.edges.push(edge); + self + } + + pub fn has(&self, id: &str) -> bool { + self.nodes.iter().any(|n| n.id == id) + } + pub fn node(&self, id: &str) -> Option<&Node> { + self.nodes.iter().find(|n| n.id == id) + } + pub fn crown_jewels(&self) -> Vec<&Node> { + self.nodes.iter().filter(|n| n.layer == Layer::CrownJewel).collect() + } + + /// Every credential currently held. + pub fn credentials(&self) -> Vec<&Node> { + self.nodes.iter().filter(|n| n.kind.is_credential()).collect() + } + + /// Turn the loop once. + /// + /// For each credential we hold, follow it to the identity it authenticates, + /// the permissions that identity has, and the machines those permissions + /// reach — and mark every machine reached as a place new credentials are + /// harvestable. That last step is what makes it a loop rather than a tree, + /// and it is why internal compromise is not linear: the loop's output is + /// its own input. + /// + /// Returns the ids of newly reachable machines. Nothing new means the loop + /// has converged and the graph is as large as the evidence allows. + pub fn turn_loop(&mut self) -> Vec { + let mut discovered = Vec::new(); + // credential → identity → privilege → machine, following only edges + // that exist. Inference here would manufacture access nobody has. + let creds: Vec = self.credentials().iter().map(|n| n.id.clone()).collect(); + for cred in creds { + // The loop's middle is not a fixed length. Sometimes a hash goes + // hash → account → ACL → host; sometimes the account IS local + // admin and it is one hop shorter. Requiring the long form meant a + // real chain turned the loop zero times, so the walk is bounded by + // distance instead: any machine within three hops of a credential + // we hold is a machine that credential reaches. + for machine in self.reachable_within(&cred, 3) { + { + { + let is_machine = matches!(self.node(&machine).map(|n| n.kind), Some(Kind::Host) | Some(Kind::Computer) | Some(Kind::Database) | Some(Kind::Share)); + if !is_machine { + continue; + } + // Reaching a machine means the credentials cached on it + // are in play. The harvest node is a hypothesis until + // somebody dumps them, and is marked as one. + let harvest = format!("{machine}::harvest"); + if !self.has(&harvest) { + let label = format!("credentials cached on {}", self.node(&machine).map(|n| n.label.clone()).unwrap_or_default()); + self.add(Node::new(&harvest, Layer::Credential, Kind::Hash, &label).on(&machine)); + self.link( + Edge::new(&machine, &harvest, "harvest cached credentials (LSASS, DPAPI, SAM)") + .attck("T1003") + .fix("Credential Guard, LSA protection, and no privileged logons to tier-2 hosts") + .detected("EDR on LSASS handle access, 4624/4672 for privileged logons"), + ); + discovered.push(harvest); + } + } + } + } + } + discovered + } + + /// Run the loop until it stops producing anything, bounded. + /// + /// The bound is not a performance guard — it is a modelling one. A loop + /// allowed to run forever will eventually "reach" everything through a + /// chain of assumptions, which is how automated tooling produces a graph + /// that is complete and worthless. + pub fn expand(&mut self, max_turns: usize) -> usize { + let mut turns = 0; + for _ in 0..max_turns { + if self.turn_loop().is_empty() { + break; + } + turns += 1; + } + turns + } + + fn successors(&self, id: &str) -> Vec { + self.edges.iter().filter(|e| e.from == id).map(|e| e.to.clone()).collect() + } + + /// Nodes within `max_hops` edges of `id`, nearest first. + fn reachable_within(&self, id: &str, max_hops: usize) -> Vec { + let mut seen: HashSet = HashSet::from([id.to_string()]); + let mut frontier = vec![id.to_string()]; + let mut out = Vec::new(); + for _ in 0..max_hops { + let mut next = Vec::new(); + for cur in &frontier { + for to in self.successors(cur) { + if seen.insert(to.clone()) { + out.push(to.clone()); + next.push(to); + } + } + } + if next.is_empty() { + break; + } + frontier = next; + } + out + } + + /// Shortest path from `start` to every crown jewel. + /// + /// `proven_only` is the difference between what was demonstrated and what + /// is believed. Both are worth having: the first is the report, the second + /// is the next day's testing plan. + pub fn paths(&self, start: &str, proven_only: bool) -> Vec { + let mut out = Vec::new(); + for jewel in self.crown_jewels() { + if let Some(p) = self.path_between(start, &jewel.id, proven_only) { + out.push(p); + } + } + // Most valuable first, then shortest: a client reads the top of the + // list, so the top of the list has to be the thing that matters. + out.sort_by(|a, b| { + b.value + .partial_cmp(&a.value) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.hops().cmp(&b.hops())) + }); + out + } + + /// Breadth-first, so the path returned is the shortest — which is also the + /// one a real attacker takes. + pub fn path_between(&self, start: &str, goal: &str, proven_only: bool) -> Option { + if !self.has(start) || !self.has(goal) { + return None; + } + let mut prev: HashMap = HashMap::new(); + let mut seen: HashSet = HashSet::from([start.to_string()]); + let mut queue = VecDeque::from([start.to_string()]); + while let Some(cur) = queue.pop_front() { + if cur == goal { + break; + } + for e in self.edges.iter().filter(|e| e.from == cur) { + if proven_only && !e.proven { + continue; + } + if seen.insert(e.to.clone()) { + prev.insert(e.to.clone(), e.clone()); + queue.push_back(e.to.clone()); + } + } + } + if !seen.contains(goal) { + return None; + } + let mut edges = Vec::new(); + let mut nodes = vec![goal.to_string()]; + let mut cur = goal.to_string(); + while cur != start { + let e = prev.get(&cur)?.clone(); + cur = e.from.clone(); + nodes.push(cur.clone()); + edges.push(e); + } + nodes.reverse(); + edges.reverse(); + let assumptions: Vec = edges + .iter() + .filter(|e| !e.proven) + .map(|e| format!("{} → {} ({})", e.from, e.to, e.technique)) + .collect(); + Some(Path { + // A path is proven when every hop was performed. Node-level + // `proven` is about having observed the object itself, which is a + // different question: some nodes (Domain Admins, NTDS.dit) exist by + // definition of the domain, and requiring evidence for their + // existence would reject a chain that was actually walked. + proven: assumptions.is_empty(), + value: self.node(goal).map(|n| n.value).unwrap_or(0.0), + nodes, + edges, + assumptions, + }) + } + + /// The edges worth fixing first. + /// + /// Not the most severe finding — the **choke point**: the single edge whose + /// removal cuts the most paths to crown jewels. This is the question an + /// internal assessment exists to answer, and the reason the graph is worth + /// building at all. A list of 40 findings sorted by CVSS does not tell a + /// client which one change buys them the most. + pub fn choke_points(&self, start: &str) -> Vec { + let baseline = self.paths(start, false); + if baseline.is_empty() { + return Vec::new(); + } + let mut scored: Vec = Vec::new(); + let mut considered: HashSet<(String, String, String)> = HashSet::new(); + for edge in &self.edges { + if !considered.insert(edge.key()) { + continue; + } + let mut without = self.clone(); + without.edges.retain(|e| e.key() != edge.key()); + let remaining = without.paths(start, false); + let cut = baseline.len().saturating_sub(remaining.len()); + if cut == 0 { + continue; + } + // Value cut matters more than path count: severing two routes to a + // test share is worth less than severing one to the domain. + let value_cut: f64 = baseline.iter().map(|p| p.value).sum::() + - remaining.iter().map(|p| p.value).sum::(); + scored.push(ChokePoint { + edge: edge.clone(), + paths_cut: cut, + value_cut, + remediation: if edge.remediation.is_empty() { + format!("remove the ability to {} from {} to {}", edge.technique, edge.from, edge.to) + } else { + edge.remediation.clone() + }, + }); + } + scored.sort_by(|a, b| { + b.value_cut + .partial_cmp(&a.value_cut) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b.paths_cut.cmp(&a.paths_cut)) + }); + scored + } + + /// Everything reachable from here, proven hops only — the blast radius of + /// one compromised position. + pub fn blast_radius(&self, start: &str) -> Vec { + let mut seen: HashSet = HashSet::from([start.to_string()]); + let mut queue = VecDeque::from([start.to_string()]); + let mut out = Vec::new(); + while let Some(cur) = queue.pop_front() { + for e in self.edges.iter().filter(|e| e.from == cur && e.proven) { + if seen.insert(e.to.clone()) { + out.push(e.to.clone()); + queue.push_back(e.to.clone()); + } + } + } + out.sort(); + out + } + + /// Edges nobody checked for detection. Reported separately from "not + /// detected", because an untested control is an open question, and a + /// report that silently turns one into "no alerting" is wrong. + pub fn detection_gaps(&self) -> Vec<&Edge> { + self.edges.iter().filter(|e| e.proven && e.detection.trim().is_empty()).collect() + } + + /// Mermaid rendering, grouped by layer. + pub fn mermaid(&self) -> String { + if self.nodes.is_empty() { + return String::new(); + } + let mut out = String::from("flowchart LR\n"); + let mut layers: Vec = self.nodes.iter().map(|n| n.layer).collect(); + layers.sort_by_key(|l| l.rank()); + layers.dedup(); + for layer in layers { + out.push_str(&format!(" subgraph {}[\"{}\"]\n", layer.as_str().replace('-', "_"), layer.as_str())); + for n in self.nodes.iter().filter(|n| n.layer == layer) { + out.push_str(&format!(" {}[\"{}\"]\n", mid(&n.id), esc(&n.label))); + } + out.push_str(" end\n"); + } + for e in &self.edges { + // A dashed edge is an assumption. The reader can see at a glance + // which parts of the picture were walked and which were guessed. + let arrow = if e.proven { "-->" } else { "-.->" }; + out.push_str(&format!(" {} {}|\"{}\"| {}\n", mid(&e.from), arrow, esc(&e.technique), mid(&e.to))); + } + out + } + + /// One-screen summary for the operator. + pub fn summary(&self, start: &str) -> String { + let proven = self.paths(start, true); + let all = self.paths(start, false); + let mut s = format!( + "{} nodes · {} edges · {} crown jewel(s) · {} proven path(s) of {} total\n", + self.nodes.len(), + self.edges.len(), + self.crown_jewels().len(), + proven.len(), + all.len() + ); + for p in proven.iter().take(5) { + s.push_str(&format!(" [{}] {} hop(s): {}\n", p.severity(), p.hops(), p.nodes.join(" → "))); + } + for c in self.choke_points(start).iter().take(3) { + s.push_str(&format!(" fix: {} (cuts {} path(s))\n", c.remediation, c.paths_cut)); + } + s + } +} + +/// One edge, and what removing it buys. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChokePoint { + pub edge: Edge, + pub paths_cut: usize, + pub value_cut: f64, + pub remediation: String, +} + +fn mid(id: &str) -> String { + id.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect() +} +fn esc(s: &str) -> String { + s.replace('"', "'").replace('\n', " ") +} + +/// A starter graph for an AD engagement: the assets and crown jewels that are +/// true of almost every domain, so an operator is not typing boilerplate +/// before they can record the one thing they actually found. +pub fn ad_scaffold(domain: &str) -> InternalGraph { + let mut g = InternalGraph::new(); + g.add(Node::new("dc", Layer::Asset, Kind::Computer, &format!("domain controller ({domain})")).on(domain)); + g.add(Node::new("domain-admins", Layer::CrownJewel, Kind::Group, "Domain Admins").on(domain).worth(1.0)); + g.add(Node::new("ntds", Layer::CrownJewel, Kind::Data, "NTDS.dit (every domain credential)").on(domain).worth(1.0)); + g.add(Node::new("dc-sync", Layer::Privilege, Kind::Acl, "DCSync (DS-Replication-Get-Changes)").on(domain)); + g.link( + Edge::new("dc-sync", "ntds", "DCSync replication of the directory") + .attck("T1003.006") + .fix("remove replication rights from non-DC principals; audit with a scheduled ACL review") + .detected("4662 on the replication GUIDs, and directory replication from a non-DC source"), + ); + g.link( + Edge::new("domain-admins", "dc", "administrative logon to the domain controller") + .attck("T1078.002") + .fix("tiered administration: no tier-0 credential ever touches a tier-1 or tier-2 host"), + ); + g +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The graph from a real-shaped engagement: a printer with anonymous SMB, + /// a service account whose hash is cached on it, that account can write to + /// a share, a jump host runs scripts from the share, and the jump host has + /// a Domain Admin session on it. + fn engagement() -> InternalGraph { + let mut g = ad_scaffold("corp.local"); + g.add(Node::new("printer", Layer::Asset, Kind::Host, "print server PRN01").proven_by(&["E01"])); + g.add(Node::new("smb-anon", Layer::Exposure, Kind::Service, "SMB with anonymous bind").proven_by(&["E02"])); + g.add(Node::new("no-signing", Layer::Weakness, Kind::Control, "SMB signing not required").proven_by(&["E03"])); + g.add(Node::new("svc-hash", Layer::Credential, Kind::Hash, "NTLM hash for svc_backup").proven_by(&["E04"])); + g.add(Node::new("svc-backup", Layer::Privilege, Kind::ServiceAccount, "svc_backup").proven_by(&["E05"])); + g.add(Node::new("share-write", Layer::Privilege, Kind::Acl, "write to \\\\FS01\\scripts").proven_by(&["E06"])); + g.add(Node::new("jump01", Layer::Movement, Kind::Computer, "JUMP01 runs scripts from the share").proven_by(&["E07"])); + g.add(Node::new("da-session", Layer::Credential, Kind::Session, "Domain Admin session on JUMP01").proven_by(&["E08"])); + + g.link(Edge::new("printer", "smb-anon", "port scan + null session").proven_by(&["E02"])); + g.link(Edge::new("smb-anon", "no-signing", "SMB dialect negotiation").proven_by(&["E03"])); + g.link(Edge::new("no-signing", "svc-hash", "NTLM relay of the backup job").attck("T1557.001").proven_by(&["E04"]).fix("require SMB signing domain-wide")); + g.link(Edge::new("svc-hash", "svc-backup", "pass-the-hash").attck("T1550.002").proven_by(&["E05"])); + g.link(Edge::new("svc-backup", "share-write", "effective ACL on the share").proven_by(&["E06"])); + g.link(Edge::new("share-write", "jump01", "script replaced, executed on next run").attck("T1080").proven_by(&["E07"]).fix("scripts share should be read-only to service accounts; sign or hash-pin what JUMP01 executes")); + g.link(Edge::new("jump01", "da-session", "token theft from the interactive session").attck("T1134").proven_by(&["E08"])); + g.link(Edge::new("da-session", "domain-admins", "the stolen token is a member").proven_by(&["E08"])); + g.link(Edge::new("domain-admins", "dc-sync", "Domain Admins hold replication rights").proven_by(&["E09"])); + g + } + + #[test] + fn the_path_to_the_domain_is_found_and_ordered_by_value() { + let g = engagement(); + let paths = g.paths("printer", true); + assert!(!paths.is_empty(), "a walked chain must produce a path"); + let top = &paths[0]; + assert_eq!(top.severity(), "critical", "reaching NTDS is critical"); + assert!(top.nodes.contains(&"svc-hash".to_string())); + assert!(top.nodes.last().unwrap() == "ntds" || top.nodes.last().unwrap() == "domain-admins"); + } + + #[test] + fn one_assumed_hop_caps_the_whole_chain() { + let mut g = engagement(); + // Replace the proven relay with an assumed one: same graph, one hop + // nobody actually performed. + g.edges.retain(|e| !(e.from == "no-signing" && e.to == "svc-hash")); + g.link(Edge::new("no-signing", "svc-hash", "NTLM relay would yield the hash")); + + let proven = g.paths("printer", true); + assert!(proven.is_empty(), "an assumed hop must not appear among proven paths"); + + let all = g.paths("printer", false); + assert!(!all.is_empty(), "it is still worth listing as a hypothesis"); + assert_eq!(all[0].severity(), "informational", "a hypothesis is not a critical"); + assert_eq!(all[0].assumptions.len(), 1); + } + + #[test] + fn the_choke_point_is_the_edge_worth_fixing_not_the_worst_finding() { + let g = engagement(); + let choke = g.choke_points("printer"); + assert!(!choke.is_empty()); + // Every route to the domain runs through the relay and the share; the + // top choke point has to be one of them, not the last hop before the DC. + let top = &choke[0]; + assert!( + matches!((top.edge.from.as_str(), top.edge.to.as_str()), + ("no-signing", "svc-hash") | ("svc-hash", "svc-backup") | ("svc-backup", "share-write") | ("share-write", "jump01") | ("printer", "smb-anon") | ("smb-anon", "no-signing")), + "unexpected choke point: {} → {}", top.edge.from, top.edge.to + ); + assert!(top.paths_cut >= 1); + assert!(!top.remediation.is_empty(), "a choke point without a fix is an observation, not advice"); + } + + #[test] + fn the_loop_turns_and_then_converges() { + let mut g = engagement(); + let before = g.nodes.len(); + let turns = g.expand(5); + assert!(turns >= 1, "reaching a machine must put its cached credentials in play"); + assert!(g.nodes.len() > before); + // Harvest nodes are hypotheses until somebody dumps them. + let harvest: Vec<&Node> = g.nodes.iter().filter(|n| n.id.ends_with("::harvest")).collect(); + assert!(!harvest.is_empty()); + assert!(harvest.iter().all(|n| !n.proven), "a cached-credential node is an assumption"); + // And it must stop: an unbounded loop eventually reaches everything. + let again = g.expand(5); + assert_eq!(again, 0, "the loop has to converge"); + } + + #[test] + fn blast_radius_follows_only_what_was_walked() { + let mut g = engagement(); + g.add(Node::new("fs02", Layer::Asset, Kind::Host, "FS02")); + g.link(Edge::new("svc-backup", "fs02", "the same account probably works here")); + let radius = g.blast_radius("svc-hash"); + assert!(radius.contains(&"share-write".to_string())); + assert!(!radius.contains(&"fs02".to_string()), "an unproven hop is not blast radius"); + } + + #[test] + fn dangling_edges_are_refused() { + let mut g = InternalGraph::new(); + g.add(Node::new("a", Layer::Asset, Kind::Host, "A")); + g.link(Edge::new("a", "ghost", "reaches something that does not exist")); + assert!(g.edges.is_empty(), "an edge to a node that does not exist invents a path"); + } + + #[test] + fn re_adding_a_node_upgrades_it_instead_of_duplicating() { + let mut g = InternalGraph::new(); + g.add(Node::new("h", Layer::Asset, Kind::Host, "HOST")); + g.add(Node::new("h", Layer::Asset, Kind::Host, "HOST").proven_by(&["E11"]).worth(0.8)); + assert_eq!(g.nodes.len(), 1); + assert!(g.nodes[0].proven); + assert_eq!(g.nodes[0].value, 0.8); + } + + #[test] + fn detection_gaps_separate_unchecked_from_unmonitored() { + let g = engagement(); + let gaps = g.detection_gaps(); + // The relay hop carries no detection note in the fixture — it is an + // open question, and must be reported as one rather than as "no alert". + assert!(gaps.iter().any(|e| e.from == "no-signing" && e.to == "svc-hash")); + // The scaffold's DCSync edge does carry one, so it is not a gap. + assert!(!gaps.iter().any(|e| e.from == "dc-sync")); + } + + #[test] + fn mermaid_marks_assumptions_differently() { + let mut g = engagement(); + g.add(Node::new("maybe", Layer::Movement, Kind::Host, "unverified host")); + g.link(Edge::new("svc-backup", "maybe", "assumed local admin")); + let m = g.mermaid(); + assert!(m.contains("-.->"), "an assumed edge must be visually distinct"); + assert!(m.contains("-->")); + assert!(m.contains("subgraph")); + } +} diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index 344e24d..f10849f 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -19,6 +19,7 @@ pub mod creds; pub mod grounding; pub mod hygiene; pub mod integrations; +pub mod internal; pub mod knowledge_graph; pub mod memory; pub mod policy;