Multi-agent foundations (Phases 1+2): dynamic attack surface + revealed_targets
Browse filesPhase 1 — schema additions (no behavior change yet, all 78 tests still pass):
- execute_tool() now returns 5-tuple (output, new_hosts, new_ports, reward, revealed_targets)
with backward-compat: 4-tuple handlers get padded to 5-tuple at the engine boundary
- env tracks _attack_surface (grows during episode), _revealed_targets log,
_active_subagents dict, _subagent_outcomes log
- New action_types accepted: spawn_subagent / return_to_parent (stubs, real impl in Phase 3)
- Tests updated: 3 test_generator.py unpack sites bumped to 5-tuple
Phase 2 — tools emit revealed_targets when they hit gateway vulns:
- New _revelations_for_vulns helper in tools_engine/testing.py: when a tool finds a
vulnerability whose ID gates a hidden host (via scenario hosts.hidden_until chain),
emit a {scope: host, target: <ip>, context: ...} revelation
- handle_test_injection / handle_test_xss / handle_test_auth surface revelations both
in their textual output ("[REVEALED] Sub-agent delegation candidates: ...") AND
via the 5th tuple element so the env can update attack_surface
- Verified end-to-end: test_injection on medium scenario's SSRF correctly reveals
the two hidden internal hosts 10.0.2.30 and 10.0.2.40
This is the foundation — Phases 3-8 wire actual sub-agent spawning, nested
inference loop, reward shaping, grader extension, and live deploy.
|
@@ -64,6 +64,15 @@ class SecurityAuditEnvironment(Environment):
|
|
| 64 |
self._episode_reward: float = 0.0
|
| 65 |
self._last_tool_call: tuple = ()
|
| 66 |
self._rng: random.Random = random.Random()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
def reset(self, seed=None, episode_id=None, **kwargs) -> SecurityAuditObservation:
|
| 69 |
"""Reset the environment for a new audit engagement.
|
|
@@ -83,6 +92,12 @@ class SecurityAuditEnvironment(Environment):
|
|
| 83 |
self._episode_reward = 0.0
|
| 84 |
self._last_tool_call = ()
|
| 85 |
self._rng = random.Random(seed) if seed is not None else random.Random()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
eid = episode_id or str(uuid4())
|
| 88 |
self._state = SecurityAuditState(
|
|
@@ -127,12 +142,16 @@ class SecurityAuditEnvironment(Environment):
|
|
| 127 |
return self._handle_use_tool(action, steps_remaining)
|
| 128 |
elif action.action_type == "submit_finding":
|
| 129 |
return self._handle_submit_finding(action, steps_remaining)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
elif action.action_type == "generate_report":
|
| 131 |
return self._finish_episode("Audit report generated.", truncated=False)
|
| 132 |
else:
|
| 133 |
return SecurityAuditObservation(
|
| 134 |
tool_output=f"Unknown action_type: {action.action_type}",
|
| 135 |
-
message="Use list_tools, use_tool, submit_finding, or generate_report.",
|
| 136 |
discovered_hosts=self._discovered_hosts,
|
| 137 |
discovered_services=self._discovered_services,
|
| 138 |
findings_submitted=len(self._submitted_findings),
|
|
@@ -142,6 +161,34 @@ class SecurityAuditEnvironment(Environment):
|
|
| 142 |
reward=-0.05,
|
| 143 |
)
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
@property
|
| 146 |
def state(self) -> SecurityAuditState:
|
| 147 |
self._state.discovered_hosts = list(self._discovered_hosts)
|
|
@@ -195,11 +242,19 @@ class SecurityAuditEnvironment(Environment):
|
|
| 195 |
redundancy_penalty = -0.01 if current_call == self._last_tool_call else 0.0
|
| 196 |
self._last_tool_call = current_call
|
| 197 |
|
| 198 |
-
output, new_hosts, new_ports, tool_reward = execute_tool(
|
| 199 |
action.tool_name, action.arguments, self._scenario,
|
| 200 |
self._discovered_hosts, self._discovered_ports, self._discovered_vulns,
|
| 201 |
)
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
# Difficulty multiplier on positive rewards
|
| 204 |
difficulty = self._scenario.get("id", "easy")
|
| 205 |
multiplier = self._DIFFICULTY_REWARD_MULTIPLIER.get(difficulty, 1.0)
|
|
|
|
| 64 |
self._episode_reward: float = 0.0
|
| 65 |
self._last_tool_call: tuple = ()
|
| 66 |
self._rng: random.Random = random.Random()
|
| 67 |
+
# Multi-agent extension: dynamic attack surface + sub-agent registry.
|
| 68 |
+
# ``_attack_surface`` starts as the original scenario hosts and grows
|
| 69 |
+
# whenever tools reveal new targets (e.g. SSRF dumping internal IPs).
|
| 70 |
+
# ``_active_subagents`` tracks delegated investigation branches keyed
|
| 71 |
+
# by spawn_id; each entry carries scope/target/budget/findings/parent_step.
|
| 72 |
+
self._attack_surface: set = set()
|
| 73 |
+
self._revealed_targets: list = []
|
| 74 |
+
self._active_subagents: dict = {}
|
| 75 |
+
self._subagent_outcomes: list = [] # for grader's Delegation Score
|
| 76 |
|
| 77 |
def reset(self, seed=None, episode_id=None, **kwargs) -> SecurityAuditObservation:
|
| 78 |
"""Reset the environment for a new audit engagement.
|
|
|
|
| 92 |
self._episode_reward = 0.0
|
| 93 |
self._last_tool_call = ()
|
| 94 |
self._rng = random.Random(seed) if seed is not None else random.Random()
|
| 95 |
+
# Reset multi-agent state. Attack surface seeds from initial scenario
|
| 96 |
+
# hosts; will grow as tools reveal targets during the episode.
|
| 97 |
+
self._attack_surface = set((self._scenario or {}).get("hosts", {}).keys())
|
| 98 |
+
self._revealed_targets = []
|
| 99 |
+
self._active_subagents = {}
|
| 100 |
+
self._subagent_outcomes = []
|
| 101 |
|
| 102 |
eid = episode_id or str(uuid4())
|
| 103 |
self._state = SecurityAuditState(
|
|
|
|
| 142 |
return self._handle_use_tool(action, steps_remaining)
|
| 143 |
elif action.action_type == "submit_finding":
|
| 144 |
return self._handle_submit_finding(action, steps_remaining)
|
| 145 |
+
elif action.action_type == "spawn_subagent":
|
| 146 |
+
return self._handle_spawn_subagent(action, steps_remaining)
|
| 147 |
+
elif action.action_type == "return_to_parent":
|
| 148 |
+
return self._handle_return_to_parent(action, steps_remaining)
|
| 149 |
elif action.action_type == "generate_report":
|
| 150 |
return self._finish_episode("Audit report generated.", truncated=False)
|
| 151 |
else:
|
| 152 |
return SecurityAuditObservation(
|
| 153 |
tool_output=f"Unknown action_type: {action.action_type}",
|
| 154 |
+
message="Use list_tools, use_tool, submit_finding, spawn_subagent, or generate_report.",
|
| 155 |
discovered_hosts=self._discovered_hosts,
|
| 156 |
discovered_services=self._discovered_services,
|
| 157 |
findings_submitted=len(self._submitted_findings),
|
|
|
|
| 161 |
reward=-0.05,
|
| 162 |
)
|
| 163 |
|
| 164 |
+
def _handle_spawn_subagent(self, action: SecurityAuditAction, steps_remaining: int) -> SecurityAuditObservation:
|
| 165 |
+
"""Phase 1 stub. Real registration + budget tracking lands in Phase 3."""
|
| 166 |
+
return SecurityAuditObservation(
|
| 167 |
+
tool_output="spawn_subagent acknowledged (full implementation in Phase 3).",
|
| 168 |
+
message="Sub-agent infra is being wired up; this action is a no-op for now.",
|
| 169 |
+
discovered_hosts=self._discovered_hosts,
|
| 170 |
+
discovered_services=self._discovered_services,
|
| 171 |
+
findings_submitted=len(self._submitted_findings),
|
| 172 |
+
steps_remaining=steps_remaining,
|
| 173 |
+
current_phase=self._current_phase(),
|
| 174 |
+
done=False,
|
| 175 |
+
reward=0.0,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
def _handle_return_to_parent(self, action: SecurityAuditAction, steps_remaining: int) -> SecurityAuditObservation:
|
| 179 |
+
"""Phase 1 stub. Sub-agent termination lands in Phase 3."""
|
| 180 |
+
return SecurityAuditObservation(
|
| 181 |
+
tool_output="return_to_parent acknowledged (no active sub-agent context).",
|
| 182 |
+
message="Use this when finishing a sub-agent investigation.",
|
| 183 |
+
discovered_hosts=self._discovered_hosts,
|
| 184 |
+
discovered_services=self._discovered_services,
|
| 185 |
+
findings_submitted=len(self._submitted_findings),
|
| 186 |
+
steps_remaining=steps_remaining,
|
| 187 |
+
current_phase=self._current_phase(),
|
| 188 |
+
done=False,
|
| 189 |
+
reward=0.0,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
@property
|
| 193 |
def state(self) -> SecurityAuditState:
|
| 194 |
self._state.discovered_hosts = list(self._discovered_hosts)
|
|
|
|
| 242 |
redundancy_penalty = -0.01 if current_call == self._last_tool_call else 0.0
|
| 243 |
self._last_tool_call = current_call
|
| 244 |
|
| 245 |
+
output, new_hosts, new_ports, tool_reward, revealed = execute_tool(
|
| 246 |
action.tool_name, action.arguments, self._scenario,
|
| 247 |
self._discovered_hosts, self._discovered_ports, self._discovered_vulns,
|
| 248 |
)
|
| 249 |
|
| 250 |
+
# Phase 1: any new revelations expand the dynamic attack_surface so
|
| 251 |
+
# later spawn_subagent actions can validate their target.
|
| 252 |
+
for r in revealed:
|
| 253 |
+
tgt = r.get("target")
|
| 254 |
+
if tgt and tgt not in self._attack_surface:
|
| 255 |
+
self._attack_surface.add(tgt)
|
| 256 |
+
self._revealed_targets.append({**r, "revealed_at_step": self._state.step_count})
|
| 257 |
+
|
| 258 |
# Difficulty multiplier on positive rewards
|
| 259 |
difficulty = self._scenario.get("id", "easy")
|
| 260 |
multiplier = self._DIFFICULTY_REWARD_MULTIPLIER.get(difficulty, 1.0)
|
|
@@ -122,12 +122,28 @@ def execute_tool(
|
|
| 122 |
discovered_hosts: List[str],
|
| 123 |
discovered_ports: Dict[str, List[int]],
|
| 124 |
discovered_vulns: Optional[Set[str]] = None,
|
| 125 |
-
) -> Tuple[str, List[str], Dict[str, List[int]], float]:
|
| 126 |
-
"""Execute a simulated tool and return
|
| 127 |
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
"""
|
| 130 |
handler = TOOL_HANDLERS.get(tool_name)
|
| 131 |
if not handler:
|
| 132 |
-
return (
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
discovered_hosts: List[str],
|
| 123 |
discovered_ports: Dict[str, List[int]],
|
| 124 |
discovered_vulns: Optional[Set[str]] = None,
|
| 125 |
+
) -> Tuple[str, List[str], Dict[str, List[int]], float, List[Dict[str, Any]]]:
|
| 126 |
+
"""Execute a simulated tool and return:
|
| 127 |
|
| 128 |
+
``(output, new_hosts, new_ports, reward, revealed_targets)``
|
| 129 |
+
|
| 130 |
+
``revealed_targets`` is a list of dicts shaped like
|
| 131 |
+
``{"scope": "host|endpoint|cred", "target": "10.0.2.30", "context": "..."}``.
|
| 132 |
+
Tools emit revelations when their output discloses a follow-up surface
|
| 133 |
+
(e.g. SSRF dumping an internal IP) — the agent can use ``spawn_subagent``
|
| 134 |
+
to delegate investigation of those branches without losing the main thread.
|
| 135 |
+
|
| 136 |
+
Handlers that haven't yet been updated to return a 5-tuple will have an
|
| 137 |
+
empty ``revealed_targets`` list back-filled here, so this stays a drop-in
|
| 138 |
+
replacement for the old 4-tuple contract during Phase 1.
|
| 139 |
"""
|
| 140 |
handler = TOOL_HANDLERS.get(tool_name)
|
| 141 |
if not handler:
|
| 142 |
+
return (
|
| 143 |
+
f"Error: Unknown tool '{tool_name}'. Use list_tools to see available tools.",
|
| 144 |
+
[], {}, -0.05, [],
|
| 145 |
+
)
|
| 146 |
+
result = handler(arguments, scenario, discovered_hosts, discovered_ports, discovered_vulns)
|
| 147 |
+
if len(result) == 4: # legacy 4-tuple — pad with empty revelations
|
| 148 |
+
return (*result, [])
|
| 149 |
+
return result
|
|
@@ -47,6 +47,47 @@ def _normalize_difficulty(scenario: Dict[str, Any]) -> str:
|
|
| 47 |
return "easy"
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
def _build_context(
|
| 51 |
vuln: Dict[str, Any],
|
| 52 |
host: str,
|
|
@@ -105,7 +146,7 @@ def handle_test_injection(
|
|
| 105 |
f" Tested: SQLi (error-based, blind, time-based), command injection, SSTI, SSRF\n"
|
| 106 |
f" Payloads: 47 injection patterns tested\n"
|
| 107 |
f" Result: No injection vulnerabilities detected on this endpoint.",
|
| 108 |
-
[], {}, 0.01,
|
| 109 |
)
|
| 110 |
|
| 111 |
output_parts = [f"Injection testing on {host}{endpoint}:", ""]
|
|
@@ -114,7 +155,14 @@ def handle_test_injection(
|
|
| 114 |
output_parts.append(format_tool_output(v, difficulty, context))
|
| 115 |
output_parts.append("")
|
| 116 |
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
|
| 120 |
# ---------------------------------------------------------------------------
|
|
@@ -160,7 +208,14 @@ def handle_test_xss(
|
|
| 160 |
output_parts.append(format_tool_output(v, difficulty, context))
|
| 161 |
output_parts.append("")
|
| 162 |
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
# ---------------------------------------------------------------------------
|
|
@@ -211,7 +266,14 @@ def handle_test_auth(
|
|
| 211 |
output_parts.append(format_tool_output(v, difficulty, context))
|
| 212 |
output_parts.append("")
|
| 213 |
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
# ---------------------------------------------------------------------------
|
|
|
|
| 47 |
return "easy"
|
| 48 |
|
| 49 |
|
| 50 |
+
def _revelations_for_vulns(
|
| 51 |
+
matched: List[Dict[str, Any]],
|
| 52 |
+
scenario: Dict[str, Any],
|
| 53 |
+
discovered_hosts: List[str],
|
| 54 |
+
) -> List[Dict[str, Any]]:
|
| 55 |
+
"""Emit `revealed_targets` for vulns that gate progressive-discovery hosts.
|
| 56 |
+
|
| 57 |
+
When a tool finds a vulnerability whose ID appears in some host's
|
| 58 |
+
`hidden_until` chain, that host becomes investigatable — but the agent
|
| 59 |
+
might not yet have submitted the finding (which is what officially adds
|
| 60 |
+
the host to discovered_hosts). Surfacing it via revealed_targets lets
|
| 61 |
+
the agent **delegate** the branch to a sub-agent without breaking its
|
| 62 |
+
primary focus.
|
| 63 |
+
"""
|
| 64 |
+
if not matched:
|
| 65 |
+
return []
|
| 66 |
+
hosts = scenario.get("hosts", {})
|
| 67 |
+
matched_ids = {v.get("id") for v in matched if v.get("id")}
|
| 68 |
+
out: List[Dict[str, Any]] = []
|
| 69 |
+
for ip, info in hosts.items():
|
| 70 |
+
if ip in discovered_hosts:
|
| 71 |
+
continue
|
| 72 |
+
gates = info.get("hidden_until") or []
|
| 73 |
+
if not gates:
|
| 74 |
+
continue
|
| 75 |
+
triggers = [g for g in gates if g in matched_ids]
|
| 76 |
+
if not triggers:
|
| 77 |
+
continue
|
| 78 |
+
out.append({
|
| 79 |
+
"scope": "host",
|
| 80 |
+
"target": ip,
|
| 81 |
+
"context": (
|
| 82 |
+
f"Investigation gateway: vulnerability {triggers[0]} indicates "
|
| 83 |
+
f"reachable internal host {ip}. Consider spawning a sub-agent "
|
| 84 |
+
f"to recon/exploit it without losing the main thread."
|
| 85 |
+
),
|
| 86 |
+
"trigger_vuln_ids": triggers,
|
| 87 |
+
})
|
| 88 |
+
return out
|
| 89 |
+
|
| 90 |
+
|
| 91 |
def _build_context(
|
| 92 |
vuln: Dict[str, Any],
|
| 93 |
host: str,
|
|
|
|
| 146 |
f" Tested: SQLi (error-based, blind, time-based), command injection, SSTI, SSRF\n"
|
| 147 |
f" Payloads: 47 injection patterns tested\n"
|
| 148 |
f" Result: No injection vulnerabilities detected on this endpoint.",
|
| 149 |
+
[], {}, 0.01, [],
|
| 150 |
)
|
| 151 |
|
| 152 |
output_parts = [f"Injection testing on {host}{endpoint}:", ""]
|
|
|
|
| 155 |
output_parts.append(format_tool_output(v, difficulty, context))
|
| 156 |
output_parts.append("")
|
| 157 |
|
| 158 |
+
revelations = _revelations_for_vulns(vulns, scenario, discovered_hosts)
|
| 159 |
+
if revelations:
|
| 160 |
+
output_parts.append("[REVEALED] Sub-agent delegation candidates:")
|
| 161 |
+
for r in revelations:
|
| 162 |
+
output_parts.append(f" - scope={r['scope']} target={r['target']} ({r['context']})")
|
| 163 |
+
output_parts.append("")
|
| 164 |
+
|
| 165 |
+
return "\n".join(output_parts), [], {}, 0.08, revelations
|
| 166 |
|
| 167 |
|
| 168 |
# ---------------------------------------------------------------------------
|
|
|
|
| 208 |
output_parts.append(format_tool_output(v, difficulty, context))
|
| 209 |
output_parts.append("")
|
| 210 |
|
| 211 |
+
revelations = _revelations_for_vulns(vulns, scenario, discovered_hosts)
|
| 212 |
+
if revelations:
|
| 213 |
+
output_parts.append("[REVEALED] Sub-agent delegation candidates:")
|
| 214 |
+
for r in revelations:
|
| 215 |
+
output_parts.append(f" - scope={r['scope']} target={r['target']} ({r['context']})")
|
| 216 |
+
output_parts.append("")
|
| 217 |
+
|
| 218 |
+
return "\n".join(output_parts), [], {}, 0.08, revelations
|
| 219 |
|
| 220 |
|
| 221 |
# ---------------------------------------------------------------------------
|
|
|
|
| 266 |
output_parts.append(format_tool_output(v, difficulty, context))
|
| 267 |
output_parts.append("")
|
| 268 |
|
| 269 |
+
revelations = _revelations_for_vulns(vulns, scenario, discovered_hosts)
|
| 270 |
+
if revelations:
|
| 271 |
+
output_parts.append("[REVEALED] Sub-agent delegation candidates:")
|
| 272 |
+
for r in revelations:
|
| 273 |
+
output_parts.append(f" - scope={r['scope']} target={r['target']} ({r['context']})")
|
| 274 |
+
output_parts.append("")
|
| 275 |
+
|
| 276 |
+
return "\n".join(output_parts), [], {}, 0.08, revelations
|
| 277 |
|
| 278 |
|
| 279 |
# ---------------------------------------------------------------------------
|
|
@@ -158,7 +158,7 @@ class TestParameterTesting:
|
|
| 158 |
from server.tools_engine import execute_tool
|
| 159 |
scenario = get_scenario("easy")
|
| 160 |
hosts = list(scenario["hosts"].keys())
|
| 161 |
-
output, _, _, _ = execute_tool(
|
| 162 |
"web_crawl", {"host": hosts[0]},
|
| 163 |
scenario, hosts, {}, set()
|
| 164 |
)
|
|
@@ -170,7 +170,7 @@ class TestParameterTesting:
|
|
| 170 |
from server.tools_engine import execute_tool
|
| 171 |
scenario = get_scenario("easy")
|
| 172 |
hosts = list(scenario["hosts"].keys())
|
| 173 |
-
output, _, _, reward = execute_tool(
|
| 174 |
"test_injection", {"host": hosts[0], "endpoint": "/api/login"},
|
| 175 |
scenario, hosts, {}, set()
|
| 176 |
)
|
|
@@ -182,7 +182,7 @@ class TestParameterTesting:
|
|
| 182 |
from server.tools_engine import execute_tool
|
| 183 |
scenario = get_scenario("easy")
|
| 184 |
hosts = list(scenario["hosts"].keys())
|
| 185 |
-
output, _, _, reward = execute_tool(
|
| 186 |
"test_injection",
|
| 187 |
{"host": hosts[0], "endpoint": "/api/login", "parameter": "username"},
|
| 188 |
scenario, hosts, {}, set()
|
|
|
|
| 158 |
from server.tools_engine import execute_tool
|
| 159 |
scenario = get_scenario("easy")
|
| 160 |
hosts = list(scenario["hosts"].keys())
|
| 161 |
+
output, _, _, _, _ = execute_tool(
|
| 162 |
"web_crawl", {"host": hosts[0]},
|
| 163 |
scenario, hosts, {}, set()
|
| 164 |
)
|
|
|
|
| 170 |
from server.tools_engine import execute_tool
|
| 171 |
scenario = get_scenario("easy")
|
| 172 |
hosts = list(scenario["hosts"].keys())
|
| 173 |
+
output, _, _, reward, _ = execute_tool(
|
| 174 |
"test_injection", {"host": hosts[0], "endpoint": "/api/login"},
|
| 175 |
scenario, hosts, {}, set()
|
| 176 |
)
|
|
|
|
| 182 |
from server.tools_engine import execute_tool
|
| 183 |
scenario = get_scenario("easy")
|
| 184 |
hosts = list(scenario["hosts"].keys())
|
| 185 |
+
output, _, _, reward, _ = execute_tool(
|
| 186 |
"test_injection",
|
| 187 |
{"host": hosts[0], "endpoint": "/api/login", "parameter": "username"},
|
| 188 |
scenario, hosts, {}, set()
|