mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-01 05:29:47 +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:
1 parent
408350539f
commit
8d44e4cf4a
3 files changed
+955
No files matched your search
@@ -151,6 +151,29 @@ enum Cmd {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: ProvCmd,
|
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.
|
/// White-box: analyse a repository's source code for vulnerabilities.
|
||||||
Whitebox {
|
Whitebox {
|
||||||
/// Local path, a GitHub URL (https://github.com/owner/repo[.git]) or an
|
/// 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::Capability { cmd } => handle_capability(cmd)?,
|
||||||
Cmd::Provenance { cmd } => handle_provenance(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 } => {
|
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 url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
||||||
let mut cfg = RunConfig::new(&url);
|
let mut cfg = RunConfig::new(&url);
|
||||||
@@ -1123,6 +1149,70 @@ fn provenance_key() -> Option<Vec<u8>> {
|
|||||||
.map(|s| s.into_bytes())
|
.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<()> {
|
fn handle_provenance(cmd: ProvCmd) -> anyhow::Result<()> {
|
||||||
use harness::provenance::{Manifest, Provenance, SIGIL};
|
use harness::provenance::{Manifest, Provenance, SIGIL};
|
||||||
match cmd {
|
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 grounding;
|
||||||
pub mod hygiene;
|
pub mod hygiene;
|
||||||
pub mod integrations;
|
pub mod integrations;
|
||||||
|
pub mod internal;
|
||||||
pub mod knowledge_graph;
|
pub mod knowledge_graph;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
|
|||||||
Reference in new issue
Block a user