Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/e2e_aws_splunk_windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -50,17 +51,110 @@ 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"
OBJC_DISABLE_INITIALIZE_FORK_SAFETY: "YES"
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: |
CONF="terraform/ansible/client_configs/client1.conf"
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}"
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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. |

---

Expand Down
40 changes: 40 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<base64-encoded-role-tarball>",
"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`
Expand Down
137 changes: 135 additions & 2 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
ProviderCheckResponse,
SimulateRequest,
SimulateResponse,
ApplyRoleRequest,
ApplyRoleResponse,
SplunkExportRequest,
SplunkExportResponse,
ShareRequest,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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],
Expand Down
Loading
Loading