#110

Security: PentestAgent's workspace scope gate is fail-open when the `.active` marker is left empty, letting scoped sessions reach out-of-scope targets

OpenEddieMurphy89opened 10d ago

Note: GitHub private vulnerability reporting is disabled for this repository and no security contact is listed, so this is being reported as a public issue.

Summary

PentestAgent stores an engagement's authorized targets in the workspace (meta.yaml) and checks every tool call whose arguments name a target against that list (pentestagent/agents/base_agent.py:613-642). The check is fail-open: it only runs when WorkspaceManager.get_active() returns a workspace name. set_active() writes .active with marker.write_text(name) (pentestagent/workspaces/manager.py:144), which truncates the file before writing it, so an interrupted write can leave an existing but empty .active. get_active() then returns "" (manager.py:183-187), if active: is false, and the entire scope check is skipped: the tool call proceeds with an out-of-scope target exactly as if no workspace had ever been configured. There is no error or warning from the gate; the empty marker is simply treated as "no active workspace".

Affected versions

Confirmed on commit 8dde193e (main, 2026-10-26; pyproject.toml version 0.2.0). The repository has no release tags, so I cannot assert a version range.

Details

Affected components:

  • pentestagent/workspaces/manager.py:183-187 — get_active() returns "" both when the marker does not exist and when it exists but is empty/whitespace.
  • pentestagent/workspaces/manager.py:140-144 — set_active() calls marker.write_text(name, encoding="utf-8"), which truncates first: an interrupted write can leave an empty .active.
  • pentestagent/workspaces/manager.py:120-133 — by contrast, _read_meta() raises WorkspaceError on a YAML parse error; the codebase already treats a malformed security state file as an error there.
  • pentestagent/agents/base_agent.py:613-642 — the gate: active = wm.get_active(); if active, allowed = wm.list_targets(active); candidates from validation.gather_candidate_targets(); if active and out_of_scope: block; the else branch at line 643 proceeds to tool.execute(...) at line 689.
  • pentestagent/workspaces/validation.py:53-119 — is_target_in_scope() is fail-closed (returns False on normalization errors, exceptions, and an empty allow-list); the defect is that it is not called at all when active is empty.

Root cause: the scope state is fail-open because it has no "unknown" value. "Never configured" and "state lost" produce the same "", and the default that follows (if active: false) is the permissive one, while the enforcement mode that was in force is dropped without an error or a warning.

Authorization impact: in a session that was scoped by a workspace, this is an authorization bypass (CWE-863), not a missing input filter. The only basis for allowing a target-bearing call is a successful scope decision; with an empty marker that decision is never made, yet the call executes with the operator's authority. A downstream tool cannot recover the authorization the gate failed to enforce.

Scope note (to avoid overlap with the existing public report): gather_candidate_targets() only inspects the keys target, host, hostname, ip, address, url, hosts, targets, so terminal calls (which only carry command) are not covered at all — that is the separate, already-public issue [#107](https://github.com/GH05TCREW/pentestagent/issues/107). This report is about the calls whose arguments the gate inspects (the browser tool's url, and MCP/custom tools whose schema names a target), whose enforcement silently disappears when the marker is empty. Fixing #107 does not fix this, and fixing this does not fix #107. The PoC uses terminal only as a convenient executor (it ignores the extra target key) and then repeats the check with a target-bearing tool, which is the class this report is about.

Attack path (conceptual): an operator sets a workspace with authorized targets; .active is truncated by an interrupted write (or cleared by a script/disk error); a later run starts with an empty marker; the agent's tool call names an out-of-scope target in a url/target argument; get_active() returns ""; the gate is skipped and the call executes.

Affected deployments: TUI/CLI sessions, and long-running mcp_server or containerized deployments started in a workspace-active directory, where an interrupted state write is plausible. The gate is only relevant for tools whose arguments carry a target.

Proof of concept

Drive the shipped BaseAgent tool-execution path (_execute_tools() -> _execute_single()) at commit 8dde193e. The workspace manager, validation, gate, and shipped terminal tool are used as-is; the Runtime transport is local (production uses DockerRuntime or a socket ToolServer), and step 3 adds a minimal target-bearing tool to model the MCP/custom-tool case.

  1. Create workspace acme, add target 10.0.0.0/24, activate it; call the terminal tool with {"command": "touch <temp-dir>/marker_control", "target": "10.9.9.9"} (structured target argument, the shape the gate inspects). get_active() is acme, list_targets() is ["10.0.0.0/24"], 10.9.9.9 is out of scope, the call is refused with "Out-of-scope target(s) … operator confirmation required", the Runtime receives no command, and no marker exists (control).
  2. Truncate workspaces/.active to an empty file (an interrupted write_text), then repeat the same call. get_active() is "", if active: is false, the terminal tool runs, the Runtime receives the command, and the marker exists (fault).
  3. Repeat control and fault with a minimal target-bearing tool using the project's Tool/ToolSchema registry (schema declares target; the tool body writes the received target to a marker, as an MCP/custom tool would act on it). Control: the product returns the same out-of-scope error before the tool runs. Fault: the tool is invoked and its marker contains 10.9.9.9, so the out-of-scope value reaches the tool that uses it.
  4. Expected difference: control = refused before the tool runs, no marker; fault = tool runs, marker present (and contains 10.9.9.9 for the target-bearing tool). Within each pair, only the content of .active differs.

Impact

For the tool calls the scope gate covers, a single empty state file removes the engagement's authorization boundary: the tool is invoked with a target the operator never authorized, silently. For a penetration-testing tool that boundary is the legal/contractual authorization, so the consequence is an out-of-scope action rather than a lost feature. Trigger requires a local .active that is empty; the impact is the out-of-scope action, not a new code-execution primitive.

Suggested severity

CWE-636 (Not Failing Securely) and CWE-863 (Incorrect Authorization). CVSS 3.1: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:L/I:L/A:L = 5.3 (Medium).

Remediation

  • Make get_active() tri-state: absent marker means "no workspace"; a present-but-empty or unreadable marker is an error that fails closed for calls carrying a candidate target.
  • Write .active atomically (tmp + os.replace), as the rest of the state handling expects.
  • Do not silently treat an unresolvable workspace name as "no workspace".
  • Separately, cover terminal command text (issue #107) or document explicitly that the scope gate only covers structured target arguments.

References

Activity

0

No activity yet.