harness: robust multi-round attack chaining (decision-driven post-exploitation)

Replaces the single-shot chain_round with attack_chain(): an iterative,
per-foothold pivot engine.
- Each round takes the newest confirmed footholds (best-first, capped) and, for
  EACH one, an agent DECIDES which directions to expand — post-exploitation
  (loot creds/keys/config/source), credential reuse, horizontal+vertical
  privesc, lateral movement to adjacent services/hosts, data exfiltration, and
  new attack surface the foothold exposes — proving each step with a receipt.
- LOOT (creds/tokens/hosts/endpoints) discovered in one round is carried forward
  and reused by later rounds (parsed from a {"findings":[...],"loot":[...]} reply).
- New findings are validated each round (never pivot off a false positive) and
  become the next round's footholds. Loop-until-dry or chain_depth rounds.
- New RunConfig.chain_depth (default 2) + --chain-depth flag on all engagement
  commands (0 disables). CHAIN_SYS rewritten for decision/post-ex framing.
This commit is contained in:
CyberSecurityUP
2026-07-01 18:22:00 -03:00
parent e9f81c164d
commit ea61ab1fdf
3 changed files with 196 additions and 54 deletions
+30 -6
View File
@@ -46,6 +46,9 @@ enum Cmd {
max_agents: usize,
#[arg(long, default_value_t = 3)]
vote_n: usize,
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
#[arg(long, default_value_t = 2)]
chain_depth: usize,
#[arg(long)]
offline: bool,
/// Use local agentic CLI subscription (Claude/Codex/Gemini/Grok login).
@@ -79,6 +82,9 @@ enum Cmd {
max_agents: usize,
#[arg(long, default_value_t = 2)]
vote_n: usize,
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
#[arg(long, default_value_t = 2)]
chain_depth: usize,
#[arg(long)]
offline: bool,
#[arg(long)]
@@ -108,6 +114,9 @@ enum Cmd {
max_agents: usize,
#[arg(long, default_value_t = 3)]
vote_n: usize,
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
#[arg(long, default_value_t = 2)]
chain_depth: usize,
#[arg(long)]
offline: bool,
#[arg(long)]
@@ -133,6 +142,9 @@ enum Cmd {
max_agents: usize,
#[arg(long, default_value_t = 3)]
vote_n: usize,
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
#[arg(long, default_value_t = 2)]
chain_depth: usize,
#[arg(long)]
subscription: bool,
#[arg(long)]
@@ -154,6 +166,9 @@ enum Cmd {
max_agents: usize,
#[arg(long, default_value_t = 3)]
vote_n: usize,
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
#[arg(long, default_value_t = 2)]
chain_depth: usize,
#[arg(long)]
offline: bool,
#[arg(long)]
@@ -172,6 +187,9 @@ enum Cmd {
models: Vec<String>,
#[arg(long, default_value_t = 2)]
vote_n: usize,
/// Attack-chaining rounds (post-exploitation pivots; 0 disables).
#[arg(long, default_value_t = 2)]
chain_depth: usize,
#[arg(long)]
subscription: bool,
/// Post a summary comment back on the PR (needs github integration on).
@@ -267,11 +285,12 @@ async fn main() -> anyhow::Result<()> {
}
}
}
Cmd::Run { url, models, max_agents, vote_n, offline, subscription, mcp, creds, focus, jira, verbose } => {
Cmd::Run { url, models, max_agents, vote_n, chain_depth, offline, subscription, mcp, creds, focus, jira, verbose } => {
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
let mut cfg = RunConfig::new(&url);
cfg.max_agents = max_agents;
cfg.vote_n = vote_n;
cfg.chain_depth = chain_depth;
cfg.offline = offline;
cfg.subscription = subscription;
cfg.verbose = verbose;
@@ -285,11 +304,12 @@ async fn main() -> anyhow::Result<()> {
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
post_integrations(&ig, &url, &out, jira, false, None).await;
}
Cmd::Whitebox { path, models, max_agents, vote_n, offline, subscription, jira, verbose } => {
Cmd::Whitebox { path, models, max_agents, vote_n, chain_depth, offline, subscription, jira, verbose } => {
let path = resolve_source(&base, &path)?; // local path OR github URL/owner/repo
let mut cfg = RunConfig::new(&path);
cfg.max_agents = max_agents;
cfg.vote_n = vote_n;
cfg.chain_depth = chain_depth;
cfg.offline = offline;
cfg.subscription = subscription;
cfg.verbose = verbose;
@@ -301,13 +321,14 @@ async fn main() -> anyhow::Result<()> {
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
post_integrations(&ig, &path, &out, jira, false, None).await;
}
Cmd::Greybox { repo, url, models, creds, focus, max_agents, vote_n, offline, subscription, mcp, verbose } => {
Cmd::Greybox { repo, url, models, creds, focus, max_agents, vote_n, chain_depth, offline, subscription, mcp, verbose } => {
let repo = resolve_source(&base, &repo)?; // local path OR github URL/owner/repo
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
let mut cfg = RunConfig::new(&url);
cfg.repo = Some(repo);
cfg.max_agents = max_agents;
cfg.vote_n = vote_n;
cfg.chain_depth = chain_depth;
cfg.offline = offline;
cfg.subscription = subscription;
cfg.verbose = verbose;
@@ -319,12 +340,13 @@ async fn main() -> anyhow::Result<()> {
let out = run_greybox_engagement(&base, cfg, mcp).await?;
print_findings(&out);
}
Cmd::Tui { url, models, repo, creds, focus, max_agents, vote_n, subscription, mcp } => {
Cmd::Tui { url, models, repo, creds, focus, max_agents, vote_n, chain_depth, subscription, mcp } => {
let repo = match repo { Some(r) => Some(resolve_source(&base, &r)?), None => None }; // github URL ok
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
let mut cfg = RunConfig::new(&url);
cfg.max_agents = max_agents;
cfg.vote_n = vote_n;
cfg.chain_depth = chain_depth;
cfg.subscription = subscription;
cfg.instructions = focus;
cfg.repo = repo.clone();
@@ -335,10 +357,11 @@ async fn main() -> anyhow::Result<()> {
let mode = if repo.is_some() { Mode::Grey } else { Mode::Black };
tui::run(&base, cfg, mcp, mode).await?;
}
Cmd::Host { target, models, creds, focus, max_agents, vote_n, offline, subscription, verbose } => {
Cmd::Host { target, models, creds, focus, max_agents, vote_n, chain_depth, offline, subscription, verbose } => {
let mut cfg = RunConfig::new(&target);
cfg.max_agents = max_agents;
cfg.vote_n = vote_n;
cfg.chain_depth = chain_depth;
cfg.offline = offline;
cfg.subscription = subscription;
cfg.verbose = verbose;
@@ -350,13 +373,14 @@ async fn main() -> anyhow::Result<()> {
let out = run_mode(&base, cfg, false, Mode::Host).await?;
print_findings(&out);
}
Cmd::Pr { repo, number, models, vote_n, subscription, comment, jira, verbose } => {
Cmd::Pr { repo, number, models, vote_n, chain_depth, subscription, comment, jira, verbose } => {
let ig = harness::integrations::Integrations::load(&repl::proj_dir());
let owner_repo = normalize_repo(&repo);
let path = clone_pr(&base, &ig, &owner_repo, number)?;
println!(" 🔍 white-box review of {owner_repo} PR #{number}");
let mut cfg = RunConfig::new(&path);
cfg.vote_n = vote_n;
cfg.chain_depth = chain_depth;
cfg.subscription = subscription;
cfg.verbose = verbose;
cfg.instructions = Some(format!("This is the code of pull request #{number} of {owner_repo}. Focus on vulnerabilities introduced or touched by this change."));