Add target list CLI option (#711)

* Add target list CLI option

* Handle target list comments and encoding errors
This commit is contained in:
alex s
2026-07-06 23:33:08 -04:00
committed by GitHub
parent 375fc9c3d0
commit 9f278b9a5c
7 changed files with 222 additions and 10 deletions
+3
View File
@@ -169,6 +169,9 @@ strix --target https://your-app.com --instruction "Perform authenticated testing
# Multi-target testing (source code + deployed app) # Multi-target testing (source code + deployed app)
strix -t https://github.com/org/app -t https://your-app.com strix -t https://github.com/org/app -t https://your-app.com
# Targets from a file, one target per non-empty, non-comment line
strix --target-list ./targets.txt
# White-box source-aware scan (local repository) # White-box source-aware scan (local repository)
strix --target ./app-directory --scan-mode standard strix --target ./app-directory --scan-mode standard
+3
View File
@@ -62,6 +62,9 @@ strix --target https://your-app.com
# Multiple targets (white-box testing) # Multiple targets (white-box testing)
strix -t https://github.com/org/repo -t https://your-app.com strix -t https://github.com/org/repo -t https://your-app.com
# Targets from a file, one target per non-empty, non-comment line
strix --target-list ./targets.txt
``` ```
## Next Steps ## Next Steps
+10 -3
View File
@@ -6,13 +6,17 @@ description: "Command-line options for Strix"
## Basic Usage ## Basic Usage
```bash ```bash
strix --target <target> [options] strix (--target <target> | --target-list <path> | --mount <path>) [options]
``` ```
## Options ## Options
<ParamField path="--target, -t" type="string" required> <ParamField path="--target, -t" type="string">
Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Target to test. Accepts URLs, repositories, local directories, domains, or IP addresses. Can be specified multiple times. Fresh runs require at least one target source: `--target`, `--target-list`, or `--mount`.
</ParamField>
<ParamField path="--target-list" type="string">
Path to a file containing targets, one per non-empty, non-comment line. Lines starting with `#` are ignored. Can be specified multiple times and combined with `--target`.
</ParamField> </ParamField>
<ParamField path="--mount" type="string"> <ParamField path="--mount" type="string">
@@ -101,6 +105,9 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main
# Multi-target white-box testing # Multi-target white-box testing
strix -t https://github.com/org/app -t https://staging.example.com strix -t https://github.com/org/app -t https://staging.example.com
# Targets from a file
strix --target-list ./targets.txt
# Large local repository — bind-mount instead of copying it in # Large local repository — bind-mount instead of copying it in
strix --mount ./huge-monorepo strix --mount ./huge-monorepo
``` ```
+27 -7
View File
@@ -44,6 +44,7 @@ from strix.interface.utils import (
infer_target_type, infer_target_type,
is_whitebox_scan, is_whitebox_scan,
process_pull_line, process_pull_line,
read_target_list_file,
resolve_diff_scope_context, resolve_diff_scope_context,
rewrite_localhost_targets, rewrite_localhost_targets,
validate_config_file, validate_config_file,
@@ -344,6 +345,9 @@ Examples:
strix --target https://github.com/user/repo --target https://example.com strix --target https://github.com/user/repo --target https://example.com
strix --target ./my-project --target https://staging.example.com --target https://prod.example.com strix --target ./my-project --target https://staging.example.com --target https://prod.example.com
# Targets from a file, one target per non-empty, non-comment line
strix --target-list ./targets.txt
# Custom instructions (inline) # Custom instructions (inline)
strix --target example.com --instruction "Focus on authentication vulnerabilities" strix --target example.com --instruction "Focus on authentication vulnerabilities"
@@ -367,7 +371,15 @@ Examples:
action="append", action="append",
help="Target to test (URL, repository, local directory path, domain name, or IP address). " help="Target to test (URL, repository, local directory path, domain name, or IP address). "
"Can be specified multiple times for multi-target scans. " "Can be specified multiple times for multi-target scans. "
"Required for fresh runs; loaded from disk when ``--resume`` is set.", "Fresh runs require at least one of --target, --target-list, or --mount.",
)
parser.add_argument(
"--target-list",
type=str,
action="append",
metavar="PATH",
help="Path to a file containing targets, one per non-empty, non-comment line. "
"Can be specified multiple times and combined with --target.",
) )
parser.add_argument( parser.add_argument(
"--mount", "--mount",
@@ -488,10 +500,11 @@ Examples:
args.user_explicit_instruction = args.instruction if args.resume else None args.user_explicit_instruction = args.instruction if args.resume else None
if args.resume: if args.resume:
if args.target or args.mount: if args.target or args.target_list or args.mount:
parser.error( parser.error(
"Cannot combine --resume with --target/--mount. --resume picks up where " "Cannot combine --resume with --target/--target-list/--mount. "
"the prior run left off, including the original target list." "--resume picks up where the prior run left off, including the "
"original target list."
) )
_load_resume_state(args, parser) _load_resume_state(args, parser)
agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json" agents_path = runtime_state_dir(run_dir_for(args.resume)) / "agents.json"
@@ -503,13 +516,20 @@ Examples:
f"or remove --resume to start over with the same targets." f"or remove --resume to start over with the same targets."
) )
else: else:
if not args.target and not args.mount: if not args.target and not args.target_list and not args.mount:
parser.error( parser.error(
"the following arguments are required: -t/--target or --mount " "the following arguments are required: -t/--target, --target-list, or --mount "
"(or use --resume <run_name> to continue a prior scan)" "(or use --resume <run_name> to continue a prior scan)"
) )
args.targets_info = [] args.targets_info = []
for target in args.target or []: targets = list(args.target or [])
for target_list_path in args.target_list or []:
try:
targets.extend(read_target_list_file(target_list_path))
except ValueError as e:
parser.error(str(e))
for target in targets:
try: try:
target_type, target_dict = infer_target_type(target) target_type, target_dict = infer_target_type(target)
+28
View File
@@ -1131,6 +1131,34 @@ def infer_target_type(target: str) -> tuple[str, dict[str, str]]: # noqa: PLR09
) )
def read_target_list_file(path_str: str) -> list[str]:
"""Read scan targets from a file, one target per non-empty, non-comment line."""
if not path_str or not path_str.strip():
raise ValueError("--target-list path must not be empty.")
path = Path(path_str).expanduser()
if not path.is_file():
raise ValueError(f"Target list file '{path_str}' is not an existing file.")
try:
targets = [
target
for line in path.read_text(encoding="utf-8").splitlines()
if (target := line.strip()) and not target.startswith("#")
]
except UnicodeDecodeError as e:
raise ValueError(
f"Target list file '{path_str}' must be valid UTF-8 text: {e!s}"
) from e
except OSError as e:
raise ValueError(f"Failed to read target list file '{path_str}': {e!s}") from e
targets = [target for target in targets if target]
if not targets:
raise ValueError(f"Target list file '{path_str}' is empty.")
return targets
def sanitize_name(name: str) -> str: def sanitize_name(name: str) -> str:
sanitized = re.sub(r"[^A-Za-z0-9._-]", "-", name.strip()) sanitized = re.sub(r"[^A-Za-z0-9._-]", "-", name.strip())
return sanitized or "target" return sanitized or "target"
+90
View File
@@ -0,0 +1,90 @@
"""Tests for CLI target-list argument parsing."""
from __future__ import annotations
import importlib
import sys
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
from pathlib import Path
cli_main: Any = importlib.import_module("strix.interface.main")
def _stub_settings(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
cli_main,
"load_settings",
lambda: SimpleNamespace(runtime=SimpleNamespace(max_local_copy_mb=1024)),
)
def test_parse_arguments_accepts_target_list_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(
"https://test1.com/\n"
"\n"
"http://test2.com:5789/\n",
encoding="utf-8",
)
_stub_settings(monkeypatch)
monkeypatch.setattr(sys, "argv", ["strix", "--target-list", str(target_list), "-n"])
args = cli_main.parse_arguments()
assert [target["original"] for target in args.targets_info] == [
"https://test1.com/",
"http://test2.com:5789/",
]
assert [target["type"] for target in args.targets_info] == [
"web_application",
"web_application",
]
def test_parse_arguments_combines_target_and_target_list(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text("http://test2.com:5789/\n", encoding="utf-8")
_stub_settings(monkeypatch)
monkeypatch.setattr(
sys,
"argv",
["strix", "-t", "https://test1.com/", "--target-list", str(target_list)],
)
args = cli_main.parse_arguments()
assert [target["original"] for target in args.targets_info] == [
"https://test1.com/",
"http://test2.com:5789/",
]
def test_parse_arguments_rejects_resume_with_target_list(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text("https://test1.com/\n", encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
["strix", "--resume", "old-run", "--target-list", str(target_list)],
)
with pytest.raises(SystemExit):
cli_main.parse_arguments()
assert (
"Cannot combine --resume with --target/--target-list/--mount"
in capsys.readouterr().err
)
+61
View File
@@ -19,6 +19,7 @@ from strix.interface.utils import (
dedupe_local_targets, dedupe_local_targets,
directory_size_bytes, directory_size_bytes,
find_oversized_local_targets, find_oversized_local_targets,
read_target_list_file,
) )
@@ -157,6 +158,66 @@ def test_build_mount_targets_info_rejects_empty_path(empty: str) -> None:
build_mount_targets_info([empty]) build_mount_targets_info([empty])
def test_read_target_list_file_strips_blank_lines(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(
"\n"
" https://test1.com/ \n"
"\n"
"http://test2.com:5789/\n"
" \n",
encoding="utf-8",
)
assert read_target_list_file(str(target_list)) == [
"https://test1.com/",
"http://test2.com:5789/",
]
def test_read_target_list_file_ignores_comment_lines(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(
"# production targets\n"
"https://test1.com/\n"
" # staging targets\n"
"http://test2.com:5789/\n",
encoding="utf-8",
)
assert read_target_list_file(str(target_list)) == [
"https://test1.com/",
"http://test2.com:5789/",
]
def test_read_target_list_file_rejects_empty_file(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_text(" \n# no targets yet\n\n", encoding="utf-8")
with pytest.raises(ValueError, match="is empty"):
read_target_list_file(str(target_list))
def test_read_target_list_file_rejects_missing_path(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="not an existing file"):
read_target_list_file(str(tmp_path / "missing.txt"))
def test_read_target_list_file_rejects_non_utf8_file(tmp_path: Path) -> None:
target_list = tmp_path / "targets.txt"
target_list.write_bytes(b"https://test1.com/\xff\n")
with pytest.raises(ValueError, match="must be valid UTF-8 text"):
read_target_list_file(str(target_list))
@pytest.mark.parametrize("empty", ["", " "])
def test_read_target_list_file_rejects_empty_path(empty: str) -> None:
with pytest.raises(ValueError, match="must not be empty"):
read_target_list_file(empty)
def test_dedupe_keeps_distinct_targets_in_order() -> None: def test_dedupe_keeps_distinct_targets_in_order() -> None:
targets = [ targets = [
_local_target("/a"), _local_target("/a"),