vault_audit_tools/commands/
kv_mounts.rs

1//! KV mount enumeration and tree listing.
2//!
3//! This command queries the Vault API to automatically discover and enumerate all KV secret mounts
4//! (both v1 and v2) and recursively lists their contents in a hierarchical tree structure.
5//!
6//! # Features
7//!
8//! - **Automatic Discovery**: Discovers all KV mounts without needing to know mount names
9//! - **Version Detection**: Automatically detects and handles both KV v1 and KV v2 mounts
10//! - **Depth Control**: Optional depth parameter to control traversal (unlimited by default)
11//! - **Multiple Output Formats**: CSV (flattened with depth), JSON (nested tree), or stdout (visual tree)
12//!
13//! # Usage Examples
14//!
15//! ```bash
16//! # List all KV mounts with unlimited depth (default)
17//! vault-audit kv-mounts --format stdout
18//!
19//! # List only the mounts themselves (no traversal)
20//! vault-audit kv-mounts --depth 0 --format csv
21//!
22//! # List mounts and traverse 2 levels deep
23//! vault-audit kv-mounts --depth 2 --format json
24//!
25//! # Save full tree to CSV file
26//! vault-audit kv-mounts --format csv --output kv-inventory.csv
27//! ```
28//!
29//! # Output Formats
30//!
31//! - **CSV**: Flattened paths with depth column, one row per path/secret
32//! - **JSON**: Nested tree structure with parent-child relationships
33//! - **stdout**: Visual tree with Unicode box-drawing characters (├──, └──, │)
34//!
35//! # Depth Parameter
36//!
37//! - `--depth 0`: Show only mount points (no traversal)
38//! - `--depth 1`: Show mounts + first level folders/secrets
39//! - `--depth 2`: Show mounts + two levels of traversal
40//! - No flag: Unlimited depth (discovers entire tree structure)
41//!
42//! # API Endpoints Used
43//!
44//! - `/v1/sys/mounts` - Discover all secret mounts
45//! - `/v1/{mount}/metadata/{path}` - List KV v2 paths (using LIST method)
46//! - `/v1/{mount}/{path}` - List KV v1 paths (using LIST method)
47
48use anyhow::{Context, Result};
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51use std::collections::HashMap;
52use std::fs::File;
53use std::io::Write;
54
55use crate::vault_api::VaultClient;
56
57#[derive(Debug, Serialize, Deserialize)]
58struct MountInfo {
59    #[serde(rename = "type")]
60    mount_type: String,
61    #[serde(default)]
62    description: String,
63    #[serde(default)]
64    accessor: String,
65    #[serde(default, deserialize_with = "deserialize_null_default")]
66    config: HashMap<String, Value>,
67    #[serde(default, deserialize_with = "deserialize_null_default")]
68    options: HashMap<String, Value>,
69}
70
71fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
72where
73    T: Default + Deserialize<'de>,
74    D: serde::Deserializer<'de>,
75{
76    let opt = Option::deserialize(deserializer)?;
77    Ok(opt.unwrap_or_default())
78}
79
80/// Recursively list paths within a KV v2 mount up to a specified depth
81#[allow(clippy::future_not_send)]
82async fn list_kv_v2_paths(
83    client: &VaultClient,
84    mount_path: &str,
85    current_depth: usize,
86    max_depth: usize,
87) -> Result<Vec<PathEntry>> {
88    if current_depth > max_depth {
89        return Ok(Vec::new());
90    }
91
92    let mut entries = Vec::new();
93    let mount_trimmed = mount_path.trim_end_matches('/');
94
95    // List the root of the mount using LIST method on metadata endpoint
96    let list_path = format!("/v1/{}/metadata", mount_trimmed);
97
98    let response: Result<Value> = client.list_json(&list_path).await;
99
100    if let Ok(resp) = response {
101        // Extract keys from the data.keys field
102        if let Some(data) = resp.get("data") {
103            if let Some(keys) = data.get("keys") {
104                if let Some(keys_array) = keys.as_array() {
105                    for key in keys_array {
106                        if let Some(key_str) = key.as_str() {
107                            let is_folder = key_str.ends_with('/');
108                            let entry_type = if is_folder { "folder" } else { "secret" };
109
110                            let children = if is_folder && current_depth < max_depth {
111                                // Pass just the relative path, not the full mount path
112                                let rel_path = key_str.trim_end_matches('/');
113                                Some(
114                                    list_kv_v2_subpath(
115                                        client,
116                                        mount_trimmed,
117                                        rel_path,
118                                        current_depth + 1,
119                                        max_depth,
120                                    )
121                                    .await?,
122                                )
123                            } else {
124                                None
125                            };
126
127                            entries.push(PathEntry {
128                                path: key_str.to_string(),
129                                entry_type: entry_type.to_string(),
130                                children,
131                            });
132                        }
133                    }
134                }
135            }
136        }
137    }
138    // If we can't list the root, that's okay - mount might be empty or no permissions
139
140    Ok(entries)
141}
142
143/// List paths within a KV v2 subpath (folder)
144#[allow(clippy::future_not_send)]
145fn list_kv_v2_subpath<'a>(
146    client: &'a VaultClient,
147    mount_path: &'a str,
148    rel_path: &'a str,
149    current_depth: usize,
150    max_depth: usize,
151) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
152    Box::pin(async move {
153        if current_depth > max_depth {
154            return Ok(Vec::new());
155        }
156
157        let mut entries = Vec::new();
158        let mount_trimmed = mount_path.trim_end_matches('/');
159
160        // For KV v2, the metadata endpoint is /v1/{mount}/metadata/{path}
161        let list_path = format!("/v1/{}/metadata/{}", mount_trimmed, rel_path);
162
163        let response: Result<Value> = client.list_json(&list_path).await;
164
165        if let Ok(resp) = response {
166            if let Some(data) = resp.get("data") {
167                if let Some(keys) = data.get("keys") {
168                    if let Some(keys_array) = keys.as_array() {
169                        for key in keys_array {
170                            if let Some(key_str) = key.as_str() {
171                                let is_folder = key_str.ends_with('/');
172                                let entry_type = if is_folder { "folder" } else { "secret" };
173
174                                let children = if is_folder && current_depth < max_depth {
175                                    let new_rel_path =
176                                        format!("{}/{}", rel_path, key_str.trim_end_matches('/'));
177                                    Some(
178                                        list_kv_v2_subpath(
179                                            client,
180                                            mount_path,
181                                            &new_rel_path,
182                                            current_depth + 1,
183                                            max_depth,
184                                        )
185                                        .await?,
186                                    )
187                                } else {
188                                    None
189                                };
190
191                                entries.push(PathEntry {
192                                    path: key_str.to_string(),
193                                    entry_type: entry_type.to_string(),
194                                    children,
195                                });
196                            }
197                        }
198                    }
199                }
200            }
201        }
202        // Silently ignore list errors for subpaths
203
204        Ok(entries)
205    })
206}
207
208/// Recursively list paths within a KV v1 mount up to a specified depth
209#[allow(clippy::future_not_send)]
210fn list_kv_v1_paths<'a>(
211    client: &'a VaultClient,
212    mount_path: &'a str,
213    subpath: &'a str,
214    current_depth: usize,
215    max_depth: usize,
216) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
217    Box::pin(async move {
218        if current_depth > max_depth {
219            return Ok(Vec::new());
220        }
221
222        let mut entries = Vec::new();
223        let mount_trimmed = mount_path.trim_end_matches('/');
224
225        // For KV v1, use LIST on the mount path directly
226        let list_path = if subpath.is_empty() {
227            format!("/v1/{}", mount_trimmed)
228        } else {
229            format!("/v1/{}/{}", mount_trimmed, subpath.trim_end_matches('/'))
230        };
231
232        let response: Result<Value> = client.list_json(&list_path).await;
233
234        if let Ok(resp) = response {
235            if let Some(data) = resp.get("data") {
236                if let Some(keys) = data.get("keys") {
237                    if let Some(keys_array) = keys.as_array() {
238                        for key in keys_array {
239                            if let Some(key_str) = key.as_str() {
240                                let is_folder = key_str.ends_with('/');
241                                let entry_type = if is_folder { "folder" } else { "secret" };
242
243                                let children = if is_folder && current_depth < max_depth {
244                                    let new_subpath = if subpath.is_empty() {
245                                        key_str.trim_end_matches('/').to_string()
246                                    } else {
247                                        format!(
248                                            "{}/{}",
249                                            subpath.trim_end_matches('/'),
250                                            key_str.trim_end_matches('/')
251                                        )
252                                    };
253                                    Some(
254                                        list_kv_v1_paths(
255                                            client,
256                                            mount_path,
257                                            &new_subpath,
258                                            current_depth + 1,
259                                            max_depth,
260                                        )
261                                        .await?,
262                                    )
263                                } else {
264                                    None
265                                };
266
267                                entries.push(PathEntry {
268                                    path: key_str.to_string(),
269                                    entry_type: entry_type.to_string(),
270                                    children,
271                                });
272                            }
273                        }
274                    }
275                }
276            }
277        }
278        // If we can't list, that's okay - might be empty or no permissions
279
280        Ok(entries)
281    })
282}
283
284/// Helper function to flatten nested path entries to CSV format
285fn flatten_paths_to_csv(output: &mut String, base_path: &str, entries: &[PathEntry], depth: usize) {
286    use std::fmt::Write as _;
287    for entry in entries {
288        let full_path = format!("{}{}", base_path, entry.path);
289        let _ = writeln!(
290            output,
291            "\"{}\",\"{}\",\"{}\",{}",
292            full_path.replace('"', "\"\""),
293            entry.entry_type,
294            base_path.replace('"', "\"\""),
295            depth
296        );
297
298        if let Some(children) = &entry.children {
299            let new_base = format!("{}{}", base_path, entry.path);
300            flatten_paths_to_csv(output, &new_base, children, depth + 1);
301        }
302    }
303}
304
305/// Helper function to print nested paths in tree format
306#[allow(clippy::only_used_in_recursion)]
307fn print_tree(base_path: &str, entries: &[PathEntry], prefix: &str, is_last_at_level: &[bool]) {
308    for (i, entry) in entries.iter().enumerate() {
309        let is_last = i == entries.len() - 1;
310        let connector = if is_last { "└──" } else { "├──" };
311
312        println!(
313            "{}{} {} ({})",
314            prefix, connector, entry.path, entry.entry_type
315        );
316
317        if let Some(children) = &entry.children {
318            let mut new_prefix = prefix.to_string();
319            new_prefix.push_str(if is_last { "    " } else { "│   " });
320
321            let mut new_is_last = is_last_at_level.to_vec();
322            new_is_last.push(is_last);
323            print_tree(base_path, children, &new_prefix, &new_is_last);
324        }
325    }
326}
327
328#[derive(Debug, Serialize)]
329struct KvMountOutput {
330    path: String,
331    mount_type: String,
332    description: String,
333    version: String,
334    accessor: String,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    children: Option<Vec<PathEntry>>,
337}
338
339#[derive(Debug, Serialize, Clone)]
340struct PathEntry {
341    path: String,
342    #[serde(rename = "type")]
343    entry_type: String, // "folder" or "secret"
344    #[serde(skip_serializing_if = "Option::is_none")]
345    children: Option<Vec<PathEntry>>,
346}
347
348/// Run the KV mount enumeration command
349#[allow(clippy::future_not_send)]
350pub async fn run(
351    vault_addr: Option<&str>,
352    vault_token: Option<&str>,
353    vault_namespace: Option<&str>,
354    insecure: bool,
355    output: Option<&str>,
356    format: &str,
357    depth: usize,
358) -> Result<()> {
359    let client = VaultClient::from_options(vault_addr, vault_token, vault_namespace, insecure)?;
360
361    eprintln!("Querying Vault API for KV mounts...");
362    eprintln!("   Vault Address: {}", client.addr());
363
364    // Query /sys/mounts to get all secret mounts
365    let response: Value = client
366        .get("/v1/sys/mounts")
367        .await
368        .context("Failed to query /v1/sys/mounts")?;
369
370    // Extract the data field which contains the actual mounts
371    let mounts_data = response
372        .get("data")
373        .or(Some(&response)) // Fallback to root if no data field
374        .context("Failed to get mounts data")?;
375
376    let mounts = mounts_data
377        .as_object()
378        .context("Expected object response from /v1/sys/mounts")?;
379
380    let mut kv_mounts = Vec::new();
381
382    for (path, mount_data) in mounts {
383        // Skip metadata fields like "request_id"
384        if path == "request_id"
385            || path == "lease_id"
386            || path == "renewable"
387            || path == "lease_duration"
388            || path == "data"
389            || path == "wrap_info"
390            || path == "warnings"
391            || path == "auth"
392        {
393            continue;
394        }
395
396        let mount_info: MountInfo = serde_json::from_value(mount_data.clone())
397            .with_context(|| format!("Failed to parse mount info for {}", path))?;
398
399        // Filter for ALL KV mounts (v1 and v2)
400        if mount_info.mount_type == "kv" {
401            let version = mount_info
402                .options
403                .get("version")
404                .and_then(|v| v.as_str())
405                .or_else(|| {
406                    mount_info
407                        .options
408                        .get("version")
409                        .and_then(serde_json::Value::as_i64)
410                        .map(|_| "2")
411                })
412                .unwrap_or("1");
413
414            // Traverse paths if depth > 0
415            let children = if depth > 0 {
416                if version == "2" {
417                    Some(list_kv_v2_paths(&client, path, 1, depth).await?)
418                } else {
419                    Some(list_kv_v1_paths(&client, path, "", 1, depth).await?)
420                }
421            } else {
422                None
423            };
424
425            kv_mounts.push(KvMountOutput {
426                path: path.clone(),
427                mount_type: mount_info.mount_type.clone(),
428                description: mount_info.description.clone(),
429                version: version.to_string(),
430                accessor: mount_info.accessor.clone(),
431                children,
432            });
433        }
434    }
435
436    eprintln!("Found {} KV mounts (v1 and v2)", kv_mounts.len());
437
438    // Output results
439    match format {
440        "json" => {
441            let json_output =
442                serde_json::to_string_pretty(&kv_mounts).context("Failed to serialize to JSON")?;
443
444            if let Some(output_path) = output {
445                let mut file = File::create(output_path).context("Failed to create output file")?;
446                file.write_all(json_output.as_bytes())
447                    .context("Failed to write JSON to file")?;
448                eprintln!("Output written to: {}", output_path);
449            } else {
450                println!("{}", json_output);
451            }
452        }
453        "csv" => {
454            use std::fmt::Write as _;
455            let mut csv_output = String::new();
456            if depth > 0 {
457                csv_output.push_str("full_path,type,mount,depth\n");
458                for mount in &kv_mounts {
459                    // Write mount itself
460                    let _ = writeln!(
461                        csv_output,
462                        "\"{}\",\"mount\",\"{}\",0",
463                        mount.path.replace('"', "\"\""),
464                        mount.path.replace('"', "\"\"")
465                    );
466
467                    // Write nested paths
468                    if let Some(children) = &mount.children {
469                        flatten_paths_to_csv(&mut csv_output, &mount.path, children, 1);
470                    }
471                }
472            } else {
473                csv_output.push_str("path,type,description,version,accessor\n");
474                for mount in &kv_mounts {
475                    let _ = writeln!(
476                        csv_output,
477                        "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
478                        mount.path.replace('"', "\"\""),
479                        mount.mount_type,
480                        mount.description.replace('"', "\"\""),
481                        mount.version,
482                        mount.accessor
483                    );
484                }
485            }
486
487            if let Some(output_path) = output {
488                let mut file = File::create(output_path).context("Failed to create output file")?;
489                file.write_all(csv_output.as_bytes())
490                    .context("Failed to write CSV to file")?;
491                eprintln!("Output written to: {}", output_path);
492            } else {
493                print!("{}", csv_output);
494            }
495        }
496        "stdout" => {
497            println!("\nKV v2 Mounts:");
498            println!("{}", "=".repeat(80));
499            for mount in &kv_mounts {
500                println!("Path: {}", mount.path);
501                println!("  Type: {}", mount.mount_type);
502                println!("  Version: {}", mount.version);
503                println!("  Description: {}", mount.description);
504                println!("  Accessor: {}", mount.accessor);
505
506                if let Some(children) = &mount.children {
507                    if !children.is_empty() {
508                        println!("  Contents:");
509                        print_tree(&mount.path, children, "    ", &[]);
510                    }
511                }
512                println!();
513            }
514        }
515        _ => {
516            return Err(anyhow::anyhow!(
517                "Invalid format: {}. Must be one of: csv, json, stdout",
518                format
519            ));
520        }
521    }
522
523    Ok(())
524}