mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 20:41:51 +02:00
feat(internal): internal network / AD attack graph with choke-point analysis
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 <node>` Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
408350539f
commit
8d44e4cf4a
@@ -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<String>,
|
||||
/// Seed the graph with the structure every domain has.
|
||||
#[arg(long = "scaffold")]
|
||||
scaffold: Option<String>,
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<Vec<u8>> {
|
||||
.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 <file.json> or --scaffold <domain>"),
|
||||
};
|
||||
if !g.has(from) {
|
||||
anyhow::bail!(
|
||||
"no node `{from}` in the graph — known nodes: {}",
|
||||
g.nodes.iter().map(|n| n.id.clone()).collect::<Vec<_>>().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 {
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
pub edges: Vec<Edge>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<Node>,
|
||||
pub edges: Vec<Edge>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
let mut discovered = Vec::new();
|
||||
// credential → identity → privilege → machine, following only edges
|
||||
// that exist. Inference here would manufacture access nobody has.
|
||||
let creds: Vec<String> = 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<String> {
|
||||
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<String> {
|
||||
let mut seen: HashSet<String> = 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<Path> {
|
||||
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<Path> {
|
||||
if !self.has(start) || !self.has(goal) {
|
||||
return None;
|
||||
}
|
||||
let mut prev: HashMap<String, Edge> = HashMap::new();
|
||||
let mut seen: HashSet<String> = 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<String> = 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<ChokePoint> {
|
||||
let baseline = self.paths(start, false);
|
||||
if baseline.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut scored: Vec<ChokePoint> = 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::<f64>()
|
||||
- remaining.iter().map(|p| p.value).sum::<f64>();
|
||||
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<String> {
|
||||
let mut seen: HashSet<String> = 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<Layer> = 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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user