diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b305a05..a7b3807 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,7 +16,7 @@ env: CI_PASS: Ci_Test_Pass1! CI_AGENT_DIR: 'C:\ci' CI_AGENT_PATH: 'C:\ci\agent.exe' - CI_CONTAINER: 'ghcr.io/thegr3atjosh/adaptix-prebuilt:latest' + CI_CONTAINER: 'ghcr.io/thegr3atjosh/adaptix-prebuilt:latest' jobs: integration-test: @@ -29,7 +29,10 @@ jobs: shell: powershell run: echo "WSLENV=CI_USER/u:CI_PASS/u:CI_AGENT_PATH/u:CI_CONTAINER/u" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - # ── Windows: CI user, OpenSSH, agent directory ────────────────────────── + # ── Windows: CI user, OpenSSH, tmp directory ──────────────────────────── + # C:\ci (agent drop dir) and Defender/firewall config are handled by the + # SSH preamble after connecting — these steps only do what must exist + # before SSH is available. - name: Create CI user shell: powershell @@ -57,18 +60,9 @@ jobs: Set-Content $cfg Restart-Service sshd - - name: Create agent drop directory + - name: Create tmp directory for SSH key transfer shell: powershell - run: | - New-Item -ItemType Directory -Force -Path $env:CI_AGENT_DIR | Out-Null - New-Item -ItemType Directory -Force -Path C:\tmp | Out-Null - - - name: Disable Defender and open callback port - shell: powershell - run: | - Set-MpPreference -DisableRealtimeMonitoring $true - Add-MpPreference -ExclusionPath $env:CI_AGENT_DIR - New-NetFirewallRule -DisplayName "CI_C2_8080" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any | Out-Null + run: New-Item -ItemType Directory -Force -Path C:\tmp | Out-Null # ── WSL: Ubuntu + Docker ──────────────────────────────────────────────── @@ -84,10 +78,10 @@ jobs: # WSL compatibility for Docker sudo update-alternatives --set iptables /usr/sbin/iptables-legacy || true sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy || true - + # Start dockerd in the background sudo dockerd > /dev/null 2>&1 & - + echo "Waiting for Docker to start..." for i in $(seq 1 30); do if sudo docker info >/dev/null 2>&1; then @@ -122,7 +116,7 @@ jobs: WSL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1) WSL_GW=$(ip route show default 2>/dev/null | awk 'NR==1{print $3}') WINDOWS_IP=$(cmd.exe /c ipconfig 2>/dev/null | tr -d '\r' | awk '/vEthernet.*WSL/{f=1} f && /IPv4 Address/{print $NF; exit}') - + if [ -z "$WINDOWS_IP" ] || ip addr show 2>/dev/null | grep -qF "$WINDOWS_IP"; then CALLBACK_HOST="127.0.0.1" SSH_HOST="127.0.0.1" @@ -132,7 +126,7 @@ jobs: SSH_HOST="${WSL_GW:-$WINDOWS_IP}" echo "=== WSL2 NAT mode: WSL_IP=$WSL_IP WSL_GW=$WSL_GW WINDOWS_IP=$WINDOWS_IP, SSH→$SSH_HOST ===" fi - + cat > /tmp/ci_config.yaml << EOF server: url: https://127.0.0.1:4321 @@ -176,6 +170,11 @@ jobs: source_path: /tmp/ci_agent.exe agent_path: '$CI_AGENT_PATH' terminate: true + preamble: + - 'New-Item -ItemType Directory -Force -Path C:\ci | Out-Null' + - 'Set-MpPreference -DisableRealtimeMonitoring \$true' + - 'Add-MpPreference -ExclusionPath C:\ci' + - 'New-NetFirewallRule -DisplayName CI_C2_8080 -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any | Out-Null' EOF - name: Pull Adaptix Container @@ -194,36 +193,55 @@ jobs: -v /tmp/ci_config.yaml:/tmp/ci_config.yaml:ro \ "$CI_CONTAINER" \ bash -c ' - # Ensure the globally installed testing-kit via `uv tool` is in PATH - export PATH="/root/.local/bin:$PATH" + export PATH="/usr/local/bin:/root/.local/bin:$PATH" + uv tool install /workspace --reinstall + # ── Feature validation (no server required) ────────────────── + echo "=== Feature validation ===" + adaptix-testing --help 2>&1 | grep -q -- "-o" && \ + echo "✓ --output flag available" || \ + { echo "✗ --output flag missing from CLI"; exit 1; } + echo "=== Feature validation passed ===" + + # ── Server startup ─────────────────────────────────────────── echo "Generating required TLS certificate..." openssl req -x509 -nodes -newkey rsa:2048 \ -keyout /tmp/adaptixc2/dist/server.rsa.key \ -out /tmp/adaptixc2/dist/server.rsa.crt \ -days 1 -subj "/CN=ci" 2>/dev/null - + echo "Starting AdaptixC2 Server..." cd /tmp/adaptixc2/dist ./adaptixserver -profile profile.yaml > /tmp/adaptixserver.log 2>&1 & SERVER_PID=$! - + # Wait up to 60s for the C2 to boot up fully for i in $(seq 1 60); do (exec 3<>/dev/tcp/127.0.0.1/4321) 2>/dev/null && break sleep 1 done (exec 3<>/dev/tcp/127.0.0.1/4321) 2>/dev/null || { - echo "=== AdaptixC2 server failed to start within 30s ===" + echo "=== AdaptixC2 server failed to start within 60s ===" cat /tmp/adaptixserver.log exit 1 } - + + # ── Integration tests (exercises --output and preamble) ────── echo "AdaptixC2 Ready! Running integration tests..." - adaptix-testing -c /tmp/ci_config.yaml -t /workspace/.github/ci/tasks.yaml + adaptix-testing -c /tmp/ci_config.yaml -t /workspace/.github/ci/tasks.yaml \ + -o /tmp/ci-results.txt TEST_EXIT_CODE=$? - - echo "Tests Finished. Tearing down container." + + echo "=== Test results ===" + cat /tmp/ci-results.txt + + # Verify output file has a summary on success + if [ $TEST_EXIT_CODE -eq 0 ]; then + grep -q "All tasks passed" /tmp/ci-results.txt && \ + echo "✓ Output file contains expected summary" || \ + { echo "✗ Output file missing success summary"; exit 1; } + fi + kill $SERVER_PID 2>/dev/null exit $TEST_EXIT_CODE ' diff --git a/README.md b/README.md index 10e68e2..57622a3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ adaptix-testing -c config.yaml -t tasks.yaml Both flags default to `config.yaml` / `tasks.yaml` in the current directory if omitted. +Write results to a file instead of stdout (silent run): + +```sh +adaptix-testing -c config.yaml -t tasks.yaml -o results.txt +``` + --- ## config.yaml @@ -118,6 +124,37 @@ When `terminate: true`, the agent process is killed via `taskkill` and its recor Combine with `setup.agent_output` + `ssh.source_path` pointing to the same path to generate and immediately deliver an agent in one run. +#### PowerShell preamble (optional) + +Run PowerShell commands on the target after connecting but before uploading and starting the agent. Use this to prepare the test environment — create directories, disable Defender, set environment variables, etc. + +```yaml +ssh: + host: 192.168.1.100 + username: administrator + source_path: ./agent.exe + agent_path: C:\ci\agent.exe + terminate: true + preamble: + - "New-Item -ItemType Directory -Force -Path C:\\ci" + - "Set-MpPreference -DisableRealtimeMonitoring $true" + - "Add-MpPreference -ExclusionPath C:\\ci" +``` + +Each command runs via PowerShell `-EncodedCommand` so quoting and special characters are handled safely. If any command exits with a non-zero code the run aborts immediately. A single string is also accepted instead of a list. + +--- + +## --output flag + +Write results to a file instead of printing to stdout. Nothing is printed during the run; the file receives only the results section when complete. + +```sh +adaptix-testing -c config.yaml -t tasks.yaml -o results.txt +``` + +On success the file contains just the summary table. On failure it also includes the failure detail panels. This is designed for CI pipelines where you want a clean artefact without interleaved progress output. + --- ## tasks.yaml diff --git a/config.yaml b/config.yaml index f2c624c..596e00c 100644 --- a/config.yaml +++ b/config.yaml @@ -56,3 +56,6 @@ operator: # source_path: ./agent.exe # Optional: upload via SCP before starting # agent_path: C:\Users\administrator\agent.exe # Required: path to execute on target # terminate: true # Optional: kill agent and remove when done +# preamble: # Optional: PowerShell commands to run after +# - "New-Item -ItemType Directory -Force -Path C:\test" # SSH connect, before agent starts. +# - "Set-MpPreference -DisableRealtimeMonitoring $true" # Run as a list or a single string. diff --git a/run.py b/run.py index 38e3d47..a76baf0 100644 --- a/run.py +++ b/run.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse import base64 +import io import json import os import re @@ -25,10 +26,12 @@ POLL_TIMEOUT = 60 # seconds before declaring a task timed-out console = Console(highlight=False) +# Always write errors to stderr so they show even when console is silenced. +_err_console = Console(stderr=True, highlight=False) def die(msg: str) -> None: - console.print(Panel(f"[bold]{escape(str(msg))}[/bold]", title="[bold red] Error [/bold red]", border_style="red")) + _err_console.print(Panel(f"[bold]{escape(str(msg))}[/bold]", title="[bold red] Error [/bold red]", border_style="red")) sys.exit(1) @@ -271,6 +274,20 @@ def _exe_name(agent_path): return agent_path.replace("\\", "/").split("/")[-1] +def _ps_run(client, cmd): + """Run a PowerShell command over SSH via -EncodedCommand. Returns (exit_code, stdout, stderr).""" + encoded = base64.b64encode(cmd.encode("utf-16-le")).decode() + _, out_ch, err_ch = client.exec_command( + f"powershell -NonInteractive -EncodedCommand {encoded}" + ) + exit_code = out_ch.channel.recv_exit_status() + return ( + exit_code, + out_ch.read().decode(errors="replace").strip(), + err_ch.read().decode(errors="replace").strip(), + ) + + def ssh_start_agent(client, agent_path): # -NoNewWindow attaches the agent to this exec channel's ConPTY. # The infinite sleep keeps the channel—and its ConPTY—alive until we @@ -327,6 +344,21 @@ def ssh_deliver(base_url, headers, ssh_cfg): client = ssh_connect(ssh_cfg) console.print("[green]✓[/green] SSH connected") + preamble = ssh_cfg.get("preamble", []) + if isinstance(preamble, str): + preamble = [preamble] + for cmd in preamble: + console.print(f" [dim]preamble →[/dim] [white]{escape(cmd)}[/white]") + exit_code, out, err = _ps_run(client, cmd) + if out: + for line in out.splitlines(): + console.print(f" [dim]{escape(line)}[/dim]") + if exit_code != 0: + client.close() + die(f"Preamble command failed (exit {exit_code}): {escape(err or 'no stderr')}") + if preamble: + console.print(f"[green]✓[/green] Preamble done ({len(preamble)} command{'s' if len(preamble) != 1 else ''})") + ssh_terminate_agent(client, agent_path) time.sleep(1) remove_agents_by_name(base_url, headers, _exe_name(agent_path)) @@ -417,6 +449,76 @@ def check_output(task_result, expected): return expected.lower() in actual.lower() +# ── Results summary ─────────────────────────────────────────────────────────── + +def _render_summary(c, results): + """Render the results summary table and any failure details to console c. + Returns 0 if all tasks passed, 1 otherwise.""" + n_passed = sum(1 for r in results if r["status"] == "passed") + n_failed = sum(1 for r in results if r["status"] == "failed") + n_dispatch = sum(1 for r in results if r["status"] == "dispatch-failed") + n_timeout = sum(1 for r in results if r["status"] == "timed-out") + n_xfail = sum(1 for r in results if r["status"] == "xfail") + n_bad = n_failed + n_dispatch + n_timeout + + c.print(Rule(style="dim")) + + table = Table(box=None, show_header=False, padding=(0, 2), collapse_padding=True) + table.add_column(justify="right") + table.add_column() + table.add_row(f"[bold]{len(results)}[/bold]", "run") + table.add_row(f"[green]{n_passed}[/green]", "passed") + if n_failed: table.add_row(f"[red]{n_failed}[/red]", "failed") + if n_dispatch: table.add_row(f"[red]{n_dispatch}[/red]", "dispatch-failed") + if n_timeout: table.add_row(f"[yellow]{n_timeout}[/yellow]", "timed out") + if n_xfail: table.add_row(f"[yellow]{n_xfail}[/yellow]", "xfail") + c.print(table) + + if n_bad == 0: + c.print("\n[bold green]All tasks passed.[/bold green]") + return 0 + + for r in [r for r in results if r["status"] == "failed"]: + res = r["result"] or {} + actual = res.get("a_text", "") + res.get("a_message", "") + exp = r["task"].get("expected", "") + + body = Text() + body.append(r["task"]["cmdline"] + "\n\n", style="bold white") + body.append("Expected\n", style="dim") + body.append("─" * 48 + "\n", style="dim") + for line in exp.strip().splitlines(): + body.append(line + "\n", style="yellow") + body.append(f"\nActual ({len(actual):,} chars)\n", style="dim") + body.append("─" * 48 + "\n", style="dim") + for line in actual.splitlines(): + body.append(line + "\n") + c.print(Panel(body, title="[bold red] FAILED [/bold red]", border_style="red")) + + dispatched = [r for r in results if r["status"] == "dispatch-failed"] + if dispatched: + body = Text() + for r in dispatched: + body.append(r["task"]["cmdline"], style="white") + if r.get("err_msg"): + body.append(f" {r['err_msg']}", style="dim") + body.append("\n") + c.print(Panel(body, title="[bold red] DISPATCH FAILED [/bold red]", border_style="red")) + + timeouts = [r for r in results if r["status"] == "timed-out"] + if timeouts: + body = Text() + for r in timeouts: + body.append(r["task"]["cmdline"] + "\n", style="white") + c.print(Panel( + body, + title=f"[bold yellow] TIMED OUT [/bold yellow][dim] (>{POLL_TIMEOUT}s)[/dim]", + border_style="yellow", + )) + + return 1 + + # ── Main ────────────────────────────────────────────────────────────────────── def main(): @@ -425,8 +527,15 @@ def main(): help="Path to config YAML (default: config.yaml)") parser.add_argument("-t", "-tasks", "--tasks", default="tasks.yaml", help="Path to tasks YAML (default: tasks.yaml)") + parser.add_argument("-o", "--output", metavar="FILE", default=None, + help="Write results to FILE; suppress all stdout during the run") args = parser.parse_args() + global console + output_path = args.output + if output_path: + console = Console(file=io.StringIO(), highlight=False) + try: cfg = load_yaml(args.config) tasks = load_yaml(args.tasks)["tasks"] @@ -462,9 +571,9 @@ def main(): listener_profile = _resolve_listener_profile(setup_cfg, project) _create_listener_from_profile(base_url, headers, listener_profile) - output_path = setup_cfg.get("agent_output", "./generated_agent") + output_path_agent = setup_cfg.get("agent_output", "./generated_agent") agent_profile = _resolve_agent_profile(setup_cfg, project, listener_profile["name"]) - _generate_agent_from_profile(base_url, headers, agent_profile, output_path) + _generate_agent_from_profile(base_url, headers, agent_profile, output_path_agent) except requests.exceptions.RequestException as e: die(f"Setup failed: {e}") @@ -556,73 +665,12 @@ def main(): console.print("[green]✓[/green] Agent terminated and removed") ssh_client.close() - # ── Summary ─────────────────────────────────────────────────────────────── - n_passed = sum(1 for r in results if r["status"] == "passed") - n_failed = sum(1 for r in results if r["status"] == "failed") - n_dispatch = sum(1 for r in results if r["status"] == "dispatch-failed") - n_timeout = sum(1 for r in results if r["status"] == "timed-out") - n_xfail = sum(1 for r in results if r["status"] == "xfail") - n_bad = n_failed + n_dispatch + n_timeout - - console.print(Rule(style="dim")) - - table = Table(box=None, show_header=False, padding=(0, 2), collapse_padding=True) - table.add_column(justify="right") - table.add_column() - table.add_row(f"[bold]{len(results)}[/bold]", "run") - table.add_row(f"[green]{n_passed}[/green]", "passed") - if n_failed: table.add_row(f"[red]{n_failed}[/red]", "failed") - if n_dispatch: table.add_row(f"[red]{n_dispatch}[/red]", "dispatch-failed") - if n_timeout: table.add_row(f"[yellow]{n_timeout}[/yellow]", "timed out") - if n_xfail: table.add_row(f"[yellow]{n_xfail}[/yellow]", "xfail") - console.print(table) - - if n_bad == 0: - console.print("\n[bold green]All tasks passed.[/bold green]") - return 0 - - # ── Failed detail ───────────────────────────────────────────────────────── - for r in [r for r in results if r["status"] == "failed"]: - res = r["result"] or {} - actual = res.get("a_text", "") + res.get("a_message", "") - exp = r["task"].get("expected", "") - - body = Text() - body.append(r["task"]["cmdline"] + "\n\n", style="bold white") - body.append("Expected\n", style="dim") - body.append("─" * 48 + "\n", style="dim") - for line in exp.strip().splitlines(): - body.append(line + "\n", style="yellow") - body.append(f"\nActual ({len(actual):,} chars)\n", style="dim") - body.append("─" * 48 + "\n", style="dim") - for line in actual.splitlines(): - body.append(line + "\n") - console.print(Panel(body, title="[bold red] FAILED [/bold red]", border_style="red")) - - # ── Dispatch failures ───────────────────────────────────────────────────── - dispatched = [r for r in results if r["status"] == "dispatch-failed"] - if dispatched: - body = Text() - for r in dispatched: - body.append(r["task"]["cmdline"], style="white") - if r.get("err_msg"): - body.append(f" {r['err_msg']}", style="dim") - body.append("\n") - console.print(Panel(body, title="[bold red] DISPATCH FAILED [/bold red]", border_style="red")) - - # ── Timeouts ────────────────────────────────────────────────────────────── - timeouts = [r for r in results if r["status"] == "timed-out"] - if timeouts: - body = Text() - for r in timeouts: - body.append(r["task"]["cmdline"] + "\n", style="white") - console.print(Panel( - body, - title=f"[bold yellow] TIMED OUT [/bold yellow][dim] (>{POLL_TIMEOUT}s)[/dim]", - border_style="yellow", - )) - - return 1 + if output_path: + with open(output_path, "w") as f: + file_console = Console(file=f, highlight=False, no_color=True) + return _render_summary(file_console, results) + else: + return _render_summary(console, results) if __name__ == "__main__":