fix(planner): resolve prerequisite handling, target aliases, cycle detection, and TCI scoring

- Issue 2: Automatically include missing prerequisites from applicable modules
  or drop dependent modules whose requirements cannot be met
- Issue 3: Map target type aliases (web_application, api_spec, local_code) and
  explicitly reject unknown target types with ValueError
- Issue 4: Return explicit input error on malformed or non-object fingerprint JSON
- Issue 5: Calculate final effective priorities before scan-mode and priority filtering
- Issue 6: Add requires_oauth condition to oauth-audit so non-OAuth targets skip it
- Issue 8: Deduplicate observations in TCI scoring to prevent artificial inflation
- Issue 9: Detect circular prerequisites during topological ordering and raise ValueError
This commit is contained in:
MriBoyd 2026-10-14 12:00:24 +03:00
parent fe60556033
commit bc949d7bfe
8 changed files with 605 additions and 73 deletions

View file

@ -142,6 +142,7 @@ class ScanPlanner:
priority_filter: list[str] | None = None,
max_steps: int | None = None,
module_overrides: dict[str, dict[str, Any]] | None = None,
include_prerequisites: bool = True,
) -> ScanPlan:
"""Generate an adaptive scan plan.
@ -153,6 +154,9 @@ class ScanPlanner:
priority_filter: Allowed priority tiers (e.g. ['critical', 'high']).
max_steps: Maximum number of steps to schedule.
module_overrides: Per-module overrides for priority, safe_mode, quota, timeout_s.
include_prerequisites: Whether to automatically include missing required
prerequisite modules from applicable pool (default: True). If False or
if requirements cannot be met on the target, dependent modules are dropped.
Returns:
Structured ScanPlan with ordered, prioritized steps.
@ -160,7 +164,7 @@ class ScanPlanner:
fp = (
fingerprint
if isinstance(fingerprint, TargetFingerprint)
else TargetFingerprint(**fingerprint)
else TargetFingerprint(**(fingerprint or {}))
)
if not fp.target and target:
fp.target = target
@ -171,26 +175,45 @@ class ScanPlanner:
tci_level = tci_res.level.value
# 2. Select applicable modules based on target context
candidates = self.registry.find_applicable(fp)
applicable = self.registry.find_applicable(fp)
applicable_by_name = {m.name: m for m in applicable}
overrides_dict = module_overrides or {}
# 3. Filter candidates by scan_mode
candidates = self._filter_by_scan_mode(candidates, scan_mode)
# 3. Calculate final effective priorities BEFORE filtering (reflects overrides)
effective_priorities: dict[str, PriorityTier] = {}
for mod in applicable:
effective_priorities[mod.name] = self._resolve_module_priority(
mod, fp, tci_score, overrides_dict.get(mod.name, {})
)
# 4. Filter by priority if requested
# 4. Filter candidates by scan_mode using effective priority
selected = self._filter_by_scan_mode(applicable, scan_mode, effective_priorities)
# 5. Filter by priority if requested using effective priority
if priority_filter:
allowed_p = {p.lower().strip() for p in priority_filter}
candidates = [m for m in candidates if m.default_priority.value.lower() in allowed_p]
selected = [
m
for m in selected
if effective_priorities.get(m.name, m.default_priority).value.lower() in allowed_p
]
# 5. Build and adapt plan steps
# 6. Resolve prerequisites: include required steps or drop unsatisfied modules
candidates = self._resolve_prerequisites(
selected, applicable_by_name, include_prerequisites=include_prerequisites
)
# 7. Build and adapt plan steps
steps = self._build_steps(
candidates=candidates,
fp=fp,
tci_score=tci_score,
safe_mode_override=safe_mode_override,
module_overrides=module_overrides or {},
module_overrides=overrides_dict,
effective_priorities=effective_priorities,
)
# 6. Apply max_steps limit if specified
# 8. Apply max_steps limit if specified
if max_steps is not None:
if max_steps < 0:
raise ValueError("max_steps must be non-negative")
@ -216,24 +239,82 @@ class ScanPlanner:
},
)
def _resolve_prerequisites(
self,
selected: list[ScanModule],
applicable_by_name: dict[str, ScanModule],
include_prerequisites: bool,
) -> list[ScanModule]:
"""Include required prerequisites or drop modules whose requirements cannot be met."""
selected_by_name = {m.name: m for m in selected}
if include_prerequisites:
added_any = True
while added_any:
added_any = False
for m in list(selected_by_name.values()):
for req in m.prerequisites:
if req not in selected_by_name and req in applicable_by_name:
selected_by_name[req] = applicable_by_name[req]
added_any = True
removed_any = True
while removed_any:
removed_any = False
for name, m in list(selected_by_name.items()):
missing = [req for req in m.prerequisites if req not in selected_by_name]
if missing:
logger.warning(
"Dropping module '%s' from plan: prerequisite(s) %s cannot be met",
name,
missing,
)
del selected_by_name[name]
removed_any = True
return list(selected_by_name.values())
def _resolve_module_priority(
self,
mod: ScanModule,
fp: TargetFingerprint,
tci_score: float,
overrides: dict[str, Any],
) -> PriorityTier:
override = overrides.get("priority")
if override is not None:
if isinstance(override, PriorityTier):
return override
try:
return PriorityTier(str(override).lower())
except ValueError:
logger.warning("Invalid priority override '%s' for module '%s'", override, mod.name)
elevated_str = self._elevate_priority(mod, fp, tci_score)
try:
return PriorityTier(elevated_str.lower())
except ValueError:
return mod.default_priority
def _filter_by_scan_mode(
self,
candidates: list[ScanModule],
scan_mode: str,
effective_priorities: dict[str, PriorityTier] | None = None,
) -> list[ScanModule]:
mode = scan_mode.lower().strip()
def get_p(m: ScanModule) -> PriorityTier:
if effective_priorities and m.name in effective_priorities:
return effective_priorities[m.name]
return m.default_priority
if mode == "quick":
return [
m
for m in candidates
if m.default_priority in (PriorityTier.CRITICAL, PriorityTier.HIGH)
]
return [m for m in candidates if get_p(m) in (PriorityTier.CRITICAL, PriorityTier.HIGH)]
if mode == "standard":
return [
m
for m in candidates
if m.default_priority
in (PriorityTier.CRITICAL, PriorityTier.HIGH, PriorityTier.MEDIUM)
if get_p(m) in (PriorityTier.CRITICAL, PriorityTier.HIGH, PriorityTier.MEDIUM)
]
return candidates
@ -290,6 +371,7 @@ class ScanPlanner:
tci_score: float,
safe_mode_override: bool | None,
module_overrides: dict[str, dict[str, Any]],
effective_priorities: dict[str, PriorityTier] | None = None,
) -> list[ScanPlanStep]:
ordered_modules = self._order_modules(candidates)
steps: list[ScanPlanStep] = []
@ -297,12 +379,15 @@ class ScanPlanner:
for idx, mod in enumerate(ordered_modules, start=1):
overrides = module_overrides.get(mod.name, {})
priority_val = overrides.get("priority")
priority = (
str(priority_val).lower()
if priority_val is not None
else self._elevate_priority(mod, fp, tci_score)
)
if effective_priorities and mod.name in effective_priorities:
priority = effective_priorities[mod.name].value.lower()
else:
priority_val = overrides.get("priority")
priority = (
str(priority_val).lower()
if priority_val is not None
else self._elevate_priority(mod, fp, tci_score)
)
safe_mode = self._resolve_safe_mode(mod, fp, tci_score, safe_mode_override, overrides)
quota = self._resolve_quota(mod, tci_score, overrides)
@ -355,7 +440,10 @@ class ScanPlanner:
return "; ".join(notes)
def _order_modules(self, modules: list[ScanModule]) -> list[ScanModule]:
"""Order modules tactically by phase and prerequisite dependencies."""
"""Order modules tactically by phase and prerequisite dependencies.
Detects circular dependencies and reports them clearly with ValueError.
"""
by_name = {m.name: m for m in modules}
def sort_key(m: ScanModule) -> tuple[int, int, int, str]:
@ -368,15 +456,31 @@ class ScanPlanner:
ordered: list[ScanModule] = []
placed: set[str] = set()
visiting: set[str] = set()
def place(m: ScanModule) -> None:
def place(m: ScanModule) -> bool:
if m.name in placed:
return
for req in m.prerequisites:
if req in by_name and req not in placed:
place(by_name[req])
ordered.append(m)
placed.add(m.name)
return True
if m.name in visiting:
msg = f"Circular prerequisite dependency detected involving module '{m.name}'"
raise ValueError(msg)
visiting.add(m.name)
try:
for req in m.prerequisites:
if req not in by_name:
logger.warning(
"Cannot place module '%s': prerequisite '%s' cannot be met",
m.name,
req,
)
return False
if not place(by_name[req]):
return False
ordered.append(m)
placed.add(m.name)
return True
finally:
visiting.remove(m.name)
for mod in sorted_mods:
place(mod)

View file

@ -18,6 +18,75 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
logger = logging.getLogger(__name__)
SUPPORTED_TARGET_TYPES: frozenset[str] = frozenset({"web_app", "api", "whitebox", "infra", "cloud"})
TARGET_TYPE_ALIASES: dict[str, str] = {
# web
"web_app": "web_app",
"web_application": "web_app",
"web": "web_app",
"url": "web_app",
"domain": "web_app",
"hostname": "web_app",
# api
"api": "api",
"api_spec": "api",
"openapi": "api",
"swagger": "api",
"rest": "api",
"graphql": "api",
# whitebox / code
"whitebox": "whitebox",
"local_code": "whitebox",
"code": "whitebox",
"source_code": "whitebox",
"repository": "whitebox",
"repo": "whitebox",
# infra
"infra": "infra",
"infrastructure": "infra",
"network": "infra",
"ip": "infra",
"ip_address": "infra",
"host": "infra",
# cloud
"cloud": "cloud",
"aws": "cloud",
"azure": "cloud",
"gcp": "cloud",
}
def normalize_target_type(target_type: str | None) -> str:
"""Normalize and validate a target type string to canonical form.
Raises:
ValueError: If target_type is unrecognized.
"""
if not target_type:
return "web_app"
clean = target_type.strip().lower()
canonical = TARGET_TYPE_ALIASES.get(clean)
if canonical is None:
valid_options = ", ".join(sorted(SUPPORTED_TARGET_TYPES))
msg = f"Unknown target type '{target_type}'. Supported canonical types: {valid_options}."
raise ValueError(msg)
return canonical
def _dedup_clean_strings(values: list[str]) -> list[str]:
"""Clean and deduplicate list of strings preserving insertion order."""
seen: set[str] = set()
result: list[str] = []
for item in values:
if isinstance(item, str):
clean = item.strip().lower()
if clean and clean not in seen:
seen.add(clean)
result.append(clean)
return result
class TCILevel(StrEnum):
"""Complexity tiers based on TCI score."""
@ -38,6 +107,11 @@ class ApiSurface(BaseModel):
has_spec: bool = False
dynamic_routes: bool = False
@field_validator("api_types", mode="after")
@classmethod
def _dedup_api_types(cls, v: list[str]) -> list[str]:
return _dedup_clean_strings(v)
class TargetFingerprint(BaseModel):
"""Structured fingerprint of a target's observable characteristics."""
@ -57,6 +131,28 @@ class TargetFingerprint(BaseModel):
patch_posture: str = "unknown"
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("target_type", mode="after")
@classmethod
def _validate_target_type(cls, v: str) -> str:
return normalize_target_type(v)
@field_validator("open_ports", mode="after")
@classmethod
def _dedup_ports(cls, v: list[int]) -> list[int]:
return sorted({p for p in v if isinstance(p, int) and p > 0})
@field_validator(
"protocols",
"auth_patterns",
"frontend_technologies",
"backend_technologies",
"waf_cdn",
mode="after",
)
@classmethod
def _dedup_string_lists(cls, v: list[str]) -> list[str]:
return _dedup_clean_strings(v)
@field_validator("api_surface", mode="before")
@classmethod
def _coerce_api_surface(cls, v: Any) -> ApiSurface:
@ -168,8 +264,8 @@ def calc_ports_protocols_score(ports: list[int], protocols: list[str]) -> tuple[
score = 0.0
notes: list[str] = []
clean_ports = list(dict.fromkeys(p for p in ports if isinstance(p, int) and p > 0))
clean_protocols = [p.lower().strip() for p in protocols if isinstance(p, str) and p.strip()]
clean_ports = sorted({p for p in ports if isinstance(p, int) and p > 0})
clean_protocols = _dedup_clean_strings(protocols)
if not clean_ports and not clean_protocols:
return 10.0, "No open ports or protocols reported (baseline web assumption)"
@ -245,13 +341,15 @@ _AUTH_BASE_SCORES: dict[str, float] = {
def calc_auth_score(auth_patterns: list[str]) -> tuple[float, str]:
"""Score authentication complexity (0..100)."""
clean_patterns = [p.lower().strip() for p in auth_patterns if isinstance(p, str) and p.strip()]
clean_patterns = _dedup_clean_strings(auth_patterns)
if not clean_patterns:
return 0.0, "No authentication mechanisms detected"
max_base = 0.0
matched: list[str] = []
bonus = 0.0
has_mfa = False
has_rbac = False
for pattern in clean_patterns:
score = _AUTH_BASE_SCORES.get(pattern)
@ -259,15 +357,20 @@ def calc_auth_score(auth_patterns: list[str]) -> tuple[float, str]:
max_base = max(max_base, score)
matched.append(pattern)
elif any(k in pattern for k in ("mfa", "2fa", "totp")):
bonus += 15.0
has_mfa = True
matched.append("mfa")
elif any(k in pattern for k in ("rbac", "abac", "multi_role", "tenant", "multi_tenant")):
bonus += 15.0
has_rbac = True
matched.append("rbac/multi-tenant")
else:
max_base = max(max_base, 30.0)
matched.append(pattern)
if has_mfa:
bonus += 15.0
if has_rbac:
bonus += 15.0
# Multi-pattern complexity
distinct_mechanisms = len(set(matched))
if distinct_mechanisms > 1:
@ -303,15 +406,15 @@ def calc_api_surface_score(api_surface: ApiSurface | dict[str, Any]) -> tuple[fl
surface = ApiSurface(**api_surface) if isinstance(api_surface, dict) else api_surface
notes: list[str] = []
base_score, count_note = _score_endpoint_count(surface.endpoints_count, bool(surface.api_types))
clean_api_types = _dedup_clean_strings(surface.api_types)
base_score, count_note = _score_endpoint_count(surface.endpoints_count, bool(clean_api_types))
if count_note:
notes.append(count_note)
score = base_score
# API Types diversity
for api_type in surface.api_types:
atype = api_type.lower().strip()
# API Types diversity (deduplicated)
for atype in clean_api_types:
if atype in ("graphql", "grpc"):
score += 20.0
notes.append(atype.upper())
@ -393,8 +496,8 @@ def calc_tech_stack_score(
backend: list[str],
) -> tuple[float, str]:
"""Score technology stack heterogeneity and modern complexity (0..100)."""
clean_fe = [f.lower().strip() for f in frontend if isinstance(f, str) and f.strip()]
clean_be = [b.lower().strip() for b in backend if isinstance(b, str) and b.strip()]
clean_fe = _dedup_clean_strings(frontend)
clean_be = _dedup_clean_strings(backend)
score = 15.0 # baseline
notes: list[str] = []
@ -407,9 +510,13 @@ def calc_tech_stack_score(
score += 15.0
notes.append(f"Frontend: {clean_fe}")
matched_be = [
b for b in clean_be if b in _BACKEND_FRAMEWORKS or any(k in b for k in _BACKEND_FRAMEWORKS)
]
matched_be = list(
dict.fromkeys(
b
for b in clean_be
if b in _BACKEND_FRAMEWORKS or any(k in b for k in _BACKEND_FRAMEWORKS)
)
)
if len(matched_be) > 1:
score += 35.0
notes.append(f"Polyglot/microservice backend: {clean_be}")
@ -420,7 +527,7 @@ def calc_tech_stack_score(
score += 15.0
notes.append(f"Backend: {clean_be}")
matched_db = [b for b in clean_be if b in _DB_ENGINES]
matched_db = list(dict.fromkeys(b for b in clean_be if b in _DB_ENGINES))
if matched_db:
db_pts = min(20.0, len(matched_db) * 10.0)
score += db_pts
@ -448,7 +555,7 @@ _WAF_PROVIDERS = frozenset(
def calc_waf_defense_score(waf_cdn: list[str]) -> tuple[float, str]:
"""Score defensive posture and WAF presence (0..100)."""
clean_defenses = [d.lower().strip() for d in waf_cdn if isinstance(d, str) and d.strip()]
clean_defenses = _dedup_clean_strings(waf_cdn)
if not clean_defenses:
return 0.0, "No WAF or edge security detected"

View file

@ -9,7 +9,7 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from strix.core.tci import TargetFingerprint
from strix.core.tci import TargetFingerprint, normalize_target_type
logger = logging.getLogger(__name__)
@ -59,6 +59,7 @@ class ScanModule(BaseModel):
requires_api: bool = False
requires_graphql: bool = False
requires_jwt: bool = False
requires_oauth: bool = False
class ModuleRegistry:
@ -171,6 +172,7 @@ class ModuleRegistry:
if isinstance(fingerprint, TargetFingerprint)
else TargetFingerprint(**fingerprint)
)
canonical_target_type = normalize_target_type(fp.target_type)
with self._lock:
candidates = list(self._modules.values())
@ -178,8 +180,12 @@ class ModuleRegistry:
applicable: list[ScanModule] = []
has_auth = bool(fp.auth_patterns)
has_jwt = any("jwt" in p.lower() for p in fp.auth_patterns)
has_oauth = any(
any(tok in p.lower() for tok in ("oauth", "oauth2", "oidc", "sso", "saml"))
for p in fp.auth_patterns
)
has_api = (
fp.target_type == "api"
canonical_target_type == "api"
or fp.api_surface.endpoints_count > 0
or bool(fp.api_surface.api_types)
)
@ -189,7 +195,11 @@ class ModuleRegistry:
for mod in candidates:
# Target type check
if fp.target_type and mod.target_types and fp.target_type not in mod.target_types:
if (
canonical_target_type
and mod.target_types
and canonical_target_type not in mod.target_types
):
continue
# Feature conditions
@ -197,6 +207,8 @@ class ModuleRegistry:
continue
if mod.requires_jwt and not has_jwt:
continue
if mod.requires_oauth and not has_oauth:
continue
if mod.requires_api and not has_api:
continue
if mod.requires_graphql and not has_graphql:
@ -354,6 +366,7 @@ class ModuleRegistry:
skills=["protocols/oauth"],
tags=["oauth", "oidc", "auth"],
requires_auth=True,
requires_oauth=True,
),
ScanModule(
name="graphql-audit",

View file

@ -16,8 +16,13 @@ from strix.modules.registry import default_registry
logger = logging.getLogger(__name__)
def _parse_fingerprint_arg(raw: str | dict[str, Any]) -> dict[str, Any]:
"""Parse JSON string or dict into fingerprint dictionary."""
def _parse_fingerprint_arg(raw: Any) -> dict[str, Any]:
"""Parse JSON string or dict into fingerprint dictionary.
Raises:
ValueError: If raw is invalid JSON.
TypeError: If raw is not a JSON string, dict, or parsed value is not a dict.
"""
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
@ -26,11 +31,15 @@ def _parse_fingerprint_arg(raw: str | dict[str, Any]) -> dict[str, Any]:
return {}
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
logger.warning("Failed to parse fingerprint JSON argument: %s", raw)
return {}
except json.JSONDecodeError as exc:
msg = f"Invalid fingerprint JSON: {exc}"
raise ValueError(msg) from exc
if not isinstance(parsed, dict):
msg = f"Fingerprint JSON must be an object/dict, got {type(parsed).__name__}"
raise TypeError(msg)
return parsed
msg = f"Fingerprint argument must be a JSON string or dict, got {type(raw).__name__}"
raise TypeError(msg)
@function_tool(timeout=30)
@ -68,12 +77,16 @@ async def calculate_target_complexity(
``sub_scores``, and descriptive ``breakdown``.
"""
del ctx # Unused by calculation
fp_dict = _parse_fingerprint_arg(fingerprint)
if target and "target" not in fp_dict:
fp_dict["target"] = target
try:
fp_dict = _parse_fingerprint_arg(fingerprint)
if target and "target" not in fp_dict:
fp_dict["target"] = target
fp = TargetFingerprint(**fp_dict)
res = calculate_tci(fp)
fp = TargetFingerprint(**fp_dict)
res = calculate_tci(fp)
except (ValueError, TypeError) as exc:
logger.warning("Error calculating target complexity: %s", exc)
return json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2)
payload = {
"target": target or fp.target,
@ -124,18 +137,22 @@ async def create_scan_plan(
```
"""
del ctx
fp_dict = _parse_fingerprint_arg(fingerprint)
if target and "target" not in fp_dict:
fp_dict["target"] = target
try:
fp_dict = _parse_fingerprint_arg(fingerprint)
if target and "target" not in fp_dict:
fp_dict["target"] = target
planner = ScanPlanner()
plan = planner.generate_plan(
target=target,
fingerprint=fp_dict,
scan_mode=scan_mode,
safe_mode_override=safe_mode,
)
return plan.to_json(compact=False, indent=2)
planner = ScanPlanner()
plan = planner.generate_plan(
target=target,
fingerprint=fp_dict,
scan_mode=scan_mode,
safe_mode_override=safe_mode,
)
return plan.to_json(compact=False, indent=2)
except (ValueError, TypeError) as exc:
logger.warning("Error creating scan plan: %s", exc)
return json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2)
@function_tool(timeout=30)

View file

@ -150,3 +150,81 @@ def test_register_module_helper() -> None:
assert default_registry.get("test-singleton-mod") == test_mod
finally:
default_registry.unregister("test-singleton-mod")
def test_oauth_audit_requires_oauth_not_just_any_auth() -> None:
"""Issue 6: oauth-audit must require OAuth/OIDC rather than any generic auth."""
reg = ModuleRegistry(populate_defaults=True)
# Basic auth target: should NOT have oauth-audit
fp_basic = TargetFingerprint(
target="https://basic.example.com",
target_type="web_app",
auth_patterns=["basic"],
)
names_basic = {m.name for m in reg.find_applicable(fp_basic)}
assert "oauth-audit" not in names_basic
assert "auth-enum" in names_basic
# Session cookie target: should NOT have oauth-audit
fp_session = TargetFingerprint(
target="https://session.example.com",
target_type="web_app",
auth_patterns=["session_cookie"],
)
names_session = {m.name for m in reg.find_applicable(fp_session)}
assert "oauth-audit" not in names_session
assert "auth-enum" in names_session
# OAuth2 target: SHOULD have oauth-audit
fp_oauth = TargetFingerprint(
target="https://oauth.example.com",
target_type="web_app",
auth_patterns=["oauth2"],
)
names_oauth = {m.name for m in reg.find_applicable(fp_oauth)}
assert "oauth-audit" in names_oauth
# OIDC target: SHOULD have oauth-audit
fp_oidc = TargetFingerprint(
target="https://oidc.example.com",
target_type="web_app",
auth_patterns=["oidc"],
)
names_oidc = {m.name for m in reg.find_applicable(fp_oidc)}
assert "oauth-audit" in names_oidc
def test_find_applicable_maps_target_type_aliases_and_rejects_unknown() -> None:
"""Issue 3: find_applicable accepts existing target type aliases and rejects unknown types."""
reg = ModuleRegistry(populate_defaults=True)
# Existing configuration uses web_application
fp_web_app = {"target": "https://app.example.com", "target_type": "web_application"}
modules_web = reg.find_applicable(fp_web_app)
assert len(modules_web) > 0
web_names = {m.name for m in modules_web}
assert "sqli-fuzz" in web_names
assert "xss-test" in web_names
# Existing configuration uses api_spec
fp_api_spec = {
"target": "https://api.example.com",
"target_type": "api_spec",
"api_surface": {"endpoints_count": 5},
}
modules_api = reg.find_applicable(fp_api_spec)
assert len(modules_api) > 0
api_names = {m.name for m in modules_api}
assert "api-route-fuzz" in api_names
# Existing configuration uses local_code
fp_code = {"target": "/src/repo", "target_type": "local_code"}
modules_code = reg.find_applicable(fp_code)
assert len(modules_code) > 0
code_names = {m.name for m in modules_code}
assert "traversal-test" in code_names
# Reject unknown target types with explicit ValueError
with pytest.raises(ValueError, match="Unknown target type 'mainframe_cobol'"):
reg.find_applicable({"target": "host", "target_type": "mainframe_cobol"})

View file

@ -105,3 +105,44 @@ async def test_list_scan_modules_tool() -> None:
filtered = json.loads(filtered_raw)
for m in filtered:
assert m["category"] == "authentication"
async def test_tools_reject_malformed_fingerprint_json() -> None:
"""Issue 4: malformed JSON returns an explicit error payload."""
# 1. Malformed JSON to calculate_target_complexity
raw_calc = await calculate_target_complexity.on_invoke_tool(
_make_ctx("calculate_target_complexity"),
json.dumps({"target": "https://test.local", "fingerprint": "{malformed json"}),
)
res_calc = json.loads(raw_calc)
assert "error" in res_calc
assert "Invalid fingerprint JSON" in res_calc["error"]
# 2. Malformed JSON to create_scan_plan
raw_plan = await create_scan_plan.on_invoke_tool(
_make_ctx("create_scan_plan"),
json.dumps({"target": "https://test.local", "fingerprint": "{malformed json"}),
)
res_plan = json.loads(raw_plan)
assert "error" in res_plan
assert "Invalid fingerprint JSON" in res_plan["error"]
# 3. Non-dict JSON (e.g. array) to calculate_target_complexity
raw_arr = await calculate_target_complexity.on_invoke_tool(
_make_ctx("calculate_target_complexity"),
json.dumps({"target": "https://test.local", "fingerprint": "[1, 2, 3]"}),
)
res_arr = json.loads(raw_arr)
assert "error" in res_arr
assert "must be an object" in res_arr["error"]
# 4. Unknown target type returns explicit error
raw_unk = await calculate_target_complexity.on_invoke_tool(
_make_ctx("calculate_target_complexity"),
json.dumps(
{"target": "https://test.local", "fingerprint": json.dumps({"target_type": "quantum"})}
),
)
res_unk = json.loads(raw_unk)
assert "error" in res_unk
assert "Unknown target type" in res_unk["error"]

View file

@ -4,8 +4,11 @@ from __future__ import annotations
import json
import pytest
from strix.agents.planner import ScanPlan, ScanPlanner
from strix.core.tci import ApiSurface, TargetFingerprint
from strix.core.tci import ApiSurface, TargetFingerprint, calculate_tci
from strix.modules.registry import PriorityTier, ScanModule
def test_planner_generates_exact_payload_schema() -> None:
@ -198,3 +201,120 @@ def test_plan_helpers_and_aggregation() -> None:
assert "quota" in full_dict
assert "skills" in full_dict
assert "reason" in full_dict
def test_priority_filter_prerequisite_resolution() -> None:
"""Issue 2: priority_filter keeps business-logic and includes auth-enum prerequisite."""
planner = ScanPlanner()
target = "https://app.local"
# Target with low complexity so business-logic is medium priority
fp = TargetFingerprint(
target=target,
auth_patterns=["session_cookie"],
data_sensitivity="low",
)
# By default, include_prerequisites=True: auth-enum is included before business-logic
plan_with_prereq = planner.generate_plan(
target,
fp,
priority_filter=["medium"],
include_prerequisites=True,
)
names = plan_with_prereq.module_names()
assert "business-logic" in names
assert "auth-enum" in names
assert names.index("auth-enum") < names.index("business-logic")
# With include_prerequisites=False: business-logic dropped when requirement cannot be met
plan_without_prereq = planner.generate_plan(
target,
fp,
priority_filter=["medium"],
include_prerequisites=False,
)
assert "business-logic" not in plan_without_prereq.module_names()
def test_priority_elevation_before_mode_filtering() -> None:
"""Issue 5: high TCI targets elevate business-logic before quick mode filtering."""
planner = ScanPlanner()
target = "https://complex.example.com"
# High complexity target (TCI >= 70)
fp_complex = TargetFingerprint(
target=target,
open_ports=[443, 8080, 8443, 5432],
protocols=["https", "grpc"],
auth_patterns=["jwt", "oauth2", "mfa", "rbac"],
api_surface=ApiSurface(endpoints_count=60, api_types=["rest", "graphql"], has_spec=True),
backend_technologies=["fastapi", "spring", "postgres", "redis"],
waf_cdn=["cloudflare_waf", "aws_waf"],
data_sensitivity="financial",
patch_posture="legacy",
)
tci = calculate_tci(fp_complex).score
assert tci >= 70.0
# Quick mode keeps business-logic because its final effective priority was elevated to high
plan_quick = planner.generate_plan(target, fp_complex, scan_mode="quick")
names = plan_quick.module_names()
assert "business-logic" in names
step = plan_quick.get_step_by_module("business-logic")
assert step is not None
assert step.priority == "high"
# User override elevates a medium module so quick mode keeps it
plan_override = planner.generate_plan(
target,
fp_complex,
scan_mode="quick",
module_overrides={"cors-misconfig": {"priority": "high"}},
)
assert "cors-misconfig" in plan_override.module_names()
def test_circular_prerequisite_detection() -> None:
"""Issue 9: circular prerequisites report ValueError clearly instead of RecursionError."""
planner = ScanPlanner()
# 2-module cycle: A -> B -> A
mod_a = ScanModule(
name="cycle-a",
description="Module A",
category="injection",
default_priority=PriorityTier.MEDIUM,
prerequisites=["cycle-b"],
)
mod_b = ScanModule(
name="cycle-b",
description="Module B",
category="injection",
default_priority=PriorityTier.MEDIUM,
prerequisites=["cycle-a"],
)
with pytest.raises(ValueError, match="Circular prerequisite dependency detected"):
planner._order_modules([mod_a, mod_b])
# 3-module cycle: X -> Y -> Z -> X
mod_x = ScanModule(
name="cycle-x",
description="Module X",
category="api",
prerequisites=["cycle-y"],
)
mod_y = ScanModule(
name="cycle-y",
description="Module Y",
category="api",
prerequisites=["cycle-z"],
)
mod_z = ScanModule(
name="cycle-z",
description="Module Z",
category="api",
prerequisites=["cycle-x"],
)
with pytest.raises(ValueError, match="Circular prerequisite dependency detected"):
planner._order_modules([mod_x, mod_y, mod_z])

View file

@ -19,6 +19,7 @@ from strix.core.tci import (
calc_tech_stack_score,
calc_waf_defense_score,
calculate_tci,
normalize_target_type,
)
@ -204,3 +205,54 @@ def test_patch_posture_scoring() -> None:
assert s_mod < s_cur < s_unk < s_lag < s_eol
assert s_eol == 95.0
def test_deduplication_prevents_inflated_scores() -> None:
"""Issue 8: repeated observations must not inflate TCI score."""
# API types deduplication
score_dupe, _ = calc_api_surface_score(
ApiSurface(endpoints_count=0, api_types=["graphql", "graphql"])
)
score_single, _ = calc_api_surface_score(ApiSurface(endpoints_count=0, api_types=["graphql"]))
assert score_dupe == 35.0
assert score_dupe == score_single
# Ports & protocols deduplication
s_proto_dupe, _ = calc_ports_protocols_score([443, 443], ["grpc", "grpc"])
s_proto_single, _ = calc_ports_protocols_score([443], ["grpc"])
assert s_proto_dupe == s_proto_single
# Auth pattern MFA & repeated deduplication
s_mfa_dupe, _ = calc_auth_score(["basic", "mfa", "totp", "mfa"])
s_mfa_single, _ = calc_auth_score(["basic", "mfa"])
assert s_mfa_dupe == s_mfa_single
# Tech stack deduplication (no false polyglot bonus)
s_stack_dupe, _ = calc_tech_stack_score(["react", "react"], ["django", "django"])
s_stack_single, _ = calc_tech_stack_score(["react"], ["django"])
assert s_stack_dupe == s_stack_single
# WAF deduplication (no multi-WAF bonus for repeated provider)
s_waf_dupe, _ = calc_waf_defense_score(["cloudflare", "cloudflare"])
s_waf_single, _ = calc_waf_defense_score(["cloudflare"])
assert s_waf_dupe == 75.0
assert s_waf_dupe == s_waf_single
def test_target_type_normalization_and_validation() -> None:
"""Issue 3: target types map aliases and reject unknown types."""
assert normalize_target_type("web_application") == "web_app"
assert normalize_target_type("api_spec") == "api"
assert normalize_target_type("local_code") == "whitebox"
assert normalize_target_type("ip_address") == "infra"
# Fingerprint model normalizes on construction
fp = TargetFingerprint(target_type="web_application")
assert fp.target_type == "web_app"
# Reject unknown target types
with pytest.raises(ValueError, match="Unknown target type 'quantum_satellite'"):
normalize_target_type("quantum_satellite")
with pytest.raises(ValueError, match="Unknown target type 'quantum_satellite'"):
TargetFingerprint(target_type="quantum_satellite")