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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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