Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Xianyun threat model

Status: Draft v1 Date: 2026-10-13 Scope: the Rust workspace under crates/ (the xianyun and xianyun-mcp binaries) Method: STRIDE per element, DREAD scoring, data flow diagram with trust boundaries Not covered: the Go original, which is no longer in the tree, the Kubernetes workloads users deploy, and the upstream tools Xianyun shells out to (helm, kubectl, k3d, zot, mirrord, skaffold).

Several findings below describe weaknesses that haven’t been fixed yet. They’re published here so that the specification and the decision records can link to them; each carries its evidence tag and the measurement that would confirm it.

How to read the evidence tags

Every threat carries one of these tags, taken from the project rule that a cause isn’t real until a measurement isolates it.

  • MEASURED means a test or command was run during this review and the result is quoted.
  • CODE means the behaviour is visible in the source at the cited path, but it wasn’t executed. The exploit path is plausible and unproven.
  • UNPROVEN names the specific measurement that would confirm or refute the threat.

Measurements were taken on 2026-10-13 against main at commit d5f74650, on Linux, with umask 022, using throwaway tests and temporary HOME directories that were deleted afterwards. The reproducers weren’t committed, so a reader can’t re-run them today; Tasks 3, 4, and 7 in the spec turn them into committed tests.

System description

Xianyun is a command-line orchestrator for local Kubernetes development. A developer (or an AI agent, through the MCP server) runs commands that read a YAML config, start a local cluster through a driver (k3d, colima, vcluster, external, mock), deploy Helm and Kustomize workloads, run hot-reload processes, and manage host state: /etc/hosts, the system trust store, and ~/.kube/config.

It has no long-running server of its own except two optional listeners: the MCP Streamable HTTP transport and a zot registry cache. Everything else is short-lived process execution on the developer’s workstation, running as the developer’s user, with sudo for a small set of privileged writes.

Assets

AssetWhy it matters
Dev CA private key (ca.key)Anything signed by it is trusted by the developer’s browsers and tools
Kubeconfig credentialsCluster-admin access to the local cluster
Developer workstation integrityXianyun runs commands from config with the developer’s privileges
/etc/hosts and system trust storeRedirect or intercept traffic for any hostname
Process environmentMay hold tokens and cloud credentials
Registry cache contentsImages that clusters pull without further verification

Data flow diagram

                    [E1 AI agent / MCP client]            [E2 Developer]
                              |  TB1                           |
                              v                                v
                    (P1 xianyun-mcp) --re-exec--------> (P2 xianyun CLI)
                                                          |   |   |   |
        TB2 +-------------------------------------------+   |   |   +---------------+ TB3
            v                                               |   |                   v
 [E3 Remote config host]--https-->(P3 config loader)        |   |      (P6 privileged writer: sudo)
 [E4 Cloned edit-mode repos]------>        |                |   |             |            |
            |                              v                |   |             v            v
            |                    [D1 xianyun.yaml +         |   |      [D5 /etc/hosts] [D6 system and
            |                         local overlay]        |   |                           NSS trust store]
            |                                               |   |
            |            TB6                                |   |  TB4
            v             v                                 v   v   v
   (P4 hook and reloader runner) --argv--> [E5 helm, kubectl, git, k3d, kustomize, mirrord, mise]
                                                            |       |
                                           TB5              v       v
                          [D2 CA key store]   [D3 kustomize staging in $TMPDIR]   [D4 kubeconfig and .bak]
                                                            |
                                                            v
                                         [E6 local cluster: API server, Secrets]
                                                            ^
                          TB7  (P5 zot registry cache, 0.0.0.0:5555) <--- [E7 other hosts on the network]

Trust boundaries

IDBoundaryWhat crosses it
TB1MCP client to xianyun-mcpTool calls with free-form args and flags; elicitation answers
TB2Config sources to the loaderLocal YAML, remote https fragments, xianyun-project.yaml from cloned repos, environment variables, file-typed variables
TB3Unprivileged process to sudotee /etc/hosts, install, update-ca-certificates, security add-trusted-cert
TB4Xianyun to the clusterKubeconfig, applied manifests, cluster-reported DNS zone and node data
TB5Xianyun to other local users$TMPDIR, ~/.kube, ~/.local/share/xianyun
TB6Xianyun to executables on PATHargv, inherited environment
TB7Registry cache listener to the networkOCI pulls and (possibly) pushes

Assumptions

Each assumption is a risk if it turns out to be wrong.

  1. The developer controls the workstation and trusts the config file in their own repository. Config content is code, because lifecycle hooks run it.
  2. Remote config fragments and cloned repositories are less trusted than the local config file. Nothing in the codebase currently enforces that difference.
  3. The MCP client is operated by an LLM that can be steered by untrusted text (prompt injection). Gating that depends on the model’s good behaviour doesn’t count as a control.
  4. The machine can be multi-user. A shared Linux server with a world-readable /tmp is a supported way to run the tool.
  5. Xianyun collects no telemetry (ADR-0018), so no threat here concerns usage data leaving the machine.

Threat register

DREAD totals use Damage, Reproducibility, Exploitability, Affected users, Discoverability, each 0 to 10. Bands: 40+ Critical, 25 to 39 High, 11 to 24 Medium, 10 and under Low. No threat here reached Critical. Scores are the reviewer’s judgement; the evidence tag says how much of each was observed.

Mitigation status values follow the repository’s artifact status rules: Not Started, PARTIAL, DESIGNED, IMPLEMENTED.

MCP server (P1, TB1)

TM-MCP-001: Unauthenticated Streamable HTTP listener binds all interfaces by default

Element: P1 xianyun-mcp Category: Spoofing, Elevation of Privilege Evidence: MEASURED. xianyun-mcp mcp stream --port 18099 with no --host produced LISTEN 0.0.0.0:18099. The help text says “Host to bind (empty → 0.0.0.0)”. The README example passes --host 127.0.0.1 but doesn’t mention the default. Description: The listener accepts connections on every interface and has no authentication. The only gate is rmcp’s Host header allow-list (localhost, 127.0.0.1, ::1, extended by --allow-host), which rejects a LAN client that sends its own hostname with a 403 but not a non-browser client that sends Host: localhost. The 0.0.0.0 default is in the brontes dependency (brontes-0.7.3/src/subcommands/stream.rs, resolve_bind_host); xianyun-mcp only mounts that subcommand. Attack vector: A client on the same LAN, container network, or VPN that sets Host: localhost calls the ungated up, deploy, or update tools. Those run config-defined hooks (see TM-MCP-002). The gated tools only check a client-supplied answer, which the attacker supplies (see TM-MCP-003, measured). Affected assets: workstation integrity, kubeconfig credentials. DREAD: D:8 R:8 E:6 A:3 D:6 = 31 (High) Mitigation: Wrap or override the stream subcommand in xianyun-mcp so --host defaults to 127.0.0.1 (or fix it upstream in brontes); refuse a non-loopback bind unless authentication is configured; document the default. Status: ⚠️ PARTIAL (DNS-rebind allow-list exists in rmcp; the bind default and authentication aren’t addressed) ADR: ADR-0005 Spec Requirement: SR-001

TM-MCP-002: Ungated state-changing tools can execute hook commands from config

Element: P1 to P2 to P4 Category: Elevation of Privilege Evidence: CODE. up, deploy, and update are in the ungated tier (crates/xianyun-mcp/src/config.rs). All three can run prepare hooks: up at crates/xianyun-cli/src/commands/up.rs:342, deploy at crates/xianyun-cli/src/commands/deploy.rs:120-161, and update at crates/xianyun-update/src/updater.rs:162-177. Hooks go through run_in_repository, which has no shell but does run an arbitrary argv. Description: The “reversible” classification treats these tools as local-only, but a config (or a remote fragment, or an edit-mode repo’s project file) can define hooks that run any program as the developer. Attack vector: A prompt-injected agent calls update to pull a repository whose xianyun-project.yaml adds a hook, then calls up. Affected assets: workstation integrity, process environment. DREAD: D:8 R:6 E:5 A:3 D:5 = 27 (High) Mitigation: Gate up, deploy, and update when the resolved config contains hooks or edit-mode repositories, or move them to the gated tier; show the resolved hook commands in the consequence message. Status: Not Started ADR: ADR-0005 Spec Requirement: SR-002

TM-MCP-003: The confirmation gate accepts a forged answer on the first call

Element: P1 Category: Spoofing, Elevation of Privilege Evidence: MEASURED. The middleware (crates/xianyun-mcp/src/config.rs:156-190) returns InputRequired only when ctx.input_responses is None; otherwise it runs the command if input_responses["confirm"] deserialises to an Accept action. Nothing records that a challenge was issued. A throwaway test against the real xianyun-mcp binary (working directory and HOME set to an empty temp directory) made two xianyun_down calls. With no input_responses the call was refused (“middleware asked the client for input, which requires MCP protocol 2026-07-28 or newer”). With input_responses = {"confirm": {"action": "accept"}} on the first call, the command ran: the result was the CLI’s own Error: finding config file: no caproni.yaml found.... brontes passes the client’s input_responses straight to the middleware (brontes-0.7.3/src/server/mod.rs:304-306). Description: The gate is stateless and trusts whatever the client sends. A client that sets the field, or an MCP client implementation that auto-approves, skips the human entirely. The user also sees only the consequence message, not the free-form arguments. Attack vector: A compromised or hostile MCP client (including one reached through TM-MCP-001) calls destroy, update-etc-hosts, or ca trust with a pre-filled answer. A prompt-injected LLM can’t set protocol fields through ordinary tool arguments, so the realistic route is the client, not the model. Affected assets: cluster data, /etc/hosts, trust store. DREAD: D:7 R:9 E:5 A:2 D:6 = 29 (High) Mitigation: Bind the confirmation to a server-issued, single-use nonce for the exact command and arguments (carry it in request_state); include the arguments in the prompt. Status: Not Started ADR: ADR-0005 Spec Requirement: SR-003

TM-MCP-004: Free-form arguments reach flags with host-level effects

Element: P1 Category: Tampering, Elevation of Privilege Evidence: CODE. Every tool exposes free-form args and flags (crates/xianyun-mcp/tests/snapshots/tools.json). Reachable flags include update-etc-hosts --ip, set-kube-context --kubeconfig, and update --stash. (config migrate --force --skip-backup is also exposed but returns “already latest” before reading either flag, so it’s latent until a second config version exists.) Description: The allowlist works at the command level. Inside an allowed command, an agent can still pass a flag whose value changes what a privileged operation writes (see TM-HOSTS-001) or where a file lands. Attack vector: A prompt-injected agent calls update-etc-hosts with a crafted --ip, or set-kube-context --kubeconfig with a path it shouldn’t touch. Affected assets: /etc/hosts, ~/.kube/config. DREAD: D:7 R:7 E:5 A:2 D:5 = 26 (High) Mitigation: Validate flag values server-side (IP parse, path under an allowed root); consider removing --stash and, ahead of a v3 schema, --force and --skip-backup from the MCP surface. Status: Not Started ADR: ADR-0005 Spec Requirement: SR-004

TM-MCP-005: Tool annotations understate network access

Element: P1 Category: Information Disclosure Evidence: MEASURED in the committed snapshot: every tool has openWorldHint: false. CODE: config validate, up, deploy, and update fetch remote fragments and repositories. Description: Clients use annotations to decide how much to trust a call. A “read-only, closed-world” config validate can still trigger network fetches from URLs in the config. Attack vector: An agent or auto-approve policy treats config validate as safe and runs it on an attacker-supplied config. Affected assets: workstation network position, remote fragment cache. DREAD: D:3 R:9 E:3 A:2 D:3 = 20 (Medium) Mitigation: Set openWorldHint: true on tools that can fetch; keep readOnlyHint only where nothing is written. Status: Not Started ADR: ADR-0005 Spec Requirement: SR-005

Configuration loading (P3, TB2)

TM-CFG-001: Remote extends fragments have no pinning, signature, or allowlist

Element: P3, E3 Category: Tampering, Elevation of Privilege Evidence: CODE (crates/xianyun-core/src/config/merge.rs:374-384). Only https and a xianyun*.yaml or caproni*.yaml basename are enforced; the fetch uses default TLS roots and a 10 MiB cap. Description: A fragment can add repositories, lifecycle_hooks, reloaders, and deployers, including kustomize deployers whose remote targets (http, https, ssh, or file, merge.rs:~971-981) are passed straight to kustomize build with no pin, and a registry_mirror.endpoint that redirects image pulls. Whoever controls the URL’s content, or compromises the host, gets command execution on the next up. Attack vector: A shared team config points at a fragment on a compromised or re-registered domain. Affected assets: workstation integrity. DREAD: D:9 R:6 E:4 A:5 D:4 = 28 (High) Mitigation: Support a content hash or pinned commit for each remote fragment; add an allowlist of hosts; print the resolved fragment URLs and hook commands before the first run. Status: Not Started ADR: ADR-0009 Spec Requirement: SR-006

TM-CFG-002: Relative file references inside a remote fragment may accept http://

Element: P3 Category: Tampering, Information Disclosure Evidence: CODE. resolve_relative_url returns a reference unchanged when it contains :// (merge.rs:1062); the https-only check at merge.rs:384 covers extends only. Downloads land in the predictable path $TMPDIR/xianyun-remote-fragments/<sha256(url)>, written with default mode and no O_EXCL (merge.rs:1080-1125). UNPROVEN. Measurement: a fragment whose helm.values_file is http://127.0.0.1:<port>/x, with a listener to see whether it’s fetched; and a pre-placed symlink at the predictable path on a shared /tmp. DREAD: D:5 R:5 E:4 A:3 D:3 = 20 (Medium) Mitigation: Reject non-https URLs in every fetch; create the cache directory 0700 and write with O_EXCL or a tempfile plus rename. Status: Not Started ADR: ADR-0009 Spec Requirement: SR-006

TM-CFG-003: Any process environment variable can be expanded and printed

Element: P3 Category: Information Disclosure Evidence: MEASURED. With cluster_domain: ${DEMO_SECRET_TOKEN} in the config and DEMO_SECRET_TOKEN=hunter2.example in the environment, xianyun config print printed cluster_domain: hunter2.example. crates/xianyun-core/src/variables/resolver.rs:47-53 falls back to the process environment for any name. Description: Config content (including a remote fragment) can reference any variable the developer has exported, and config print, a read-only MCP tool, shows the result to the agent. Attack vector: A fragment references ${AWS_SECRET_ACCESS_KEY} in a field that config print echoes; the agent’s context now holds the secret. Affected assets: process environment. DREAD: D:6 R:8 E:4 A:2 D:5 = 25 (High) Mitigation: Restrict environment fallback to an explicit allowlist or a XIANYUN_/CAPRONI_ prefix; redact values from variables not declared in the config’s own variables block when printing. Status: Not Started ADR: ADR-0009 Spec Requirement: SR-007

TM-CFG-004: A cloned edit-mode repository can contribute hooks through its project file

Element: P3, E4 Category: Elevation of Privilege Evidence: CODE (merge.rs:849-940). The xianyun-project.yaml (or caproni-project.yaml) found in an edit_mode repository’s working tree adds reloaders and lifecycle_hooks to the merged config. Description: Pointing repositories at a third-party repo is enough for that repo to run commands during up and run. Attack vector: A malicious commit to a tracked branch of an edit-mode repository; xianyun update fast-forwards to it. Affected assets: workstation integrity. DREAD: D:8 R:6 E:4 A:3 D:4 = 25 (High) Mitigation: Require explicit per-repository opt-in (allow_project_config: true) and show added hooks on first load or when they change. Status: Not Started ADR: ADR-0009 Spec Requirement: SR-008

TM-CFG-005: Positional arguments to git, helm, and kustomize accept option-shaped values

Element: P3 to E5 Category: Tampering, Elevation of Privilege Evidence: CODE. git clone <url> <dir> has no -- separator (crates/xianyun-backends/src/git/mod.rs:196); helm pull <chart_ref> and kustomize build <target> are also positional. UNPROVEN. Measurement: load a config with repository: "--upload-pack=touch /tmp/x" and see whether schema validation ("format": "uri") rejects it, then whether git clone runs it. No shell is involved, so only the target tool’s own option parsing matters. DREAD: D:6 R:4 E:4 A:2 D:3 = 19 (Medium) Mitigation: Insert -- before positional values where the tool supports it; reject values that begin with -. Status: Not Started ADR: ADR-0004 Spec Requirement: SR-009

TM-CFG-006: YAML alias expansion could exhaust memory

Element: P3 Category: Denial of Service Evidence: UNPROVEN. serde_yaml 0.9.34 is used with no documented expansion limit. Measurement: load a “billion laughs” file and watch peak RSS. DREAD: D:3 R:5 E:3 A:2 D:3 = 16 (Medium) Mitigation: Cap input size before parsing (the remote fetch already caps at 10 MiB; local files have no cap); test with an alias bomb. Status: Not Started ADR: ADR-0009 Spec Requirement: NFR-006

TM-CFG-007: Config discovery walks to the filesystem root outside the home directory

Element: P3 Category: Elevation of Privilege Evidence: CODE. find_config_file (crates/xianyun-core/src/config/global.rs:107-149) stops only when search_dir == home. From a working directory outside $HOME it checks every ancestor up to /. (Inside the measured temp-home run the error read “up to home directory”, confirming the stop rule applies there.) UNPROVEN. Measurement: create /tmp/xianyun.yaml with a lifecycle hook, run xianyun config validate from /tmp/x/y with a different HOME, and see whether it loads. Description: On a shared server, a file planted in /tmp or another common ancestor is picked up by anyone who runs the tool from beneath it, and the config can carry hooks. DREAD: D:8 R:4 E:4 A:2 D:3 = 21 (Medium) Mitigation: Stop at the repository root (.git) or the home directory, and refuse configs not owned by the current user. Status: Not Started ADR: ADR-0009 Spec Requirement: SR-008

Dev CA and key material (D2, D3, TB5)

TM-PKI-001: The CA private key is copied into a world-readable staging directory

Element: D3 kustomize staging Category: Information Disclosure Evidence: MEASURED. A kustomize build of a directory containing tls.key staged to xianyun-kustomize-<pid>-<n>-<nanos> with mode 755, containing tls.key at mode 644, under umask 022. StagingDir::create and stage_directory (crates/xianyun-backends/src/kustomize/mod.rs:240-291) set no modes. The key comes from write_ca_material (crates/xianyun-deploy/src/deployer/kustomize/camaterial.rs). Description: For the length of each kustomize build, any local user who can list $TMPDIR can read the CA key. The name is guessable from the PID and a timestamp, and the directory is listable. No longer latent: this path runs through the kustomize deployer with builtin:dev-ca. GAP-001 was closed on 2026-10-14, so the deployer runs. The measurement was taken at the backend, and the e2e-deploy scenario doesn’t use builtin:dev-ca, so the staged key’s mode in a real up is still unmeasured. Attack vector: On a shared host, a second user polls /tmp for xianyun-kustomize-* and copies tls.key. Affected assets: dev CA private key. DREAD: D:7 R:8 E:6 A:4 D:5 = 30 (High) Mitigation: Create staging with tempfile::Builder (mode 0700) and write secret-bearing files 0600. Status: Not Started ADR: ADR-0006 Spec Requirement: SR-010

TM-PKI-002: The CA key is deployed into the cluster as a Secret

Element: D2 to E6 Category: Information Disclosure Evidence: CODE. PkiCaProvider reads both PEMs and the kustomize overlay turns them into a Secret consumed by cert-manager. Description: Anyone with read access to Secrets in the local cluster holds the CA key. The Name Constraints on the CA are the remaining limit (see TM-PKI-003 and TM-PKI-006), and they aren’t guaranteed: PkiCaProvider::load calls load_or_generate(&pki_dir, &[]) (camaterial.rs:76), so on a profile with no CA yet it generates one with no constraints (ca.rs:290, if !permitted_dns.is_empty()). The code comment at camaterial.rs:72-75 assumes a pure load. The Secret path is reachable now that deployers run (GAP-001, closed), though no scenario deploys builtin:dev-ca. DREAD: D:6 R:5 E:3 A:3 D:4 = 21 (Medium) Mitigation: Accept for a local development cluster; keep the Name Constraints mandatory (refuse to generate an unconstrained CA); document the exposure. Status: ⚠️ PARTIAL (Name Constraints are applied when a permitted-DNS list is non-empty; the unconstrained case isn’t refused) ADR: ADR-0006 Spec Requirement: SR-011

TM-PKI-003: A cluster-reported DNS zone flows into CA Name Constraints without validation

Element: E6 to D2 Category: Tampering, Elevation of Privilege Evidence: CODE. When cluster.network.cluster_domain is unset, resolve() (crates/xianyun-cluster/src/clusterdomain/resolve.rs:46-90) copies the discovered zone into it (a configured value that differs returns ConfigMismatch instead); build_permitted_dns (crates/xianyun-pki/src/constraints.rs) appends .{domain}. UNPROVEN. Measurement: with a fake DnsReader returning com, generate the CA and read its permitted subtrees. Applies to driver: external, where Xianyun doesn’t own the cluster. DREAD: D:7 R:4 E:3 A:3 D:3 = 20 (Medium) Mitigation: Reject single-label and public-suffix zones; require the zone to be a subdomain of a configured base domain. Status: Not Started ADR: ADR-0006 Spec Requirement: SR-011

TM-PKI-004: CA directories use the default umask

Element: D2 Category: Information Disclosure Evidence: CODE. ca.key and ca.crt are created 0600 with create_new (ca.rs:525-543), but pki/ and gen-*/ come from create_dir and create_dir_all with no mode. Description: The key file itself is private, so exposure is limited to metadata and the certificate. It matters mostly as defence in depth. DREAD: D:3 R:6 E:3 A:2 D:4 = 18 (Medium) Mitigation: Create the PKI directories 0700. Status: Not Started ADR: ADR-0006 Spec Requirement: SR-010

TM-PKI-005: Loading a CA doesn’t check that the certificate and key belong together

Element: D2 Category: Tampering Evidence: CODE (ca.rs load, about line 560); ca.rs:735 has a test with a tautological assertion labelled “placeholder pairing check pending real crypto”. Description: A swapped or corrupted file would be trusted and used to sign. DREAD: D:3 R:3 E:2 A:2 D:3 = 13 (Medium) Mitigation: Verify the public key in the certificate matches the private key at load; replace the placeholder test. Status: Not Started ADR: ADR-0006 Spec Requirement: FR-008

TM-PKI-006: Some trust stores may ignore Name Constraints

Element: D6 Category: Elevation of Privilege Evidence: UNPROVEN. The CA is trusted system-wide (Linux anchors, macOS System keychain) and in NSS with flag C. Whether every client enforces permittedSubtrees varies. Measurement: issue a leaf for example.com from the generated CA and attempt a TLS connection from curl, Firefox, and Go with the CA trusted. DREAD: D:9 R:3 E:4 A:4 D:3 = 23 (Medium) Mitigation: Keep lifetime short (it’s currently 10 years); document which clients enforce constraints. Status: Not Started ADR: ADR-0006 Spec Requirement: SR-011

Kubeconfig (D4, TB5)

TM-KUBE-001: The kubeconfig backup file is world-readable

Element: D4 Category: Information Disclosure Evidence: MEASURED. After two writes under umask 022, the kubeconfig was mode 600 and its .caproni.bak was mode 644. backup() (crates/xianyun-cluster/src/kubeconfig/file.rs:137) uses std::fs::write. Description: The backup holds the same credentials as the live file, including client certificates and tokens for any cluster in ~/.kube/config. Attack vector: Another local user reads ~/.kube/config.caproni.bak if the home directory permits traversal. Affected assets: kubeconfig credentials for every cluster in the file. DREAD: D:6 R:8 E:6 A:3 D:5 = 28 (High) Mitigation: Create the backup with mode 0600 (copy the source mode). Status: Not Started ADR: ADR-0003 Spec Requirement: SR-010

TM-KUBE-002: Temporary kubeconfigs for every kubectl and helm call are world-readable

Element: D4 Category: Information Disclosure Evidence: CODE. kubeconfig_env in crates/xianyun-backends/src/helm/mod.rs:109-114 and crates/xianyun-backends/src/kubectl/mod.rs:239-244 write the full kubeconfig with std::fs::write to $TMPDIR/xianyun-helm-kubeconfig-<pid>-<nanos> and $TMPDIR/xianyun-kubeconfig-<pid>-<nanos>. The call pattern is the same one measured for the kubeconfig backup in TM-KUBE-001 (mode 0644 under umask 022); this path wasn’t measured separately. The mirrord config uses Tempfile::new with the default mode and a PID-and-time suffix (crates/xianyun-deploy/src/reloader/mirror/manager.rs:507). Description: Each helm or kubectl invocation leaves cluster credentials in a guessable file for as long as the command runs. It happens far more often than the one-off backup. Attack vector: On a shared host, another user lists $TMPDIR and copies the file while a deploy runs. Affected assets: kubeconfig credentials for the local cluster (or the external cluster for driver: external). DREAD: D:6 R:7 E:5 A:3 D:4 = 25 (High) Mitigation: Use tempfile::Builder (0600, random name) as kubeconfig/file.rs already does for the virtual kubeconfig. Status: Not Started ADR: ADR-0004 Spec Requirement: SR-010

Host files and privileged operations (P6, D5, D6, TB3)

TM-HOSTS-001: An unvalidated --ip injects arbitrary lines into /etc/hosts

Element: P2 to P6 to D5 Category: Tampering Evidence: MEASURED at the backend: Client::update with ip_address = "1.2.3.4 x\n6.6.6.6 gitlab.com" rendered the line 6.6.6.6 gitlab.com gitlab.test. CODE for the CLI path: get_cluster_ip returns the flag verbatim (crates/xianyun-cli/src/commands/updateetchosts.rs:99) and the content is written with sudo tee /etc/hosts. The CLI-level dry run printed nothing in this environment, so the end-to-end write wasn’t observed (and wasn’t attempted). Description: The --ip value is treated as an opaque string. A newline lets the caller add host entries that redirect real domains. Attack vector: A prompt-injected agent calls the gated update-etc-hosts tool with a crafted --ip (the user sees a generic consequence message; see TM-MCP-004). Affected assets: /etc/hosts. DREAD: D:7 R:8 E:4 A:2 D:4 = 25 (High) Mitigation: Parse --ip as std::net::IpAddr and reject anything else; reject hostnames containing whitespace in HostsFile. Status: Not Started ADR: ADR-0004 Spec Requirement: SR-004

TM-HOSTS-002: /etc/hosts is rewritten in place with no backup

Element: P6, D5 Category: Tampering, Denial of Service Evidence: CODE (updateetchosts.rs:194-215). The new content is piped to sudo tee /etc/hosts, which truncates before writing. Description: An interruption mid-write leaves a truncated hosts file. The hand-rolled parser (crates/xianyun-backends/src/hosts/mod.rs) also has to round-trip every line it didn’t touch. DREAD: D:5 R:3 E:2 A:2 D:3 = 15 (Medium) Mitigation: Write a temp file beside /etc/hosts and mv it over; keep a timestamped backup. Status: Not Started ADR: ADR-0004 Spec Requirement: FR-009

TM-HOSTS-003: Config-supplied hostnames can claim real domains

Element: P3 to P6 Category: Tampering Evidence: CODE. The schema validates hostnames against RFC 1123, with a second anyOf branch for variable references that is unanchored (config.v2.schema.json:~83-85, \$[a-zA-Z_]|\$\{[a-zA-Z_]), so a string containing $name also passes. UNPROVEN: whether a surviving $ plus whitespace can reach HostsFile; measure by expanding such a value and rendering it. Description: A config or remote fragment can list github.com or a bank’s hostname in additional_hostnames, and update-etc-hosts will point it at the cluster IP. DREAD: D:6 R:7 E:3 A:3 D:4 = 23 (Medium) Mitigation: Show the full hostname list and ask for confirmation before the first write; warn on names outside configured domains. Status: Not Started ADR: ADR-0009 Spec Requirement: SR-004

Registry cache (P5, TB7)

TM-REG-001: The zot registry cache listens on all interfaces without authentication

Element: P5 Category: Tampering, Information Disclosure Evidence: CODE. crates/xianyun-deploy/src/registrycache/config.rs:120 sets address: "0.0.0.0" on port 5555; the generated config has no accessControl or authentication section (searched for both). UNPROVEN. Measurement: start the cache, then curl -X POST http://<lan-ip>:5555/v2/test/blobs/uploads/ from another host. A success means anonymous writes are possible, which allows cache poisoning. Description: Clusters pull images from this cache without further verification, so poisoned blobs run in the dev cluster. DREAD: D:6 R:7 E:5 A:4 D:6 = 28 (High) Mitigation: Bind to the loopback or bridge address the cluster uses; add read-only anonymous policy. Status: Not Started ADR: ADR-0007 Spec Requirement: SR-012

TM-REG-002: The zot PID file isn’t validated before signalling

Element: P5 Category: Denial of Service Evidence: CODE. read_pid parses an i32 with no positivity check (crates/xianyun-deploy/src/registrycache/process.rs:194-201), and stop_process calls libc::kill(pid, SIGTERM) then SIGKILL. A value of 0 or -1 has process-group or all-process semantics under POSIX. Not executed. The measurement (write -1, call stop_process) would signal every process the user owns, so it was deliberately skipped. A safe variant is a unit test over read_pid that asserts pid > 0. Description: A stale PID is the likelier case than a hostile one: after a reboot or crash that leaves zot.pid behind, registry stop sends SIGTERM and then SIGKILL to whichever same-user process now holds that PID, and is_running treats EPERM as running. A malicious value needs same-user write access to the data directory. DREAD: D:5 R:5 E:3 A:1 D:3 = 17 (Medium) Mitigation: Reject pid <= 1 and confirm the process name before signalling. Status: Not Started ADR: ADR-0007 Spec Requirement: FR-011

TM-REG-003: The k3d mirror configuration skips TLS verification

Element: P5 to E6 Category: Spoofing Evidence: CODE (crates/xianyun-cluster/src/driver/k3d.rs:499): insecureSkipVerify: true for the mirror endpoint. The config schema also requires an http:// mirror endpoint. Description: The cluster-to-cache hop is unauthenticated and unencrypted. Together with TM-REG-001, anything on the path can alter pulled images. DREAD: D:4 R:5 E:4 A:3 D:3 = 19 (Medium) Mitigation: Accept for loopback-only mirrors; document it. Status: Not Started ADR: ADR-0007 Spec Requirement: SR-012

Process execution (P4, TB6)

TM-EXEC-001: Lifecycle hooks run with the full parent environment

Element: P4 Category: Information Disclosure, Elevation of Privilege Evidence: CODE (crates/xianyun-update/src/lifecyclehooks.rs:171-185). Hooks inherit every parent variable plus resolver variables and KUBECONFIG. Hooks aren’t run through a shell, so there’s no metacharacter expansion. Description: Inherent to the feature. It becomes a threat only when the hook’s origin is untrusted (TM-CFG-001, TM-CFG-004). DREAD: D:6 R:7 E:2 A:2 D:3 = 20 (Medium) Mitigation: Accept; the origin-trust controls above are what limits this. Status: ⚠️ PARTIAL (no shell is involved; environment filtering isn’t offered) ADR: ADR-0004 Spec Requirement: FR-013

TM-EXEC-002: Two divergent shell-word splitters parse commands

Element: P4 Category: Tampering Evidence: CODE. lifecyclehooks.rs:193 has no backslash handling; crates/xianyun-cli/src/commands/editmodeprocesssupervisor.rs:62 does. Description: The same string can split into different argv depending on the path. That is a correctness risk with a security flavour: reviewing a config by one parser’s rules and executing it by another’s. DREAD: D:2 R:5 E:2 A:1 D:3 = 13 (Medium) Mitigation: Use one splitter (the shlex crate or a shared internal function). Status: Not Started ADR: ADR-0004 Spec Requirement: FR-013

TM-EXEC-003: mise exec wraps repository commands and may load repository-supplied tool config

Element: P4 Category: Elevation of Privilege Evidence: CODE (crates/xianyun-executor/src/exec/mise_decorator.rs:12-24): when mise is on PATH, every run_in_repository command becomes mise exec -- <cmd>. UNPROVEN. Measurement: put a .mise.toml with an [env] template that runs a command into a test repository, run a hook, and see whether it executes without a trust prompt. DREAD: D:7 R:5 E:4 A:2 D:4 = 22 (Medium) Mitigation: Document that mise trust applies; consider an opt-out. Status: Not Started ADR: ADR-0004 Spec Requirement: FR-013

TM-EXEC-004: Cluster domain discovery runs an unpinned image

Element: E6 Category: Tampering Evidence: CODE (crates/xianyun-cluster/src/clusterdomain/mod.rs:68): the discovery Job uses busybox:1.36 by tag. MEASURED 2026-10-14: it’s the first source tried, so on a cluster that can’t pull the image up, deploy, and ca inspect each wait out its 120-second timeout (GAP-009). DREAD: D:5 R:4 E:3 A:3 D:3 = 18 (Medium) Mitigation: Pin by digest; the NixOS VM tests already pin k3d images this way. Status: Not Started ADR: ADR-0008 Spec Requirement: SR-013

TM-EXEC-005: sudo and zot are resolved through PATH

Element: P4, P6 Category: Elevation of Privilege Evidence: CODE. apply_sudo prepends the PATH-resolved sudo (crates/xianyun-executor/src/exec/mod.rs:535), and lookup_zot resolves zot the same way (crates/xianyun-deploy/src/registrycache/process.rs). Trust Boundary TB6 names PATH, but no threat did until now. Description: A writable directory early in the developer’s PATH with a program named sudo or zot gets run, and sudo receives the tee /etc/hosts content. This is a general property of CLI tools and mostly matters on a compromised account. DREAD: D:6 R:3 E:3 A:2 D:3 = 17 (Medium) Mitigation: Accept; document that PATH is trusted. Status: Not Started ADR: ADR-0004 Spec Requirement: NFR-002

Repudiation, supply chain, and planned features

TM-AUD-001: Privileged and agent-initiated actions leave no durable audit record

Element: P1, P2, P6 Category: Repudiation Evidence: CODE. Privileged writes log through tracing to stderr; MCP calls re-exec the binary and return captured output. Nothing records which MCP client requested what, or that the user confirmed it. DREAD: D:4 R:8 E:3 A:2 D:5 = 22 (Medium) Mitigation: Append one structured line per privileged operation and per MCP tool call (command, arguments, confirmation outcome) to a file in the data directory. Status: Not Started ADR: ADR-0005 Spec Requirement: SR-014

TM-SUP-001: No CI job runs tests or dependency scans for the Rust workspace

Element: build Category: Tampering Evidence: MEASURED. .gitlab-ci.yml contains no cargo or nix invocation, and no deny.toml or audit.toml exists. It does include standard-build@v5.25, whose own comment mentions “gitlab-scanners”; whether that template scans Cargo.lock wasn’t checked. The flake devShell does provide cargo-audit 0.22.2 and cargo-deny 0.20.2, and nix develop -c cargo audit on 2026-10-13 scanned 336 crates and found 0 vulnerabilities and 4 warnings: paste 1.0.15 unmaintained (RUSTSEC-2024-0436) and lru 0.12.5 unsound (RUSTSEC-2026-0253 and RUSTSEC-2026-0002), both through ratatui 0.29; and unsafe-libyaml 0.2.11 unmaintained (RUSTSEC-2024-0449), through serde_yaml. Description: The tools exist and the scan was clean of known vulnerabilities when measured, but nothing ran it on a schedule or on merge requests, so the next advisory would go unnoticed until someone ran it by hand. DREAD: D:6 R:5 E:3 A:5 D:4 = 23 (Medium) Mitigation: A CI job that runs cargo test --workspace, cargo clippy, and cargo deny check, plus a committed deny.toml that records each accepted advisory with a reason. Update 2026-10-13: .gitlab-ci.yml now has those jobs, and a scheduled pipeline that runs cargo deny check alone. deny.toml records four accepted advisories (paste and the two lru soundness advisories, all via ratatui, and unsafe-libyaml, via serde_yaml). Writing it exposed a gap: with cargo deny’s default settings the two lru advisories were silently not reported, though cargo audit reported them, because the default only checks workspace crates for unsound advisories. deny.toml sets unsound = "all" so the two tools agree. Each command passed locally. The pipeline hasn’t run on GitLab, and the schedule doesn’t exist until someone creates it. Status: ⚠️ PARTIAL (jobs and policy committed and verified locally; not yet run in CI) ADR: ADR-0008 Spec Requirement: NFR-007

TM-SUP-002: serde_yaml is deprecated and sits on an unmaintained parser

Element: P3 Category: Tampering Evidence: MEASURED. Cargo.lock has serde_yaml 0.9.34+deprecated, and cargo audit flags its dependency unsafe-libyaml 0.2.11 as unmaintained (RUSTSEC-2024-0449). cargo tree -i unsafe-libyaml shows it reaches xianyun-cli, xianyun-cluster, and others through serde_yaml. DREAD: D:3 R:3 E:3 A:5 D:5 = 19 (Medium) Mitigation: Plan a move to a maintained YAML crate; the config tests ported from Go are the safety net. Status: Not Started ADR: ADR-0009 Spec Requirement: NFR-006

TM-TEL-001: Planned telemetry would carry command arguments and hook scripts

Element: future Category: Information Disclosure Evidence: CODE, as it stood before removal. The dormant event types included CommandEvent{args} and HookEvent{script, repository}, and the code decided whether to enable telemetry by looking for an @gitlab.com address in git config, a GitLab Zoom configuration file, or a GitLab hostname pattern. Nothing called it. Description: Design-time risk: had the emitter been ported as written, secrets in arguments or scripts would have left the machine, and a public, application-agnostic tool would have carried a GitLab-employee heuristic. DREAD: D:5 R:4 E:2 A:5 D:3 = 19 (Medium) Mitigation: Eliminate the feature. The module, its event types, and its environment variables were removed on 2026-10-13. Status: ✅ IMPLEMENTED (eliminated; SR-less, see NFR-005, 2026-10-13) ADR: ADR-0018 Spec Requirement: NFR-005

Risk summary

BandCountIDs
Critical0none
High12TM-MCP-001, TM-MCP-002, TM-MCP-003, TM-MCP-004, TM-CFG-001, TM-CFG-003, TM-CFG-004, TM-PKI-001, TM-KUBE-001, TM-KUBE-002, TM-HOSTS-001, TM-REG-001
Medium22the rest
Eliminated1TM-TEL-001

Evidence breakdown across 35 threats: 7 have a MEASURED runtime observation (TM-MCP-001, TM-MCP-003, TM-MCP-005, TM-CFG-003, TM-PKI-001, TM-KUBE-001, TM-HOSTS-001), 2 more are MEASURED by file search and cargo audit (TM-SUP-001, TM-SUP-002), and the remainder are CODE or UNPROVEN with the confirming measurement named. TM-MCP-005 is measured only against the committed tool snapshot, not a live client, and TM-PKI-001 was measured at the backend rather than through xianyun up (see its note on GAP-001).

Residual risk and what wasn’t examined

  • The Go binary still ships in this repository and is documented as the primary tool. Its threats overlap but aren’t analysed here.
  • The macOS Keychain path (system_darwin.rs) was read but not run. Its use of sudo security add-trusted-cert into the System keychain deserves its own review on a real macOS host.
  • The Nix VM test harness, the Helm charts users deploy, and mirrord’s own cluster-side agent are out of scope.
  • cargo audit ran against the committed Cargo.lock only; cargo deny was not run, and no licence or source policy was reviewed.
  • No fuzzing or dynamic testing of the YAML parser, hosts parser, or variable expander was done.
  • The zot log file’s mode, and whether the logs tool can return secrets that edit-mode processes print, weren’t checked.
  • Time-of-check to time-of-use behaviour in ensure_current (CA regeneration) was noted but not tested.

Changelog

DateChange
2026-10-13Initial model, created by the all-aboard review