mirror of
https://github.com/usestrix/strix.git
synced 2026-10-16 04:28:23 +00:00
- 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
200 lines
5.8 KiB
Python
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
|