mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 09:26:39 +02:00
420 lines
16 KiB
Python
420 lines
16 KiB
Python
"""Command-line argument parsing for the ``strix`` scan entrypoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from strix.config import apply_config_override
|
|
from strix.config.settings import DEFAULT_MAX_TURNS
|
|
from strix.core.paths import run_dir_for, runtime_state_dir
|
|
from strix.interface.scan_setup import attach_workspace_mount, build_targets_info
|
|
from strix.interface.update_check import self_update
|
|
from strix.interface.utils import (
|
|
check_mountable_dir,
|
|
collect_local_sources,
|
|
resolve_workspace_files,
|
|
validate_config_file,
|
|
)
|
|
|
|
|
|
def get_version() -> str:
|
|
try:
|
|
from importlib.metadata import version
|
|
|
|
return version("strix-agent")
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
|
|
def _positive_budget(value: str) -> float:
|
|
try:
|
|
budget = float(value)
|
|
except ValueError as exc:
|
|
raise argparse.ArgumentTypeError(f"invalid float value: {value!r}") from exc
|
|
import math
|
|
|
|
if not math.isfinite(budget) or budget <= 0:
|
|
raise argparse.ArgumentTypeError("must be a finite number greater than 0")
|
|
return budget
|
|
|
|
|
|
def _positive_int(value: str) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except ValueError as exc:
|
|
raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc
|
|
if parsed <= 0:
|
|
raise argparse.ArgumentTypeError("must be an integer greater than 0")
|
|
return parsed
|
|
|
|
|
|
def parse_arguments() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Strix Multi-Agent Cybersecurity Penetration Testing Tool",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Web application penetration test
|
|
strix --target https://example.com
|
|
|
|
# GitHub repository analysis
|
|
strix --target https://github.com/user/repo
|
|
strix --target git@github.com:user/repo.git
|
|
|
|
# Local code analysis
|
|
strix --target ./my-project
|
|
|
|
# API spec test (OpenAPI/Swagger file or Postman collection export)
|
|
strix --target ./openapi.yaml --target https://api.example.com
|
|
strix --target ./collection.postman_collection.json
|
|
|
|
# Postman collection pulled live by id (needs POSTMAN_API_KEY); optional environment
|
|
strix --target postman://<collection-uuid> --target https://api.example.com
|
|
strix --target "postman://<collection-uuid>?env=<environment-uuid>"
|
|
|
|
# Domain penetration test
|
|
strix --target example.com
|
|
|
|
# IP address penetration test
|
|
strix --target 192.168.1.42
|
|
|
|
# Multiple targets (e.g., white-box testing with source and deployed app)
|
|
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"
|
|
|
|
# Custom instructions (from file)
|
|
strix --target example.com --instruction-file ./instructions.txt
|
|
strix --target https://app.com --instruction-file /path/to/detailed_instructions.md
|
|
|
|
# Extra files placed in the sandbox workspace
|
|
strix --target ./my-project --workspace-file ./wordlist.txt
|
|
strix --target https://app.com --workspace-file ./openapi.yaml:specs/openapi.yaml
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-v",
|
|
"--version",
|
|
action="version",
|
|
version=f"strix {get_version()}",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--update",
|
|
action="store_true",
|
|
help="Update strix to the latest version and exit. Self-updates the "
|
|
"standalone binary install; for pip/pipx/uv installs, prints the "
|
|
"matching upgrade command instead.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-t",
|
|
"--target",
|
|
type=str,
|
|
action="append",
|
|
help="Target to test: URL, repository, local directory path, domain name, IP address, "
|
|
"an API spec file (OpenAPI/Swagger .json/.yaml or a Postman collection export), or a "
|
|
"Postman collection by id (postman://<collection-uuid>[?env=<environment-uuid>], needs "
|
|
"POSTMAN_API_KEY). Local directories are mounted into the sandbox writable. "
|
|
"Can be specified multiple times for multi-target scans. "
|
|
"Fresh runs require --target or --target-list.",
|
|
)
|
|
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(
|
|
"--instruction",
|
|
type=str,
|
|
help="Custom instructions for the penetration test. This can be "
|
|
"specific vulnerability types to focus on (e.g., 'Focus on IDOR and XSS'), "
|
|
"testing approaches (e.g., 'Perform thorough authentication testing'), "
|
|
"test credentials (e.g., 'Use the following credentials to access the app: "
|
|
"admin:password123'), "
|
|
"or areas of interest (e.g., 'Check login API endpoint for security issues').",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--instruction-file",
|
|
type=str,
|
|
help="Path to a file containing detailed custom instructions for the penetration test. "
|
|
"Use this option when you have lengthy or complex instructions saved in a file "
|
|
"(e.g., '--instruction-file ./detailed_instructions.txt').",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--workspace-file",
|
|
type=str,
|
|
action="append",
|
|
metavar="PATH[:DEST]",
|
|
help="Place a file from this machine into the sandbox workspace before the scan "
|
|
"starts, for example a wordlist, an API specification, or notes. Repeat the option "
|
|
"for more files. DEST is the path inside /workspace and defaults to the file name "
|
|
"(for example '--workspace-file ./wordlist.txt:lists/wordlist.txt'). The file is "
|
|
"read-only inside the sandbox and lands outside every target directory.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-n",
|
|
"--non-interactive",
|
|
action="store_true",
|
|
help=(
|
|
"Run in non-interactive mode (no TUI, exits on completion). "
|
|
"Default is interactive mode with TUI."
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-m",
|
|
"--scan-mode",
|
|
type=str,
|
|
choices=["quick", "standard", "deep"],
|
|
default="deep",
|
|
help=(
|
|
"Scan mode: "
|
|
"'quick' for fast CI/CD checks, "
|
|
"'standard' for routine testing, "
|
|
"'deep' for thorough security reviews (default). "
|
|
"Default: deep."
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--scope-mode",
|
|
type=str,
|
|
choices=["auto", "diff", "full"],
|
|
default="auto",
|
|
help=(
|
|
"Scope mode for code targets: "
|
|
"'auto' enables PR diff-scope in CI/headless runs, "
|
|
"'diff' forces changed-files scope, "
|
|
"'full' disables diff-scope."
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--diff-base",
|
|
type=str,
|
|
help=(
|
|
"Target branch or commit to compare against (e.g., origin/main). "
|
|
"Defaults to the repository's default branch."
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--config",
|
|
type=str,
|
|
help="Path to a custom config file (JSON) to use instead of ~/.strix/cli-config.json",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--max-budget",
|
|
"--max-budget-usd",
|
|
dest="max_budget_usd",
|
|
metavar="USD",
|
|
type=_positive_budget,
|
|
default=None,
|
|
help=(
|
|
"Maximum LLM cost in USD (> 0). The scan stops cleanly when this limit is reached. "
|
|
"Graduated wrap-up warnings are sent to all agents as it is approached."
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--max-turns",
|
|
dest="max_turns",
|
|
metavar="N",
|
|
type=_positive_int,
|
|
default=DEFAULT_MAX_TURNS,
|
|
help=(
|
|
"Maximum turns per agent (> 0, default %(default)s). Each agent is force-stopped "
|
|
"when it reaches this limit, with graduated wrap-up warnings as it is approached."
|
|
),
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--resume",
|
|
type=str,
|
|
metavar="RUN_NAME",
|
|
help=(
|
|
"Resume a prior scan by its run name (the dir under ./strix_runs/). "
|
|
"Picks up the root + every non-terminal subagent's full LLM history "
|
|
"and agent topology. Skips fresh run-name generation."
|
|
),
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
# Startup-resolved state lives alongside the parsed flags. The full schema
|
|
# is established here so downstream code reads attributes directly.
|
|
args.needs_setup = False
|
|
args.targets_info = []
|
|
args.local_sources = []
|
|
args.diff_scope = {"active": False}
|
|
args.run_name = None
|
|
|
|
if args.config:
|
|
apply_config_override(validate_config_file(args.config))
|
|
|
|
if args.update:
|
|
sys.exit(0 if self_update() else 1)
|
|
|
|
if args.instruction and args.instruction_file:
|
|
parser.error(
|
|
"Cannot specify both --instruction and --instruction-file. Use one or the other."
|
|
)
|
|
|
|
if args.instruction_file:
|
|
instruction_path = Path(args.instruction_file)
|
|
try:
|
|
with instruction_path.open(encoding="utf-8") as f:
|
|
args.instruction = f.read().strip()
|
|
if not args.instruction:
|
|
parser.error(f"Instruction file '{instruction_path}' is empty")
|
|
except Exception as e:
|
|
parser.error(f"Failed to read instruction file '{instruction_path}': {e}")
|
|
|
|
try:
|
|
args.workspace_files = resolve_workspace_files(getattr(args, "workspace_file", None))
|
|
except ValueError as error:
|
|
parser.error(f"--workspace-file: {error}")
|
|
|
|
args.user_explicit_instruction = args.instruction if args.resume else None
|
|
# What the user actually asked for, kept apart from args.instruction because
|
|
# prepare_run prepends the diff-scope preamble to that. This is the text the
|
|
# transcript shows as their opening message.
|
|
args.user_instruction = args.instruction or None
|
|
|
|
if args.resume:
|
|
if args.target or args.target_list:
|
|
parser.error(
|
|
"Cannot combine --resume with --target/--target-list. "
|
|
"--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"
|
|
if not agents_path.exists():
|
|
parser.error(
|
|
f"--resume {args.resume}: missing {agents_path}. The run was "
|
|
f"persisted but never reached its first agent snapshot — "
|
|
f"there's nothing to resume from. Pick a fresh --run-name "
|
|
f"or remove --resume to start over with the same targets."
|
|
)
|
|
else:
|
|
if not args.target and not args.target_list:
|
|
if args.non_interactive:
|
|
parser.error(
|
|
"the following arguments are required: -t/--target or --target-list "
|
|
"(or use --resume <run_name> to continue a prior scan)"
|
|
)
|
|
# Interactive launch with no target: open the normal TUI on its
|
|
# start screen, where the user gives a target or a bare prompt
|
|
# before the scan starts.
|
|
args.needs_setup = True
|
|
return args
|
|
|
|
try:
|
|
build_targets_info(args)
|
|
except ValueError as e:
|
|
parser.error(str(e))
|
|
|
|
return args
|
|
|
|
|
|
def _load_resume_state(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
|
"""Populate ``args.targets_info`` and friends from a prior run's run.json."""
|
|
from strix.report.writer import read_run_record
|
|
|
|
run_dir = run_dir_for(args.resume)
|
|
state_path = run_dir / "run.json"
|
|
if not state_path.exists():
|
|
parser.error(
|
|
f"--resume {args.resume}: no such run "
|
|
f"(missing {state_path}; remove --resume for a fresh start)"
|
|
)
|
|
try:
|
|
state = read_run_record(run_dir)
|
|
except RuntimeError as exc:
|
|
parser.error(f"--resume {args.resume}: run.json unreadable: {exc}")
|
|
|
|
args.targets_info = state.get("targets_info") or []
|
|
# A target-less run has no targets_info at all. It is driven by its
|
|
# instruction, over a mounted working directory or over nothing when the
|
|
# mount was declined, so either of those is enough to resume it.
|
|
workspace_mount = state.get("workspace_mount") or None
|
|
if not args.targets_info and not workspace_mount and not state.get("user_instruction"):
|
|
parser.error(f"--resume {args.resume}: run.json has no targets_info")
|
|
|
|
for target in args.targets_info:
|
|
if not isinstance(target, dict):
|
|
continue
|
|
details = target.get("details") or {}
|
|
if target.get("type") == "local_code" and details.get("target_path"):
|
|
try:
|
|
check_mountable_dir(Path(details["target_path"]).expanduser())
|
|
except ValueError as exc:
|
|
parser.error(f"--resume {args.resume}: {exc}")
|
|
continue
|
|
if target.get("type") != "repository":
|
|
continue
|
|
cloned = details.get("cloned_repo_path")
|
|
if not cloned:
|
|
continue
|
|
if not Path(cloned).expanduser().exists():
|
|
parser.error(
|
|
f"--resume {args.resume}: cloned repo at {cloned} is missing. "
|
|
f"It was deleted between runs. Pick a fresh --run-name to "
|
|
f"re-clone, or restore the directory before resuming."
|
|
)
|
|
|
|
if args.instruction is None:
|
|
args.instruction = state.get("instruction")
|
|
if not getattr(args, "user_instruction", None):
|
|
args.user_instruction = state.get("user_instruction") or None
|
|
args.local_sources = collect_local_sources(args.targets_info)
|
|
# Remount the workspace the run was started with. The user already confirmed
|
|
# this directory, so the target mount guard does not apply to it; it only has
|
|
# to still be there.
|
|
args.workspace_mount = workspace_mount
|
|
|
|
# Replace the workspace files the run started with, unless this resume names
|
|
# its own. The persisted record is revalidated like a fresh flag, so an
|
|
# edited run.json cannot widen what a resume places. A file deleted between
|
|
# runs is dropped rather than fatal: it is context for the agent, not scope.
|
|
if not getattr(args, "workspace_files", None):
|
|
restored = [
|
|
f"{source_path}:{workspace_path}"
|
|
for workspace_file in state.get("workspace_files") or []
|
|
if isinstance(workspace_file, dict)
|
|
and (source_path := Path(str(workspace_file.get("source_path") or ""))).is_file()
|
|
and (workspace_path := str(workspace_file.get("workspace_path") or ""))
|
|
]
|
|
try:
|
|
args.workspace_files = resolve_workspace_files(restored)
|
|
except ValueError as error:
|
|
parser.error(f"--resume {args.resume}: invalid workspace file: {error}")
|
|
if workspace_mount:
|
|
if not Path(workspace_mount).expanduser().is_dir():
|
|
parser.error(
|
|
f"--resume {args.resume}: the working directory {workspace_mount} "
|
|
f"is missing. Restore it before resuming, or start a fresh run."
|
|
)
|
|
attach_workspace_mount(args)
|
|
if state.get("diff_scope"):
|
|
args.diff_scope = state.get("diff_scope")
|
|
persisted_scan_mode = state.get("scan_mode")
|
|
if persisted_scan_mode and args.scan_mode == "deep":
|
|
args.scan_mode = persisted_scan_mode
|