diff --git a/README.md b/README.md index d3033da..390d04d 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,9 @@ strix --target https://your-app.com --instruction "Perform authenticated testing # Multi-target testing (source code + deployed app) 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) strix --target ./app-directory --scan-mode standard diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 681bf02..ea9ddd8 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -62,6 +62,9 @@ strix --target https://your-app.com # Multiple targets (white-box testing) 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 diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index 0827046..68f7d5e 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -6,13 +6,17 @@ description: "Command-line options for Strix" ## Basic Usage ```bash -strix --target [options] +strix (--target | --target-list | --mount ) [options] ``` ## Options - - 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`. + + + + 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`. @@ -101,6 +105,9 @@ strix -n --target ./ --scan-mode quick --scope-mode diff --diff-base origin/main # Multi-target white-box testing 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 strix --mount ./huge-monorepo ``` diff --git a/strix/interface/main.py b/strix/interface/main.py index 25df618..15301ba 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -44,6 +44,7 @@ from strix.interface.utils import ( infer_target_type, is_whitebox_scan, process_pull_line, + read_target_list_file, resolve_diff_scope_context, rewrite_localhost_targets, validate_config_file, @@ -344,6 +345,9 @@ Examples: 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 + # Targets from a file, one target per non-empty, non-comment line + strix --target-list ./targets.txt + # Custom instructions (inline) strix --target example.com --instruction "Focus on authentication vulnerabilities" @@ -367,7 +371,15 @@ Examples: action="append", help="Target to test (URL, repository, local directory path, domain name, or IP address). " "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( "--mount", @@ -488,10 +500,11 @@ Examples: args.user_explicit_instruction = args.instruction if args.resume else None if args.resume: - if args.target or args.mount: + if args.target or args.target_list or args.mount: parser.error( - "Cannot combine --resume with --target/--mount. --resume picks up where " - "the prior run left off, including the original target list." + "Cannot combine --resume with --target/--target-list/--mount. " + "--resume picks up where the prior run left off, including the " + "original target list." ) _load_resume_state(args, parser) 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." ) else: - if not args.target and not args.mount: + if not args.target and not args.target_list and not args.mount: 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 to continue a prior scan)" ) 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: target_type, target_dict = infer_target_type(target) diff --git a/strix/interface/utils.py b/strix/interface/utils.py index bffc0d4..27c10a4 100644 --- a/strix/interface/utils.py +++ b/strix/interface/utils.py @@ -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: sanitized = re.sub(r"[^A-Za-z0-9._-]", "-", name.strip()) return sanitized or "target" diff --git a/tests/test_cli_target_list.py b/tests/test_cli_target_list.py new file mode 100644 index 0000000..17b289d --- /dev/null +++ b/tests/test_cli_target_list.py @@ -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 + ) diff --git a/tests/test_local_sources.py b/tests/test_local_sources.py index bd3448d..22ed4b9 100644 --- a/tests/test_local_sources.py +++ b/tests/test_local_sources.py @@ -19,6 +19,7 @@ from strix.interface.utils import ( dedupe_local_targets, directory_size_bytes, 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]) +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: targets = [ _local_target("/a"),