项目文件夹

文件
2026-02-05 18:56:42 +03:00

225 行
7.4 KiB
Python

"""Tests for branch pruning and credential-driven re-evaluation.
Unit tests covering should_prune thresholds, prune_branch descendant
marking, and reevaluate_pruned reopening of auth-related branches.
"""
from __future__ import annotations
import pytest
from excalibur.planner.models import (
AttackNode,
AttackTree,
NodeStatus,
NodeType,
TDIScore,
)
from excalibur.planner.pruning import (
prune_branch,
reevaluate_pruned,
should_prune,
)
def _high_tdi(value: float = 0.9) -> TDIScore:
"""Return a TDIScore whose .value property approximates *value*.
We set horizon to a high value and other dimensions to make the
composite close to *value*.
"""
return TDIScore(
horizon=value,
success_rate=0.0,
context_load=value,
evidence_confidence=0.0,
weight_horizon=0.3,
weight_evidence=0.3,
weight_context=0.2,
weight_success=0.2,
)
def _low_tdi() -> TDIScore:
"""Return a TDI score with a low composite value."""
return TDIScore(
horizon=0.0,
success_rate=1.0,
context_load=0.0,
evidence_confidence=1.0,
)
@pytest.mark.unit
class TestShouldPrune:
"""Tests for the should_prune decision function."""
def test_prune_when_tdi_above_threshold_and_enough_visits(
self,
) -> None:
"""Returns True when TDI > threshold and visits >= min."""
node = AttackNode(tdi=_high_tdi(0.95), visit_count=5)
assert should_prune(node, threshold=0.8, min_attempts=3)
def test_no_prune_when_tdi_below_threshold(self) -> None:
"""Returns False when TDI is below the threshold."""
node = AttackNode(tdi=_low_tdi(), visit_count=10)
assert not should_prune(node, threshold=0.8, min_attempts=3)
def test_no_prune_when_visits_below_minimum(self) -> None:
"""Returns False when visit_count is less than min_attempts."""
node = AttackNode(tdi=_high_tdi(), visit_count=1)
assert not should_prune(node, threshold=0.8, min_attempts=3)
def test_no_prune_when_tdi_is_none(self) -> None:
"""Returns False when node has no TDI score at all."""
node = AttackNode(tdi=None, visit_count=100)
assert not should_prune(node, threshold=0.5, min_attempts=1)
def test_prune_at_exact_threshold_boundary(self) -> None:
"""At exactly the threshold boundary: must exceed, not equal."""
# Create a TDI with value == 0.8 exactly
score = TDIScore(
horizon=0.8,
success_rate=0.8,
context_load=0.8,
evidence_confidence=0.2,
weight_horizon=1.0,
weight_evidence=0.0,
weight_context=0.0,
weight_success=0.0,
)
# value = 1.0*0.8 = 0.8, which is NOT > 0.8
node = AttackNode(tdi=score, visit_count=5)
assert not should_prune(node, threshold=0.8, min_attempts=3)
@pytest.mark.unit
class TestPruneBranch:
"""Tests for prune_branch marking descendants as PRUNED."""
def _build_subtree(self) -> tuple[AttackTree, AttackNode]:
"""Build a tree: root -> A -> [B, C] for pruning tests."""
root = AttackNode(
id="root",
node_type=NodeType.OBSERVATION,
status=NodeStatus.ACTIVE,
)
a = AttackNode(
id="a",
parent_id="root",
status=NodeStatus.PENDING,
)
b = AttackNode(
id="b",
parent_id="a",
status=NodeStatus.PENDING,
)
c = AttackNode(
id="c",
parent_id="a",
status=NodeStatus.PENDING,
)
tree = AttackTree(root_id="root")
for nd in [root, a, b, c]:
tree.add_node(nd)
return tree, a
def test_prune_sets_descendants_to_pruned(self) -> None:
"""prune_branch marks the node and all descendants."""
tree, node_a = self._build_subtree()
pruned_ids = prune_branch(tree, node_a)
assert "a" in pruned_ids
assert "b" in pruned_ids
assert "c" in pruned_ids
for nid in pruned_ids:
assert tree.get_node(nid).status == NodeStatus.PRUNED
def test_prune_returns_pruned_ids(self) -> None:
"""prune_branch returns the list of affected node IDs."""
tree, node_a = self._build_subtree()
result = prune_branch(tree, node_a)
assert set(result) == {"a", "b", "c"}
def test_prune_does_not_affect_root(self) -> None:
"""Pruning a subtree does not mark the root as PRUNED."""
tree, node_a = self._build_subtree()
prune_branch(tree, node_a)
root = tree.get_node("root")
assert root.status == NodeStatus.ACTIVE
def test_prune_already_pruned_is_noop(self) -> None:
"""Pruning an already-pruned node returns empty list."""
tree, node_a = self._build_subtree()
prune_branch(tree, node_a)
# Prune again
result = prune_branch(tree, node_a)
assert result == []
@pytest.mark.unit
class TestReevaluatePruned:
"""Tests for reevaluate_pruned reopening auth branches."""
def _build_pruned_tree(self) -> AttackTree:
"""Build a tree with two pruned auth-related nodes."""
root = AttackNode(id="root", status=NodeStatus.ACTIVE)
ssh_node = AttackNode(
id="ssh",
parent_id="root",
status=NodeStatus.PRUNED,
description="Try SSH login on 10.10.10.1",
visit_count=5,
success_count=0,
failure_count=5,
)
web_node = AttackNode(
id="web",
parent_id="root",
status=NodeStatus.PRUNED,
description="Enumerate web directory on port 80",
visit_count=3,
)
tree = AttackTree(root_id="root")
for nd in [root, ssh_node, web_node]:
tree.add_node(nd)
return tree
def test_reopens_auth_related_branch(self) -> None:
"""Pruned SSH node is reopened when credentials arrive."""
tree = self._build_pruned_tree()
reopened = reevaluate_pruned(tree, ["admin:password123"])
assert "ssh" in reopened
node = tree.get_node("ssh")
assert node.status == NodeStatus.PENDING
def test_does_not_reopen_non_auth_branch(self) -> None:
"""Non-auth-related pruned node stays pruned."""
tree = self._build_pruned_tree()
reopened = reevaluate_pruned(tree, ["admin:password123"])
assert "web" not in reopened
node = tree.get_node("web")
assert node.status == NodeStatus.PRUNED
def test_resets_statistics_on_reopen(self) -> None:
"""Reopened node has visit/success/failure counts reset."""
tree = self._build_pruned_tree()
reevaluate_pruned(tree, ["root:toor"])
node = tree.get_node("ssh")
assert node.visit_count == 0
assert node.success_count == 0
assert node.failure_count == 0
def test_empty_credentials_no_change(self) -> None:
"""Empty credential list produces no reopened nodes."""
tree = self._build_pruned_tree()
reopened = reevaluate_pruned(tree, [])
assert reopened == []
def test_non_pruned_nodes_unaffected(self) -> None:
"""Active nodes are never touched by reevaluate_pruned."""
tree = self._build_pruned_tree()
reevaluate_pruned(tree, ["cred"])
root = tree.get_node("root")
assert root.status == NodeStatus.ACTIVE