mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 12:22:37 +02:00
feat(safety): collect target-list files passed via -w/-l flags
Recon tools route their target list through a flag — `ffuf -w wordlist.txt`, `httpx -l hosts.txt`, `nuclei --list targets.txt` — not the `<` redirect the input-file collector already handled, so the reviewer kept blocking "probes every host listed in hosts.txt" because the list was never in the packet. Parse the value of the common list-file flags and collect it the same bounded way, alongside redirect inputs. The value is only read when it resolves to a workspace file, so a boolean `-l` (grep, wc) whose next token is not a file collects nothing and never makes the packet incomplete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,12 +82,13 @@ A command that runs code Strix cannot resolve to an inspectable script — an
|
||||
unrecognized interpreter, or an interpreter given no script — is blocked rather
|
||||
than reviewed against an empty evidence packet.
|
||||
|
||||
When a command reads a workspace data file through input redirection (for
|
||||
example a host list consumed by `while read … done < hosts.txt`), that file's
|
||||
contents are attached to the packet so the reviewer can check the entries —
|
||||
queried hosts, fuzz inputs — against scope instead of blocking because it can't
|
||||
see them. Only workspace-resident files are read; an oversize file is attached
|
||||
truncated. An authorized domain covers its subdomains.
|
||||
When a command reads a workspace data file — through input redirection
|
||||
(`while read … done < hosts.txt`) or a target-list flag (`ffuf -w words.txt`,
|
||||
`httpx -l hosts.txt`) — that file's contents are attached to the packet so the
|
||||
reviewer can check the entries, queried hosts or fuzz inputs, against scope
|
||||
instead of blocking because it can't see them. Only workspace-resident files are
|
||||
read; an oversize file is attached truncated. An authorized domain covers its
|
||||
subdomains.
|
||||
|
||||
Browser automation inside scripts is blocked in safety modes. Issue browser
|
||||
operations as individual raw `agent-browser` commands so each action can be
|
||||
|
||||
@@ -33,6 +33,24 @@ _SHELL_SEPARATOR_CHARS = frozenset(";&|\n\r")
|
||||
_REDIRECT_INPUT_RE = re.compile(
|
||||
r"""(?<![<])\d?<(?![<(])\s*(?P<file>"[^"]+"|'[^']+'|[^\s;|&<>()]+)""",
|
||||
)
|
||||
# Flags whose value is a file of targets a command reads — wordlists, host lists.
|
||||
# Recon tools (ffuf, httpx, nuclei, subfinder, dnsx, gobuster) route their input this
|
||||
# way rather than through a `<` redirect, so the same evidence must be collected. The
|
||||
# value is only read when it resolves to a workspace file, so a boolean `-l` (wc, grep)
|
||||
# whose next token is not a file collects nothing.
|
||||
_LIST_FILE_FLAGS = frozenset(
|
||||
{
|
||||
"-w",
|
||||
"-l",
|
||||
"-iL",
|
||||
"-list",
|
||||
"-wordlist",
|
||||
"--list",
|
||||
"--wordlist",
|
||||
"--input-file",
|
||||
"--input",
|
||||
}
|
||||
)
|
||||
_SCRIPT_SUFFIXES = (".py", ".sh", ".bash", ".js", ".mjs", ".rb", ".pl")
|
||||
# `awk` is intentionally absent: its program is an inline positional argument, not a
|
||||
# `-c`/script-file the entrypoint reader can resolve, so it belongs with the data tools.
|
||||
@@ -810,6 +828,9 @@ def parse_command(command: str) -> CommandPlan:
|
||||
executable = PurePosixPath(tokens[index]).name
|
||||
args = tokens[index + 1 :]
|
||||
plan.executable = executable
|
||||
for candidate in _list_flag_files(args):
|
||||
if candidate not in plan.input_files:
|
||||
plan.input_files.append(candidate)
|
||||
|
||||
if executable in _OPAQUE_WRAPPERS:
|
||||
plan.parse_error = (
|
||||
@@ -980,6 +1001,26 @@ def _redirect_input_files(command: str) -> list[str]:
|
||||
return files
|
||||
|
||||
|
||||
def _list_flag_files(args: list[str]) -> list[str]:
|
||||
"""Files named as the value of a target-list flag (`-w wordlist`, `-l hosts`)."""
|
||||
files: list[str] = []
|
||||
index = 0
|
||||
while index < len(args):
|
||||
name, separator, inline = args[index].partition("=")
|
||||
if name in _LIST_FILE_FLAGS:
|
||||
if separator:
|
||||
value = inline
|
||||
elif index + 1 < len(args):
|
||||
value = args[index + 1]
|
||||
index += 1
|
||||
else:
|
||||
value = ""
|
||||
if value and not value.startswith("-") and value not in files:
|
||||
files.append(value)
|
||||
index += 1
|
||||
return files
|
||||
|
||||
|
||||
async def _read_sandbox_file(session: Any, path: PurePosixPath, limit: int) -> bytes:
|
||||
stream = await session.read(Path(path.as_posix()))
|
||||
try:
|
||||
|
||||
@@ -916,3 +916,46 @@ def test_data_tools_reading_script_named_files_are_not_execution(command: str) -
|
||||
"""A read/transfer/text tool takes a script-named file as data, not as a program to
|
||||
run, so it must not trip the unresolved-execution guard."""
|
||||
assert parse_command(command).parse_error is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command", "expected"),
|
||||
[
|
||||
("ffuf -w /workspace/words.txt -u https://x/FUZZ", ["/workspace/words.txt"]),
|
||||
("httpx -l hosts.txt -sc -title", ["hosts.txt"]),
|
||||
("nuclei --list targets.txt -severity high", ["targets.txt"]),
|
||||
("ffuf -w=words.txt -u https://x/FUZZ", ["words.txt"]),
|
||||
("subfinder -d x -o out.txt", []), # -o is output, not a list input
|
||||
],
|
||||
)
|
||||
def test_list_flag_files_are_parsed(command: str, expected: list[str]) -> None:
|
||||
assert parse_command(command).input_files == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wordlist_flag_file_is_attached_for_scope_review() -> None:
|
||||
"""Recon tools route their target list through `-w`/`-l`, not a `<` redirect, so the
|
||||
same evidence must be collected for the reviewer to check it against scope."""
|
||||
bundle = await _compile(
|
||||
"ffuf -w /workspace/paths.txt -u https://api.fiuu.com/FUZZ -mc all",
|
||||
{"/workspace/paths.txt": "admin\napi\nlogin\n"},
|
||||
workdir="/workspace",
|
||||
)
|
||||
try:
|
||||
inputs = [a for a in bundle.packet["artifacts"] if a.get("role") == "input"]
|
||||
assert [a["path"] for a in inputs] == ["/workspace/paths.txt"]
|
||||
assert "admin" in inputs[0]["source"]
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_flag_value_that_is_not_a_workspace_file_collects_nothing() -> None:
|
||||
"""A boolean `-l` (grep, wc) whose next token is not a workspace file must not make
|
||||
the packet incomplete or attach anything."""
|
||||
bundle = await _compile("grep -l pattern /workspace/app.py", workdir="/workspace")
|
||||
try:
|
||||
assert [a for a in bundle.packet["artifacts"] if a.get("role") == "input"] == []
|
||||
assert bundle.deterministic_block is None
|
||||
finally:
|
||||
bundle.cleanup()
|
||||
|
||||
Reference in New Issue
Block a user