diff --git a/.github/workflows/e2e_aws_splunk_windows.yml b/.github/workflows/e2e_aws_splunk_windows.yml index 6d398e3f..7b6e9c29 100644 --- a/.github/workflows/e2e_aws_splunk_windows.yml +++ b/.github/workflows/e2e_aws_splunk_windows.yml @@ -12,6 +12,7 @@ concurrency: jobs: e2e_splunk_windows: runs-on: ubuntu-latest + # Reserve time after the test step for destroy cleanup (see step timeouts below). timeout-minutes: 60 steps: @@ -50,6 +51,7 @@ jobs: pip install pytest "moto[s3]" - name: Run E2E test (build, verify, destroy) + timeout-minutes: 45 env: ATTACK_RANGE_E2E: "1" ATTACK_RANGE_CI: "1" @@ -57,6 +59,32 @@ jobs: run: | pytest tests/e2e/test_splunk_windows_aws.py -v -s --log-cli-level=INFO + - name: Destroy attack range (cleanup) + if: always() + timeout-minutes: 10 + env: + ATTACK_RANGE_CI: "1" + run: | + shopt -s nullglob + configs=(config/*.yml) + if [ ${#configs[@]} -eq 0 ]; then + echo "No config files to destroy." + exit 0 + fi + for cfg in "${configs[@]}"; do + echo "Destroying attack range from ${cfg}" + python attack_range.py destroy --config "${cfg}" || \ + echo "Destroy failed for ${cfg}; fallback job will retry." + done + + - name: Upload config for destroy fallback + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-attack-range-config + path: config/ + if-no-files-found: ignore + - name: Disconnect WireGuard (cleanup) if: always() run: | @@ -64,3 +92,69 @@ jobs: if [ -f "$CONF" ]; then sudo wg-quick down "$CONF" 2>/dev/null || true fi + + destroy_e2e_splunk_windows: + needs: e2e_splunk_windows + if: always() + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Install system packages + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends unzip curl + + - name: Install Terraform + run: | + TERRAFORM_VERSION=1.14.4 + curl -s "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" -o /tmp/terraform.zip + unzip -o /tmp/terraform.zip -d /tmp + sudo mv /tmp/terraform /usr/local/bin/ + rm /tmp/terraform.zip + terraform version + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + architecture: 'x64' + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v5 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-2 + + - name: Install Python dependencies + run: pip install -r requirements.txt + + - name: Download E2E config artifact + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: e2e-attack-range-config + path: config + + - name: Destroy attack range (timeout fallback) + env: + ATTACK_RANGE_CI: "1" + run: | + shopt -s nullglob + configs=(config/*.yml) + if [ ${#configs[@]} -eq 0 ]; then + echo "No config files to destroy (test likely cleaned up successfully)." + exit 0 + fi + failed=0 + for cfg in "${configs[@]}"; do + echo "Destroying attack range from ${cfg}" + if ! python attack_range.py destroy --config "${cfg}"; then + echo "Destroy failed for ${cfg} (manual cleanup may be required)" + failed=1 + fi + done + exit "${failed}" diff --git a/README.md b/README.md index 419e4f15..e6036774 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The Splunk Attack Range builds instrumented cloud environments (AWS, Azure, GCP) docker compose --profile cli -f docker/docker-compose.yml run --rm attack_range build -t aws/splunk_minimal_aws ``` - Other actions: `destroy`, `simulate`, `share`. See [Detailed documentation](https://attack-range.readthedocs.io/en/latest/) for CLI usage and flags. + Other actions: `destroy`, `simulate`, `apply-role`, `share`. See [Detailed documentation](https://attack-range.readthedocs.io/en/latest/) for CLI usage and flags. --- @@ -60,7 +60,7 @@ The Splunk Attack Range builds instrumented cloud environments (AWS, Azure, GCP) | **Docker Compose** (recommended) | Run API + web app + optional CLI with one `docker compose`; no local Python/Ansible/Terraform. | | **Web app** | Build, destroy, simulate, and share via the UI at port 4321. | | **REST API** | Automate from scripts or CI; full OpenAPI docs at `/openapi/swagger`. | -| **CLI** | `attack_range.py build | destroy | simulate | share` for terminal-based workflows. | +| **CLI** | `attack_range.py build | destroy | simulate | apply-role | share` for terminal-based workflows. | --- diff --git a/api/README.md b/api/README.md index d4db42dd..8ae3c0a6 100644 --- a/api/README.md +++ b/api/README.md @@ -178,6 +178,46 @@ Get the status of a build or destroy operation by attack_range_id. - `running`: Attack range is fully deployed and running - `error`: Operation failed (includes `error`, `error_phase`, and `traceback` fields) +#### `POST /attack-range/apply-role` + +Stage and execute local Ansible roles against a target server in a running attack range. This is a **synchronous** operation. The attack range must be in `running` or `completed` status. + +Each role is provided as a **base64-encoded gzip tarball** of the role root directory (`tasks/`, `meta/`, etc.). Package a role with: + +```bash +tar czf my_role.tar.gz -C /path/to/parent my_role +ROLE_B64=$(base64 -i my_role.tar.gz | tr -d '\n') +``` + +**Request Body:** +```json +{ + "attack_range_id": "550e8400-e29b-41d4-a716-446655440000", + "target": "splunk", + "roles": [ + { + "content_base64": "", + "name": "optional.namespace.role_name", + "vars": { + "example_var": "value" + } + } + ] +} +``` + +**Response (200 OK):** +```json +{ + "status": "success", + "message": "Successfully applied 1 role(s) on splunk", + "attack_range_id": "550e8400-e29b-41d4-a716-446655440000", + "target": "splunk", + "roles_applied": ["custom.my_role"], + "execution_output": null +} +``` + ### Template Management #### `GET /templates` diff --git a/api/app.py b/api/app.py index dad6f5e5..f7678145 100644 --- a/api/app.py +++ b/api/app.py @@ -48,6 +48,8 @@ ProviderCheckResponse, SimulateRequest, SimulateResponse, + ApplyRoleRequest, + ApplyRoleResponse, SplunkExportRequest, SplunkExportResponse, ShareRequest, @@ -439,12 +441,33 @@ def _wait_abort_then_destroy(attack_range_id: str, config_path: str) -> None: pass +def _set_terraform_running(attack_range_id: str, running: bool) -> None: + """Track whether Terraform is currently provisioning infrastructure for a build.""" + with operations_lock: + op = running_operations.get(attack_range_id) + if op is not None: + op["terraform_running"] = running + + +def _is_terraform_running(attack_range_id: str) -> bool: + with operations_lock: + op = running_operations.get(attack_range_id) + return bool(op and op.get("terraform_running")) + + +def _is_abort_allowed(attack_range_id: str, status: str) -> bool: + build_statuses = ("queued", "build_vpn", "build_lab") + return status in build_statuses and not _is_terraform_running(attack_range_id) + + def _check_abort_and_set_aborted(attack_range_id: str, config_path: Optional[str] = None) -> bool: """If abort_requested is set for this attack_range_id, set status to aborted and return True. Else return False.""" with operations_lock: op = running_operations.get(attack_range_id) if not op or not op.get("abort_requested"): return False + if op.get("terraform_running"): + return False running_operations[attack_range_id]["status"] = "aborted" running_operations[attack_range_id]["end_time"] = datetime.now().isoformat() path = config_path or op.get("config_path") or get_config_path_from_attack_range_id(attack_range_id) @@ -474,7 +497,11 @@ def run_build_vpn_phase(config: Dict[str, Any], config_path: str, attack_range_i controller = AttackRangeController(config, config_path=config_path) # Build VPN phase (handles all steps including status updates; checks abort between steps) - router_public_ip, wireguard_config = controller.build_vpn_phase(attack_range_id, abort_check=lambda: _check_abort_and_set_aborted(attack_range_id, config_path)) + router_public_ip, wireguard_config = controller.build_vpn_phase( + attack_range_id, + abort_check=lambda: _check_abort_and_set_aborted(attack_range_id, config_path), + terraform_running_callback=lambda running: _set_terraform_running(attack_range_id, running), + ) wireguard_config_path = os.path.join(WIREGUARD_CONFIG_DIR, f"{attack_range_id}.conf") @@ -822,6 +849,11 @@ def destroy_attack_range(body: DestroyRequest): status = config_for_status.get("general", {}).get("status") or "" if status in build_statuses: + if _is_terraform_running(attack_range_id): + return jsonify(ErrorResponse( + message="Cannot destroy while Terraform is provisioning infrastructure", + details="Wait for Terraform to finish, then retry destroy or abort" + ).model_dump()), 400 with operations_lock: op = running_operations.get(attack_range_id) if op: @@ -886,7 +918,7 @@ def destroy_attack_range(body: DestroyRequest): tags=[attack_range_tag], responses={200: DestroyResponse, 400: ErrorResponse, 404: ErrorResponse, 500: ErrorResponse}, summary="Abort attack range build", - description="Abort a build operation in progress. Sets status to 'aborted'." + description="Abort a build operation in progress. Sets status to 'aborted'. Not allowed while Terraform is provisioning infrastructure." ) def abort_attack_range(body: DestroyRequest): """Abort a build operation by setting abort_requested flag and status to aborted.""" @@ -921,6 +953,12 @@ def abort_attack_range(body: DestroyRequest): details="Abort can only be called during build (queued, build_vpn, build_lab)" ).model_dump()), 400 + if _is_terraform_running(attack_range_id): + return jsonify(ErrorResponse( + message="Cannot abort while Terraform is provisioning infrastructure", + details="Abort is disabled during Terraform init/apply to avoid leaving broken cloud resources. Retry after provisioning completes." + ).model_dump()), 400 + # Set abort_requested flag with operations_lock: op = running_operations.get(attack_range_id) @@ -1025,6 +1063,9 @@ def get_attack_range_status(path: AttackRangeIdPath): if "result" in operation and "config_file" not in operation["result"]: operation["result"]["config_file"] = config_path + build_statuses = ("queued", "build_vpn", "build_lab") + operation["abort_allowed"] = operation.get("status") in build_statuses and not operation.get("terraform_running", False) + # Validate and return as OperationStatusResponse return jsonify(OperationStatusResponse(**operation).model_dump()), 200 @@ -1527,6 +1568,98 @@ def simulate_attack_range(body: SimulateRequest): ).model_dump()), 500 +@app.post( + "/attack-range/apply-role", + tags=[attack_range_tag], + responses={200: ApplyRoleResponse, 400: ErrorResponse, 404: ErrorResponse, 500: ErrorResponse}, + summary="Apply local Ansible roles", + description=( + "Stage and execute local Ansible roles against a target server in a running attack range. " + "Each role is provided as a base64-encoded gzip tarball of the role root. " + "This is a synchronous operation." + ), +) +def apply_role_attack_range(body: ApplyRoleRequest): + """Stage and execute local Ansible roles on a target server.""" + try: + config_path = os.path.join(CONFIG_DIR, f"{body.attack_range_id}.yml") + if not os.path.exists(config_path): + return jsonify(ErrorResponse( + message=f"Attack range with ID '{body.attack_range_id}' not found", + details=f"Config file not found: {config_path}", + ).model_dump()), 404 + + config = load_yaml_file(config_path) + if not config: + return jsonify(ErrorResponse( + message=f"Failed to load config for attack range '{body.attack_range_id}'", + ).model_dump()), 500 + + status = config.get("general", {}).get("status", "") + if status not in ["running", "completed"]: + return jsonify(ErrorResponse( + message=( + f"Cannot apply roles. Attack range status is '{status}'. " + "Must be 'running' or 'completed'." + ), + ).model_dump()), 400 + + attack_range_config = config.get("attack_range", []) + target_found = any(server.get("name") == body.target for server in attack_range_config) + if not target_found: + available_servers = [s.get("name") for s in attack_range_config if s.get("name")] + return jsonify(ErrorResponse( + message=f"Target server '{body.target}' not found in attack range configuration", + details=f"Available servers: {', '.join(available_servers) if available_servers else 'None'}", + ).model_dump()), 400 + + controller = AttackRangeController(config, config_path=config_path) + try: + roles_payload = [ + { + "content_base64": role.content_base64, + "name": role.name, + "vars": role.vars, + } + for role in body.roles + ] + result = controller.apply_role(body.target, roles_payload) + except ValueError as e: + return jsonify(ErrorResponse( + message="Apply role validation failed", + details=str(e), + ).model_dump()), 400 + except RuntimeError as e: + return jsonify(ErrorResponse( + message="Apply role execution failed", + details=str(e), + ).model_dump()), 500 + + roles_applied = result.get("roles_applied", []) + return jsonify(ApplyRoleResponse( + status="success", + message=( + f"Successfully applied {len(roles_applied)} role(s) on {body.target}" + ), + attack_range_id=body.attack_range_id, + target=body.target, + roles_applied=roles_applied, + execution_output=result.get("execution_output"), + ).model_dump()), 200 + + except ValidationError as e: + return jsonify(ErrorResponse( + message="Invalid apply-role request", + details=str(e), + ).model_dump()), 400 + except Exception as e: + error_traceback = traceback.format_exc() + return jsonify(ErrorResponse( + message="Failed to apply roles", + details=f"{str(e)}\n\n{error_traceback}", + ).model_dump()), 500 + + @app.post( "/attack-range/splunk/export", tags=[attack_range_tag], diff --git a/api/models.py b/api/models.py index f1709360..5201b473 100644 --- a/api/models.py +++ b/api/models.py @@ -84,6 +84,8 @@ class OperationStatusResponse(BaseModel): error: Optional[str] = Field(None, description="Error message (for failed operations)") error_phase: Optional[str] = Field(None, description="Build phase where error occurred") traceback: Optional[str] = Field(None, description="Error traceback (for failed operations)") + terraform_running: Optional[bool] = Field(None, description="True while Terraform init/apply is in progress") + abort_allowed: Optional[bool] = Field(None, description="True when abort is permitted for the current build state") class ServerInfo(BaseModel): @@ -309,6 +311,60 @@ class SimulateResponse(BaseModel): ) +class LocalRoleTarget(BaseModel): + """Local Ansible role packaged as a base64-encoded gzip tarball.""" + content_base64: str = Field( + ..., + description="Base64-encoded gzip tarball of the role root (tasks/, meta/, etc.)", + ) + name: Optional[str] = Field( + None, + description="Optional Ansible role name override (defaults to meta/main.yml or directory name)", + ) + vars: Dict[str, Any] = Field( + default_factory=dict, + description="Optional variables passed to the role", + ) + + +class ApplyRoleRequest(BaseModel): + """Request model for applying local Ansible roles after build.""" + attack_range_id: str = Field(..., description="Attack range ID") + target: str = Field(..., description="Target server name (must match a server name in attack_range config)") + roles: List[LocalRoleTarget] = Field( + ..., + min_length=1, + description="Local roles to stage and execute on the target host", + ) + + class Config: + json_schema_extra = { + "example": { + "attack_range_id": "550e8400-e29b-41d4-a716-446655440000", + "target": "splunk", + "roles": [ + { + "content_base64": "", + "vars": {"example_var": "value"}, + } + ], + } + } + + +class ApplyRoleResponse(BaseModel): + """Response model for apply-role operation.""" + status: str = Field(..., description="Operation status") + message: str = Field(..., description="Status message") + attack_range_id: str = Field(..., description="Attack range ID") + target: str = Field(..., description="Target server name") + roles_applied: List[str] = Field(..., description="Resolved Ansible role names that were applied") + execution_output: Optional[Dict[str, Any]] = Field( + None, + description="Optional Ansible execution details", + ) + + class SplunkExportRequest(BaseModel): """Request model for exporting raw events from the attack range Splunk server.""" attack_range_id: str = Field(..., description="Attack range ID") diff --git a/app/src/pages/attack-ranges/[id].astro b/app/src/pages/attack-ranges/[id].astro index abcf9931..e91bb02c 100644 --- a/app/src/pages/attack-ranges/[id].astro +++ b/app/src/pages/attack-ranges/[id].astro @@ -58,8 +58,13 @@ if (id) {

Basic Information

{['queued', 'build_vpn', 'build_lab'].includes(attackRange.status) ? ( - ) : (