strix/tests/test_scan_planner.py
MriBoyd 64622e8d7e feat(agent): add Target Complexity Index (TCI) + adaptive ScanPlanner for dynamic vulnerability scanning (#46)
- Implement Target Complexity Index (TCI) calculation (0..100) in strix.core.tci based on target fingerprint with configurable weights.
- Implement ScanModule and ModuleRegistry in strix.modules.registry supporting priority metadata, quotas, timeouts, and safe_mode hints.
- Implement adaptive ScanPlanner in strix.agents.planner mapping TCI and fingerprints to prioritized, context-aware scan plans.
- Add planner agent tools in strix.tools.planner.tools and register in factory._BASE_TOOLS.
- Update root_agent.md coordination documentation for dynamic scan planning.
- Add comprehensive unit test suites in tests/test_tci.py, tests/test_module_registry.py, tests/test_scan_planner.py, and tests/test_planner_tools.py.

Resolves usestrix/strix#46
2026-10-14 11:12:00 +03:00

200 lines
5.8 KiB
Python

"""Tests for adaptive ScanPlanner and plan generation from TCI."""
from __future__ import annotations
import json
from strix.agents.planner import ScanPlan, ScanPlanner
from strix.core.tci import ApiSurface, TargetFingerprint
def test_planner_generates_exact_payload_schema() -> None:
planner = ScanPlanner()
target = "https://api.example.local"
fp = {
"target_type": "api",
"open_ports": [443],
"protocols": ["https"],
"auth_patterns": ["jwt"],
"api_surface": {
"endpoints_count": 30,
"api_types": ["rest"],
},
"waf_cdn": ["cloudflare"],
"data_sensitivity": "high",
}
plan = planner.generate_plan(target, fp, scan_mode="deep")
assert isinstance(plan, ScanPlan)
assert plan.target == target
assert isinstance(plan.tci, float)
assert plan.tci > 0.0
assert len(plan.plan) > 0
wire_dict = plan.to_dict(compact=True)
assert wire_dict["target"] == target
assert wire_dict["tci"] == plan.tci
assert isinstance(wire_dict["fingerprint"], dict)
assert isinstance(wire_dict["plan"], list)
for item in wire_dict["plan"]:
assert "step" in item
assert "module" in item
assert "priority" in item
assert "safe_mode" in item
assert isinstance(item["step"], int)
assert isinstance(item["module"], str)
assert isinstance(item["safe_mode"], bool)
# Validate JSON serialization round-trip
serialized = plan.to_json(compact=True)
parsed = json.loads(serialized)
assert parsed["target"] == target
assert parsed["tci"] == plan.tci
assert len(parsed["plan"]) == len(plan.plan)
def test_tactical_ordering_and_prerequisites() -> None:
planner = ScanPlanner()
target = "https://app.local"
fp = TargetFingerprint(
target=target,
auth_patterns=["session_cookie"],
api_surface=ApiSurface(endpoints_count=15),
backend_technologies=["django", "postgres"],
)
plan = planner.generate_plan(target, fp, scan_mode="deep")
mod_order = plan.module_names()
# auth-enum must be scheduled before id_or and business-logic
if "auth-enum" in mod_order and "id_or" in mod_order:
auth_idx = mod_order.index("auth-enum")
idor_idx = mod_order.index("id_or")
assert auth_idx < idor_idx
if "auth-enum" in mod_order and "business-logic" in mod_order:
auth_idx = mod_order.index("auth-enum")
logic_idx = mod_order.index("business-logic")
assert auth_idx < logic_idx
def test_waf_activates_safe_mode() -> None:
planner = ScanPlanner()
target = "https://protected.local"
# Target behind Cloudflare WAF
fp_waf = TargetFingerprint(
target=target,
auth_patterns=["basic"],
waf_cdn=["cloudflare_waf"],
)
plan_waf = planner.generate_plan(target, fp_waf)
# Intrusive modules should have safe_mode=True
sqli_step = plan_waf.get_step_by_module("sqli-fuzz")
if sqli_step:
assert sqli_step.safe_mode is True
# Target without WAF and low complexity
fp_clean = TargetFingerprint(
target=target,
auth_patterns=["basic"],
waf_cdn=[],
data_sensitivity="low",
)
plan_clean = planner.generate_plan(target, fp_clean)
sqli_clean_step = plan_clean.get_step_by_module("sqli-fuzz")
if sqli_clean_step:
# sqli-fuzz default is False when no WAF and low complexity
assert sqli_clean_step.safe_mode is False
def test_quick_mode_filters_priorities() -> None:
planner = ScanPlanner()
target = "https://quick.local"
fp = TargetFingerprint(
target=target,
auth_patterns=["jwt"],
api_surface=ApiSurface(endpoints_count=10),
)
plan_quick = planner.generate_plan(target, fp, scan_mode="quick")
for step in plan_quick.plan:
assert step.priority in ("critical", "high")
def test_max_steps_constraint() -> None:
planner = ScanPlanner()
target = "https://limited.local"
fp = TargetFingerprint(
target=target,
auth_patterns=["jwt", "oauth2"],
api_surface=ApiSurface(endpoints_count=20, api_types=["rest", "graphql"]),
)
plan = planner.generate_plan(target, fp, scan_mode="deep", max_steps=3)
assert len(plan.plan) == 3
assert [s.step for s in plan.plan] == [1, 2, 3]
def test_module_overrides_application() -> None:
planner = ScanPlanner()
target = "https://overrides.local"
fp = TargetFingerprint(
target=target,
auth_patterns=["jwt"],
)
overrides = {
"auth-enum": {
"priority": "critical",
"safe_mode": False,
"quota": 99,
"timeout_s": 555.0,
}
}
plan = planner.generate_plan(
target,
fp,
module_overrides=overrides,
)
step = plan.get_step_by_module("auth-enum")
assert step is not None
assert step.priority == "critical"
assert step.safe_mode is False
assert step.quota == 99
assert step.timeout_s == 555.0
def test_plan_helpers_and_aggregation() -> None:
planner = ScanPlanner()
target = "https://helpers.local"
fp = TargetFingerprint(
target=target,
auth_patterns=["jwt"],
api_surface=ApiSurface(endpoints_count=10),
)
plan = planner.generate_plan(target, fp)
step1 = plan.get_step(1)
assert step1 is not None
assert step1.step == 1
assert plan.get_step(999) is None
high_steps = plan.get_steps_by_priority("high")
assert isinstance(high_steps, list)
assert plan.total_quota() > 0
assert plan.estimated_duration_s() > 0.0
# Non-compact serialization contains extended fields
full_dict = step1.to_dict(compact=False)
assert "timeout_s" in full_dict
assert "quota" in full_dict
assert "skills" in full_dict
assert "reason" in full_dict