Add target list CLI option (#711)
* Add target list CLI option * Handle target list comments and encoding errors
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user