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
| Asset | Why it matters |
|---|---|
Dev CA private key (ca.key) | Anything signed by it is trusted by the developer’s browsers and tools |
| Kubeconfig credentials | Cluster-admin access to the local cluster |
| Developer workstation integrity | Xianyun runs commands from config with the developer’s privileges |
/etc/hosts and system trust store | Redirect or intercept traffic for any hostname |
| Process environment | May hold tokens and cloud credentials |
| Registry cache contents | Images 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
| ID | Boundary | What crosses it |
|---|---|---|
| TB1 | MCP client to xianyun-mcp | Tool calls with free-form args and flags; elicitation answers |
| TB2 | Config sources to the loader | Local YAML, remote https fragments, xianyun-project.yaml from cloned repos, environment variables, file-typed variables |
| TB3 | Unprivileged process to sudo | tee /etc/hosts, install, update-ca-certificates, security add-trusted-cert |
| TB4 | Xianyun to the cluster | Kubeconfig, applied manifests, cluster-reported DNS zone and node data |
| TB5 | Xianyun to other local users | $TMPDIR, ~/.kube, ~/.local/share/xianyun |
| TB6 | Xianyun to executables on PATH | argv, inherited environment |
| TB7 | Registry cache listener to the network | OCI pulls and (possibly) pushes |
Assumptions
Each assumption is a risk if it turns out to be wrong.
- The developer controls the workstation and trusts the config file in their own repository. Config content is code, because lifecycle hooks run it.
- Remote config fragments and cloned repositories are less trusted than the local config file. Nothing in the codebase currently enforces that difference.
- 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.
- The machine can be multi-user. A shared Linux server with a world-readable
/tmpis a supported way to run the tool. - 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
| Band | Count | IDs |
|---|---|---|
| Critical | 0 | none |
| High | 12 | TM-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 |
| Medium | 22 | the rest |
| Eliminated | 1 | TM-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 ofsudo security add-trusted-certinto 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 auditran against the committedCargo.lockonly;cargo denywas 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
logstool 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
| Date | Change |
|---|---|
| 2026-10-13 | Initial model, created by the all-aboard review |