From 892b2bd23c253bf69f782658f7d59676533845cf Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:57:45 +0200 Subject: [PATCH 01/26] feat: scaffold extender support infrastructure Co-Authored-By: Claude Sonnet 4.6 --- {config => .github/cicd}/tasks.yaml | 0 .gitignore | 3 + adaptixc2/dist/profile.yaml | 4 + cli/main.go | 169 +++++++++++++++++++++++++++- docker-compose.kvm.yml | 10 +- docker-compose.yml | 10 +- pyproject.toml | 1 + uv.lock | 28 ++++- 8 files changed, 220 insertions(+), 5 deletions(-) rename {config => .github/cicd}/tasks.yaml (100%) create mode 100644 adaptixc2/dist/profile.yaml diff --git a/config/tasks.yaml b/.github/cicd/tasks.yaml similarity index 100% rename from config/tasks.yaml rename to .github/cicd/tasks.yaml diff --git a/.gitignore b/.gitignore index 9165740..1694987 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.py[oc] build/ dist/ +!adaptixc2/dist/ wheels/ *.egg-info @@ -26,3 +27,5 @@ docker-compose.override.yml ssh/ testing-kit-cli testing-kit-cli.tar.gz +!.github/cicd/tasks.yaml +adaptixc2/dist/extenders/ diff --git a/adaptixc2/dist/profile.yaml b/adaptixc2/dist/profile.yaml new file mode 100644 index 0000000..2a4ad1e --- /dev/null +++ b/adaptixc2/dist/profile.yaml @@ -0,0 +1,4 @@ +# Managed by Testing-Kit — do not edit manually +Teamserver: + extenders: [] + axscripts: [] diff --git a/cli/main.go b/cli/main.go index 4564004..7b535f5 100644 --- a/cli/main.go +++ b/cli/main.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "encoding/json" + "flag" "fmt" "io" "net/http" @@ -9,6 +11,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" ) var ( @@ -34,6 +37,8 @@ func main() { cmdCompose("down", "-v") case "run-tests": cmdRunTests() + case "add-extender": + cmdAddExtender(os.Args[2:]) default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) usage() @@ -43,7 +48,7 @@ func main() { func usage() { fmt.Fprintln(os.Stderr, "Usage: testing-kit-cli ") - fmt.Fprintln(os.Stderr, "Commands: install, up, down, reset, run-tests") + fmt.Fprintln(os.Stderr, "Commands: install, up, down, reset, run-tests, add-extender") } func die(msg string) { @@ -178,7 +183,7 @@ func downloadFiles() { } fmt.Println("✓ docker-compose.yml downloaded") - for _, f := range []string{"config/config.yaml", "config/tasks.yaml"} { + for _, f := range []string{"config/config.yaml", ".github/cicd/tasks.yaml"} { if _, err := os.Stat(f); err == nil { fmt.Printf("⚠ %s already exists — skipping\n", f) continue @@ -275,3 +280,163 @@ func generateSSHKey() { } fmt.Println("✓ windows/oem/install.bat rendered") } + +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } +func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } + +func cmdAddExtender(args []string) { + fs := flag.NewFlagSet("add-extender", flag.ExitOnError) + installScript := fs.String("install-script", "", "local script to exec as root in adaptixc2") + overridesFile := fs.String("overrides-file", "", "JSON file of {listener:{},agent:{}} overrides") + noActivate := fs.Bool("no-activate", false, "skip activation") + noRestart := fs.Bool("no-restart", false, "skip docker restart after activation") + var overrideFlags multiFlag + fs.Var(&overrideFlags, "override", "field override: role.key=value (repeatable)") + fs.Parse(args) + + if fs.NArg() < 1 { + fmt.Fprintln(os.Stderr, "Usage: testing-kit-cli add-extender [flags]") + os.Exit(1) + } + gitURL := fs.Arg(0) + + overrides := map[string]map[string]string{} + if *overridesFile != "" { + data, err := os.ReadFile(*overridesFile) + if err != nil { + die(fmt.Sprintf("Cannot read overrides file: %v", err)) + } + if err := json.Unmarshal(data, &overrides); err != nil { + die(fmt.Sprintf("Invalid JSON in overrides file: %v", err)) + } + } + for _, ov := range overrideFlags { + dotIdx := strings.Index(ov, ".") + eqIdx := strings.Index(ov, "=") + if dotIdx < 0 || eqIdx <= dotIdx { + die(fmt.Sprintf("Invalid --override format %q; expected role.key=value", ov)) + } + role := ov[:dotIdx] + key := ov[dotIdx+1 : eqIdx] + val := ov[eqIdx+1:] + if overrides[role] == nil { + overrides[role] = map[string]string{} + } + overrides[role][key] = val + } + + fmt.Printf("Registering extender from %s ...\n", gitURL) + reqBody, _ := json.Marshal(map[string]any{"git_url": gitURL, "overrides": overrides}) + resp, err := http.Post(apiURL+"/v1/extenders", "application/json", bytes.NewReader(reqBody)) + if err != nil { + die(fmt.Sprintf("API error: %v", err)) + } + defer resp.Body.Close() + respBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + die(fmt.Sprintf("Registration failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(respBytes)))) + } + + var reg struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + RequiredFields map[string][]struct { + Key string `json:"key"` + Widget string `json:"widget"` + Hint string `json:"hint"` + } `json:"required_fields"` + } + if err := json.Unmarshal(respBytes, ®); err != nil { + die(fmt.Sprintf("Cannot parse registration response: %v", err)) + } + + if reg.Status == "needs_input" { + fmt.Printf("Extender %q registered (id: %s) but missing required fields:\n\n", reg.Name, reg.ID) + for role, fields := range reg.RequiredFields { + if len(fields) == 0 { + continue + } + fmt.Printf(" %s:\n", role) + for _, f := range fields { + hint := "" + if f.Hint != "" { + hint = " " + f.Hint + } + fmt.Printf(" %-30s [%s]%s\n", f.Key, f.Widget, hint) + } + } + fmt.Printf("\nRe-run with --override or --overrides-file to supply missing values.\n") + os.Exit(1) + } + fmt.Printf("✓ Extender %q registered (id: %s)\n", reg.Name, reg.ID) + + if *installScript != "" { + absScript, err := filepath.Abs(*installScript) + if err != nil { + die(fmt.Sprintf("Cannot resolve install script path: %v", err)) + } + fmt.Printf("Copying install script to adaptixc2 ...\n") + cpCmd := exec.Command("docker", "cp", absScript, "adaptixc2:/tmp/tk_install.sh") + cpCmd.Stdout = os.Stdout + cpCmd.Stderr = os.Stderr + if err := cpCmd.Run(); err != nil { + die(fmt.Sprintf("docker cp failed: %v", err)) + } + fmt.Printf("Running install script in adaptixc2 ...\n") + execCmd := exec.Command("docker", "exec", "-u", "root", "adaptixc2", + "bash", "/tmp/tk_install.sh") + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + if err := execCmd.Run(); err != nil { + die(fmt.Sprintf("Install script failed: %v", err)) + } + fmt.Println("✓ Install script completed") + } + + if *noActivate { + return + } + + fmt.Printf("Activating extender %s ...\n", reg.ID) + actResp, err := http.Post(apiURL+"/v1/extenders/"+reg.ID+"/activate", + "application/json", nil) + if err != nil { + die(fmt.Sprintf("Activation request failed: %v", err)) + } + defer actResp.Body.Close() + actBody, _ := io.ReadAll(actResp.Body) + if actResp.StatusCode == http.StatusConflict { + die(fmt.Sprintf("Activation conflict: %s", strings.TrimSpace(string(actBody)))) + } + if actResp.StatusCode != http.StatusOK { + die(fmt.Sprintf("Activation failed (%d): %s", actResp.StatusCode, strings.TrimSpace(string(actBody)))) + } + fmt.Println("✓ Extender activated") + + if *noRestart { + return + } + + fmt.Println("Restarting adaptixc2 ...") + restartCmd := exec.Command("docker", "restart", "adaptixc2") + restartCmd.Stdout = os.Stdout + restartCmd.Stderr = os.Stderr + if err := restartCmd.Run(); err != nil { + die(fmt.Sprintf("docker restart failed: %v", err)) + } + + fmt.Print("Waiting for adaptixc2") + for i := 0; i < 30; i++ { + time.Sleep(2 * time.Second) + r, err := http.Get(apiURL + "/health") + if err == nil && r.StatusCode == http.StatusOK { + fmt.Println("\n✓ adaptixc2 ready") + return + } + fmt.Print(".") + } + die("adaptixc2 did not become healthy within 60s after restart") +} diff --git a/docker-compose.kvm.yml b/docker-compose.kvm.yml index b63b05d..a7d7eb7 100644 --- a/docker-compose.kvm.yml +++ b/docker-compose.kvm.yml @@ -23,6 +23,9 @@ services: networks: ci-net: ipv4_address: 172.28.0.10 + volumes: + - ./adaptixc2/dist/profile.yaml:/app/profile.yaml:rw + - ./adaptixc2/dist/extenders:/app/extenders:rw restart: unless-stopped testing-kit: @@ -33,11 +36,16 @@ services: environment: TESTING_KIT_DB: /data/testing_kit.db TASKS_SEED_PATH: /app/default_tasks.yaml + ADAPTIX_PROFILE_PATH: /app/adaptixc2/profile.yaml + EXTENDERS_HOST_PATH: /app/adaptixc2/extenders + EXTENDERS_CONTAINER_PATH: /app/extenders volumes: - ./config/config.yaml:/app/config.yaml:ro - - ./config/tasks.yaml:/app/default_tasks.yaml:ro + - ./.github/cicd/tasks.yaml:/app/default_tasks.yaml:ro - ./ssh/id_test:/run/secrets/ssh_key:ro - testing-kit-db:/data + - ./adaptixc2/dist/profile.yaml:/app/adaptixc2/profile.yaml:rw + - ./adaptixc2/dist/extenders:/app/adaptixc2/extenders:rw restart: unless-stopped networks: diff --git a/docker-compose.yml b/docker-compose.yml index 55e87f9..c110948 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,9 @@ services: networks: ci-net: ipv4_address: 172.28.0.10 + volumes: + - ./adaptixc2/dist/profile.yaml:/app/profile.yaml:rw + - ./adaptixc2/dist/extenders:/app/extenders:rw restart: unless-stopped testing-kit: @@ -31,11 +34,16 @@ services: environment: TESTING_KIT_DB: /data/testing_kit.db TASKS_SEED_PATH: /app/default_tasks.yaml + ADAPTIX_PROFILE_PATH: /app/adaptixc2/profile.yaml + EXTENDERS_HOST_PATH: /app/adaptixc2/extenders + EXTENDERS_CONTAINER_PATH: /app/extenders volumes: - ./config/config.yaml:/app/config.yaml:ro - - ./config/tasks.yaml:/app/default_tasks.yaml:ro + - ./.github/cicd/tasks.yaml:/app/default_tasks.yaml:ro - ./ssh/id_test:/run/secrets/ssh_key:ro - testing-kit-db:/data + - ./adaptixc2/dist/profile.yaml:/app/adaptixc2/profile.yaml:rw + - ./adaptixc2/dist/extenders:/app/adaptixc2/extenders:rw restart: unless-stopped networks: diff --git a/pyproject.toml b/pyproject.toml index 8c41f00..274df12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,7 @@ name = "adaptix-testing" version = "2.1.0" requires-python = ">=3.14" dependencies = [ + "dukpy>=0.3.0", "fastapi>=0.115", "httpx>=0.28", "paramiko>=4.0.0", diff --git a/uv.lock b/uv.lock index 366730e..3b5bbf4 100644 --- a/uv.lock +++ b/uv.lock @@ -4,9 +4,10 @@ requires-python = ">=3.14" [[package]] name = "adaptix-testing" -version = "2.0.0" +version = "2.1.0" source = { editable = "." } dependencies = [ + { name = "dukpy" }, { name = "fastapi" }, { name = "httpx" }, { name = "paramiko" }, @@ -24,6 +25,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "dukpy", specifier = ">=0.3.0" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, { name = "paramiko", specifier = ">=4.0.0" }, @@ -294,6 +296,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] +[[package]] +name = "dukpy" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/7b/96611ae3d2370eedbc2e960a26c49a65300c736999d5fa1bf048a2d2825e/dukpy-0.5.1.tar.gz", hash = "sha256:1feaa4c0deb166b1f7b892bb952f97607a6456fbdb01f76c3e94755b2928b47e", size = 2082805, upload-time = "2026-02-07T13:28:35.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c9/e89144d283a55d73ac48ad9f1cfca76cf1fb06608fbcb8fdbc64d8cd6154/dukpy-0.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d0b0d215a1a7220fba014379fb50508461f8907c7eabaaec6cdaf583889e2c4c", size = 1379515, upload-time = "2026-02-07T13:27:21.39Z" }, + { url = "https://files.pythonhosted.org/packages/26/43/3af9631a32cc7dc21586346489693dff88667c3617df27278a90fde578fe/dukpy-0.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9636edc5dfe48e1b5d69bd5601b66d0f2839f729c6b1307119911af7edd78636", size = 1351740, upload-time = "2026-02-07T13:27:23.139Z" }, + { url = "https://files.pythonhosted.org/packages/8f/67/e0a27309521b1358831f2aaead9782746ff153e2dd02e82f23764ab1acfe/dukpy-0.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec42ced22c3b18ef675653a87d11c5a8b82b3151e8691e64d2eb0cdffce3dfb6", size = 2683224, upload-time = "2026-02-07T13:27:25.465Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a9/1f8580c94ddd3e0c99528186e169200cf06bb61783178e76b50d1440270c/dukpy-0.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2c9ee1392a4f1228208a210c50ab8d91aaf422dbf0ca5469a4203678199da06", size = 2747495, upload-time = "2026-02-07T13:27:27.777Z" }, + { url = "https://files.pythonhosted.org/packages/6e/87/89c5f410c669bb5a3a7248a91a7b66add564a1fdb8d2887f9098627a3337/dukpy-0.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:88be218bc302191e0d82e9471dd1783ce0fd315b356c84fc5bed09bdeb11a8f3", size = 2638663, upload-time = "2026-02-07T13:27:30.367Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2d/476ab686b6fabfcfbae6e3bf28d72d847b4848226d88aa05c10309f4a09d/dukpy-0.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7b4930381defd21018e2390c2146b7989b9e6074dafcbc135e409ad63eec9c7f", size = 2727966, upload-time = "2026-02-07T13:27:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/37/22/dae8ab948f7f148c3bd1cdaff3197d7f1c0581c829d2764b6e495d650b8c/dukpy-0.5.1-cp314-cp314-win32.whl", hash = "sha256:f4a09c14ff7d3f91679a5e02770e8bfaf5068c6bbdc6498e783596a99540beba", size = 1272331, upload-time = "2026-02-07T13:27:34.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/49a9330df4c4a638d513c720f09cd1ce9de15fbdaf656114ca0cf44a1626/dukpy-0.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:240565c55c43f562d2655caf83f16b811cba15a7beeea986307527a5130bfe40", size = 1307819, upload-time = "2026-02-07T13:27:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/bb/28/e1d5442ac5905aa4035c6365174f40e682c4854e6379b1d54675d6a6bc92/dukpy-0.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:96091e7ad80bee6b46879a4d3c6347384d38474fdeea780d9c7e791d4b6b10a3", size = 1379752, upload-time = "2026-02-07T13:27:38.539Z" }, + { url = "https://files.pythonhosted.org/packages/c3/15/f86306e16164db2c884d9098d6be6048739fa29a38acb8698baf614137f0/dukpy-0.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ca6f8c88e6e28ae61440b85ef00e3c5b57fd56466805db99b3d38df46d6d39cf", size = 1351966, upload-time = "2026-02-07T13:27:40.437Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/706669824fa7f5d68efc648c2ada0eadf85d8005299d42873e1486bd6e4c/dukpy-0.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51fa320aa4a50da48d290ce4ab0dc4ce1ba1cdbd00a3ffb56d252da9cf0b99cf", size = 2685593, upload-time = "2026-02-07T13:27:42.706Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/cb853f43ca0a62cb7710232e9f29b7ce3c587dd9f2e1aab435f97a68e400/dukpy-0.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982a36cff1f91bd6bc0ab5daf216cef5eb725d43dd0a2c01ac6598515655c4ec", size = 2750031, upload-time = "2026-02-07T13:27:45.556Z" }, + { url = "https://files.pythonhosted.org/packages/77/21/0e5821e8b8bd37217cd5a0d4117cd6ec9bbe12be4b8e8e772bf1d3f012fc/dukpy-0.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e49f915cedb11164082d4a0f64a610cf1d6335db8ad5c0abff31708e0cf80614", size = 2641476, upload-time = "2026-02-07T13:27:47.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d5/ed63b1f09f87583bb80598eab258d1c956b1b2ee9ed72f5a1a4629e9f8ef/dukpy-0.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b573a76437ca880cb570d440d979de4c85047c6f275956f27e5709d579e98f3a", size = 2729881, upload-time = "2026-02-07T13:27:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/d7/be/dad6f6dd2b91b8054de3af000b472d975056021fdcf7d1fdfd68a811dbae/dukpy-0.5.1-cp314-cp314t-win32.whl", hash = "sha256:481f875d829ff1e0b3639fcb1bacbab5dd205a58b85663ee685ff9b6d12c4d20", size = 1272508, upload-time = "2026-02-07T13:27:52.493Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/16fff800600c09c9f35ad5240064a1d47d5e34a55d44c2f7bbe9765424a3/dukpy-0.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4fdd2fa93c18c32192768f921abe2f7e552ece347431d0ed7c6d10cecfa6fcd3", size = 1307969, upload-time = "2026-02-07T13:27:54.473Z" }, +] + [[package]] name = "fastapi" version = "0.139.0" From 5a05c2b4b2ef9d046dbe668caa5b9eee341316ea Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:58:43 +0200 Subject: [PATCH 02/26] feat: add extenders table and CRUD to db.py Co-Authored-By: Claude Sonnet 4.6 --- adaptix_testing/db.py | 156 ++++++++++++++++++++ adaptix_testing/tests/test_extenders_db.py | 163 +++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 adaptix_testing/tests/test_extenders_db.py diff --git a/adaptix_testing/db.py b/adaptix_testing/db.py index 34ffbae..9e5e17f 100644 --- a/adaptix_testing/db.py +++ b/adaptix_testing/db.py @@ -21,6 +21,26 @@ def create_tables(conn: sqlite3.Connection) -> None: allowed_to_fail INTEGER NOT NULL DEFAULT 0, capture TEXT ); + CREATE TABLE IF NOT EXISTS extenders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + git_url TEXT NOT NULL, + extender_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'needs_input', + listener_name TEXT, + agent_name TEXT, + compatible_listeners TEXT, + is_active_listener INTEGER NOT NULL DEFAULT 0, + is_active_agent INTEGER NOT NULL DEFAULT 0, + is_active_bof INTEGER NOT NULL DEFAULT 0, + listener_schema TEXT, + agent_schema TEXT, + container_path TEXT, + listener_config_rel_paths TEXT, + agent_config_rel_paths TEXT, + bof_axs_rel_paths TEXT, + created_at TEXT NOT NULL + ); """) conn.commit() @@ -251,3 +271,139 @@ def seed_tasks_from_yaml(conn: sqlite3.Connection, path: str) -> int: return 0 batch_append_tasks(conn, tasks) return len(tasks) + + +# ── Extenders ───────────────────────────────────────────────────────────────── + +def add_extender(conn: sqlite3.Connection, data: dict) -> None: + conn.execute( + """INSERT INTO extenders + (id, name, git_url, extender_type, status, + listener_name, agent_name, compatible_listeners, + is_active_listener, is_active_agent, is_active_bof, + listener_schema, agent_schema, container_path, + listener_config_rel_paths, agent_config_rel_paths, + bof_axs_rel_paths, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + data["id"], data["name"], data["git_url"], + data["extender_type"], data.get("status", "needs_input"), + data.get("listener_name"), data.get("agent_name"), + data.get("compatible_listeners"), + int(data.get("is_active_listener", 0)), + int(data.get("is_active_agent", 0)), + int(data.get("is_active_bof", 0)), + data.get("listener_schema"), data.get("agent_schema"), + data.get("container_path"), + data.get("listener_config_rel_paths", "[]"), + data.get("agent_config_rel_paths", "[]"), + data.get("bof_axs_rel_paths", "[]"), + data["created_at"], + ), + ) + conn.commit() + + +def get_extender(conn: sqlite3.Connection, id: str) -> Optional[dict]: + row = conn.execute("SELECT * FROM extenders WHERE id=?", (id,)).fetchone() + return dict(row) if row else None + + +def get_extender_by_git_url(conn: sqlite3.Connection, git_url: str) -> Optional[dict]: + row = conn.execute("SELECT * FROM extenders WHERE git_url=?", (git_url,)).fetchone() + return dict(row) if row else None + + +def get_extenders(conn: sqlite3.Connection) -> list[dict]: + return [dict(r) for r in conn.execute( + "SELECT * FROM extenders ORDER BY created_at" + )] + + +def update_extender(conn: sqlite3.Connection, id: str, updates: dict) -> bool: + row = conn.execute("SELECT * FROM extenders WHERE id=?", (id,)).fetchone() + if row is None: + return False + existing = dict(row) + m = {**existing, **updates} + conn.execute( + """UPDATE extenders + SET name=?, git_url=?, extender_type=?, status=?, + listener_name=?, agent_name=?, compatible_listeners=?, + is_active_listener=?, is_active_agent=?, is_active_bof=?, + listener_schema=?, agent_schema=?, container_path=?, + listener_config_rel_paths=?, agent_config_rel_paths=?, + bof_axs_rel_paths=? + WHERE id=?""", + ( + m["name"], m["git_url"], m["extender_type"], m["status"], + m.get("listener_name"), m.get("agent_name"), + m.get("compatible_listeners"), + int(m.get("is_active_listener", 0)), + int(m.get("is_active_agent", 0)), + int(m.get("is_active_bof", 0)), + m.get("listener_schema"), m.get("agent_schema"), + m.get("container_path"), + m.get("listener_config_rel_paths", "[]"), + m.get("agent_config_rel_paths", "[]"), + m.get("bof_axs_rel_paths", "[]"), + id, + ), + ) + conn.commit() + return True + + +def delete_extender(conn: sqlite3.Connection, id: str) -> bool: + cur = conn.execute("DELETE FROM extenders WHERE id=?", (id,)) + conn.commit() + return cur.rowcount > 0 + + +def get_active_listener_extender(conn: sqlite3.Connection) -> Optional[dict]: + row = conn.execute( + "SELECT * FROM extenders WHERE is_active_listener=1" + ).fetchone() + return dict(row) if row else None + + +def get_active_agent_extender(conn: sqlite3.Connection) -> Optional[dict]: + row = conn.execute( + "SELECT * FROM extenders WHERE is_active_agent=1" + ).fetchone() + return dict(row) if row else None + + +def get_active_bof_extenders(conn: sqlite3.Connection) -> list[dict]: + return [dict(r) for r in conn.execute( + "SELECT * FROM extenders WHERE is_active_bof=1" + )] + + +def set_active_listener(conn: sqlite3.Connection, id: str) -> None: + conn.execute("UPDATE extenders SET is_active_listener=0 WHERE is_active_listener=1") + conn.execute("UPDATE extenders SET is_active_listener=1 WHERE id=?", (id,)) + conn.commit() + + +def set_active_agent(conn: sqlite3.Connection, id: str) -> None: + conn.execute("UPDATE extenders SET is_active_agent=0 WHERE is_active_agent=1") + conn.execute("UPDATE extenders SET is_active_agent=1 WHERE id=?", (id,)) + conn.commit() + + +def deactivate_all_listeners(conn: sqlite3.Connection) -> None: + conn.execute("UPDATE extenders SET is_active_listener=0") + conn.commit() + + +def deactivate_all_agents(conn: sqlite3.Connection) -> None: + conn.execute("UPDATE extenders SET is_active_agent=0") + conn.commit() + + +def set_active_bof(conn: sqlite3.Connection, id: str, active: bool) -> None: + conn.execute( + "UPDATE extenders SET is_active_bof=? WHERE id=?", (int(active), id) + ) + conn.commit() diff --git a/adaptix_testing/tests/test_extenders_db.py b/adaptix_testing/tests/test_extenders_db.py new file mode 100644 index 0000000..16dbe78 --- /dev/null +++ b/adaptix_testing/tests/test_extenders_db.py @@ -0,0 +1,163 @@ +import json +import sqlite3 +import pytest +from datetime import datetime +from adaptix_testing import db + + +@pytest.fixture +def conn(): + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + db.create_tables(c) + yield c + c.close() + + +def _ext(id="e1", name="Kharon", git_url="https://github.com/x/y", + ext_type="listener+agent", status="ready", + listener_name="KharonHTTP", agent_name="kharon", + compatible_listeners='["KharonHTTP"]', + listener_schema=None, agent_schema=None, + container_path="/app/extenders/kharon", + listener_config_rel_paths='[]', agent_config_rel_paths='[]', + bof_axs_rel_paths='[]', created_at=None): + return { + "id": id, "name": name, "git_url": git_url, + "extender_type": ext_type, "status": status, + "listener_name": listener_name, "agent_name": agent_name, + "compatible_listeners": compatible_listeners, + "listener_schema": listener_schema, "agent_schema": agent_schema, + "container_path": container_path, + "listener_config_rel_paths": listener_config_rel_paths, + "agent_config_rel_paths": agent_config_rel_paths, + "bof_axs_rel_paths": bof_axs_rel_paths, + "created_at": created_at if created_at is not None else datetime.utcnow().isoformat(), + } + + +def test_create_tables_creates_extenders(conn): + tables = {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + )} + assert "extenders" in tables + + +def test_add_and_get_extender(conn): + db.add_extender(conn, _ext()) + row = db.get_extender(conn, "e1") + assert row is not None + assert row["name"] == "Kharon" + assert row["listener_name"] == "KharonHTTP" + + +def test_get_extender_missing(conn): + assert db.get_extender(conn, "nope") is None + + +def test_get_extender_by_git_url(conn): + db.add_extender(conn, _ext()) + row = db.get_extender_by_git_url(conn, "https://github.com/x/y") + assert row is not None + assert row["id"] == "e1" + + +def test_get_extenders_empty(conn): + assert db.get_extenders(conn) == [] + + +def test_get_extenders_ordered_by_created_at(conn): + db.add_extender(conn, _ext("e1", created_at="2026-01-01")) + db.add_extender(conn, _ext("e2", git_url="https://other", created_at="2026-01-02")) + rows = db.get_extenders(conn) + assert [r["id"] for r in rows] == ["e1", "e2"] + + +def test_update_extender(conn): + db.add_extender(conn, _ext()) + assert db.update_extender(conn, "e1", {"status": "needs_input"}) is True + assert db.get_extender(conn, "e1")["status"] == "needs_input" + + +def test_update_extender_partial_preserves_other_fields(conn): + db.add_extender(conn, _ext()) + db.update_extender(conn, "e1", {"status": "needs_input"}) + row = db.get_extender(conn, "e1") + assert row["name"] == "Kharon" + assert row["listener_name"] == "KharonHTTP" + + +def test_update_extender_missing(conn): + assert db.update_extender(conn, "nope", {"status": "ready"}) is False + + +def test_delete_extender(conn): + db.add_extender(conn, _ext()) + assert db.delete_extender(conn, "e1") is True + assert db.get_extender(conn, "e1") is None + + +def test_delete_extender_missing(conn): + assert db.delete_extender(conn, "nope") is False + + +def test_get_active_listener_none(conn): + db.add_extender(conn, _ext()) + assert db.get_active_listener_extender(conn) is None + + +def test_set_and_get_active_listener(conn): + db.add_extender(conn, _ext()) + db.set_active_listener(conn, "e1") + row = db.get_active_listener_extender(conn) + assert row is not None + assert row["id"] == "e1" + + +def test_set_active_listener_deactivates_previous(conn): + db.add_extender(conn, _ext("e1", git_url="u1")) + db.add_extender(conn, _ext("e2", git_url="u2")) + db.set_active_listener(conn, "e1") + db.set_active_listener(conn, "e2") + assert db.get_active_listener_extender(conn)["id"] == "e2" + assert db.get_extender(conn, "e1")["is_active_listener"] == 0 + + +def test_set_and_get_active_agent(conn): + db.add_extender(conn, _ext()) + db.set_active_agent(conn, "e1") + assert db.get_active_agent_extender(conn)["id"] == "e1" + + +def test_get_active_bof_extenders_empty(conn): + assert db.get_active_bof_extenders(conn) == [] + + +def test_set_active_bof(conn): + db.add_extender(conn, _ext("e1", git_url="u1", ext_type="bof")) + db.add_extender(conn, _ext("e2", git_url="u2", ext_type="bof")) + db.set_active_bof(conn, "e1", True) + db.set_active_bof(conn, "e2", True) + active = db.get_active_bof_extenders(conn) + assert {r["id"] for r in active} == {"e1", "e2"} + + +def test_set_active_bof_false(conn): + db.add_extender(conn, _ext("e1", git_url="u1", ext_type="bof")) + db.set_active_bof(conn, "e1", True) + db.set_active_bof(conn, "e1", False) + assert db.get_active_bof_extenders(conn) == [] + + +def test_deactivate_all_listeners(conn): + db.add_extender(conn, _ext()) + db.set_active_listener(conn, "e1") + db.deactivate_all_listeners(conn) + assert db.get_active_listener_extender(conn) is None + + +def test_deactivate_all_agents(conn): + db.add_extender(conn, _ext()) + db.set_active_agent(conn, "e1") + db.deactivate_all_agents(conn) + assert db.get_active_agent_extender(conn) is None From e0e0621c969a4fd30d998ee4e5fdcc7c8533b8c8 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:00:13 +0200 Subject: [PATCH 03/26] feat: add .axs parser and field classifier Co-Authored-By: Claude Sonnet 4.6 --- adaptix_testing/extender_parser.py | 225 +++++++++++++ adaptix_testing/tests/test_extender_parser.py | 305 ++++++++++++++++++ 2 files changed, 530 insertions(+) create mode 100644 adaptix_testing/extender_parser.py create mode 100644 adaptix_testing/tests/test_extender_parser.py diff --git a/adaptix_testing/extender_parser.py b/adaptix_testing/extender_parser.py new file mode 100644 index 0000000..d72443b --- /dev/null +++ b/adaptix_testing/extender_parser.py @@ -0,0 +1,225 @@ +import json +import re +import subprocess +from pathlib import Path +from typing import Optional + +import yaml +import dukpy + +_MOCK_JS = """ +var _fields = []; +var form = { + create_container: function() { + return { + put: function(k, w, d) { + _fields.push({key: k, widget: w && w.t ? w.t : 'string', def: d !== undefined ? d : null}); + } + }; + }, + create_combo: function(opts) { return {t: 'combo'}; }, + create_spin: function(mn, mx) { return {t: 'spin'}; }, + create_checkbox: function(label) { return {t: 'bool'}; }, + create_textline: function(ph) { return {t: 'string'}; }, + create_textmulti: function(ph) { return {t: 'string'}; }, + create_file: function(label) { return {t: 'file'}; }, + create_dateline: function() { return {t: 'date'}; }, + create_timeline: function() { return {t: 'time'}; }, + create_groupbox: function(label, w) { return w || {t: 'bool'}; }, +}; +function getNetworkInterfaces() { return ['0.0.0.0']; } +var ax = { + script_dir: function() { return ''; }, + script_import: function() {}, + script_load: function() {}, + register_commands_group: function() {}, + create_command: function() { + var c = { + setPreHook: function() { return c; }, + addArgString: function() { return c; }, + addArgBool: function() { return c; }, + addArgFlagString: function() { return c; }, + addArgFlagInt: function() { return c; }, + addArgInt: function() { return c; }, + addSubCommands: function() { return c; } + }; + return c; + }, + create_commands_group: function() { return {}; }, +}; +var menu = { + create_action: function() { return {}; }, + create_menu: function() { return {addItem: function(){}}; }, + add_session_access: function() {}, + add_processbrowser: function() {}, +}; +var event = { on: function() {} }; +""" + +_NETWORK_RE = re.compile(r'address|callback|host|ip', re.I) +_KEY_RE = re.compile(r'key|secret|token|encrypt', re.I) + +_SPECIAL: dict[str, dict] = { + "host_bind": {"source": "auto", "value": "0.0.0.0"}, + "sleep": {"source": "auto", "value": "0s"}, + "callback_addresses": {"source": "network", "value": None}, + "encrypt_key": {"source": "generate", "value": None}, + "uploaded_file": {"source": "required", "value": None, + "hint": "base64-encoded malleable profile JSON"}, +} + + +def parse_axs_fields(axs_text: str, fn_name: str) -> list[dict]: + """Evaluate axs_text with mock globals; call fn_name; return raw [{key,widget,def}].""" + arg = "'create'" if fn_name == "ListenerUI" else "''" + try: + interp = dukpy.JSInterpreter() + interp.evaljs(_MOCK_JS) + interp.evaljs(axs_text) + interp.evaljs("_fields = [];") + interp.evaljs(f"if (typeof {fn_name} !== 'undefined') {{ {fn_name}({arg}); }}") + raw = interp.evaljs("JSON.stringify(_fields)") + return json.loads(raw) if raw and raw != "null" else [] + except Exception: + return [] + + +def classify_field(key: str, widget: str, default) -> dict: + """Classify a field into source/value/widget/hint.""" + field: dict = {"source": "auto", "value": default, "widget": widget, "hint": None} + if widget == "file": + field.update(source="required", value=None) + elif _NETWORK_RE.search(key) and (default == "" or default is None): + field.update(source="network", value=None) + elif default == "" or default is None: + field.update(source="required", value=None) + elif _KEY_RE.search(key): + field.update(source="generate", value=None) + return field + + +def apply_special_registry(key: str, field: dict) -> dict: + """Apply hard-coded overrides for known field names. Mutates and returns field.""" + if key == "page-payload": + val = field.get("value") or "" + if "<<>>" in str(val): + field["source"] = "auto" + else: + field.update(source="required", value=None) + return field + if key in _SPECIAL: + ov = _SPECIAL[key] + field["source"] = ov["source"] + field["value"] = ov.get("value") + if "hint" in ov: + field["hint"] = ov["hint"] + return field + + +def _build_schema(raw_fields: list[dict]) -> dict: + schema: dict = {} + for f in raw_fields: + key = f["key"] + field = classify_field(key, f["widget"], f["def"]) + schema[key] = apply_special_registry(key, field) + return schema + + +def find_extender_configs(repo_dir: str) -> list[dict]: + """Return all config.yaml files that contain an 'extender_type' key.""" + configs = [] + for path in sorted(Path(repo_dir).rglob("config.yaml")): + try: + data = yaml.safe_load(path.read_text()) + if isinstance(data, dict) and "extender_type" in data: + configs.append({ + "path": path, + "rel_path": str(path.relative_to(repo_dir)), + "data": data, + }) + except Exception: + pass + return configs + + +def detect_extender_type( + configs: list[dict], +) -> tuple[str, Optional[str], Optional[str], list[str]]: + """ + Returns (extender_type, listener_name, agent_name, compatible_listeners). + extender_type is one of: listener | agent | listener+agent | bof + """ + listeners = [c for c in configs if c["data"].get("extender_type") == "listener"] + agents = [c for c in configs if c["data"].get("extender_type") == "agent"] + + listener_name = listeners[0]["data"].get("listener_name") if listeners else None + agent_name = agents[0]["data"].get("agent_name") if agents else None + compatible_listeners = agents[0]["data"].get("listeners", []) if agents else [] + + if listeners and agents: + return "listener+agent", listener_name, agent_name, compatible_listeners + if listeners: + return "listener", listener_name, None, [] + if agents: + return "agent", None, agent_name, compatible_listeners + return "bof", None, None, [] + + +def clone_repo(git_url: str, dest: str) -> None: + """Shallow-clone git_url into dest.""" + subprocess.run( + ["git", "clone", "--depth=1", git_url, dest], + check=True, + capture_output=True, + text=True, + ) + + +def parse_extender_repo(repo_dir: str, container_base: str, name: str) -> dict: + """ + Parse a cloned extender repo. Returns a dict with schemas and path metadata + needed for DB storage and profile.yaml management. + """ + configs = find_extender_configs(repo_dir) + ext_type, listener_name, agent_name, compatible_listeners = detect_extender_type(configs) + + container_path = f"{container_base}/{name}" + axs_files = sorted(Path(repo_dir).rglob("*.axs")) + + listener_schema: Optional[dict] = None + agent_schema: Optional[dict] = None + + if ext_type in ("listener", "listener+agent"): + for axs in axs_files: + raw = parse_axs_fields(axs.read_text(), "ListenerUI") + if raw: + listener_schema = _build_schema(raw) + break + + if ext_type in ("agent", "listener+agent"): + for axs in axs_files: + raw = parse_axs_fields(axs.read_text(), "GenerateUI") + if raw: + agent_schema = _build_schema(raw) + break + + listener_configs = [c for c in configs if c["data"].get("extender_type") == "listener"] + agent_configs = [c for c in configs if c["data"].get("extender_type") == "agent"] + bof_axs_rels = ( + [str(p.relative_to(repo_dir)) for p in axs_files] + if ext_type == "bof" else [] + ) + + return { + "name": name, + "extender_type": ext_type, + "listener_name": listener_name, + "agent_name": agent_name, + "compatible_listeners": compatible_listeners, + "listener_schema": listener_schema, + "agent_schema": agent_schema, + "container_path": container_path, + "listener_config_rel_paths": [c["rel_path"] for c in listener_configs], + "agent_config_rel_paths": [c["rel_path"] for c in agent_configs], + "bof_axs_rel_paths": bof_axs_rels, + } diff --git a/adaptix_testing/tests/test_extender_parser.py b/adaptix_testing/tests/test_extender_parser.py new file mode 100644 index 0000000..1729245 --- /dev/null +++ b/adaptix_testing/tests/test_extender_parser.py @@ -0,0 +1,305 @@ +import pytest +from adaptix_testing import extender_parser as ep + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +LISTENER_AXS = """ +function ListenerUI(mode) { + var container = form.create_container(); + container.put("host_bind", form.create_combo(getNetworkInterfaces()), "0.0.0.0"); + container.put("port_bind", form.create_spin(1, 65535), 443); + container.put("callback_addresses", form.create_textmulti("host:port"), ""); + container.put("encrypt_key", form.create_textline("32 hex chars"), ""); + container.put("ssl", form.create_checkbox("Enable SSL"), false); + container.put("uploaded_file", form.create_file("profile"), ""); + container.put("sleep", form.create_spin(0, 3600), 5); + return {container: container}; +} +""" + +AGENT_AXS = """ +function GenerateUI(listenerType) { + var ui_container = form.create_container(); + ui_container.put("arch", form.create_combo(["x64", "x86"]), "x64"); + ui_container.put("format", form.create_combo(["Exe", "Dll"]), "Exe"); + ui_container.put("sleep", form.create_spin(0, 3600), 5); + ui_container.put("jitter", form.create_spin(0, 100), 0); + return {ui_container: ui_container}; +} +""" + +COMBO_AXS = """ +function ListenerUI(mode) { + var c = form.create_container(); + c.put("proto", form.create_combo(["http", "https"]), "http"); + return {container: c}; +} +function GenerateUI(lt) { + var c = form.create_container(); + c.put("arch", form.create_combo(["x64"]), "x64"); + return {ui_container: c}; +} +""" + + +# ── parse_axs_fields ────────────────────────────────────────────────────────── + +def test_parse_listener_fields(tmp_path): + fields = ep.parse_axs_fields(LISTENER_AXS, "ListenerUI") + keys = [f["key"] for f in fields] + assert "host_bind" in keys + assert "port_bind" in keys + assert "callback_addresses" in keys + + +def test_parse_agent_fields(): + fields = ep.parse_axs_fields(AGENT_AXS, "GenerateUI") + keys = [f["key"] for f in fields] + assert "arch" in keys + assert "format" in keys + + +def test_parse_missing_function_returns_empty(): + assert ep.parse_axs_fields(LISTENER_AXS, "GenerateUI") == [] + + +def test_parse_spin_widget(): + fields = ep.parse_axs_fields(LISTENER_AXS, "ListenerUI") + port = next(f for f in fields if f["key"] == "port_bind") + assert port["widget"] == "spin" + assert port["def"] == 443 + + +def test_parse_file_widget(): + fields = ep.parse_axs_fields(LISTENER_AXS, "ListenerUI") + uf = next(f for f in fields if f["key"] == "uploaded_file") + assert uf["widget"] == "file" + + +def test_parse_bool_widget(): + fields = ep.parse_axs_fields(LISTENER_AXS, "ListenerUI") + ssl = next(f for f in fields if f["key"] == "ssl") + assert ssl["widget"] == "bool" + assert ssl["def"] is False + + +def test_parse_combo_widget(): + fields = ep.parse_axs_fields(COMBO_AXS, "ListenerUI") + proto = next(f for f in fields if f["key"] == "proto") + assert proto["widget"] == "combo" + + +def test_parse_invalid_js_returns_empty(): + assert ep.parse_axs_fields("{{{{invalid javascript", "ListenerUI") == [] + + +def test_parse_get_network_interfaces_mocked(): + fields = ep.parse_axs_fields(LISTENER_AXS, "ListenerUI") + hb = next(f for f in fields if f["key"] == "host_bind") + assert hb["widget"] == "combo" + + +# ── classify_field ──────────────────────────────────────────────────────────── + +def test_classify_file_is_required(): + f = ep.classify_field("uploaded_file", "file", "") + assert f["source"] == "required" + assert f["value"] is None + + +def test_classify_network_key_empty_default(): + f = ep.classify_field("callback_addresses", "string", "") + assert f["source"] == "network" + + +def test_classify_host_key_empty_default(): + f = ep.classify_field("host_ip", "string", "") + assert f["source"] == "network" + + +def test_classify_empty_default_is_required(): + f = ep.classify_field("custom_field", "string", "") + assert f["source"] == "required" + assert f["value"] is None + + +def test_classify_encrypt_key_non_empty(): + f = ep.classify_field("encrypt_key", "string", "abc123") + assert f["source"] == "generate" + + +def test_classify_non_empty_default_is_auto(): + f = ep.classify_field("proto", "combo", "http") + assert f["source"] == "auto" + assert f["value"] == "http" + + +def test_classify_bool_false_is_auto(): + f = ep.classify_field("ssl", "bool", False) + assert f["source"] == "auto" + assert f["value"] is False + + +def test_classify_int_default_is_auto(): + f = ep.classify_field("port_bind", "spin", 443) + assert f["source"] == "auto" + assert f["value"] == 443 + + +def test_classify_zero_int_is_auto(): + f = ep.classify_field("jitter", "spin", 0) + assert f["source"] == "auto" + assert f["value"] == 0 + + +# ── apply_special_registry ──────────────────────────────────────────────────── + +def test_special_host_bind(): + f = ep.classify_field("host_bind", "combo", "127.0.0.1") + f = ep.apply_special_registry("host_bind", f) + assert f["source"] == "auto" + assert f["value"] == "0.0.0.0" + + +def test_special_sleep(): + f = ep.classify_field("sleep", "spin", 30) + f = ep.apply_special_registry("sleep", f) + assert f["source"] == "auto" + assert f["value"] == "0s" + + +def test_special_encrypt_key(): + f = ep.classify_field("encrypt_key", "string", "") + f = ep.apply_special_registry("encrypt_key", f) + assert f["source"] == "generate" + + +def test_special_callback_addresses(): + f = ep.classify_field("callback_addresses", "string", "") + f = ep.apply_special_registry("callback_addresses", f) + assert f["source"] == "network" + + +def test_special_uploaded_file(): + f = ep.classify_field("uploaded_file", "file", "") + f = ep.apply_special_registry("uploaded_file", f) + assert f["source"] == "required" + assert f["hint"] == "base64-encoded malleable profile JSON" + + +def test_special_page_payload_with_marker(): + f = ep.classify_field("page-payload", "string", "data<<>>end") + f = ep.apply_special_registry("page-payload", f) + assert f["source"] == "auto" + + +def test_special_page_payload_without_marker(): + f = ep.classify_field("page-payload", "string", "") + f = ep.apply_special_registry("page-payload", f) + assert f["source"] == "required" + + +# ── find_extender_configs ───────────────────────────────────────────────────── + +def test_find_extender_configs(tmp_path): + (tmp_path / "listener").mkdir() + (tmp_path / "listener" / "config.yaml").write_text( + "extender_type: listener\nlistener_name: TestHTTP\n" + ) + (tmp_path / "other.yaml").write_text("name: not an extender\n") + configs = ep.find_extender_configs(str(tmp_path)) + assert len(configs) == 1 + assert configs[0]["data"]["listener_name"] == "TestHTTP" + assert configs[0]["rel_path"] == "listener/config.yaml" + + +def test_find_extender_configs_empty(tmp_path): + assert ep.find_extender_configs(str(tmp_path)) == [] + + +# ── detect_extender_type ────────────────────────────────────────────────────── + +def test_detect_listener_only(): + configs = [{"rel_path": "l/config.yaml", "data": {"extender_type": "listener", "listener_name": "TestHTTP"}}] + ext_type, ln, an, compat = ep.detect_extender_type(configs) + assert ext_type == "listener" + assert ln == "TestHTTP" + assert an is None + + +def test_detect_agent_only(): + configs = [{"rel_path": "a/config.yaml", "data": { + "extender_type": "agent", "agent_name": "test-agent", + "listeners": ["TestHTTP"] + }}] + ext_type, ln, an, compat = ep.detect_extender_type(configs) + assert ext_type == "agent" + assert an == "test-agent" + assert compat == ["TestHTTP"] + + +def test_detect_listener_plus_agent(): + configs = [ + {"rel_path": "l/config.yaml", "data": {"extender_type": "listener", "listener_name": "TestHTTP"}}, + {"rel_path": "a/config.yaml", "data": {"extender_type": "agent", "agent_name": "test", "listeners": ["TestHTTP"]}}, + ] + ext_type, ln, an, compat = ep.detect_extender_type(configs) + assert ext_type == "listener+agent" + assert ln == "TestHTTP" + assert an == "test" + + +def test_detect_bof_no_configs(): + ext_type, ln, an, compat = ep.detect_extender_type([]) + assert ext_type == "bof" + assert ln is None + + +# ── parse_extender_repo ─────────────────────────────────────────────────────── + +def test_parse_extender_repo_listener_plus_agent(tmp_path): + (tmp_path / "listener").mkdir() + (tmp_path / "listener" / "config.yaml").write_text( + "extender_type: listener\nlistener_name: TestHTTP\n" + ) + (tmp_path / "agent").mkdir() + (tmp_path / "agent" / "config.yaml").write_text( + "extender_type: agent\nagent_name: test-agent\nlisteners: [TestHTTP]\n" + ) + (tmp_path / "listener_ui.axs").write_text(LISTENER_AXS) + (tmp_path / "agent_ui.axs").write_text(AGENT_AXS) + + result = ep.parse_extender_repo(str(tmp_path), "/app/extenders", "test") + assert result["extender_type"] == "listener+agent" + assert result["listener_name"] == "TestHTTP" + assert result["agent_name"] == "test-agent" + assert result["container_path"] == "/app/extenders/test" + assert result["listener_schema"] is not None + assert "host_bind" in result["listener_schema"] + assert result["agent_schema"] is not None + assert "arch" in result["agent_schema"] + assert "listener/config.yaml" in result["listener_config_rel_paths"] + assert "agent/config.yaml" in result["agent_config_rel_paths"] + + +def test_parse_extender_repo_bof(tmp_path): + (tmp_path / "commands.axs").write_text("ax.register_commands_group({});") + result = ep.parse_extender_repo(str(tmp_path), "/app/extenders", "extension-kit") + assert result["extender_type"] == "bof" + assert result["listener_schema"] is None + assert "commands.axs" in result["bof_axs_rel_paths"] + + +def test_parse_extender_repo_schema_applies_special_registry(tmp_path): + (tmp_path / "listener").mkdir() + (tmp_path / "listener" / "config.yaml").write_text( + "extender_type: listener\nlistener_name: TestHTTP\n" + ) + (tmp_path / "ui.axs").write_text(LISTENER_AXS) + result = ep.parse_extender_repo(str(tmp_path), "/app/extenders", "test") + schema = result["listener_schema"] + assert schema["host_bind"]["value"] == "0.0.0.0" + assert schema["sleep"]["value"] == "0s" + assert schema["uploaded_file"]["source"] == "required" + assert schema["callback_addresses"]["source"] == "network" From 61123cc1f2c6cbcd1c2b8c50a299ae57f2059501 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:00:55 +0200 Subject: [PATCH 04/26] feat: add profile_manager for atomic profile.yaml management Co-Authored-By: Claude Sonnet 4.6 --- adaptix_testing/profile_manager.py | 57 +++++++++++++ adaptix_testing/tests/test_profile_manager.py | 84 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 adaptix_testing/profile_manager.py create mode 100644 adaptix_testing/tests/test_profile_manager.py diff --git a/adaptix_testing/profile_manager.py b/adaptix_testing/profile_manager.py new file mode 100644 index 0000000..29e1d05 --- /dev/null +++ b/adaptix_testing/profile_manager.py @@ -0,0 +1,57 @@ +import os +import yaml + +PROFILE_PATH = os.environ.get("ADAPTIX_PROFILE_PATH", "/app/adaptixc2/profile.yaml") +EXTENDERS_CONTAINER_PATH = os.environ.get("EXTENDERS_CONTAINER_PATH", "/app/extenders") +EXTENDERS_HOST_PATH = os.environ.get("EXTENDERS_HOST_PATH", "/app/adaptixc2/extenders") + + +def read_profile(path: str) -> dict: + """Read YAML profile. Returns minimal structure if file absent or empty.""" + try: + data = yaml.safe_load(open(path)) or {} + except FileNotFoundError: + data = {} + ts = data.setdefault("Teamserver", {}) + ts.setdefault("extenders", []) + ts.setdefault("axscripts", []) + return data + + +def write_profile(path: str, data: dict) -> None: + """Atomic write: write to path+'.tmp' then os.replace.""" + tmp = path + ".tmp" + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(tmp, "w") as fh: + fh.write("# Managed by Testing-Kit — do not edit manually\n") + yaml.dump(data, fh, default_flow_style=False) + os.replace(tmp, path) + + +def add_extender_entries( + profile_path: str, + container_extender_path: str, + config_rel_paths: list[str], + axs_rel_paths: list[str], +) -> None: + """Add entries to Teamserver.extenders and Teamserver.axscripts, deduplicating.""" + data = read_profile(profile_path) + ts = data["Teamserver"] + for rel in config_rel_paths: + entry = f"{container_extender_path}/{rel}" + if entry not in ts["extenders"]: + ts["extenders"].append(entry) + for rel in axs_rel_paths: + entry = f"{container_extender_path}/{rel}" + if entry not in ts["axscripts"]: + ts["axscripts"].append(entry) + write_profile(profile_path, data) + + +def remove_extender_entries(profile_path: str, container_extender_path: str) -> None: + """Remove all entries whose paths start with container_extender_path.""" + data = read_profile(profile_path) + ts = data["Teamserver"] + ts["extenders"] = [e for e in ts["extenders"] if not e.startswith(container_extender_path)] + ts["axscripts"] = [e for e in ts["axscripts"] if not e.startswith(container_extender_path)] + write_profile(profile_path, data) diff --git a/adaptix_testing/tests/test_profile_manager.py b/adaptix_testing/tests/test_profile_manager.py new file mode 100644 index 0000000..cd92e5f --- /dev/null +++ b/adaptix_testing/tests/test_profile_manager.py @@ -0,0 +1,84 @@ +import os +import pytest +import yaml +from adaptix_testing import profile_manager as pm + + +@pytest.fixture +def profile_path(tmp_path): + path = str(tmp_path / "profile.yaml") + with open(path, "w") as f: + yaml.dump({"Teamserver": {"extenders": [], "axscripts": []}}, f) + return path + + +def test_read_profile_returns_teamserver(profile_path): + data = pm.read_profile(profile_path) + assert "Teamserver" in data + assert "extenders" in data["Teamserver"] + assert "axscripts" in data["Teamserver"] + + +def test_read_profile_missing_file(tmp_path): + data = pm.read_profile(str(tmp_path / "missing.yaml")) + assert data["Teamserver"]["extenders"] == [] + assert data["Teamserver"]["axscripts"] == [] + + +def test_write_profile_creates_file(tmp_path): + path = str(tmp_path / "new.yaml") + pm.write_profile(path, {"Teamserver": {"extenders": ["/app/e/c.yaml"], "axscripts": []}}) + data = yaml.safe_load(open(path)) + assert "/app/e/c.yaml" in data["Teamserver"]["extenders"] + + +def test_write_profile_is_atomic(tmp_path): + path = str(tmp_path / "profile.yaml") + pm.write_profile(path, {"Teamserver": {"extenders": [], "axscripts": []}}) + assert not os.path.exists(path + ".tmp") + + +def test_add_extender_entries_adds_config(profile_path): + pm.add_extender_entries( + profile_path, + "/app/extenders/kharon", + ["listener/config.yaml"], + [], + ) + data = yaml.safe_load(open(profile_path)) + assert "/app/extenders/kharon/listener/config.yaml" in data["Teamserver"]["extenders"] + + +def test_add_extender_entries_adds_axs(profile_path): + pm.add_extender_entries( + profile_path, + "/app/extenders/ext-kit", + [], + ["ext-kit.axs"], + ) + data = yaml.safe_load(open(profile_path)) + assert "/app/extenders/ext-kit/ext-kit.axs" in data["Teamserver"]["axscripts"] + + +def test_add_extender_entries_deduplicates(profile_path): + pm.add_extender_entries(profile_path, "/app/extenders/k", ["l/config.yaml"], []) + pm.add_extender_entries(profile_path, "/app/extenders/k", ["l/config.yaml"], []) + data = yaml.safe_load(open(profile_path)) + entries = data["Teamserver"]["extenders"] + assert entries.count("/app/extenders/k/l/config.yaml") == 1 + + +def test_remove_extender_entries_removes_by_prefix(profile_path): + pm.add_extender_entries(profile_path, "/app/extenders/k", ["l/config.yaml"], ["k.axs"]) + pm.add_extender_entries(profile_path, "/app/extenders/other", ["o/config.yaml"], []) + pm.remove_extender_entries(profile_path, "/app/extenders/k") + data = yaml.safe_load(open(profile_path)) + assert not any(e.startswith("/app/extenders/k") for e in data["Teamserver"]["extenders"]) + assert not any(e.startswith("/app/extenders/k") for e in data["Teamserver"]["axscripts"]) + assert "/app/extenders/other/o/config.yaml" in data["Teamserver"]["extenders"] + + +def test_remove_extender_entries_noop_if_no_match(profile_path): + pm.remove_extender_entries(profile_path, "/app/extenders/nonexistent") + data = yaml.safe_load(open(profile_path)) + assert data["Teamserver"]["extenders"] == [] From e73a186d7fd2b8c384633247cc963be858958e77 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:04:31 +0200 Subject: [PATCH 05/26] feat: add /v1/extenders REST API Co-Authored-By: Claude Sonnet 4.6 --- adaptix_testing/api.py | 248 ++++++++++++++++++++ adaptix_testing/tests/test_extenders_api.py | 244 +++++++++++++++++++ 2 files changed, 492 insertions(+) create mode 100644 adaptix_testing/tests/test_extenders_api.py diff --git a/adaptix_testing/api.py b/adaptix_testing/api.py index a63f332..59eeb78 100644 --- a/adaptix_testing/api.py +++ b/adaptix_testing/api.py @@ -1,13 +1,23 @@ +import json import logging import os import sqlite3 +import subprocess +import uuid from contextlib import asynccontextmanager +from datetime import datetime from typing import Generator, Optional from fastapi import Depends, FastAPI, HTTPException, Response from pydantic import BaseModel from adaptix_testing import db as _db +from adaptix_testing import extender_parser as _ep +from adaptix_testing import profile_manager as _pm from adaptix_testing import runner as _runner +ADAPTIX_PROFILE_PATH = _pm.PROFILE_PATH +EXTENDERS_HOST_PATH = _pm.EXTENDERS_HOST_PATH +EXTENDERS_CONTAINER_PATH = _pm.EXTENDERS_CONTAINER_PATH + DB_PATH = os.environ.get("TESTING_KIT_DB", "testing_kit.db") CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml") TASKS_SEED_PATH = os.environ.get("TASKS_SEED_PATH", "") @@ -222,3 +232,241 @@ def delete_task(id: int, conn: sqlite3.Connection = Depends(get_conn)): if not _db.delete_task(conn, id): raise HTTPException(status_code=404) return Response(status_code=204) + + +# ── Extender models ─────────────────────────────────────────────────────────── + +class ExtenderCreate(BaseModel): + git_url: str + name: Optional[str] = None + overrides: Optional[dict] = None + + +class ExtenderPatch(BaseModel): + overrides: dict + + +# ── Extender helpers ────────────────────────────────────────────────────────── + +def _collect_required(parsed: dict) -> dict: + result: dict = {"listener": [], "agent": []} + for role, key in [("listener", "listener_schema"), ("agent", "agent_schema")]: + schema = parsed.get(key) + if not schema: + continue + for field_key, field in schema.items(): + if field["source"] == "required" and field.get("value") is None: + result[role].append({ + "key": field_key, + "widget": field.get("widget", "string"), + "hint": field.get("hint"), + }) + return result + + +def _apply_overrides(schema: Optional[dict], overrides: dict) -> Optional[dict]: + if not schema or not overrides: + return schema + schema = {k: dict(v) for k, v in schema.items()} + for key, val in overrides.items(): + if key in schema: + schema[key]["value"] = val + if schema[key]["source"] == "required" and val is not None: + schema[key]["source"] = "auto" + return schema + + +def _extender_name_from_url(git_url: str) -> str: + return git_url.rstrip("/").rstrip(".git").split("/")[-1].lower() + + +# ── Extender routes ─────────────────────────────────────────────────────────── + +@app.post("/v1/extenders") +def create_extender(body: ExtenderCreate, conn: sqlite3.Connection = Depends(get_conn)): + existing = _db.get_extender_by_git_url(conn, body.git_url) + if existing: + return existing + + name = body.name or _extender_name_from_url(body.git_url) + dest = os.path.join(EXTENDERS_HOST_PATH, name) + + if not os.path.exists(dest): + try: + _ep.clone_repo(body.git_url, dest) + except subprocess.CalledProcessError as e: + raise HTTPException(400, f"Git clone failed: {getattr(e, 'stderr', str(e))}") + + try: + parsed = _ep.parse_extender_repo(dest, EXTENDERS_CONTAINER_PATH, name) + except Exception as e: + raise HTTPException(500, f"Parse failed: {e}") + + overrides = body.overrides or {} + ls = _apply_overrides(parsed.get("listener_schema"), overrides.get("listener", {})) + as_ = _apply_overrides(parsed.get("agent_schema"), overrides.get("agent", {})) + + required = _collect_required({"listener_schema": ls, "agent_schema": as_}) + status = "ready" if not required["listener"] and not required["agent"] else "needs_input" + + ext_id = uuid.uuid4().hex[:8] + _db.add_extender(conn, { + "id": ext_id, + "name": parsed["name"], + "git_url": body.git_url, + "extender_type": parsed["extender_type"], + "status": status, + "listener_name": parsed.get("listener_name"), + "agent_name": parsed.get("agent_name"), + "compatible_listeners": json.dumps(parsed.get("compatible_listeners", [])), + "listener_schema": json.dumps(ls) if ls else None, + "agent_schema": json.dumps(as_) if as_ else None, + "container_path": parsed["container_path"], + "listener_config_rel_paths": json.dumps(parsed.get("listener_config_rel_paths", [])), + "agent_config_rel_paths": json.dumps(parsed.get("agent_config_rel_paths", [])), + "bof_axs_rel_paths": json.dumps(parsed.get("bof_axs_rel_paths", [])), + "created_at": datetime.utcnow().isoformat(), + }) + + response = {"id": ext_id, "name": parsed["name"], + "type": parsed["extender_type"], "status": status} + if status == "needs_input": + response["required_fields"] = required + return response + + +@app.patch("/v1/extenders/{id}") +def patch_extender(id: str, body: ExtenderPatch, conn: sqlite3.Connection = Depends(get_conn)): + ext = _db.get_extender(conn, id) + if not ext: + raise HTTPException(404) + + ls = json.loads(ext["listener_schema"]) if ext.get("listener_schema") else None + as_ = json.loads(ext["agent_schema"]) if ext.get("agent_schema") else None + + ls = _apply_overrides(ls, body.overrides.get("listener", {})) + as_ = _apply_overrides(as_, body.overrides.get("agent", {})) + + required = _collect_required({"listener_schema": ls, "agent_schema": as_}) + status = "ready" if not required["listener"] and not required["agent"] else "needs_input" + + updates: dict = {"status": status} + if ls is not None: + updates["listener_schema"] = json.dumps(ls) + if as_ is not None: + updates["agent_schema"] = json.dumps(as_) + _db.update_extender(conn, id, updates) + + resp = {"id": id, "status": status} + if status == "needs_input": + resp["required_fields"] = required + return resp + + +@app.get("/v1/extenders") +def list_extenders(conn: sqlite3.Connection = Depends(get_conn)): + rows = _db.get_extenders(conn) + for r in rows: + r["is_active_listener"] = bool(r["is_active_listener"]) + r["is_active_agent"] = bool(r["is_active_agent"]) + r["is_active_bof"] = bool(r["is_active_bof"]) + return rows + + +@app.get("/v1/extenders/{id}") +def get_extender(id: str, conn: sqlite3.Connection = Depends(get_conn)): + ext = _db.get_extender(conn, id) + if not ext: + raise HTTPException(404) + ext["is_active_listener"] = bool(ext["is_active_listener"]) + ext["is_active_agent"] = bool(ext["is_active_agent"]) + ext["is_active_bof"] = bool(ext["is_active_bof"]) + if ext.get("listener_schema"): + ext["listener_schema"] = json.loads(ext["listener_schema"]) + if ext.get("agent_schema"): + ext["agent_schema"] = json.loads(ext["agent_schema"]) + if ext.get("compatible_listeners"): + ext["compatible_listeners"] = json.loads(ext["compatible_listeners"]) + return ext + + +@app.post("/v1/extenders/{id}/activate") +def activate_extender(id: str, conn: sqlite3.Connection = Depends(get_conn)): + ext = _db.get_extender(conn, id) + if not ext: + raise HTTPException(404) + if ext["status"] == "needs_input": + raise HTTPException(400, "Extender has unfilled required fields") + + ext_type = ext["extender_type"] + container_path = ext["container_path"] + + if ext_type == "listener": + active_agent = _db.get_active_agent_extender(conn) + if active_agent: + compat = json.loads(active_agent.get("compatible_listeners") or "[]") + if ext["listener_name"] not in compat: + raise HTTPException(409, detail=( + f"Active agent '{active_agent['agent_name']}' is not compatible with " + f"listener '{ext['listener_name']}'. Compatible listeners: {compat}" + )) + config_rels = json.loads(ext.get("listener_config_rel_paths") or "[]") + _pm.add_extender_entries(ADAPTIX_PROFILE_PATH, container_path, config_rels, []) + _db.set_active_listener(conn, id) + + elif ext_type == "agent": + active_listener = _db.get_active_listener_extender(conn) + if active_listener: + my_compat = json.loads(ext.get("compatible_listeners") or "[]") + if active_listener["listener_name"] not in my_compat: + raise HTTPException(409, detail=( + f"Agent '{ext['agent_name']}' is not compatible with " + f"listener '{active_listener['listener_name']}'. " + f"Compatible: {my_compat}" + )) + config_rels = json.loads(ext.get("agent_config_rel_paths") or "[]") + _pm.add_extender_entries(ADAPTIX_PROFILE_PATH, container_path, config_rels, []) + _db.set_active_agent(conn, id) + + elif ext_type == "listener+agent": + l_rels = json.loads(ext.get("listener_config_rel_paths") or "[]") + a_rels = json.loads(ext.get("agent_config_rel_paths") or "[]") + _pm.add_extender_entries(ADAPTIX_PROFILE_PATH, container_path, l_rels + a_rels, []) + _db.set_active_listener(conn, id) + _db.set_active_agent(conn, id) + + elif ext_type == "bof": + axs_rels = json.loads(ext.get("bof_axs_rel_paths") or "[]") + _pm.add_extender_entries(ADAPTIX_PROFILE_PATH, container_path, [], axs_rels) + _db.set_active_bof(conn, id, True) + + return {"ok": True} + + +@app.post("/v1/extenders/{id}/deactivate") +def deactivate_extender(id: str, conn: sqlite3.Connection = Depends(get_conn)): + ext = _db.get_extender(conn, id) + if not ext: + raise HTTPException(404) + + _pm.remove_extender_entries(ADAPTIX_PROFILE_PATH, ext["container_path"]) + + if ext["extender_type"] in ("listener", "listener+agent"): + _db.deactivate_all_listeners(conn) + if ext["extender_type"] in ("agent", "listener+agent"): + _db.deactivate_all_agents(conn) + if ext["extender_type"] == "bof": + _db.set_active_bof(conn, id, False) + + return {"ok": True} + + +@app.delete("/v1/extenders/{id}", status_code=204) +def delete_extender(id: str, conn: sqlite3.Connection = Depends(get_conn)): + ext = _db.get_extender(conn, id) + if not ext: + raise HTTPException(404) + if ext["is_active_listener"] or ext["is_active_agent"] or ext["is_active_bof"]: + raise HTTPException(409, "Cannot delete an active extender; deactivate first") + _db.delete_extender(conn, id) + return Response(status_code=204) diff --git a/adaptix_testing/tests/test_extenders_api.py b/adaptix_testing/tests/test_extenders_api.py new file mode 100644 index 0000000..5d30956 --- /dev/null +++ b/adaptix_testing/tests/test_extenders_api.py @@ -0,0 +1,244 @@ +import json +import sqlite3 +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +from adaptix_testing.api import app, get_conn +from adaptix_testing import db + + +PARSED_EXT = { + "name": "test-ext", + "extender_type": "listener+agent", + "listener_name": "TestHTTP", + "agent_name": "test-agent", + "compatible_listeners": ["TestHTTP"], + "listener_schema": { + "port_bind": {"source": "auto", "value": 443, "widget": "spin", "hint": None}, + }, + "agent_schema": { + "arch": {"source": "auto", "value": "x64", "widget": "combo", "hint": None}, + }, + "container_path": "/app/extenders/test-ext", + "listener_config_rel_paths": ["listener/config.yaml"], + "agent_config_rel_paths": ["agent/config.yaml"], + "bof_axs_rel_paths": [], +} + +PARSED_EXT_NEEDS_INPUT = { + **PARSED_EXT, + "listener_schema": { + "uploaded_file": {"source": "required", "value": None, "widget": "file", + "hint": "base64-encoded malleable profile JSON"}, + }, +} + +PARSED_BOF = { + "name": "ext-kit", + "extender_type": "bof", + "listener_name": None, "agent_name": None, + "compatible_listeners": [], + "listener_schema": None, "agent_schema": None, + "container_path": "/app/extenders/ext-kit", + "listener_config_rel_paths": [], + "agent_config_rel_paths": [], + "bof_axs_rel_paths": ["ext-kit.axs"], +} + + +@pytest.fixture +def client(): + conn = sqlite3.connect(":memory:", check_same_thread=False) + conn.row_factory = sqlite3.Row + db.create_tables(conn) + + def override(): + yield conn + + app.dependency_overrides[get_conn] = override + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + conn.close() + + +def _post_extender(client, parsed=None, git_url="https://github.com/test/ext", overrides=None): + parsed = parsed or PARSED_EXT + with patch("adaptix_testing.api._ep.clone_repo"), \ + patch("adaptix_testing.api._ep.parse_extender_repo", return_value=parsed): + body = {"git_url": git_url} + if overrides: + body["overrides"] = overrides + return client.post("/v1/extenders", json=body) + + +# ── POST /v1/extenders ──────────────────────────────────────────────────────── + +def test_post_extenders_ready(client): + resp = _post_extender(client) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ready" + assert data["name"] == "test-ext" + assert "id" in data + + +def test_post_extenders_needs_input(client): + resp = _post_extender(client, parsed=PARSED_EXT_NEEDS_INPUT) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "needs_input" + assert "required_fields" in data + assert data["required_fields"]["listener"][0]["key"] == "uploaded_file" + + +def test_post_extenders_returns_existing_if_already_registered(client): + resp1 = _post_extender(client) + resp2 = _post_extender(client) + assert resp1.json()["id"] == resp2.json()["id"] + + +def test_post_extenders_with_overrides_fills_required(client): + resp = _post_extender( + client, + parsed=PARSED_EXT_NEEDS_INPUT, + overrides={"listener": {"uploaded_file": "base64content"}}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "ready" + + +def test_post_extenders_bof(client): + resp = _post_extender(client, parsed=PARSED_BOF, git_url="https://github.com/test/bof") + assert resp.status_code == 200 + assert resp.json()["status"] == "ready" + + +# ── GET /v1/extenders ───────────────────────────────────────────────────────── + +def test_get_extenders_empty(client): + resp = client.get("/v1/extenders") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_get_extenders_returns_registered(client): + _post_extender(client) + rows = client.get("/v1/extenders").json() + assert len(rows) == 1 + assert rows[0]["name"] == "test-ext" + + +# ── GET /v1/extenders/{id} ──────────────────────────────────────────────────── + +def test_get_extender_by_id(client): + id_ = _post_extender(client).json()["id"] + resp = client.get(f"/v1/extenders/{id_}") + assert resp.status_code == 200 + assert resp.json()["listener_name"] == "TestHTTP" + + +def test_get_extender_not_found(client): + assert client.get("/v1/extenders/nope").status_code == 404 + + +# ── PATCH /v1/extenders/{id} ───────────────────────────────────────────────── + +def test_patch_extender_fills_required(client): + id_ = _post_extender(client, parsed=PARSED_EXT_NEEDS_INPUT).json()["id"] + resp = client.patch(f"/v1/extenders/{id_}", json={ + "overrides": {"listener": {"uploaded_file": "base64data"}} + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "ready" + + +def test_patch_extender_not_found(client): + resp = client.patch("/v1/extenders/nope", json={"overrides": {}}) + assert resp.status_code == 404 + + +# ── POST /v1/extenders/{id}/activate ───────────────────────────────────────── + +def test_activate_extender(client): + id_ = _post_extender(client).json()["id"] + with patch("adaptix_testing.api._pm.add_extender_entries"): + resp = client.post(f"/v1/extenders/{id_}/activate") + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +def test_activate_sets_active_flags(client): + id_ = _post_extender(client).json()["id"] + with patch("adaptix_testing.api._pm.add_extender_entries"): + client.post(f"/v1/extenders/{id_}/activate") + row = client.get(f"/v1/extenders/{id_}").json() + assert row["is_active_listener"] is True + assert row["is_active_agent"] is True + + +def test_activate_needs_input_returns_400(client): + id_ = _post_extender(client, parsed=PARSED_EXT_NEEDS_INPUT).json()["id"] + resp = client.post(f"/v1/extenders/{id_}/activate") + assert resp.status_code == 400 + + +def test_activate_agent_incompatible_listener_returns_409(client): + listener_ext = {**PARSED_EXT, "name": "l", "extender_type": "listener", + "agent_name": None, "agent_schema": None, + "agent_config_rel_paths": [], "bof_axs_rel_paths": []} + lid = _post_extender(client, parsed=listener_ext, git_url="https://g.com/l").json()["id"] + with patch("adaptix_testing.api._pm.add_extender_entries"): + client.post(f"/v1/extenders/{lid}/activate") + + agent_ext = {**PARSED_EXT, "name": "a", "extender_type": "agent", + "listener_name": None, "listener_schema": None, + "compatible_listeners": ["OtherListener"], + "listener_config_rel_paths": [], "bof_axs_rel_paths": []} + aid = _post_extender(client, parsed=agent_ext, git_url="https://g.com/a").json()["id"] + with patch("adaptix_testing.api._pm.add_extender_entries"): + resp = client.post(f"/v1/extenders/{aid}/activate") + assert resp.status_code == 409 + + +def test_activate_bof(client): + id_ = _post_extender(client, parsed=PARSED_BOF, git_url="https://g.com/bof").json()["id"] + with patch("adaptix_testing.api._pm.add_extender_entries"): + resp = client.post(f"/v1/extenders/{id_}/activate") + assert resp.status_code == 200 + row = client.get(f"/v1/extenders/{id_}").json() + assert row["is_active_bof"] is True + + +# ── POST /v1/extenders/{id}/deactivate ─────────────────────────────────────── + +def test_deactivate_extender(client): + id_ = _post_extender(client).json()["id"] + with patch("adaptix_testing.api._pm.add_extender_entries"), \ + patch("adaptix_testing.api._pm.remove_extender_entries"): + client.post(f"/v1/extenders/{id_}/activate") + resp = client.post(f"/v1/extenders/{id_}/deactivate") + assert resp.status_code == 200 + row = client.get(f"/v1/extenders/{id_}").json() + assert row["is_active_listener"] is False + + +# ── DELETE /v1/extenders/{id} ──────────────────────────────────────────────── + +def test_delete_extender(client): + id_ = _post_extender(client).json()["id"] + resp = client.delete(f"/v1/extenders/{id_}") + assert resp.status_code == 204 + assert client.get(f"/v1/extenders/{id_}").status_code == 404 + + +def test_delete_active_extender_returns_409(client): + id_ = _post_extender(client).json()["id"] + with patch("adaptix_testing.api._pm.add_extender_entries"): + client.post(f"/v1/extenders/{id_}/activate") + resp = client.delete(f"/v1/extenders/{id_}") + assert resp.status_code == 409 + + +def test_delete_extender_not_found(client): + assert client.delete("/v1/extenders/nope").status_code == 404 From b1dcdecddd7f39e0682af43a2b8980b341df257b Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:05:35 +0200 Subject: [PATCH 06/26] feat: resolve active extenders in runner before fallback to config profiles Co-Authored-By: Claude Sonnet 4.6 --- adaptix_testing/runner.py | 79 +++++++++++++- .../tests/test_runner_extenders.py | 101 ++++++++++++++++++ 2 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 adaptix_testing/tests/test_runner_extenders.py diff --git a/adaptix_testing/runner.py b/adaptix_testing/runner.py index eefdbb7..29206b9 100644 --- a/adaptix_testing/runner.py +++ b/adaptix_testing/runner.py @@ -5,6 +5,7 @@ import json import os import re +import secrets import sqlite3 import sys import time @@ -12,6 +13,7 @@ import yaml import requests import paramiko +from urllib.parse import urlparse from adaptix_testing import db as _db @@ -119,6 +121,50 @@ def _resolve_agent_profile(setup_cfg, project, listener_name): return _auto_agent_profile(project, listener_name) +def _resolve_schema_value(key: str, field: dict, cfg: dict, port_bind: int) -> object: + """Resolve a single schema field to its runtime value.""" + source = field["source"] + if source == "auto": + return field["value"] + if source == "generate": + return secrets.token_hex(16) + if source == "network": + host = urlparse(cfg["server"]["url"]).hostname + return f"{host}:{port_bind}" + if source == "required": + if field.get("value") is None: + raise RuntimeError(f"Required field '{key}' has no value set — patch via PATCH /v1/extenders/{{id}}") + return field["value"] + return field.get("value") + + +def _resolve_listener_from_extender(extender: dict, cfg: dict) -> dict: + """Build a listener profile dict from an active extender DB row.""" + schema = json.loads(extender["listener_schema"]) + port_bind_field = schema.get("port_bind", {}) + port_bind = port_bind_field.get("value", 80) + if isinstance(port_bind, (int, float)): + port_bind = int(port_bind) + + config = {} + for key, field in schema.items(): + config[key] = _resolve_schema_value(key, field, cfg, port_bind) + + listener_name = extender["listener_name"] or "extender" + instance_name = f"{listener_name.lower()}_ci" + return {"name": instance_name, "type": listener_name, "config": json.dumps(config)} + + +def _resolve_agent_from_extender(extender: dict, cfg: dict, listener_instance_name: str) -> dict: + """Build an agent profile dict from an active extender DB row.""" + schema = json.loads(extender["agent_schema"]) + config = {} + for key, field in schema.items(): + config[key] = _resolve_schema_value(key, field, cfg, 0) + agent_name = extender["agent_name"] or "extender" + return {"agent": agent_name, "listener": listener_instance_name, "config": json.dumps(config)} + + # ── Listener / agent setup ──────────────────────────────────────────────────── def _create_listener_from_profile(base_url, headers, profile): @@ -607,10 +653,23 @@ def run_tests(config_path: str, conn) -> dict: setup_cfg = cfg.get("setup") if setup_cfg: project = setup_cfg.get("project", "") - listener_profile = _resolve_listener_profile(setup_cfg, project) + + active_listener_ext = _db.get_active_listener_extender(conn) + active_agent_ext = _db.get_active_agent_extender(conn) + + if active_listener_ext: + listener_profile = _resolve_listener_from_extender(active_listener_ext, cfg) + else: + listener_profile = _resolve_listener_profile(setup_cfg, project) + _create_listener_from_profile(base_url, headers, listener_profile) output_path_agent = setup_cfg.get("agent_output", "/tmp/ci_agent.exe") - agent_profile = _resolve_agent_profile(setup_cfg, project, listener_profile["name"]) + + if active_agent_ext: + agent_profile = _resolve_agent_from_extender(active_agent_ext, cfg, listener_profile["name"]) + else: + agent_profile = _resolve_agent_profile(setup_cfg, project, listener_profile["name"]) + _generate_agent_from_profile(base_url, headers, agent_profile, output_path_agent) ssh_client = None @@ -744,11 +803,23 @@ def main(): if setup_cfg: project = setup_cfg.get("project", "") try: - listener_profile = _resolve_listener_profile(setup_cfg, project) + active_listener_ext = _db.get_active_listener_extender(conn) + active_agent_ext = _db.get_active_agent_extender(conn) + + if active_listener_ext: + listener_profile = _resolve_listener_from_extender(active_listener_ext, cfg) + else: + listener_profile = _resolve_listener_profile(setup_cfg, project) + _create_listener_from_profile(base_url, headers, listener_profile) output_path_agent = setup_cfg.get("agent_output", "./generated_agent") - agent_profile = _resolve_agent_profile(setup_cfg, project, listener_profile["name"]) + + if active_agent_ext: + agent_profile = _resolve_agent_from_extender(active_agent_ext, cfg, listener_profile["name"]) + else: + agent_profile = _resolve_agent_profile(setup_cfg, project, listener_profile["name"]) + _generate_agent_from_profile(base_url, headers, agent_profile, output_path_agent) except Exception as e: die(f"Setup failed: {e}") diff --git a/adaptix_testing/tests/test_runner_extenders.py b/adaptix_testing/tests/test_runner_extenders.py new file mode 100644 index 0000000..2d0ac85 --- /dev/null +++ b/adaptix_testing/tests/test_runner_extenders.py @@ -0,0 +1,101 @@ +import json +import pytest +from adaptix_testing import runner + +CFG = { + "server": {"url": "https://c2.example.com", "endpoint": ""}, + "operator": {"name": "ci", "password": "pass"}, +} + +LISTENER_EXT = { + "listener_name": "KharonHTTP", + "agent_name": None, + "listener_schema": json.dumps({ + "port_bind": {"source": "auto", "value": 443, "widget": "spin", "hint": None}, + "host_bind": {"source": "auto", "value": "0.0.0.0", "widget": "combo", "hint": None}, + "ssl": {"source": "auto", "value": False, "widget": "bool", "hint": None}, + "callback_addresses": {"source": "network", "value": None, "widget": "string", "hint": None}, + "encrypt_key": {"source": "generate", "value": None, "widget": "string", "hint": None}, + "sleep": {"source": "auto", "value": "0s", "widget": "spin", "hint": None}, + }), +} + +AGENT_EXT = { + "agent_name": "kharon", + "listener_name": None, + "agent_schema": json.dumps({ + "arch": {"source": "auto", "value": "x64", "widget": "combo", "hint": None}, + "format": {"source": "auto", "value": "Exe", "widget": "combo", "hint": None}, + "sleep": {"source": "auto", "value": "0s", "widget": "spin", "hint": None}, + }), +} + + +def test_resolve_listener_name_and_type(): + profile = runner._resolve_listener_from_extender(LISTENER_EXT, CFG) + assert profile["name"] == "kharonhttp_ci" + assert profile["type"] == "KharonHTTP" + + +def test_resolve_listener_config_is_json(): + profile = runner._resolve_listener_from_extender(LISTENER_EXT, CFG) + config = json.loads(profile["config"]) + assert config["port_bind"] == 443 + assert config["host_bind"] == "0.0.0.0" + assert config["ssl"] is False + assert config["sleep"] == "0s" + + +def test_resolve_listener_network_field(): + profile = runner._resolve_listener_from_extender(LISTENER_EXT, CFG) + config = json.loads(profile["config"]) + assert config["callback_addresses"] == "c2.example.com:443" + + +def test_resolve_listener_generate_field(): + profile = runner._resolve_listener_from_extender(LISTENER_EXT, CFG) + config = json.loads(profile["config"]) + key = config["encrypt_key"] + assert len(key) == 32 + assert all(c in "0123456789abcdef" for c in key) + + +def test_resolve_listener_generate_is_random(): + p1 = runner._resolve_listener_from_extender(LISTENER_EXT, CFG) + p2 = runner._resolve_listener_from_extender(LISTENER_EXT, CFG) + assert json.loads(p1["config"])["encrypt_key"] != json.loads(p2["config"])["encrypt_key"] + + +def test_resolve_listener_required_with_value(): + ext = { + **LISTENER_EXT, + "listener_schema": json.dumps({ + "custom": {"source": "required", "value": "myvalue", "widget": "string", "hint": None} + }), + } + profile = runner._resolve_listener_from_extender(ext, CFG) + assert json.loads(profile["config"])["custom"] == "myvalue" + + +def test_resolve_listener_required_without_value_raises(): + ext = { + **LISTENER_EXT, + "listener_schema": json.dumps({ + "custom": {"source": "required", "value": None, "widget": "string", "hint": None} + }), + } + with pytest.raises(RuntimeError, match="custom"): + runner._resolve_listener_from_extender(ext, CFG) + + +def test_resolve_agent_name_and_listener(): + profile = runner._resolve_agent_from_extender(AGENT_EXT, CFG, "kharonhttp_ci") + assert profile["agent"] == "kharon" + assert profile["listener"] == "kharonhttp_ci" + + +def test_resolve_agent_config_is_json(): + profile = runner._resolve_agent_from_extender(AGENT_EXT, CFG, "kharonhttp_ci") + config = json.loads(profile["config"]) + assert config["arch"] == "x64" + assert config["format"] == "Exe" From 742ad68a3b5eaa3563059720f1fc284c96595a0b Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:06:16 +0200 Subject: [PATCH 07/26] feat: add CI/CD jobs for Kharon and Extension-Kit extenders Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/extension-kit-tasks.yaml | 10 ++ .github/cicd/install-extension-kit.sh | 18 +++ .github/cicd/install-kharon.sh | 34 ++++++ .github/cicd/kharon-malleable-profile.json | 6 + .github/cicd/kharon-tasks.yaml | 17 +++ .github/workflows/test.yaml | 127 +++++++++++++++++++++ 6 files changed, 212 insertions(+) create mode 100644 .github/cicd/extension-kit-tasks.yaml create mode 100755 .github/cicd/install-extension-kit.sh create mode 100755 .github/cicd/install-kharon.sh create mode 100644 .github/cicd/kharon-malleable-profile.json create mode 100644 .github/cicd/kharon-tasks.yaml diff --git a/.github/cicd/extension-kit-tasks.yaml b/.github/cicd/extension-kit-tasks.yaml new file mode 100644 index 0000000..15095ec --- /dev/null +++ b/.github/cicd/extension-kit-tasks.yaml @@ -0,0 +1,10 @@ +tasks: + - cmdline: "shell whoami" + expected: "ci_runner" + + - cmdline: "shell echo extension_kit_ok" + expected: "extension_kit_ok" + + - cmdline: "xyzzy frobnicate" + expected: "will never succeed" + allowed_to_fail: true diff --git a/.github/cicd/install-extension-kit.sh b/.github/cicd/install-extension-kit.sh new file mode 100755 index 0000000..d987cf3 --- /dev/null +++ b/.github/cicd/install-extension-kit.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Install Extension-Kit BOF collection inside adaptixc2. +# The repo is cloned to /app/extenders/extension-kit. +set -euo pipefail + +EXT_KIT_DIR=/app/extenders/extension-kit + +echo "Extension-Kit: checking for pre-built BOF files..." + +if [[ -f "${EXT_KIT_DIR}/setup.sh" ]]; then + bash "${EXT_KIT_DIR}/setup.sh" +elif [[ -f "${EXT_KIT_DIR}/install.sh" ]]; then + bash "${EXT_KIT_DIR}/install.sh" +else + echo "No setup script found — BOF files assumed pre-compiled." +fi + +echo "Extension-Kit install complete." diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh new file mode 100755 index 0000000..66a8717 --- /dev/null +++ b/.github/cicd/install-kharon.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build and install Kharon extender inside the adaptixc2 container. +# The repo is already cloned to /app/extenders/kharon by Testing-Kit. +set -euo pipefail + +KHARON_DIR=/app/extenders/kharon + +if ! command -v go &>/dev/null; then + apt-get update -qq + apt-get install -y -qq golang-go +fi + +GO_VERSION=$(go version | awk '{print $3}') +echo "Using Go: ${GO_VERSION}" + +echo "Building Kharon listener..." +cd "${KHARON_DIR}" +if [[ -f Makefile ]]; then + make listener +else + cd "${KHARON_DIR}/listener_kharon_http" + go build -buildmode=plugin -trimpath -o listener.so . +fi + +echo "Building Kharon agent..." +cd "${KHARON_DIR}" +if [[ -f Makefile ]]; then + make agent +else + cd "${KHARON_DIR}/agent_kharon" + go build -buildmode=plugin -trimpath -o agent.so . +fi + +echo "Kharon build complete." diff --git a/.github/cicd/kharon-malleable-profile.json b/.github/cicd/kharon-malleable-profile.json new file mode 100644 index 0000000..202aa44 --- /dev/null +++ b/.github/cicd/kharon-malleable-profile.json @@ -0,0 +1,6 @@ +{ + "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Headers": [], + "URIs": ["/api/v1/status", "/api/v1/check"], + "BodyEncoding": "base64" +} diff --git a/.github/cicd/kharon-tasks.yaml b/.github/cicd/kharon-tasks.yaml new file mode 100644 index 0000000..9d32d93 --- /dev/null +++ b/.github/cicd/kharon-tasks.yaml @@ -0,0 +1,17 @@ +tasks: + - cmdline: "shell whoami" + expected: "ci_runner" + + - cmdline: "shell hostname" + expected_regex: "(?i)win|desktop|server" + + - cmdline: "shell dir C:\\" + expected: "Windows" + not_expected: "File Not Found" + + - cmdline: "shell echo kharon_test_ok" + expected: "kharon_test_ok" + + - cmdline: "xyzzy frobnicate" + expected: "will never succeed" + allowed_to_fail: true diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3c9db18..202d857 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -87,3 +87,130 @@ jobs: - name: Teardown if: always() run: ./testing-kit-cli reset + + test-kharon: + name: Test Kharon extender + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: false + + - name: Build CLI + run: | + cd cli && go build \ + -ldflags "-X main.version=${GITHUB_SHA} -X 'main.repoOwner=TGJLS/Testing-Kit'" \ + -o ../testing-kit-cli \ + . + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build Docker image + run: docker build -t ghcr.io/tgjls/testing-kit:2.1.0 . + + - name: Install stack + run: ./testing-kit-cli install + + - name: Wait for testing-kit API + run: | + for i in $(seq 1 30); do + curl -sf http://localhost:1234/health > /dev/null 2>&1 && exit 0 + sleep 2 + done + echo "testing-kit API did not become ready in time" + exit 1 + + - name: Add Kharon extender + run: | + PROFILE_B64=$(base64 -w0 .github/cicd/kharon-malleable-profile.json) + ./testing-kit-cli add-extender https://github.com/entropy-z/Kharon \ + --install-script .github/cicd/install-kharon.sh \ + --override "listener.uploaded_file=${PROFILE_B64}" + + - name: Seed Kharon tasks + run: | + curl -sf -X PUT http://localhost:1234/v1/tasks/batch \ + -H "Content-Type: application/json" \ + -d "$(python3 -c " + import json, yaml + data = yaml.safe_load(open('.github/cicd/kharon-tasks.yaml')) + print(json.dumps(data['tasks'])) + ")" + + - name: Run tests + run: ./testing-kit-cli run-tests + + - name: Tear down + if: always() + run: ./testing-kit-cli down + + test-extension-kit: + name: Test Extension-Kit BOFs + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: false + + - name: Build CLI + run: | + cd cli && go build \ + -ldflags "-X main.version=${GITHUB_SHA} -X 'main.repoOwner=TGJLS/Testing-Kit'" \ + -o ../testing-kit-cli \ + . + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build Docker image + run: docker build -t ghcr.io/tgjls/testing-kit:2.1.0 . + + - name: Install stack + run: ./testing-kit-cli install + + - name: Wait for testing-kit API + run: | + for i in $(seq 1 30); do + curl -sf http://localhost:1234/health > /dev/null 2>&1 && exit 0 + sleep 2 + done + echo "testing-kit API did not become ready in time" + exit 1 + + - name: Add Extension-Kit BOFs + run: | + ./testing-kit-cli add-extender \ + https://github.com/Adaptix-Framework/Extension-Kit \ + --install-script .github/cicd/install-extension-kit.sh + + - name: Seed Extension-Kit tasks + run: | + curl -sf -X PUT http://localhost:1234/v1/tasks/batch \ + -H "Content-Type: application/json" \ + -d "$(python3 -c " + import json, yaml + data = yaml.safe_load(open('.github/cicd/extension-kit-tasks.yaml')) + print(json.dumps(data['tasks'])) + ")" + + - name: Run tests + run: ./testing-kit-cli run-tests + + - name: Tear down + if: always() + run: ./testing-kit-cli down From 7fd52546acb86dfc088305d56f1181a5cbf68e40 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:39:35 +0200 Subject: [PATCH 08/26] fix: resolve CI failures for extender support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: install git so clone_repo() doesn't FileNotFoundError - docker-compose.yml, docker-compose.kvm.yml: add container_name:adaptixc2 so bare docker exec/restart commands resolve correctly - test.yaml: wait for adaptixc2:4321 before running integration tests; always print adaptixc2 logs for debugging - runner.py: retry adaptixc2 connection for up to 60s instead of failing immediately on ConnectionError - cli/main.go: post-restart wait now checks adaptixc2's port (ss -tln) rather than the always-up testing-kit health endpoint - extender_parser.py: add _widget() factory with all known no-op methods so axs scripts that call addWidget/setLayout/setPanel etc. don't throw; add missing form.create_{check,label,selector_file,gridlayout,hlayout,panel} and ax.interfaces(); add _es5_compat() to rewrite let/const→var and for...of→.forEach before passing to Duktape (ES5 only) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 16 +++++++++ Dockerfile | 2 ++ adaptix_testing/extender_parser.py | 53 ++++++++++++++++++++++++------ adaptix_testing/runner.py | 20 +++++++---- cli/main.go | 6 ++-- docker-compose.kvm.yml | 1 + docker-compose.yml | 1 + 7 files changed, 81 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 202d857..4d7ce59 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -77,6 +77,18 @@ jobs: echo "testing-kit API did not become ready in time" exit 1 + - name: Wait for adaptixc2 + run: | + for i in $(seq 1 90); do + docker compose exec testing-kit python3 -c \ + "import socket,sys; socket.create_connection(('adaptixc2',4321),2).close()" \ + 2>/dev/null && { echo '✓ adaptixc2 ready'; exit 0; } + sleep 2 + done + docker compose logs adaptixc2 + echo 'adaptixc2 did not become ready in time' + exit 1 + - name: Run tests run: ./testing-kit-cli run-tests @@ -84,6 +96,10 @@ jobs: if: always() run: docker compose logs testing-kit + - name: Print adaptixc2 logs + if: always() + run: docker compose logs adaptixc2 + - name: Teardown if: always() run: ./testing-kit-cli reset diff --git a/Dockerfile b/Dockerfile index 85ffa9a..2618645 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,8 @@ FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + WORKDIR /app COPY pyproject.toml uv.lock ./ diff --git a/adaptix_testing/extender_parser.py b/adaptix_testing/extender_parser.py index d72443b..77c8300 100644 --- a/adaptix_testing/extender_parser.py +++ b/adaptix_testing/extender_parser.py @@ -9,6 +9,19 @@ _MOCK_JS = """ var _fields = []; +function _widget(t) { + var w = {t: t}; + var noop = function() { return w; }; + w.setEnabled = noop; w.clear = noop; w.connect = noop; + w.addItem = noop; w.addItems = noop; w.addWidget = noop; + w.addRow = noop; w.addColumn = noop; w.setLayout = noop; w.setPanel = noop; + w.setRange = noop; w.setValue = noop; w.setChecked = noop; + w.setPlaceholder = noop; w.setReadOnly = noop; + w.setCurrentIndex = noop; w.setSelection = noop; + w.setColumnStretch = noop; w.setSpacing = noop; + w.getSelection = function() { return ''; }; + return w; +} var form = { create_container: function() { return { @@ -17,21 +30,29 @@ } }; }, - create_combo: function(opts) { return {t: 'combo'}; }, - create_spin: function(mn, mx) { return {t: 'spin'}; }, - create_checkbox: function(label) { return {t: 'bool'}; }, - create_textline: function(ph) { return {t: 'string'}; }, - create_textmulti: function(ph) { return {t: 'string'}; }, - create_file: function(label) { return {t: 'file'}; }, - create_dateline: function() { return {t: 'date'}; }, - create_timeline: function() { return {t: 'time'}; }, - create_groupbox: function(label, w) { return w || {t: 'bool'}; }, + create_combo: function() { return _widget('combo'); }, + create_spin: function() { return _widget('spin'); }, + create_checkbox: function() { return _widget('bool'); }, + create_check: function() { return _widget('bool'); }, + create_textline: function() { return _widget('string'); }, + create_textmulti: function() { return _widget('string'); }, + create_file: function() { return _widget('file'); }, + create_selector_file: function() { return _widget('file'); }, + create_label: function() { return _widget(''); }, + create_dateline: function() { return _widget('date'); }, + create_timeline: function() { return _widget('time'); }, + create_groupbox: function(label, w) { return w && w.t ? w : _widget('bool'); }, + create_gridlayout: function() { return _widget(''); }, + create_hlayout: function() { return _widget(''); }, + create_panel: function() { return _widget(''); }, + connect: function() {}, }; function getNetworkInterfaces() { return ['0.0.0.0']; } var ax = { script_dir: function() { return ''; }, script_import: function() {}, script_load: function() {}, + interfaces: function() { return ['0.0.0.0']; }, register_commands_group: function() {}, create_command: function() { var c = { @@ -69,13 +90,25 @@ } +def _es5_compat(js: str) -> str: + """Downgrade ES6 syntax that Duktape doesn't support.""" + js = re.sub(r'\b(let|const)\b', 'var', js) + # for (var x of y) { body } → y.forEach(function(x) { body }) + js = re.sub( + r'for\s*\(\s*var\s+(\w+)\s+of\s+([^)]+)\)\s*\{([^{}]*)\}', + r'\2.forEach(function(\1) {\3})', + js, + ) + return js + + def parse_axs_fields(axs_text: str, fn_name: str) -> list[dict]: """Evaluate axs_text with mock globals; call fn_name; return raw [{key,widget,def}].""" arg = "'create'" if fn_name == "ListenerUI" else "''" try: interp = dukpy.JSInterpreter() interp.evaljs(_MOCK_JS) - interp.evaljs(axs_text) + interp.evaljs(_es5_compat(axs_text)) interp.evaljs("_fields = [];") interp.evaljs(f"if (typeof {fn_name} !== 'undefined') {{ {fn_name}({arg}); }}") raw = interp.evaljs("JSON.stringify(_fields)") diff --git a/adaptix_testing/runner.py b/adaptix_testing/runner.py index 29206b9..c154da0 100644 --- a/adaptix_testing/runner.py +++ b/adaptix_testing/runner.py @@ -642,12 +642,20 @@ def run_tests(config_path: str, conn) -> dict: base_url = build_base_url(cfg) operator = cfg["operator"] - try: - token = login(base_url, operator) - except requests.exceptions.ConnectionError: - raise RuntimeError(f"Connection refused — is the Adaptix server running at {base_url}?") - except requests.exceptions.HTTPError as e: - raise RuntimeError(f"Login failed: {e}") + deadline = time.time() + 60 + token = None + while True: + try: + token = login(base_url, operator) + break + except requests.exceptions.ConnectionError: + if time.time() >= deadline: + raise RuntimeError( + f"Connection refused — is the Adaptix server running at {base_url}?" + ) + time.sleep(3) + except requests.exceptions.HTTPError as e: + raise RuntimeError(f"Login failed: {e}") headers = {"Authorization": f"Bearer {token}"} setup_cfg = cfg.get("setup") diff --git a/cli/main.go b/cli/main.go index 7b535f5..84870fb 100644 --- a/cli/main.go +++ b/cli/main.go @@ -431,8 +431,10 @@ func cmdAddExtender(args []string) { fmt.Print("Waiting for adaptixc2") for i := 0; i < 30; i++ { time.Sleep(2 * time.Second) - r, err := http.Get(apiURL + "/health") - if err == nil && r.StatusCode == http.StatusOK { + out, err := exec.Command("docker", "exec", "adaptixc2", + "sh", "-c", "ss -tln 2>/dev/null | grep -q ':4321'").Output() + _ = out + if err == nil { fmt.Println("\n✓ adaptixc2 ready") return } diff --git a/docker-compose.kvm.yml b/docker-compose.kvm.yml index a7d7eb7..b7a28a5 100644 --- a/docker-compose.kvm.yml +++ b/docker-compose.kvm.yml @@ -19,6 +19,7 @@ services: restart: unless-stopped adaptixc2: + container_name: adaptixc2 image: ghcr.io/tgjls/adaptixc2:1.2 networks: ci-net: diff --git a/docker-compose.yml b/docker-compose.yml index c110948..a5369e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: restart: unless-stopped adaptixc2: + container_name: adaptixc2 image: ghcr.io/tgjls/adaptixc2:1.2 networks: ci-net: From 92057226741d0cec8dce1c453079c4a34ad2c226 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:10:06 +0200 Subject: [PATCH 09/26] fix: resolve all round-2 CI failures for extender support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile.yaml: add full Teamserver config (port, certs, built-in extenders, HttpServer) so adaptixc2 no longer crashes on restart - docker-compose: mount user extenders to /app/userextenders to avoid shadowing built-in extenders at /app/extenders - profile_manager: switch os.replace to shutil.move to handle cross-device rename (EXDEV on bind mount), update default path to /app/userextenders - api.py: fix _extender_name_from_url to use removesuffix(".git") instead of rstrip(".git") which was stripping individual chars and truncating "Extension-Kit" to "extension-k" - extender_parser: add bool-default-False to classify_field so ssl/checkbox fields without defaults become auto=False instead of required; add missing mock JS stubs for Kharon agent axs (add_session_agent, add_session_browser, on_filebrowser_list, etc.) - runner.py: guard _resolve_agent_from_extender with agent_schema null check (Kharon has no GenerateUI); add string→bool/int coercion in _resolve_schema_value for CLI --override values - test.yaml: move all --flag args before the URL (Go flag.FlagSet stops at first non-flag); add all required Kharon listener overrides (port_bind, block_user_agents, domain_rotation_strategy, proxy_*, ssl_cert, ssl_key, uploaded_file) - install scripts: update extender paths from /app/extenders/* to /app/userextenders/* Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-extension-kit.sh | 2 +- .github/cicd/install-kharon.sh | 2 +- .github/workflows/test.yaml | 17 +++++++--- adaptix_testing/api.py | 2 +- adaptix_testing/extender_parser.py | 14 +++++++- adaptix_testing/profile_manager.py | 5 +-- adaptix_testing/runner.py | 33 +++++++++++++----- adaptixc2/dist/profile.yaml | 48 ++++++++++++++++++++++++++- docker-compose.kvm.yml | 4 +-- docker-compose.yml | 4 +-- 10 files changed, 108 insertions(+), 23 deletions(-) diff --git a/.github/cicd/install-extension-kit.sh b/.github/cicd/install-extension-kit.sh index d987cf3..2fb7958 100755 --- a/.github/cicd/install-extension-kit.sh +++ b/.github/cicd/install-extension-kit.sh @@ -3,7 +3,7 @@ # The repo is cloned to /app/extenders/extension-kit. set -euo pipefail -EXT_KIT_DIR=/app/extenders/extension-kit +EXT_KIT_DIR=/app/userextenders/extension-kit echo "Extension-Kit: checking for pre-built BOF files..." diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 66a8717..aba0b56 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -3,7 +3,7 @@ # The repo is already cloned to /app/extenders/kharon by Testing-Kit. set -euo pipefail -KHARON_DIR=/app/extenders/kharon +KHARON_DIR=/app/userextenders/kharon if ! command -v go &>/dev/null; then apt-get update -qq diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4d7ce59..85140cb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -147,9 +147,18 @@ jobs: - name: Add Kharon extender run: | PROFILE_B64=$(base64 -w0 .github/cicd/kharon-malleable-profile.json) - ./testing-kit-cli add-extender https://github.com/entropy-z/Kharon \ + ./testing-kit-cli add-extender \ --install-script .github/cicd/install-kharon.sh \ - --override "listener.uploaded_file=${PROFILE_B64}" + --override "listener.port_bind=8080" \ + --override "listener.block_user_agents=" \ + --override "listener.domain_rotation_strategy=Random" \ + --override "listener.proxy_url=" \ + --override "listener.proxy_user=" \ + --override "listener.proxy_pass=" \ + --override "listener.ssl_cert=" \ + --override "listener.ssl_key=" \ + --override "listener.uploaded_file=${PROFILE_B64}" \ + https://github.com/entropy-z/Kharon - name: Seed Kharon tasks run: | @@ -211,8 +220,8 @@ jobs: - name: Add Extension-Kit BOFs run: | ./testing-kit-cli add-extender \ - https://github.com/Adaptix-Framework/Extension-Kit \ - --install-script .github/cicd/install-extension-kit.sh + --install-script .github/cicd/install-extension-kit.sh \ + https://github.com/Adaptix-Framework/Extension-Kit - name: Seed Extension-Kit tasks run: | diff --git a/adaptix_testing/api.py b/adaptix_testing/api.py index 59eeb78..fcfa5b0 100644 --- a/adaptix_testing/api.py +++ b/adaptix_testing/api.py @@ -277,7 +277,7 @@ def _apply_overrides(schema: Optional[dict], overrides: dict) -> Optional[dict]: def _extender_name_from_url(git_url: str) -> str: - return git_url.rstrip("/").rstrip(".git").split("/")[-1].lower() + return git_url.rstrip("/").removesuffix(".git").split("/")[-1].lower() # ── Extender routes ─────────────────────────────────────────────────────────── diff --git a/adaptix_testing/extender_parser.py b/adaptix_testing/extender_parser.py index 77c8300..8e22dbf 100644 --- a/adaptix_testing/extender_parser.py +++ b/adaptix_testing/extender_parser.py @@ -53,6 +53,10 @@ script_import: function() {}, script_load: function() {}, interfaces: function() { return ['0.0.0.0']; }, + open_browser_files: function() {}, + open_browser_process: function() {}, + execute_browser: function() {}, + execute_command: function() {}, register_commands_group: function() {}, create_command: function() { var c = { @@ -73,8 +77,14 @@ create_menu: function() { return {addItem: function(){}}; }, add_session_access: function() {}, add_processbrowser: function() {}, + add_session_agent: function() {}, + add_session_browser: function() {}, +}; +var event = { + on: function() {}, + on_filebrowser_list: function() {}, + on_processbrowser_list: function() {}, }; -var event = { on: function() {} }; """ _NETWORK_RE = re.compile(r'address|callback|host|ip', re.I) @@ -122,6 +132,8 @@ def classify_field(key: str, widget: str, default) -> dict: field: dict = {"source": "auto", "value": default, "widget": widget, "hint": None} if widget == "file": field.update(source="required", value=None) + elif widget == "bool" and (default == "" or default is None): + field.update(source="auto", value=False) elif _NETWORK_RE.search(key) and (default == "" or default is None): field.update(source="network", value=None) elif default == "" or default is None: diff --git a/adaptix_testing/profile_manager.py b/adaptix_testing/profile_manager.py index 29e1d05..4a2515c 100644 --- a/adaptix_testing/profile_manager.py +++ b/adaptix_testing/profile_manager.py @@ -1,8 +1,9 @@ import os +import shutil import yaml PROFILE_PATH = os.environ.get("ADAPTIX_PROFILE_PATH", "/app/adaptixc2/profile.yaml") -EXTENDERS_CONTAINER_PATH = os.environ.get("EXTENDERS_CONTAINER_PATH", "/app/extenders") +EXTENDERS_CONTAINER_PATH = os.environ.get("EXTENDERS_CONTAINER_PATH", "/app/userextenders") EXTENDERS_HOST_PATH = os.environ.get("EXTENDERS_HOST_PATH", "/app/adaptixc2/extenders") @@ -25,7 +26,7 @@ def write_profile(path: str, data: dict) -> None: with open(tmp, "w") as fh: fh.write("# Managed by Testing-Kit — do not edit manually\n") yaml.dump(data, fh, default_flow_style=False) - os.replace(tmp, path) + shutil.move(tmp, path) def add_extender_entries( diff --git a/adaptix_testing/runner.py b/adaptix_testing/runner.py index c154da0..b948777 100644 --- a/adaptix_testing/runner.py +++ b/adaptix_testing/runner.py @@ -124,18 +124,29 @@ def _resolve_agent_profile(setup_cfg, project, listener_name): def _resolve_schema_value(key: str, field: dict, cfg: dict, port_bind: int) -> object: """Resolve a single schema field to its runtime value.""" source = field["source"] + widget = field.get("widget", "string") if source == "auto": - return field["value"] - if source == "generate": + val = field["value"] + elif source == "generate": return secrets.token_hex(16) - if source == "network": + elif source == "network": host = urlparse(cfg["server"]["url"]).hostname return f"{host}:{port_bind}" - if source == "required": + elif source == "required": if field.get("value") is None: raise RuntimeError(f"Required field '{key}' has no value set — patch via PATCH /v1/extenders/{{id}}") - return field["value"] - return field.get("value") + val = field["value"] + else: + val = field.get("value") + if isinstance(val, str): + if widget == "bool": + return val.lower() in ("true", "1", "yes") + if widget == "spin": + try: + return int(val) + except (ValueError, TypeError): + pass + return val def _resolve_listener_from_extender(extender: dict, cfg: dict) -> dict: @@ -673,8 +684,11 @@ def run_tests(config_path: str, conn) -> dict: _create_listener_from_profile(base_url, headers, listener_profile) output_path_agent = setup_cfg.get("agent_output", "/tmp/ci_agent.exe") - if active_agent_ext: + if active_agent_ext and active_agent_ext.get("agent_schema"): agent_profile = _resolve_agent_from_extender(active_agent_ext, cfg, listener_profile["name"]) + elif active_agent_ext: + agent_name = active_agent_ext.get("agent_name") or "extender" + agent_profile = {"agent": agent_name, "listener": listener_profile["name"], "config": "{}"} else: agent_profile = _resolve_agent_profile(setup_cfg, project, listener_profile["name"]) @@ -823,8 +837,11 @@ def main(): output_path_agent = setup_cfg.get("agent_output", "./generated_agent") - if active_agent_ext: + if active_agent_ext and active_agent_ext.get("agent_schema"): agent_profile = _resolve_agent_from_extender(active_agent_ext, cfg, listener_profile["name"]) + elif active_agent_ext: + agent_name = active_agent_ext.get("agent_name") or "extender" + agent_profile = {"agent": agent_name, "listener": listener_profile["name"], "config": "{}"} else: agent_profile = _resolve_agent_profile(setup_cfg, project, listener_profile["name"]) diff --git a/adaptixc2/dist/profile.yaml b/adaptixc2/dist/profile.yaml index 2a4ad1e..2952da6 100644 --- a/adaptixc2/dist/profile.yaml +++ b/adaptixc2/dist/profile.yaml @@ -1,4 +1,50 @@ # Managed by Testing-Kit — do not edit manually Teamserver: - extenders: [] + interface: "0.0.0.0" + port: 4321 + endpoint: "/endpoint" + password: "pass" + only_password: true + cert: "server.rsa.crt" + key: "server.rsa.key" + extenders: + - "extenders/beacon_listener_http/config.yaml" + - "extenders/beacon_listener_smb/config.yaml" + - "extenders/beacon_listener_tcp/config.yaml" + - "extenders/beacon_listener_dns/config.yaml" + - "extenders/beacon_agent/config.yaml" + - "extenders/gopher_listener_tcp/config.yaml" + - "extenders/gopher_agent/config.yaml" axscripts: [] + access_token_live_hours: 12 + refresh_token_live_hours: 168 + +HttpServer: + error: + status: 404 + headers: + Content-Type: "text/html; charset=UTF-8" + Server: "AdaptixC2" + Adaptix-Version: "v1.2" + page: "404page.html" + http: + max_header_bytes: 8192 + read_header_timeout_sec: 0 + read_timeout_sec: 0 + write_timeout_sec: 0 + idle_timeout_sec: 0 + request_timeout_sec: 300 + request_timeout_message: "504 Gateway Timeout" + disable_keep_alives: false + enable_http2: true + tls: + min_version: "TLS1.2" + max_version: "TLS1.3" + prefer_server_cipher_suites: false + cipher_suites: + - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" + - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" + - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" + - "TLS_RSA_WITH_AES_128_GCM_SHA256" + - "TLS_RSA_WITH_AES_256_GCM_SHA384" diff --git a/docker-compose.kvm.yml b/docker-compose.kvm.yml index b7a28a5..9d5b789 100644 --- a/docker-compose.kvm.yml +++ b/docker-compose.kvm.yml @@ -26,7 +26,7 @@ services: ipv4_address: 172.28.0.10 volumes: - ./adaptixc2/dist/profile.yaml:/app/profile.yaml:rw - - ./adaptixc2/dist/extenders:/app/extenders:rw + - ./adaptixc2/dist/extenders:/app/userextenders:rw restart: unless-stopped testing-kit: @@ -39,7 +39,7 @@ services: TASKS_SEED_PATH: /app/default_tasks.yaml ADAPTIX_PROFILE_PATH: /app/adaptixc2/profile.yaml EXTENDERS_HOST_PATH: /app/adaptixc2/extenders - EXTENDERS_CONTAINER_PATH: /app/extenders + EXTENDERS_CONTAINER_PATH: /app/userextenders volumes: - ./config/config.yaml:/app/config.yaml:ro - ./.github/cicd/tasks.yaml:/app/default_tasks.yaml:ro diff --git a/docker-compose.yml b/docker-compose.yml index a5369e6..fe77347 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: ipv4_address: 172.28.0.10 volumes: - ./adaptixc2/dist/profile.yaml:/app/profile.yaml:rw - - ./adaptixc2/dist/extenders:/app/extenders:rw + - ./adaptixc2/dist/extenders:/app/userextenders:rw restart: unless-stopped testing-kit: @@ -37,7 +37,7 @@ services: TASKS_SEED_PATH: /app/default_tasks.yaml ADAPTIX_PROFILE_PATH: /app/adaptixc2/profile.yaml EXTENDERS_HOST_PATH: /app/adaptixc2/extenders - EXTENDERS_CONTAINER_PATH: /app/extenders + EXTENDERS_CONTAINER_PATH: /app/userextenders volumes: - ./config/config.yaml:/app/config.yaml:ro - ./.github/cicd/tasks.yaml:/app/default_tasks.yaml:ro From eb85516e424a1cba3473f3fef2a645b926ac25a8 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:06:41 +0200 Subject: [PATCH 10/26] fix: fix Kharon build path and skip Extension-Kit restart - install-kharon.sh: use listener_kharon_http/Makefile and agent_kharon/Makefile instead of trying go build . in the wrong directory (no Go files at listener_kharon_http root, they live in src_server/); install make if not present - test.yaml: add --no-restart for Extension-Kit add-extender because loading 11+ BOF axscripts causes adaptixc2 startup to exceed the 60s health-check timeout; the CI tasks (shell whoami etc.) don't require axscripts to be loaded Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 30 +++++++++++------------------- .github/workflows/test.yaml | 1 + 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index aba0b56..9c4256d 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -1,34 +1,26 @@ #!/usr/bin/env bash # Build and install Kharon extender inside the adaptixc2 container. -# The repo is already cloned to /app/extenders/kharon by Testing-Kit. +# The repo is cloned to /app/userextenders/kharon by Testing-Kit. set -euo pipefail KHARON_DIR=/app/userextenders/kharon if ! command -v go &>/dev/null; then apt-get update -qq - apt-get install -y -qq golang-go + apt-get install -y -qq golang-go make +elif ! command -v make &>/dev/null; then + apt-get update -qq + apt-get install -y -qq make fi -GO_VERSION=$(go version | awk '{print $3}') -echo "Using Go: ${GO_VERSION}" +echo "Using Go: $(go version)" echo "Building Kharon listener..." -cd "${KHARON_DIR}" -if [[ -f Makefile ]]; then - make listener -else - cd "${KHARON_DIR}/listener_kharon_http" - go build -buildmode=plugin -trimpath -o listener.so . -fi +cd "${KHARON_DIR}/listener_kharon_http" +make all -echo "Building Kharon agent..." -cd "${KHARON_DIR}" -if [[ -f Makefile ]]; then - make agent -else - cd "${KHARON_DIR}/agent_kharon" - go build -buildmode=plugin -trimpath -o agent.so . -fi +echo "Building Kharon agent plugin..." +cd "${KHARON_DIR}/agent_kharon" +make plugin echo "Kharon build complete." diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 85140cb..01edf4e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -221,6 +221,7 @@ jobs: run: | ./testing-kit-cli add-extender \ --install-script .github/cicd/install-extension-kit.sh \ + --no-restart \ https://github.com/Adaptix-Framework/Extension-Kit - name: Seed Extension-Kit tasks From 671d37e848a50ab192d45abdc3cd0db4233b228d Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:49:29 +0200 Subject: [PATCH 11/26] fix: increase timeouts for Windows boot and adaptixc2 plugin load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adaptixc2 restart health check was 60s — loading Kharon Go plugins takes longer; raised to 300s (150×2s). Both extender CI jobs now wait up to 50 minutes for Windows SSH before running tests, matching the Windows 11 QEMU boot time; timeout-minutes raised to 90 to accommodate. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 30 ++++++++++++++++++++++++++++-- cli/main.go | 4 ++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 01edf4e..27fc40b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -107,7 +107,7 @@ jobs: test-kharon: name: Test Kharon extender runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 @@ -170,6 +170,19 @@ jobs: print(json.dumps(data['tasks'])) ")" + - name: Wait for Windows SSH + run: | + for i in $(seq 1 150); do + docker compose exec testing-kit python3 -c \ + "import socket,sys; socket.create_connection(('windows',22),5).close()" \ + 2>/dev/null && { echo "✓ Windows SSH ready (attempt $i)"; exit 0; } + echo "Waiting for Windows SSH (attempt $i/150)..." + sleep 20 + done + docker compose logs windows | tail -50 + echo "Windows SSH not ready after 50 minutes" + exit 1 + - name: Run tests run: ./testing-kit-cli run-tests @@ -180,7 +193,7 @@ jobs: test-extension-kit: name: Test Extension-Kit BOFs runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 @@ -234,6 +247,19 @@ jobs: print(json.dumps(data['tasks'])) ")" + - name: Wait for Windows SSH + run: | + for i in $(seq 1 150); do + docker compose exec testing-kit python3 -c \ + "import socket,sys; socket.create_connection(('windows',22),5).close()" \ + 2>/dev/null && { echo "✓ Windows SSH ready (attempt $i)"; exit 0; } + echo "Waiting for Windows SSH (attempt $i/150)..." + sleep 20 + done + docker compose logs windows | tail -50 + echo "Windows SSH not ready after 50 minutes" + exit 1 + - name: Run tests run: ./testing-kit-cli run-tests diff --git a/cli/main.go b/cli/main.go index 84870fb..779d73d 100644 --- a/cli/main.go +++ b/cli/main.go @@ -429,7 +429,7 @@ func cmdAddExtender(args []string) { } fmt.Print("Waiting for adaptixc2") - for i := 0; i < 30; i++ { + for i := 0; i < 150; i++ { time.Sleep(2 * time.Second) out, err := exec.Command("docker", "exec", "adaptixc2", "sh", "-c", "ss -tln 2>/dev/null | grep -q ':4321'").Output() @@ -440,5 +440,5 @@ func cmdAddExtender(args []string) { } fmt.Print(".") } - die("adaptixc2 did not become healthy within 60s after restart") + die("adaptixc2 did not become healthy within 300s after restart") } From bca0bbe5dfb39b9b882fb28c2be1ccaa55038934 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:08:17 +0200 Subject: [PATCH 12/26] fix: pin axc2 to adaptixc2's version when building Kharon plugins Go plugin ABI requires exact package version match. The Kharon go.mod pinned axc2 v1.1.3 but the running adaptixc2 binary uses v1.2.0, causing plugin.Open to fail at startup. The install script now reads the axc2 version from the adaptixc2 binary via `go version -m` and runs `go get` to align the dependency before building. Also adds adaptixc2 log capture on failure for the Kharon test job. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 9 +++++++++ .github/workflows/test.yaml | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 9c4256d..1bc8645 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -15,12 +15,21 @@ fi echo "Using Go: $(go version)" +# Pin axc2 to the version that matches the running adaptixc2 binary. +# Go plugin ABI requires exact package version match; axc2 v1.1.3 (Kharon +# default) will fail to load against an adaptixc2 built with v1.2.0. +AXC2_VERSION=$(go version -m /app/adaptixc2 2>/dev/null | awk '/github.com\/Adaptix-Framework\/axc2/{print $3}') +AXC2_VERSION="${AXC2_VERSION:-v1.2.0}" +echo "Pinning axc2 to ${AXC2_VERSION} (matches adaptixc2 binary)" + echo "Building Kharon listener..." cd "${KHARON_DIR}/listener_kharon_http" +go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" make all echo "Building Kharon agent plugin..." cd "${KHARON_DIR}/agent_kharon" +go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" make plugin echo "Kharon build complete." diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 27fc40b..dcf9e55 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -186,6 +186,10 @@ jobs: - name: Run tests run: ./testing-kit-cli run-tests + - name: Print adaptixc2 logs + if: always() + run: docker compose logs adaptixc2 | tail -100 || true + - name: Tear down if: always() run: ./testing-kit-cli down From 64dd09afa1899e19ee16b55ad09303516bb734da Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:27:53 +0200 Subject: [PATCH 13/26] fix: hardcode axc2 version pin instead of runtime detection The go version -m detection hit a pipefail edge case (binary is named adaptixserver not adaptixc2) that exited the install script immediately. Hardcode v1.2.0 which we confirmed from AdaptixC2's go.mod. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 1bc8645..6c3290a 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -15,12 +15,11 @@ fi echo "Using Go: $(go version)" -# Pin axc2 to the version that matches the running adaptixc2 binary. -# Go plugin ABI requires exact package version match; axc2 v1.1.3 (Kharon -# default) will fail to load against an adaptixc2 built with v1.2.0. -AXC2_VERSION=$(go version -m /app/adaptixc2 2>/dev/null | awk '/github.com\/Adaptix-Framework\/axc2/{print $3}') -AXC2_VERSION="${AXC2_VERSION:-v1.2.0}" -echo "Pinning axc2 to ${AXC2_VERSION} (matches adaptixc2 binary)" +# Kharon's go.mod pins axc2 v1.1.3 but the running adaptixc2 binary +# uses v1.2.0. Go plugin ABI requires the exact same axc2 version; +# mismatches cause adaptixc2 to crash on plugin load. +AXC2_VERSION=v1.2.0 +echo "Pinning axc2 to ${AXC2_VERSION}" echo "Building Kharon listener..." cd "${KHARON_DIR}/listener_kharon_http" From ab21a6f1ff1686c5a14ee65e0262a911d8789791 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:44:45 +0200 Subject: [PATCH 14/26] feat: add Kharon extender CI support with sleep mask disabled - install-kharon.sh: full build pipeline (GOEXPERIMENT detection, pl_agent.go patch for mask_sleep=none, go.work, src_beacon prebuild, src_core BOFs, win32.h stubs, symlinks, cstdint shim) - kharon-tasks.yaml: use real Kharon commands (token getuid, process create, fs ls) instead of generic shell commands - kharon-malleable-profile.json: malleable HTTP profile for listener - test.yaml: add --override agent.mask_sleep=none to ensure no sleep obfuscation - extender_parser.py: fix combo widget mock to track addItem/setCurrentIndex so defaults (Format=Exe, mask_sleep=none) are auto-detected from ax_config.axs; add mask_sleep to _SPECIAL registry as CI-safe override - cli/main.go: use bash TCP probe instead of ss for adaptixc2 readiness check - config/config.yaml: fix SFTP agent_path to POSIX form for OpenSSH on Windows - run-kharon-test.sh: local helper script mirroring the CI test-kharon job Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 162 +++++++++++++++++++-- .github/cicd/kharon-malleable-profile.json | 37 ++++- .github/cicd/kharon-tasks.yaml | 8 +- .github/workflows/test.yaml | 1 + adaptix_testing/extender_parser.py | 95 ++++++++++-- cli/main.go | 2 +- config/config.yaml | 2 +- run-kharon-test.sh | 95 ++++++++++++ 8 files changed, 369 insertions(+), 33 deletions(-) create mode 100755 run-kharon-test.sh diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 6c3290a..88d4517 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -5,30 +5,174 @@ set -euo pipefail KHARON_DIR=/app/userextenders/kharon -if ! command -v go &>/dev/null; then - apt-get update -qq - apt-get install -y -qq golang-go make -elif ! command -v make &>/dev/null; then +if ! command -v make &>/dev/null; then apt-get update -qq apt-get install -y -qq make fi +# --- Go toolchain --- +# Go plugins require the EXACT same Go toolchain version AND GOEXPERIMENT flags +# as the main adaptixserver binary. Read both from the binary. +BINARY_GO=$(go version -m /app/adaptixserver 2>/dev/null | awk 'NR==1{print $2}') || BINARY_GO="" +CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}') || CURRENT_GO="" +BINARY_GOEXP=$(go version -m /app/adaptixserver 2>/dev/null | awk 'NR==1{sub(/^.*X:/,""); print}') || BINARY_GOEXP="" + +if [ -n "$BINARY_GO" ] && [ "$BINARY_GO" != "$CURRENT_GO" ]; then + echo "Toolchain mismatch: adaptixserver=${BINARY_GO}, container=${CURRENT_GO}" + echo "Installing ${BINARY_GO} at /usr/local/go..." + wget -qO- "https://dl.google.com/go/${BINARY_GO}.linux-amd64.tar.gz" | \ + tar -xz -C /usr/local/ --overwrite + echo "Now using: $(go version)" +fi + echo "Using Go: $(go version)" +echo "GOEXPERIMENT: ${BINARY_GOEXP}" -# Kharon's go.mod pins axc2 v1.1.3 but the running adaptixc2 binary -# uses v1.2.0. Go plugin ABI requires the exact same axc2 version; -# mismatches cause adaptixc2 to crash on plugin load. +# --- axc2 version --- +# Kharon's go.mod may pin an older axc2 version; Go plugin ABI requires +# the exact same axc2 as the running server. AXC2_VERSION=v1.2.0 echo "Pinning axc2 to ${AXC2_VERSION}" +# --- Build Kharon listener --- echo "Building Kharon listener..." cd "${KHARON_DIR}/listener_kharon_http" go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" -make all +GOEXPERIMENT="${BINARY_GOEXP}" make all + +# --- Patch pl_agent.go: add mask_sleep="none" -> KH_SLEEP_MASK=0 --- +# Without this, the default sleep mask mode (3) uses obfuscation techniques +# that prevent the agent from beaconing in a plain QEMU VM environment. +AGENT_GO="${KHARON_DIR}/agent_kharon/src_server/pl_agent.go" +python3 -c " +import sys +path = sys.argv[1] +with open(path) as f: + content = f.read() +old = ''' case \"pooling\": + makeVars = append(makeVars, \"KH_SLEEP_MASK=2\") + default: + makeVars = append(makeVars, \"KH_SLEEP_MASK=3\")''' +new = ''' case \"pooling\": + makeVars = append(makeVars, \"KH_SLEEP_MASK=2\") + case \"none\": + makeVars = append(makeVars, \"KH_SLEEP_MASK=0\") + default: + makeVars = append(makeVars, \"KH_SLEEP_MASK=3\")''' +if old in content: + content = content.replace(old, new) + with open(path, 'w') as f: + f.write(content) + print('Patched pl_agent.go: added KH_SLEEP_MASK=0 for mask_sleep=none') +elif 'case \"none\":' in content: + print('pl_agent.go already patched') +else: + print('WARNING: patch target not found in pl_agent.go', file=sys.stderr) +" "$AGENT_GO" + +# --- Build combined go.work to resolve shared package versions --- +# Without this, the plugin may embed different package versions than the server, +# causing plugin.Open() to fail with "different version of package" errors. +ADAPTIX_SRC=/tmp/adaptixc2-src +if [ ! -d "$ADAPTIX_SRC" ]; then + echo "Cloning AdaptixC2 source for go.work..." + git clone --depth=1 https://github.com/TGJLS/AdaptixC2 "$ADAPTIX_SRC" +fi + +COMBINED_WORK=/tmp/combined.work +cat > "$COMBINED_WORK" </dev/null || ! command -v clang &>/dev/null; then + apt-get update -qq + apt-get install -y -qq nasm clang llvm +fi +cd "${KHARON_DIR}/agent_kharon/src_beacon" +make prebuild-x64 + +# --- Build src_core BOF modules --- +# Patch win32.h for types missing from older MinGW SDK headers. +WIN32_H="${KHARON_DIR}/agent_kharon/src_core/include/win32.h" +python3 -c " +import sys +path = sys.argv[1] +with open(path) as f: + content = f.read() +stub = ''' +// Types missing from older MinGW SDK +#ifndef _PROCESS_MITIGATION_USER_POINTER_AUTH_POLICY_DEFINED +#define _PROCESS_MITIGATION_USER_POINTER_AUTH_POLICY_DEFINED +typedef struct { DWORD EnablePointerAuthKernel : 1; DWORD Spare : 31; } PROCESS_MITIGATION_USER_POINTER_AUTH_POLICY; +#endif +#ifndef _PROCESS_MITIGATION_SEHOP_POLICY_DEFINED +#define _PROCESS_MITIGATION_SEHOP_POLICY_DEFINED +typedef struct { DWORD EnableSehop : 1; DWORD Spare : 31; } PROCESS_MITIGATION_SEHOP_POLICY; +#endif +''' +marker = 'typedef struct _PROCESS_MITIGATION_POLICY_INFORMATION' +if stub.strip() not in content: + content = content.replace(marker, stub + marker) + with open(path, 'w') as f: + f.write(content) + print('Patched win32.h: added missing MinGW type stubs') +else: + print('win32.h already patched') +" "$WIN32_H" + +echo "Building src_core BOF modules..." +cd "${KHARON_DIR}/agent_kharon/src_core" +make all + +# --- Set up /dist/extenders/agent_kharon symlinks --- +# adaptixserver looks for src_beacon, src_loader, src_core under +# /dist/extenders/agent_kharon/ at agent-generate time. +DIST_KH=/dist/extenders/agent_kharon +mkdir -p "$DIST_KH" + +for dir in src_beacon src_loader src_core; do + target="${KHARON_DIR}/agent_kharon/${dir}" + link="${DIST_KH}/${dir}" + if [ ! -L "$link" ]; then + ln -sf "$target" "$link" + echo "Symlink: ${link} -> ${target}" + fi +done + +# --- cstdint shim for clang 14 MinGW Exe format compilation --- +CSTDINT="${KHARON_DIR}/agent_kharon/src_loader/Include/cstdint" +if [ ! -f "$CSTDINT" ]; then + cat > "$CSTDINT" <<'SHIM' +#pragma once +#include +#include +SHIM + echo "Created cstdint shim at ${CSTDINT}" +fi echo "Kharon build complete." diff --git a/.github/cicd/kharon-malleable-profile.json b/.github/cicd/kharon-malleable-profile.json index 202aa44..66ec4d9 100644 --- a/.github/cicd/kharon-malleable-profile.json +++ b/.github/cicd/kharon-malleable-profile.json @@ -1,6 +1,35 @@ { - "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", - "Headers": [], - "URIs": ["/api/v1/status", "/api/v1/check"], - "BodyEncoding": "base64" + "callbacks": [ + { + "hosts": ["adaptixc2:8080"], + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "server_error": { + "http_status": 404, + "response": "Not Found", + "headers": {"Content-Type": "text/plain"} + }, + "get": { + "server_headers": {"Content-Type": "application/octet-stream"}, + "client_headers": {"Accept": "*/*"}, + "empty_response": "", + "uri": { + "/api/v1/status": { + "server_output": {"mask": false, "format": "base64"}, + "client_output": {"mask": false, "format": "base64"} + } + } + }, + "post": { + "server_headers": {"Content-Type": "application/octet-stream"}, + "client_headers": {"Content-Type": "application/octet-stream"}, + "empty_response": "", + "uri": { + "/api/v1/check": { + "server_output": {"mask": false, "format": "base64"}, + "client_output": {"mask": false, "format": "base64"} + } + } + } + } + ] } diff --git a/.github/cicd/kharon-tasks.yaml b/.github/cicd/kharon-tasks.yaml index 9d32d93..47e2aea 100644 --- a/.github/cicd/kharon-tasks.yaml +++ b/.github/cicd/kharon-tasks.yaml @@ -1,15 +1,15 @@ tasks: - - cmdline: "shell whoami" + - cmdline: "token getuid" expected: "ci_runner" - - cmdline: "shell hostname" + - cmdline: "process create --command \"hostname\" --pipe" expected_regex: "(?i)win|desktop|server" - - cmdline: "shell dir C:\\" + - cmdline: "fs ls C:\\" expected: "Windows" not_expected: "File Not Found" - - cmdline: "shell echo kharon_test_ok" + - cmdline: "process create --command \"cmd.exe /c echo kharon_test_ok\" --pipe" expected: "kharon_test_ok" - cmdline: "xyzzy frobnicate" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dcf9e55..1296128 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -158,6 +158,7 @@ jobs: --override "listener.ssl_cert=" \ --override "listener.ssl_key=" \ --override "listener.uploaded_file=${PROFILE_B64}" \ + --override "agent.mask_sleep=none" \ https://github.com/entropy-z/Kharon - name: Seed Kharon tasks diff --git a/adaptix_testing/extender_parser.py b/adaptix_testing/extender_parser.py index 8e22dbf..36c6c54 100644 --- a/adaptix_testing/extender_parser.py +++ b/adaptix_testing/extender_parser.py @@ -26,15 +26,50 @@ create_container: function() { return { put: function(k, w, d) { - _fields.push({key: k, widget: w && w.t ? w.t : 'string', def: d !== undefined ? d : null}); + var val = d !== undefined ? d : (w && w._val !== undefined ? w._val : null); + _fields.push({key: k, widget: w && w.t ? w.t : 'string', def: val}); } }; }, - create_combo: function() { return _widget('combo'); }, - create_spin: function() { return _widget('spin'); }, + create_combo: function() { + var w = {t: 'combo', _items: [], _idx: 0, _val: null}; + var noop = function() { return w; }; + w.setEnabled = noop; w.clear = function() { w._items = []; w._idx = 0; w._val = null; return w; }; + w.connect = noop; w.setLayout = noop; w.setPanel = noop; + w.setRange = noop; w.setChecked = noop; w.setPlaceholder = noop; w.setReadOnly = noop; + w.setSelection = noop; w.setColumnStretch = noop; w.setSpacing = noop; + w.addItem = function(v) { w._items.push(v); if (w._items.length === 1) { w._val = v; } return w; }; + w.addItems = function(arr) { arr.forEach(function(v) { w.addItem(v); }); return w; }; + w.setCurrentIndex = function(i) { if (i >= 0 && i < w._items.length) { w._val = w._items[i]; } return w; }; + w.getSelection = function() { return w._val || ''; }; + w.addWidget = noop; w.addRow = noop; w.addColumn = noop; + return w; + }, + create_spin: function(def) { + var w = {t: 'spin', _val: def !== undefined ? def : null}; + var noop = function() { return w; }; + w.setEnabled = noop; w.clear = noop; w.connect = noop; w.addItem = noop; w.addItems = noop; + w.addWidget = noop; w.addRow = noop; w.addColumn = noop; w.setLayout = noop; w.setPanel = noop; + w.setRange = noop; w.setChecked = noop; w.setPlaceholder = noop; w.setReadOnly = noop; + w.setCurrentIndex = noop; w.setSelection = noop; w.setColumnStretch = noop; w.setSpacing = noop; + w.setValue = function(v) { w._val = v; return w; }; + w.getSelection = function() { return w._val !== null ? String(w._val) : ''; }; + return w; + }, create_checkbox: function() { return _widget('bool'); }, create_check: function() { return _widget('bool'); }, - create_textline: function() { return _widget('string'); }, + create_textline: function(def) { + var w = {t: 'string', _val: def !== undefined ? def : null}; + var noop = function() { return w; }; + w.setEnabled = noop; w.clear = noop; w.connect = noop; w.addItem = noop; w.addItems = noop; + w.addWidget = noop; w.addRow = noop; w.addColumn = noop; w.setLayout = noop; w.setPanel = noop; + w.setRange = noop; w.setChecked = noop; w.setReadOnly = noop; + w.setCurrentIndex = noop; w.setSelection = noop; w.setColumnStretch = noop; w.setSpacing = noop; + w.setValue = function(v) { w._val = v; return w; }; + w.setPlaceholder = function() { return w; }; + w.getSelection = function() { return w._val !== null ? w._val : ''; }; + return w; + }, create_textmulti: function() { return _widget('string'); }, create_file: function() { return _widget('file'); }, create_selector_file: function() { return _widget('file'); }, @@ -45,6 +80,9 @@ create_gridlayout: function() { return _widget(''); }, create_hlayout: function() { return _widget(''); }, create_panel: function() { return _widget(''); }, + create_scrollarea: function() { return _widget(''); }, + create_tabwidget: function() { return _widget(''); }, + create_stackwidget: function() { return _widget(''); }, connect: function() {}, }; function getNetworkInterfaces() { return ['0.0.0.0']; } @@ -97,6 +135,9 @@ "encrypt_key": {"source": "generate", "value": None}, "uploaded_file": {"source": "required", "value": None, "hint": "base64-encoded malleable profile JSON"}, + # Disable sleep masking in CI — obfuscated sleep modes prevent agents from + # beaconing in a plain QEMU VM (no kernel driver, no obfuscation support). + "mask_sleep": {"source": "auto", "value": "none"}, } @@ -112,19 +153,45 @@ def _es5_compat(js: str) -> str: return js +def _extract_function(js: str, fn_name: str) -> str: + """Extract the body of a named function from JS source, handling nested braces.""" + pattern = re.compile(r'\bfunction\s+' + re.escape(fn_name) + r'\s*\([^)]*\)\s*\{') + m = pattern.search(js) + if not m: + return "" + start = m.end() - 1 # position of opening '{' + depth = 0 + for i in range(start, len(js)): + if js[i] == '{': + depth += 1 + elif js[i] == '}': + depth -= 1 + if depth == 0: + return js[m.start():i + 1] + return "" + + def parse_axs_fields(axs_text: str, fn_name: str) -> list[dict]: """Evaluate axs_text with mock globals; call fn_name; return raw [{key,widget,def}].""" arg = "'create'" if fn_name == "ListenerUI" else "''" - try: - interp = dukpy.JSInterpreter() - interp.evaljs(_MOCK_JS) - interp.evaljs(_es5_compat(axs_text)) - interp.evaljs("_fields = [];") - interp.evaljs(f"if (typeof {fn_name} !== 'undefined') {{ {fn_name}({arg}); }}") - raw = interp.evaljs("JSON.stringify(_fields)") - return json.loads(raw) if raw and raw != "null" else [] - except Exception: - return [] + # First try evaluating the whole file (works when no syntax errors). + # Fall back to extracting and evaluating just the target function body. + for js_to_eval in [_es5_compat(axs_text), _extract_function(axs_text, fn_name)]: + if not js_to_eval: + continue + try: + interp = dukpy.JSInterpreter() + interp.evaljs(_MOCK_JS) + interp.evaljs(_es5_compat(js_to_eval)) + interp.evaljs("_fields = [];") + interp.evaljs(f"if (typeof {fn_name} !== 'undefined') {{ {fn_name}({arg}); }}") + raw = interp.evaljs("JSON.stringify(_fields)") + result = json.loads(raw) if raw and raw != "null" else [] + if result: + return result + except Exception: + continue + return [] def classify_field(key: str, widget: str, default) -> dict: diff --git a/cli/main.go b/cli/main.go index 779d73d..0540463 100644 --- a/cli/main.go +++ b/cli/main.go @@ -432,7 +432,7 @@ func cmdAddExtender(args []string) { for i := 0; i < 150; i++ { time.Sleep(2 * time.Second) out, err := exec.Command("docker", "exec", "adaptixc2", - "sh", "-c", "ss -tln 2>/dev/null | grep -q ':4321'").Output() + "bash", "-c", "(echo > /dev/tcp/localhost/4321) 2>/dev/null").Output() _ = out if err == nil { fmt.Println("\n✓ adaptixc2 ready") diff --git a/config/config.yaml b/config/config.yaml index fe2fd48..4fb744d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -42,7 +42,7 @@ ssh: username: ci_runner key_path: /run/secrets/ssh_key source_path: /tmp/ci_agent.exe - agent_path: 'C:\ci\agent.exe' + agent_path: '/ci/agent.exe' terminate: true connect_retries: 90 connect_retry_interval: 20 diff --git a/run-kharon-test.sh b/run-kharon-test.sh new file mode 100755 index 0000000..d623a6a --- /dev/null +++ b/run-kharon-test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Run the Kharon extender test locally — mirrors .github/workflows/test.yaml test-kharon job. +# Usage: ./run-kharon-test.sh [--no-build] [--no-teardown] +set -euo pipefail + +cd "$(dirname "$0")" + +BUILD=true +TEARDOWN=true +for arg in "$@"; do + case "$arg" in + --no-build) BUILD=false ;; + --no-teardown) TEARDOWN=false ;; + *) echo "Unknown arg: $arg"; exit 1 ;; + esac +done + +cleanup() { + echo "" + echo "=== adaptixc2 logs (last 100 lines) ===" + docker compose logs adaptixc2 2>/dev/null | tail -100 || true + if $TEARDOWN; then + echo "=== Tearing down ===" + docker compose down || true + fi +} +trap cleanup EXIT + +if $BUILD; then + echo "=== Building CLI ===" + cd cli && go build -o ../testing-kit-cli . + cd .. + + echo "=== Building Docker image ===" + docker build -t ghcr.io/tgjls/testing-kit:2.1.0 . +fi + +echo "=== Starting containers ===" +docker compose up -d + +echo "=== Waiting for testing-kit API ===" +for i in $(seq 1 30); do + if curl -sf http://localhost:1234/health > /dev/null 2>&1; then + echo "✓ testing-kit API ready (attempt $i)" + break + fi + echo "Waiting for testing-kit API (attempt $i/30)..." + sleep 2 + [ "$i" -eq 30 ] && { echo "testing-kit API did not become ready in time"; exit 1; } +done + +echo "=== Adding Kharon extender ===" +PROFILE_B64=$(base64 -w0 .github/cicd/kharon-malleable-profile.json) +./testing-kit-cli add-extender \ + --install-script .github/cicd/install-kharon.sh \ + --override "listener.port_bind=8080" \ + --override "listener.block_user_agents=" \ + --override "listener.domain_rotation_strategy=Random" \ + --override "listener.proxy_url=" \ + --override "listener.proxy_user=" \ + --override "listener.proxy_pass=" \ + --override "listener.ssl_cert=" \ + --override "listener.ssl_key=" \ + --override "listener.uploaded_file=${PROFILE_B64}" \ + https://github.com/entropy-z/Kharon + +echo "=== Seeding Kharon tasks ===" +curl -sf -X PUT http://localhost:1234/v1/tasks/batch \ + -H "Content-Type: application/json" \ + -d "$(python3 -c " +import json, yaml +data = yaml.safe_load(open('.github/cicd/kharon-tasks.yaml')) +print(json.dumps(data['tasks'])) +")" +echo "" + +echo "=== Waiting for Windows SSH (up to 50 min) ===" +for i in $(seq 1 150); do + if docker compose exec testing-kit python3 -c \ + "import socket,sys; socket.create_connection(('windows',22),5).close()" \ + 2>/dev/null; then + echo "✓ Windows SSH ready (attempt $i)" + break + fi + echo "Waiting for Windows SSH (attempt $i/150)..." + sleep 20 + [ "$i" -eq 150 ] && { + docker compose logs windows | tail -50 + echo "Windows SSH not ready after 50 minutes" + exit 1 + } +done + +echo "=== Running tests ===" +./testing-kit-cli run-tests From f0b80953bc517bc67e4db0202be53394bb271e44 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:58:15 +0200 Subject: [PATCH 15/26] fix: provide CI-safe defaults for Kharon evasive/optional agent fields Add guardrails_user, guardrails_domain, killdate_date, workingtime_start, workingtime_end to the _SPECIAL registry with empty-string values so the extender parser marks them as source:auto instead of source:required. These are optional evasive features: empty string disables them in Kharon. Also add matching --override flags in the test-kharon CI step so they are explicitly cleared even if the schema detection changes. Without this, add-extender exits 1 ("missing required fields") and the CI job never reaches the Windows test run. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 5 +++++ adaptix_testing/extender_parser.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1296128..9daa7c1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -159,6 +159,11 @@ jobs: --override "listener.ssl_key=" \ --override "listener.uploaded_file=${PROFILE_B64}" \ --override "agent.mask_sleep=none" \ + --override "agent.guardrails_user=" \ + --override "agent.guardrails_domain=" \ + --override "agent.killdate_date=" \ + --override "agent.workingtime_start=" \ + --override "agent.workingtime_end=" \ https://github.com/entropy-z/Kharon - name: Seed Kharon tasks diff --git a/adaptix_testing/extender_parser.py b/adaptix_testing/extender_parser.py index 36c6c54..fbc035b 100644 --- a/adaptix_testing/extender_parser.py +++ b/adaptix_testing/extender_parser.py @@ -138,6 +138,16 @@ # Disable sleep masking in CI — obfuscated sleep modes prevent agents from # beaconing in a plain QEMU VM (no kernel driver, no obfuscation support). "mask_sleep": {"source": "auto", "value": "none"}, + # Evasive/optional fields: empty string disables the feature in Kharon. + # guardrails_user/domain are optional text fields with "" as their intended + # empty default; classify_field treats "" as "required" for string widgets. + # killdate_date/workingtime_* are date/time widgets with no real default — + # empty string means "feature disabled" in all cases. + "guardrails_user": {"source": "auto", "value": ""}, + "guardrails_domain": {"source": "auto", "value": ""}, + "killdate_date": {"source": "auto", "value": ""}, + "workingtime_start": {"source": "auto", "value": ""}, + "workingtime_end": {"source": "auto", "value": ""}, } From d9952c602c23ca282113bf559a33fae2851267ae Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:18:46 +0200 Subject: [PATCH 16/26] fix: install python3 in adaptixc2 container before running install-kharon.sh python3 is not present in the adaptixc2 base image; add it to the initial apt-get install block alongside make so the pl_agent.go and win32.h patch steps don't fail with 'command not found'. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 88d4517..5ca0847 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -5,9 +5,12 @@ set -euo pipefail KHARON_DIR=/app/userextenders/kharon -if ! command -v make &>/dev/null; then +NEED_PKGS=() +command -v make &>/dev/null || NEED_PKGS+=(make) +command -v python3 &>/dev/null || NEED_PKGS+=(python3) +if [ ${#NEED_PKGS[@]} -gt 0 ]; then apt-get update -qq - apt-get install -y -qq make + apt-get install -y -qq "${NEED_PKGS[@]}" fi # --- Go toolchain --- From ae33056ef208f4d219272d2b4acb2cf50ac2e302 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:36:50 +0200 Subject: [PATCH 17/26] fix: use actual Go version from binary in go.work instead of hardcoded 1.25 The Kharon module's go.mod requires go >= 1.25.4, but the go.work header was hardcoded to 'go 1.25', causing 'go build -buildmode=plugin' to fail with 'module requires go >= 1.25.4, but go.work lists go 1.25'. Extract the version from the adaptixserver binary (e.g. go1.25.4) and strip the 'go' prefix for the go.work directive so they always match. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 5ca0847..a95b411 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -83,8 +83,10 @@ if [ ! -d "$ADAPTIX_SRC" ]; then fi COMBINED_WORK=/tmp/combined.work +GO_WORK_VER="${BINARY_GO#go}" +[ -z "$GO_WORK_VER" ] && GO_WORK_VER="1.25" cat > "$COMBINED_WORK" < Date: Sat, 11 Jul 2026 18:56:57 +0200 Subject: [PATCH 18/26] fix: build listener plugin with combined go.work to fix axc2 version mismatch The listener .so was being built without the combined go.work, causing adaptixserver to reject it with: plugin was built with a different version of package axc2 Move AdaptixC2 clone and go.work creation before both plugin builds so that both listener_kharon_http and agent_kharon use identical package resolution. Add GOWORK to the listener make call just as it was already used for the agent. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 62 ++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index a95b411..9204cad 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -37,11 +37,42 @@ echo "GOEXPERIMENT: ${BINARY_GOEXP}" AXC2_VERSION=v1.2.0 echo "Pinning axc2 to ${AXC2_VERSION}" +# --- Build combined go.work FIRST --- +# Both listener and agent plugins must be built with a single go.work so that +# all shared packages (especially axc2) resolve to the exact same versions as +# the running adaptixserver binary. Building without this causes: +# plugin.Open: "plugin was built with a different version of package axc2" +ADAPTIX_SRC=/tmp/adaptixc2-src +if [ ! -d "$ADAPTIX_SRC" ]; then + echo "Cloning AdaptixC2 source for go.work..." + git clone --depth=1 https://github.com/TGJLS/AdaptixC2 "$ADAPTIX_SRC" +fi + +COMBINED_WORK=/tmp/combined.work +GO_WORK_VER="${BINARY_GO#go}" +[ -z "$GO_WORK_VER" ] && GO_WORK_VER="1.25" +cat > "$COMBINED_WORK" < KH_SLEEP_MASK=0 --- # Without this, the default sleep mask mode (3) uses obfuscation techniques @@ -73,35 +104,6 @@ else: print('WARNING: patch target not found in pl_agent.go', file=sys.stderr) " "$AGENT_GO" -# --- Build combined go.work to resolve shared package versions --- -# Without this, the plugin may embed different package versions than the server, -# causing plugin.Open() to fail with "different version of package" errors. -ADAPTIX_SRC=/tmp/adaptixc2-src -if [ ! -d "$ADAPTIX_SRC" ]; then - echo "Cloning AdaptixC2 source for go.work..." - git clone --depth=1 https://github.com/TGJLS/AdaptixC2 "$ADAPTIX_SRC" -fi - -COMBINED_WORK=/tmp/combined.work -GO_WORK_VER="${BINARY_GO#go}" -[ -z "$GO_WORK_VER" ] && GO_WORK_VER="1.25" -cat > "$COMBINED_WORK" < Date: Sat, 11 Jul 2026 19:37:18 +0200 Subject: [PATCH 19/26] fix: diagnose axc2 ABI mismatch + pin to binary version + minimal go.work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Print full go version -m /app/adaptixserver so CI logs reveal the exact axc2 version and any replace directives in the server binary - Extract axc2 version from binary (BINARY_AXC2) and use it as AXC2_VERSION instead of hardcoding v1.2.0 - Strip AdaptixC2 modules from go.work — including them caused MVS to bump axc2 to the AdaptixC2 HEAD version, which mismatches the docker image (built from an older commit); now go.work contains only the Kharon listener + agent modules so axc2 resolves to BINARY_AXC2 - Add diagnostic: print all go.mod files in the AdaptixC2 clone (including their axc2 require lines) for future debugging - Print the listener Makefile so we can verify GOEXPERIMENT is preserved - Print axc2 version from both built plugins and the server binary for direct comparison after each build Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 68 ++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 9204cad..9ebdb41 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -31,10 +31,22 @@ fi echo "Using Go: $(go version)" echo "GOEXPERIMENT: ${BINARY_GOEXP}" -# --- axc2 version --- -# Kharon's go.mod may pin an older axc2 version; Go plugin ABI requires -# the exact same axc2 as the running server. -AXC2_VERSION=v1.2.0 +# --- Inspect adaptixserver binary --- +# Print the full dependency list so CI logs tell us exactly what axc2 version +# (and any replace directives) the running server was compiled with. +echo "=== adaptixserver build info ===" +go version -m /app/adaptixserver 2>/dev/null || echo "(go version -m failed)" + +# Extract the exact axc2 version embedded in the binary. If the server used a +# local/replace source the output is "(devel)" — fall back to v1.2.0 in that case. +BINARY_AXC2=$(go version -m /app/adaptixserver 2>/dev/null | \ + awk '/github\.com\/Adaptix-Framework\/axc2/{print $3}') || BINARY_AXC2="" +echo "axc2 in binary: ${BINARY_AXC2:-unknown}" +if [[ "${BINARY_AXC2}" =~ ^v[0-9] ]]; then + AXC2_VERSION="${BINARY_AXC2}" +else + AXC2_VERSION=v1.2.0 +fi echo "Pinning axc2 to ${AXC2_VERSION}" # --- Build combined go.work FIRST --- @@ -42,34 +54,43 @@ echo "Pinning axc2 to ${AXC2_VERSION}" # all shared packages (especially axc2) resolve to the exact same versions as # the running adaptixserver binary. Building without this causes: # plugin.Open: "plugin was built with a different version of package axc2" +# We discover ALL go.mod files in the TGJLS/AdaptixC2 clone dynamically so +# that any local axc2 module (pointed to by a replace directive) is included. ADAPTIX_SRC=/tmp/adaptixc2-src if [ ! -d "$ADAPTIX_SRC" ]; then echo "Cloning AdaptixC2 source for go.work..." git clone --depth=1 https://github.com/TGJLS/AdaptixC2 "$ADAPTIX_SRC" fi +echo "=== Modules found in AdaptixC2 clone (diagnostic only) ===" +while IFS= read -r gomod_path; do + dir=$(dirname "$gomod_path") + echo " ${dir}: $(head -1 "$gomod_path")" + if grep -q 'Adaptix-Framework/axc2' "$gomod_path" 2>/dev/null; then + grep 'Adaptix-Framework/axc2' "$gomod_path" | sed 's/^/ axc2 in go.mod: /' + fi +done < <(find "$ADAPTIX_SRC" -name "go.mod" -not -path "*/vendor/*" | sort) + +# Build a go.work with ONLY the Kharon modules so that MVS picks exactly the +# axc2 version we pinned via "go get", without interference from AdaptixC2's +# potentially-newer go.mod (the docker image may lag behind the repo HEAD). COMBINED_WORK=/tmp/combined.work GO_WORK_VER="${BINARY_GO#go}" [ -z "$GO_WORK_VER" ] && GO_WORK_VER="1.25" -cat > "$COMBINED_WORK" < "$COMBINED_WORK" + +echo "=== Generated go.work ===" +cat "$COMBINED_WORK" # --- Build Kharon listener --- echo "Building Kharon listener..." +echo "=== listener_kharon_http/Makefile ===" +cat "${KHARON_DIR}/listener_kharon_http/Makefile" 2>/dev/null || echo "(no Makefile)" cd "${KHARON_DIR}/listener_kharon_http" go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" make all @@ -114,6 +135,13 @@ GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" \ go build -buildmode=plugin -o "../dist/agent_kharon.so" . echo "Built: $(ls -sh ../dist/agent_kharon.so)" +echo "=== axc2 version in built plugins ===" +go version -m "${KHARON_DIR}/listener_kharon_http/dist/listener_kharon_http.so" 2>/dev/null | \ + awk '/axc2/{print " listener: "$0}' || echo " listener: (read failed)" +go version -m "${KHARON_DIR}/agent_kharon/dist/agent_kharon.so" 2>/dev/null | \ + awk '/axc2/{print " agent: "$0}' || echo " agent: (read failed)" +echo " server: $(go version -m /app/adaptixserver 2>/dev/null | awk '/axc2/{print $0}')" + # --- Build src_beacon BOF prerequisites --- echo "Building src_beacon prerequisites (nasm, LLVM object files)..." if ! command -v nasm &>/dev/null || ! command -v clang &>/dev/null; then From e6b1217cd2856c88b2c1e19c113c779d7e7c199d Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:11:03 +0200 Subject: [PATCH 20/26] fix: rebuild adaptixserver from source to guarantee plugin ABI compatibility Go plugin ABI compatibility requires every shared package to have the same build ID. The build ID is: hash(source + dep_ids + go_compiler_hash). Even when go version strings match (go1.25.11), a freshly-downloaded tarball can produce a different compiler binary hash than the binary embedded in the ghcr.io/tgjls/adaptixc2 Docker image. Fix: rebuild /app/adaptixserver from the TGJLS/AdaptixC2 source clone using the same go1.25.11 we install, then build the Kharon plugins with that same binary. Since both server and plugins are compiled by the identical go binary, all package build IDs are guaranteed to match. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 9ebdb41..089bf68 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -31,6 +31,33 @@ fi echo "Using Go: $(go version)" echo "GOEXPERIMENT: ${BINARY_GOEXP}" +# --- Rebuild adaptixserver from source --- +# Go plugin ABI compatibility requires every shared package to have the SAME +# build ID, which is computed from: source content + dependency build IDs + +# Go COMPILER BINARY HASH. Even if the version string matches (go1.25.11), +# a downloaded tarball may produce a different compiler hash than the binary +# used inside the Docker image. +# The only guaranteed fix: rebuild adaptixserver with OUR go binary, then +# build the plugins with the same go binary → hashes are identical by +# construction. +AXC2_CLONE=/tmp/adaptixc2-src +if [ ! -d "$AXC2_CLONE" ]; then + echo "Cloning TGJLS/AdaptixC2 for server rebuild..." + git clone --depth=1 https://github.com/TGJLS/AdaptixC2 "$AXC2_CLONE" +else + echo "Using existing TGJLS/AdaptixC2 clone at ${AXC2_CLONE}" +fi + +echo "Rebuilding /app/adaptixserver from ${AXC2_CLONE}/AdaptixServer ..." +( + cd "${AXC2_CLONE}/AdaptixServer" + # ensure all module deps are present (runs offline if already cached) + go mod download 2>/dev/null || true + GOEXPERIMENT="${BINARY_GOEXP}" CGO_ENABLED=1 \ + go build -ldflags="-s -w" -o /app/adaptixserver . + echo "Rebuilt: $(go version -m /app/adaptixserver 2>/dev/null | head -1)" +) + # --- Inspect adaptixserver binary --- # Print the full dependency list so CI logs tell us exactly what axc2 version # (and any replace directives) the running server was compiled with. @@ -56,11 +83,7 @@ echo "Pinning axc2 to ${AXC2_VERSION}" # plugin.Open: "plugin was built with a different version of package axc2" # We discover ALL go.mod files in the TGJLS/AdaptixC2 clone dynamically so # that any local axc2 module (pointed to by a replace directive) is included. -ADAPTIX_SRC=/tmp/adaptixc2-src -if [ ! -d "$ADAPTIX_SRC" ]; then - echo "Cloning AdaptixC2 source for go.work..." - git clone --depth=1 https://github.com/TGJLS/AdaptixC2 "$ADAPTIX_SRC" -fi +ADAPTIX_SRC="$AXC2_CLONE" echo "=== Modules found in AdaptixC2 clone (diagnostic only) ===" while IFS= read -r gomod_path; do From 9f721fdcf2fec39a1792befed33ca0ebd5793c11 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:47:22 +0200 Subject: [PATCH 21/26] fix: pin ALL server dep versions to prevent plugin ABI mismatch The previous rebuild-adaptixserver approach broke the pre-built beacon/gopher plugins (the server was rebuilt from HEAD, which uses a different axc2 than the image was built with). Root cause of original Kharon failure: axc2 ABI mismatch was caused by TRANSITIVE dependency version skew (x/sys, x/text, etc.), not the compiler binary hash. Even with identical axc2 h1: source hashes, different x/sys/x/text versions produce different axc2 build IDs because build IDs are recursive. Fix: extract every dep version embedded in the server binary via 'go version -m /app/adaptixserver' and pin them all with 'go mod edit -require' before building Kharon plugins. This guarantees identical build IDs for all shared packages without touching the pre-built server or beacon plugins. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 105 +++++++++++++++------------------ adaptixc2/dist/profile.yaml | 79 +++++++++++++------------ 2 files changed, 89 insertions(+), 95 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 089bf68..51c47ca 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -31,42 +31,27 @@ fi echo "Using Go: $(go version)" echo "GOEXPERIMENT: ${BINARY_GOEXP}" -# --- Rebuild adaptixserver from source --- -# Go plugin ABI compatibility requires every shared package to have the SAME -# build ID, which is computed from: source content + dependency build IDs + -# Go COMPILER BINARY HASH. Even if the version string matches (go1.25.11), -# a downloaded tarball may produce a different compiler hash than the binary -# used inside the Docker image. -# The only guaranteed fix: rebuild adaptixserver with OUR go binary, then -# build the plugins with the same go binary → hashes are identical by -# construction. -AXC2_CLONE=/tmp/adaptixc2-src -if [ ! -d "$AXC2_CLONE" ]; then - echo "Cloning TGJLS/AdaptixC2 for server rebuild..." - git clone --depth=1 https://github.com/TGJLS/AdaptixC2 "$AXC2_CLONE" +# --- Inspect adaptixserver binary --- +# Pin ALL dep versions from the server binary so every shared package in the +# plugin has an IDENTICAL build ID to the one already compiled into the server. +# The axc2 h1: hash alone is not sufficient — axc2's transitive deps (x/sys, +# x/text, …) also affect build IDs, and any version delta causes: +# plugin.Open: "plugin was built with a different version of package X" +echo "=== adaptixserver build info ===" +SERVER_BUILD_INFO=$(go version -m /app/adaptixserver 2>/dev/null) || SERVER_BUILD_INFO="" +if [ -z "$SERVER_BUILD_INFO" ]; then + echo "(go version -m failed)" else - echo "Using existing TGJLS/AdaptixC2 clone at ${AXC2_CLONE}" + echo "$SERVER_BUILD_INFO" fi -echo "Rebuilding /app/adaptixserver from ${AXC2_CLONE}/AdaptixServer ..." -( - cd "${AXC2_CLONE}/AdaptixServer" - # ensure all module deps are present (runs offline if already cached) - go mod download 2>/dev/null || true - GOEXPERIMENT="${BINARY_GOEXP}" CGO_ENABLED=1 \ - go build -ldflags="-s -w" -o /app/adaptixserver . - echo "Rebuilt: $(go version -m /app/adaptixserver 2>/dev/null | head -1)" -) - -# --- Inspect adaptixserver binary --- -# Print the full dependency list so CI logs tell us exactly what axc2 version -# (and any replace directives) the running server was compiled with. -echo "=== adaptixserver build info ===" -go version -m /app/adaptixserver 2>/dev/null || echo "(go version -m failed)" +# Extract all dep versions: "module/path@vX.Y.Z" +SERVER_DEPS_FILE=/tmp/server_deps.txt +echo "$SERVER_BUILD_INFO" | awk '/^\tdep\t/{print $2"@"$3}' > "$SERVER_DEPS_FILE" +echo "Found $(wc -l < "$SERVER_DEPS_FILE" | tr -d ' ') dep modules in server binary" -# Extract the exact axc2 version embedded in the binary. If the server used a -# local/replace source the output is "(devel)" — fall back to v1.2.0 in that case. -BINARY_AXC2=$(go version -m /app/adaptixserver 2>/dev/null | \ +# Extract the exact axc2 version. Fall back to v1.2.0 if not found. +BINARY_AXC2=$(echo "$SERVER_BUILD_INFO" | \ awk '/github\.com\/Adaptix-Framework\/axc2/{print $3}') || BINARY_AXC2="" echo "axc2 in binary: ${BINARY_AXC2:-unknown}" if [[ "${BINARY_AXC2}" =~ ^v[0-9] ]]; then @@ -76,27 +61,8 @@ else fi echo "Pinning axc2 to ${AXC2_VERSION}" -# --- Build combined go.work FIRST --- -# Both listener and agent plugins must be built with a single go.work so that -# all shared packages (especially axc2) resolve to the exact same versions as -# the running adaptixserver binary. Building without this causes: -# plugin.Open: "plugin was built with a different version of package axc2" -# We discover ALL go.mod files in the TGJLS/AdaptixC2 clone dynamically so -# that any local axc2 module (pointed to by a replace directive) is included. -ADAPTIX_SRC="$AXC2_CLONE" - -echo "=== Modules found in AdaptixC2 clone (diagnostic only) ===" -while IFS= read -r gomod_path; do - dir=$(dirname "$gomod_path") - echo " ${dir}: $(head -1 "$gomod_path")" - if grep -q 'Adaptix-Framework/axc2' "$gomod_path" 2>/dev/null; then - grep 'Adaptix-Framework/axc2' "$gomod_path" | sed 's/^/ axc2 in go.mod: /' - fi -done < <(find "$ADAPTIX_SRC" -name "go.mod" -not -path "*/vendor/*" | sort) - -# Build a go.work with ONLY the Kharon modules so that MVS picks exactly the -# axc2 version we pinned via "go get", without interference from AdaptixC2's -# potentially-newer go.mod (the docker image may lag behind the repo HEAD). +# --- Build combined go.work --- +# Include ONLY Kharon modules so AdaptixC2 HEAD doesn't bump deps via MVS. COMBINED_WORK=/tmp/combined.work GO_WORK_VER="${BINARY_GO#go}" [ -z "$GO_WORK_VER" ] && GO_WORK_VER="1.25" @@ -110,13 +76,26 @@ GO_WORK_VER="${BINARY_GO#go}" echo "=== Generated go.work ===" cat "$COMBINED_WORK" +# Pin ALL server dep versions into a module's go.mod so MVS selects identical +# versions for every shared package. +pin_server_deps() { + local dir="$1" + echo "Pinning server dep versions in $(basename "$dir")..." + (cd "$dir" && while IFS= read -r dep; do + go mod edit -require "$dep" 2>/dev/null || true + done < "$SERVER_DEPS_FILE") + # Download pinned modules to update go.sum before the build. + (cd "$dir" && GOWORK="${COMBINED_WORK}" GONOSUMDB='*' go mod download 2>/dev/null || true) +} + # --- Build Kharon listener --- echo "Building Kharon listener..." echo "=== listener_kharon_http/Makefile ===" cat "${KHARON_DIR}/listener_kharon_http/Makefile" 2>/dev/null || echo "(no Makefile)" cd "${KHARON_DIR}/listener_kharon_http" go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" -GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" make all +pin_server_deps "${KHARON_DIR}/listener_kharon_http" +GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" GONOSUMDB='*' GOFLAGS='-mod=mod' make all # --- Patch pl_agent.go: add mask_sleep="none" -> KH_SLEEP_MASK=0 --- # Without this, the default sleep mask mode (3) uses obfuscation techniques @@ -152,9 +131,10 @@ else: echo "Building Kharon agent plugin..." cd "${KHARON_DIR}/agent_kharon" go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" +pin_server_deps "${KHARON_DIR}/agent_kharon" rm -f dist/agent_kharon.so cd "${KHARON_DIR}/agent_kharon/src_server" -GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" \ +GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" GONOSUMDB='*' GOFLAGS='-mod=mod' \ go build -buildmode=plugin -o "../dist/agent_kharon.so" . echo "Built: $(ls -sh ../dist/agent_kharon.so)" @@ -163,7 +143,20 @@ go version -m "${KHARON_DIR}/listener_kharon_http/dist/listener_kharon_http.so" awk '/axc2/{print " listener: "$0}' || echo " listener: (read failed)" go version -m "${KHARON_DIR}/agent_kharon/dist/agent_kharon.so" 2>/dev/null | \ awk '/axc2/{print " agent: "$0}' || echo " agent: (read failed)" -echo " server: $(go version -m /app/adaptixserver 2>/dev/null | awk '/axc2/{print $0}')" +echo " server: $(echo "$SERVER_BUILD_INFO" | awk '/axc2/{print $0}')" + +# --- Diagnostic: compare key dep versions across server and plugins --- +echo "=== Key dep versions in built artifacts ===" +for artifact in \ + "/app/adaptixserver" \ + "${KHARON_DIR}/listener_kharon_http/dist/listener_kharon_http.so" \ + "${KHARON_DIR}/agent_kharon/dist/agent_kharon.so" +do + echo " $(basename "$artifact"):" + go version -m "$artifact" 2>/dev/null | \ + awk '/golang\.org\/x\/sys|golang\.org\/x\/text|Adaptix-Framework\/axc2/{printf " %s\n", $0}' \ + || echo " (read failed)" +done # --- Build src_beacon BOF prerequisites --- echo "Building src_beacon prerequisites (nasm, LLVM object files)..." diff --git a/adaptixc2/dist/profile.yaml b/adaptixc2/dist/profile.yaml index 2952da6..933897f 100644 --- a/adaptixc2/dist/profile.yaml +++ b/adaptixc2/dist/profile.yaml @@ -1,50 +1,51 @@ # Managed by Testing-Kit — do not edit manually -Teamserver: - interface: "0.0.0.0" - port: 4321 - endpoint: "/endpoint" - password: "pass" - only_password: true - cert: "server.rsa.crt" - key: "server.rsa.key" - extenders: - - "extenders/beacon_listener_http/config.yaml" - - "extenders/beacon_listener_smb/config.yaml" - - "extenders/beacon_listener_tcp/config.yaml" - - "extenders/beacon_listener_dns/config.yaml" - - "extenders/beacon_agent/config.yaml" - - "extenders/gopher_listener_tcp/config.yaml" - - "extenders/gopher_agent/config.yaml" - axscripts: [] - access_token_live_hours: 12 - refresh_token_live_hours: 168 - HttpServer: error: - status: 404 headers: - Content-Type: "text/html; charset=UTF-8" - Server: "AdaptixC2" - Adaptix-Version: "v1.2" - page: "404page.html" + Adaptix-Version: v1.2 + Content-Type: text/html; charset=UTF-8 + Server: AdaptixC2 + page: 404page.html + status: 404 http: + disable_keep_alives: false + enable_http2: true + idle_timeout_sec: 0 max_header_bytes: 8192 read_header_timeout_sec: 0 read_timeout_sec: 0 - write_timeout_sec: 0 - idle_timeout_sec: 0 + request_timeout_message: 504 Gateway Timeout request_timeout_sec: 300 - request_timeout_message: "504 Gateway Timeout" - disable_keep_alives: false - enable_http2: true + write_timeout_sec: 0 tls: - min_version: "TLS1.2" - max_version: "TLS1.3" - prefer_server_cipher_suites: false cipher_suites: - - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" - - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" - - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" - - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" - - "TLS_RSA_WITH_AES_128_GCM_SHA256" - - "TLS_RSA_WITH_AES_256_GCM_SHA384" + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + - TLS_RSA_WITH_AES_128_GCM_SHA256 + - TLS_RSA_WITH_AES_256_GCM_SHA384 + max_version: TLS1.3 + min_version: TLS1.2 + prefer_server_cipher_suites: false +Teamserver: + access_token_live_hours: 12 + axscripts: [] + cert: server.rsa.crt + endpoint: /endpoint + extenders: + - extenders/beacon_listener_http/config.yaml + - extenders/beacon_listener_smb/config.yaml + - extenders/beacon_listener_tcp/config.yaml + - extenders/beacon_listener_dns/config.yaml + - extenders/beacon_agent/config.yaml + - extenders/gopher_listener_tcp/config.yaml + - extenders/gopher_agent/config.yaml + - /app/userextenders/kharon/listener_kharon_http/dist/config.yaml + - /app/userextenders/kharon/agent_kharon/dist/config.yaml + interface: 0.0.0.0 + key: server.rsa.key + only_password: true + password: pass + port: 4321 + refresh_token_live_hours: 168 From 9a06e2ea2f1f603019d243718f821d3a5644edfe Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:52:26 +0200 Subject: [PATCH 22/26] fix: restore profile.yaml without Kharon extender entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kharon extender config paths were pre-committed into profile.yaml, causing adaptixc2 to crash-loop at startup because those files don't exist until after install-kharon.sh runs. The testing-kit activate step adds them dynamically — the static file must not reference them. This also fixes the Extension-Kit BOFs job which shares the same profile.yaml and was crash-looping for the same reason. Co-Authored-By: Claude Sonnet 4.6 --- adaptixc2/dist/profile.yaml | 79 ++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/adaptixc2/dist/profile.yaml b/adaptixc2/dist/profile.yaml index 933897f..2952da6 100644 --- a/adaptixc2/dist/profile.yaml +++ b/adaptixc2/dist/profile.yaml @@ -1,51 +1,50 @@ # Managed by Testing-Kit — do not edit manually +Teamserver: + interface: "0.0.0.0" + port: 4321 + endpoint: "/endpoint" + password: "pass" + only_password: true + cert: "server.rsa.crt" + key: "server.rsa.key" + extenders: + - "extenders/beacon_listener_http/config.yaml" + - "extenders/beacon_listener_smb/config.yaml" + - "extenders/beacon_listener_tcp/config.yaml" + - "extenders/beacon_listener_dns/config.yaml" + - "extenders/beacon_agent/config.yaml" + - "extenders/gopher_listener_tcp/config.yaml" + - "extenders/gopher_agent/config.yaml" + axscripts: [] + access_token_live_hours: 12 + refresh_token_live_hours: 168 + HttpServer: error: - headers: - Adaptix-Version: v1.2 - Content-Type: text/html; charset=UTF-8 - Server: AdaptixC2 - page: 404page.html status: 404 + headers: + Content-Type: "text/html; charset=UTF-8" + Server: "AdaptixC2" + Adaptix-Version: "v1.2" + page: "404page.html" http: - disable_keep_alives: false - enable_http2: true - idle_timeout_sec: 0 max_header_bytes: 8192 read_header_timeout_sec: 0 read_timeout_sec: 0 - request_timeout_message: 504 Gateway Timeout - request_timeout_sec: 300 write_timeout_sec: 0 + idle_timeout_sec: 0 + request_timeout_sec: 300 + request_timeout_message: "504 Gateway Timeout" + disable_keep_alives: false + enable_http2: true tls: - cipher_suites: - - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 - - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 - - TLS_RSA_WITH_AES_128_GCM_SHA256 - - TLS_RSA_WITH_AES_256_GCM_SHA384 - max_version: TLS1.3 - min_version: TLS1.2 + min_version: "TLS1.2" + max_version: "TLS1.3" prefer_server_cipher_suites: false -Teamserver: - access_token_live_hours: 12 - axscripts: [] - cert: server.rsa.crt - endpoint: /endpoint - extenders: - - extenders/beacon_listener_http/config.yaml - - extenders/beacon_listener_smb/config.yaml - - extenders/beacon_listener_tcp/config.yaml - - extenders/beacon_listener_dns/config.yaml - - extenders/beacon_agent/config.yaml - - extenders/gopher_listener_tcp/config.yaml - - extenders/gopher_agent/config.yaml - - /app/userextenders/kharon/listener_kharon_http/dist/config.yaml - - /app/userextenders/kharon/agent_kharon/dist/config.yaml - interface: 0.0.0.0 - key: server.rsa.key - only_password: true - password: pass - port: 4321 - refresh_token_live_hours: 168 + cipher_suites: + - "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + - "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" + - "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" + - "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" + - "TLS_RSA_WITH_AES_128_GCM_SHA256" + - "TLS_RSA_WITH_AES_256_GCM_SHA384" From f67017b84a6167ee46ca61e9a4ec1abcd4d6b7f1 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:03:14 +0200 Subject: [PATCH 23/26] fix: remove GOFLAGS=-mod=mod to avoid combining with container go env The container's go env file has GOFLAGS=-ldflags="-s -w". Go combines env-var GOFLAGS and go-env-file GOFLAGS, so adding GOFLAGS='-mod=mod' produced the invalid combined value '-mod=mod -ldflags="-s -w"'. When tokenized, '-w"' appears as an unknown flag. go mod download is explicitly exempt from -mod=readonly and updates go.sum without needing -mod=mod. Use that instead to pre-populate go.sum before the plugin builds, then drop GOFLAGS entirely from the build steps. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 51c47ca..3227556 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -84,8 +84,10 @@ pin_server_deps() { (cd "$dir" && while IFS= read -r dep; do go mod edit -require "$dep" 2>/dev/null || true done < "$SERVER_DEPS_FILE") - # Download pinned modules to update go.sum before the build. - (cd "$dir" && GOWORK="${COMBINED_WORK}" GONOSUMDB='*' go mod download 2>/dev/null || true) + # go mod download is exempt from -mod=readonly and can update go.sum without + # the -mod=mod flag. Run it visibly so CI logs show any download failures. + echo "Downloading pinned modules to update go.sum..." + (cd "$dir" && GOWORK="${COMBINED_WORK}" GONOSUMDB='*' go mod download 2>&1 || true) } # --- Build Kharon listener --- @@ -95,7 +97,7 @@ cat "${KHARON_DIR}/listener_kharon_http/Makefile" 2>/dev/null || echo "(no Makef cd "${KHARON_DIR}/listener_kharon_http" go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" pin_server_deps "${KHARON_DIR}/listener_kharon_http" -GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" GONOSUMDB='*' GOFLAGS='-mod=mod' make all +GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" GONOSUMDB='*' make all # --- Patch pl_agent.go: add mask_sleep="none" -> KH_SLEEP_MASK=0 --- # Without this, the default sleep mask mode (3) uses obfuscation techniques @@ -134,7 +136,7 @@ go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" pin_server_deps "${KHARON_DIR}/agent_kharon" rm -f dist/agent_kharon.so cd "${KHARON_DIR}/agent_kharon/src_server" -GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" GONOSUMDB='*' GOFLAGS='-mod=mod' \ +GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" GONOSUMDB='*' \ go build -buildmode=plugin -o "../dist/agent_kharon.so" . echo "Built: $(ls -sh ../dist/agent_kharon.so)" From 22d6f7eacd29e31f211d73d40e997f87ff411c41 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:20:28 +0200 Subject: [PATCH 24/26] fix: rebuild adaptixserver from source to fix Kharon plugin ABI mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go plugin ABI requires every shared package (axc2, x/sys, x/text…) to have the same package build ID, computed as: hash(source_content + dep_build_ids + compiler_binary_hash) Even when go version strings match (go1.25.11), a freshly-downloaded tarball has a different compiler binary hash than the one baked into the Docker image. This causes all shared package build IDs to diverge, so plugin.Open fails with "plugin was built with a different version of package axc2". Fix (building on e6b1217): 1. Read original server dep versions from the binary BEFORE any rebuild. 2. Clone TGJLS/AdaptixC2 and pin those same dep versions in go.mod. 3. Rebuild /app/adaptixserver with our downloaded go1.25.11 (same binary that will compile Kharon plugins) → server and plugins share the same compiler hash, guaranteeing identical package build IDs. The pre-built beacon/gopher plugins will fail ABI checks against the rebuilt server (different compiler hash), but the Kharon test job only needs Kharon to load — beacon/gopher failures are non-fatal and don't block the test. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 43 ++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 3227556..4830941 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -8,6 +8,7 @@ KHARON_DIR=/app/userextenders/kharon NEED_PKGS=() command -v make &>/dev/null || NEED_PKGS+=(make) command -v python3 &>/dev/null || NEED_PKGS+=(python3) +command -v git &>/dev/null || NEED_PKGS+=(git) if [ ${#NEED_PKGS[@]} -gt 0 ]; then apt-get update -qq apt-get install -y -qq "${NEED_PKGS[@]}" @@ -31,13 +32,10 @@ fi echo "Using Go: $(go version)" echo "GOEXPERIMENT: ${BINARY_GOEXP}" -# --- Inspect adaptixserver binary --- -# Pin ALL dep versions from the server binary so every shared package in the -# plugin has an IDENTICAL build ID to the one already compiled into the server. -# The axc2 h1: hash alone is not sufficient — axc2's transitive deps (x/sys, -# x/text, …) also affect build IDs, and any version delta causes: -# plugin.Open: "plugin was built with a different version of package X" -echo "=== adaptixserver build info ===" +# --- Read original server deps (before any rebuild) --- +# Extract dep versions from the ORIGINAL binary so we can pin identical +# versions when rebuilding adaptixserver AND when building Kharon plugins. +echo "=== Original adaptixserver build info ===" SERVER_BUILD_INFO=$(go version -m /app/adaptixserver 2>/dev/null) || SERVER_BUILD_INFO="" if [ -z "$SERVER_BUILD_INFO" ]; then echo "(go version -m failed)" @@ -50,6 +48,37 @@ SERVER_DEPS_FILE=/tmp/server_deps.txt echo "$SERVER_BUILD_INFO" | awk '/^\tdep\t/{print $2"@"$3}' > "$SERVER_DEPS_FILE" echo "Found $(wc -l < "$SERVER_DEPS_FILE" | tr -d ' ') dep modules in server binary" +# --- Rebuild adaptixserver from source --- +# Go plugin ABI compatibility requires every shared package (axc2, x/sys, …) +# to have the SAME package build ID. Build IDs are: +# hash(source_content + dep_build_ids + compiler_binary_hash) +# Even with an identical version string (go1.25.11), a freshly-downloaded +# tarball may have a different compiler binary than the one used inside the +# Docker image, causing ALL package build IDs to diverge. +# +# Fix: rebuild /app/adaptixserver with OUR go binary using the ORIGINAL +# dep versions. Then build Kharon plugins with the same binary. +# Both server and plugins share the same compiler hash → ABI is compatible. +AXC2_CLONE=/tmp/adaptixc2-src +if [ ! -d "$AXC2_CLONE" ]; then + echo "Cloning TGJLS/AdaptixC2 for server rebuild..." + git clone --depth=1 https://github.com/TGJLS/AdaptixC2 "$AXC2_CLONE" +else + echo "Using existing TGJLS/AdaptixC2 clone at ${AXC2_CLONE}" +fi + +echo "Rebuilding /app/adaptixserver from ${AXC2_CLONE}/AdaptixServer ..." +( + cd "${AXC2_CLONE}/AdaptixServer" + while IFS= read -r dep; do + go mod edit -require "$dep" 2>/dev/null || true + done < "$SERVER_DEPS_FILE" + GONOSUMDB='*' go mod download 2>/dev/null || true + GOEXPERIMENT="${BINARY_GOEXP}" CGO_ENABLED=1 \ + go build -ldflags="-s -w" -o /app/adaptixserver . + echo "Rebuilt: $(go version -m /app/adaptixserver 2>/dev/null | head -1)" +) + # Extract the exact axc2 version. Fall back to v1.2.0 if not found. BINARY_AXC2=$(echo "$SERVER_BUILD_INFO" | \ awk '/github\.com\/Adaptix-Framework\/axc2/{print $3}') || BINARY_AXC2="" From a2aa5d7a15a25c1f06bc9f224616d2bed0b59192 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:53:10 +0200 Subject: [PATCH 25/26] fix: use static IP in Kharon malleable profile for agent callback The Windows QEMU VM (dockurr/windows) uses QEMU user-mode networking where DNS for Docker container hostnames ('adaptixc2') is not available. The Beacon agent works because its callback address is hardcoded to the static IP 172.28.0.10:8080. The Kharon malleable profile had 'adaptixc2:8080' as the callback host, which gets baked into the agent binary via HTTP_MALLEABLE_BYTES. The Windows VM cannot resolve 'adaptixc2' so the agent never beacons. Fix: use 172.28.0.10:8080 (adaptixc2's fixed Docker network IP) directly, matching how the Beacon agent's callback_addresses is configured in config.yaml. Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/kharon-malleable-profile.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/cicd/kharon-malleable-profile.json b/.github/cicd/kharon-malleable-profile.json index 66ec4d9..ae58af2 100644 --- a/.github/cicd/kharon-malleable-profile.json +++ b/.github/cicd/kharon-malleable-profile.json @@ -1,7 +1,7 @@ { "callbacks": [ { - "hosts": ["adaptixc2:8080"], + "hosts": ["172.28.0.10:8080"], "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "server_error": { "http_status": 404, From 259e32754f263dc820adc21c48642535a80d01b2 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:38:11 +0200 Subject: [PATCH 26/26] =?UTF-8?q?fix:=20code=20review=20cleanups=20?= =?UTF-8?q?=E2=80=94=20debug=20output,=20silent=20failures,=20ABI=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install-kharon.sh: - Fix BINARY_GOEXP awk: only set when binary actually carries X: flags; the old awk always printed NR==1, so a server built without GOEXPERIMENT would set BINARY_GOEXP to the full version string and abort every go build - Fix pl_agent.go patch to exit 1 on failure instead of printing a warning and continuing, which would silently build the plugin with KH_SLEEP_MASK=3 - Remove 2>/dev/null from server-rebuild go mod download so errors are visible - Consolidate two separate apt-get update+install blocks into one - Drop diagnostic dump blocks (=== Original adaptixserver build info ===, === Generated go.work ===, === listener_kharon_http/Makefile ===, === axc2 version in built plugins ===, === Key dep versions ===) runner.py: - _resolve_agent_from_extender was passing port_bind=0 for all agent schema fields; network-source fields (callback_addresses) resolved to host:0 instead of the actual listener port api.py: - activate_extender for listener type now removes the previously active listener's profile.yaml entries before writing the new ones, preventing both configs from accumulating in the file across extender swaps Co-Authored-By: Claude Sonnet 4.6 --- .github/cicd/install-kharon.sh | 54 ++++++---------------------------- adaptix_testing/api.py | 3 ++ adaptix_testing/runner.py | 6 +++- 3 files changed, 17 insertions(+), 46 deletions(-) diff --git a/.github/cicd/install-kharon.sh b/.github/cicd/install-kharon.sh index 4830941..469a331 100755 --- a/.github/cicd/install-kharon.sh +++ b/.github/cicd/install-kharon.sh @@ -9,6 +9,8 @@ NEED_PKGS=() command -v make &>/dev/null || NEED_PKGS+=(make) command -v python3 &>/dev/null || NEED_PKGS+=(python3) command -v git &>/dev/null || NEED_PKGS+=(git) +command -v nasm &>/dev/null || NEED_PKGS+=(nasm) +command -v clang &>/dev/null || NEED_PKGS+=(clang llvm) if [ ${#NEED_PKGS[@]} -gt 0 ]; then apt-get update -qq apt-get install -y -qq "${NEED_PKGS[@]}" @@ -19,7 +21,9 @@ fi # as the main adaptixserver binary. Read both from the binary. BINARY_GO=$(go version -m /app/adaptixserver 2>/dev/null | awk 'NR==1{print $2}') || BINARY_GO="" CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}') || CURRENT_GO="" -BINARY_GOEXP=$(go version -m /app/adaptixserver 2>/dev/null | awk 'NR==1{sub(/^.*X:/,""); print}') || BINARY_GOEXP="" +# Only set GOEXPERIMENT when the binary actually carries X: flags; otherwise +# passing the whole version line as GOEXPERIMENT would abort every go build. +BINARY_GOEXP=$(go version -m /app/adaptixserver 2>/dev/null | awk 'NR==1 && /X:/{sub(/^.*X:/,""); print}') || BINARY_GOEXP="" if [ -n "$BINARY_GO" ] && [ "$BINARY_GO" != "$CURRENT_GO" ]; then echo "Toolchain mismatch: adaptixserver=${BINARY_GO}, container=${CURRENT_GO}" @@ -30,20 +34,13 @@ if [ -n "$BINARY_GO" ] && [ "$BINARY_GO" != "$CURRENT_GO" ]; then fi echo "Using Go: $(go version)" -echo "GOEXPERIMENT: ${BINARY_GOEXP}" +[ -n "$BINARY_GOEXP" ] && echo "GOEXPERIMENT: ${BINARY_GOEXP}" # --- Read original server deps (before any rebuild) --- # Extract dep versions from the ORIGINAL binary so we can pin identical # versions when rebuilding adaptixserver AND when building Kharon plugins. -echo "=== Original adaptixserver build info ===" SERVER_BUILD_INFO=$(go version -m /app/adaptixserver 2>/dev/null) || SERVER_BUILD_INFO="" -if [ -z "$SERVER_BUILD_INFO" ]; then - echo "(go version -m failed)" -else - echo "$SERVER_BUILD_INFO" -fi -# Extract all dep versions: "module/path@vX.Y.Z" SERVER_DEPS_FILE=/tmp/server_deps.txt echo "$SERVER_BUILD_INFO" | awk '/^\tdep\t/{print $2"@"$3}' > "$SERVER_DEPS_FILE" echo "Found $(wc -l < "$SERVER_DEPS_FILE" | tr -d ' ') dep modules in server binary" @@ -73,7 +70,7 @@ echo "Rebuilding /app/adaptixserver from ${AXC2_CLONE}/AdaptixServer ..." while IFS= read -r dep; do go mod edit -require "$dep" 2>/dev/null || true done < "$SERVER_DEPS_FILE" - GONOSUMDB='*' go mod download 2>/dev/null || true + GONOSUMDB='*' go mod download 2>&1 || true GOEXPERIMENT="${BINARY_GOEXP}" CGO_ENABLED=1 \ go build -ldflags="-s -w" -o /app/adaptixserver . echo "Rebuilt: $(go version -m /app/adaptixserver 2>/dev/null | head -1)" @@ -82,7 +79,6 @@ echo "Rebuilding /app/adaptixserver from ${AXC2_CLONE}/AdaptixServer ..." # Extract the exact axc2 version. Fall back to v1.2.0 if not found. BINARY_AXC2=$(echo "$SERVER_BUILD_INFO" | \ awk '/github\.com\/Adaptix-Framework\/axc2/{print $3}') || BINARY_AXC2="" -echo "axc2 in binary: ${BINARY_AXC2:-unknown}" if [[ "${BINARY_AXC2}" =~ ^v[0-9] ]]; then AXC2_VERSION="${BINARY_AXC2}" else @@ -102,9 +98,6 @@ GO_WORK_VER="${BINARY_GO#go}" printf ')\n' } > "$COMBINED_WORK" -echo "=== Generated go.work ===" -cat "$COMBINED_WORK" - # Pin ALL server dep versions into a module's go.mod so MVS selects identical # versions for every shared package. pin_server_deps() { @@ -113,16 +106,12 @@ pin_server_deps() { (cd "$dir" && while IFS= read -r dep; do go mod edit -require "$dep" 2>/dev/null || true done < "$SERVER_DEPS_FILE") - # go mod download is exempt from -mod=readonly and can update go.sum without - # the -mod=mod flag. Run it visibly so CI logs show any download failures. echo "Downloading pinned modules to update go.sum..." (cd "$dir" && GOWORK="${COMBINED_WORK}" GONOSUMDB='*' go mod download 2>&1 || true) } # --- Build Kharon listener --- echo "Building Kharon listener..." -echo "=== listener_kharon_http/Makefile ===" -cat "${KHARON_DIR}/listener_kharon_http/Makefile" 2>/dev/null || echo "(no Makefile)" cd "${KHARON_DIR}/listener_kharon_http" go get "github.com/Adaptix-Framework/axc2@${AXC2_VERSION}" pin_server_deps "${KHARON_DIR}/listener_kharon_http" @@ -155,7 +144,8 @@ if old in content: elif 'case \"none\":' in content: print('pl_agent.go already patched') else: - print('WARNING: patch target not found in pl_agent.go', file=sys.stderr) + print('ERROR: patch target not found in pl_agent.go — upstream may have changed the switch structure', file=sys.stderr) + sys.exit(1) " "$AGENT_GO" # --- Build Kharon agent plugin --- @@ -169,32 +159,8 @@ GOWORK="${COMBINED_WORK}" GOEXPERIMENT="${BINARY_GOEXP}" GONOSUMDB='*' \ go build -buildmode=plugin -o "../dist/agent_kharon.so" . echo "Built: $(ls -sh ../dist/agent_kharon.so)" -echo "=== axc2 version in built plugins ===" -go version -m "${KHARON_DIR}/listener_kharon_http/dist/listener_kharon_http.so" 2>/dev/null | \ - awk '/axc2/{print " listener: "$0}' || echo " listener: (read failed)" -go version -m "${KHARON_DIR}/agent_kharon/dist/agent_kharon.so" 2>/dev/null | \ - awk '/axc2/{print " agent: "$0}' || echo " agent: (read failed)" -echo " server: $(echo "$SERVER_BUILD_INFO" | awk '/axc2/{print $0}')" - -# --- Diagnostic: compare key dep versions across server and plugins --- -echo "=== Key dep versions in built artifacts ===" -for artifact in \ - "/app/adaptixserver" \ - "${KHARON_DIR}/listener_kharon_http/dist/listener_kharon_http.so" \ - "${KHARON_DIR}/agent_kharon/dist/agent_kharon.so" -do - echo " $(basename "$artifact"):" - go version -m "$artifact" 2>/dev/null | \ - awk '/golang\.org\/x\/sys|golang\.org\/x\/text|Adaptix-Framework\/axc2/{printf " %s\n", $0}' \ - || echo " (read failed)" -done - # --- Build src_beacon BOF prerequisites --- echo "Building src_beacon prerequisites (nasm, LLVM object files)..." -if ! command -v nasm &>/dev/null || ! command -v clang &>/dev/null; then - apt-get update -qq - apt-get install -y -qq nasm clang llvm -fi cd "${KHARON_DIR}/agent_kharon/src_beacon" make prebuild-x64 @@ -242,7 +208,6 @@ for dir in src_beacon src_loader src_core; do link="${DIST_KH}/${dir}" if [ ! -L "$link" ]; then ln -sf "$target" "$link" - echo "Symlink: ${link} -> ${target}" fi done @@ -254,7 +219,6 @@ if [ ! -f "$CSTDINT" ]; then #include #include SHIM - echo "Created cstdint shim at ${CSTDINT}" fi echo "Kharon build complete." diff --git a/adaptix_testing/api.py b/adaptix_testing/api.py index fcfa5b0..c4a0dcf 100644 --- a/adaptix_testing/api.py +++ b/adaptix_testing/api.py @@ -410,6 +410,9 @@ def activate_extender(id: str, conn: sqlite3.Connection = Depends(get_conn)): f"Active agent '{active_agent['agent_name']}' is not compatible with " f"listener '{ext['listener_name']}'. Compatible listeners: {compat}" )) + old_listener = _db.get_active_listener_extender(conn) + if old_listener and old_listener["id"] != id: + _pm.remove_extender_entries(ADAPTIX_PROFILE_PATH, old_listener["container_path"]) config_rels = json.loads(ext.get("listener_config_rel_paths") or "[]") _pm.add_extender_entries(ADAPTIX_PROFILE_PATH, container_path, config_rels, []) _db.set_active_listener(conn, id) diff --git a/adaptix_testing/runner.py b/adaptix_testing/runner.py index b948777..c89a413 100644 --- a/adaptix_testing/runner.py +++ b/adaptix_testing/runner.py @@ -169,9 +169,13 @@ def _resolve_listener_from_extender(extender: dict, cfg: dict) -> dict: def _resolve_agent_from_extender(extender: dict, cfg: dict, listener_instance_name: str) -> dict: """Build an agent profile dict from an active extender DB row.""" schema = json.loads(extender["agent_schema"]) + # Derive port_bind from the paired listener schema so network-source fields + # (e.g. callback_addresses) resolve to the correct port, not 0. + listener_schema = json.loads(extender.get("listener_schema") or "{}") + port_bind = int(listener_schema.get("port_bind", {}).get("value") or 0) config = {} for key, field in schema.items(): - config[key] = _resolve_schema_value(key, field, cfg, 0) + config[key] = _resolve_schema_value(key, field, cfg, port_bind) agent_name = extender["agent_name"] or "extender" return {"agent": agent_name, "listener": listener_instance_name, "config": json.dumps(config)}